Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/classroom/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,25 @@ export default function ClassroomDetailPage() {
if (res.ok) {
const json = await res.json();
if (json.success && json.classroom) {
const { stage, scenes } = json.classroom;
const { stage, scenes, agents } = json.classroom;
useStageStore.getState().setStage(stage);
useStageStore.setState({
scenes,
currentSceneId: scenes[0]?.id ?? null,
});
log.info('Loaded from server-side storage:', classroomId);

// Register server-persisted agents into the agent registry
if (agents && agents.length > 0) {
const { registerPersistedAgents } =
await import('@/lib/orchestration/registry/store');
const agentIds = registerPersistedAgents(classroomId, agents);
if (agentIds.length > 0) {
const { useSettingsStore } = await import('@/lib/store/settings');
useSettingsStore.getState().setSelectedAgentIds(agentIds);
}
log.info(`Registered ${agentIds.length} server-persisted agents`);
}
}
}
} catch (fetchErr) {
Expand Down
7 changes: 6 additions & 1 deletion components/roundtable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,12 @@ export function Roundtable({
</div>
{teacherConfig?.persona && (
<p className="text-xs text-muted-foreground mt-2 leading-relaxed whitespace-pre-line">
{teacherConfig.persona}
{(() => {
const i18nDesc = t(`settings.agentDescriptions.${teacherConfig.id}`);
return i18nDesc !== `settings.agentDescriptions.${teacherConfig.id}`
? i18nDesc
: teacherConfig.persona;
})()}
</p>
)}
</>
Expand Down
62 changes: 62 additions & 0 deletions lib/orchestration/registry/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,27 @@ export function getDefaultAgents(): AgentInfo[] {
}));
}

/** Return default agents with full display data for server-side persistence. */
export function getDefaultAgentsForPersistence(): Array<{
id: string;
name: string;
role: string;
persona: string;
avatar: string;
color: string;
priority: number;
}> {
return Object.values(DEFAULT_AGENTS).map((a) => ({
id: a.id,
name: a.name,
role: a.role,
persona: a.persona,
avatar: a.avatar,
color: a.color,
priority: a.priority,
}));
}

export const useAgentRegistry = create<AgentRegistryState>()(
persist(
(set, get) => ({
Expand Down Expand Up @@ -363,6 +384,47 @@ export async function loadGeneratedAgentsForStage(stageId: string): Promise<stri
return ids;
}

/**
* Register agents from server-side persisted data (no IndexedDB involved).
* Used when loading API-generated classrooms that include agent profiles.
*/
export function registerPersistedAgents(
stageId: string,
agents: Array<{
id: string;
name: string;
role: string;
persona: string;
avatar: string;
color: string;
priority: number;
}>,
): string[] {
const registry = useAgentRegistry.getState();

// Clear previously loaded generated agents
for (const agent of registry.listAgents()) {
if (agent.isGenerated) registry.deleteAgent(agent.id);
}

const ids: string[] = [];
const now = new Date();
for (const agent of agents) {
registry.addAgent({
...agent,
allowedActions: getActionsForRole(agent.role),
isDefault: false,
isGenerated: true,
boundStageId: stageId,
createdAt: now,
updatedAt: now,
});
ids.push(agent.id);
}

return ids;
}

/**
* Save generated agents to IndexedDB and registry.
* Clears old generated agents for this stage first.
Expand Down
12 changes: 5 additions & 7 deletions lib/playback/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,14 +468,12 @@ export class PlaybackEngine {
.play(speechAction.audioId || '', speechAction.audioUrl)
.then((audioStarted) => {
if (!audioStarted) {
// No pre-generated audio — try browser-native TTS if selected
// No pre-generated audio — try browser-native TTS as fallback
const settings = useSettingsStore.getState();
if (
settings.ttsEnabled &&
settings.ttsProviderId === 'browser-native-tts' &&
typeof window !== 'undefined' &&
window.speechSynthesis
) {
const browserTTSExplicit =
settings.ttsEnabled && settings.ttsProviderId === 'browser-native-tts';
const browserTTSAvailable = typeof window !== 'undefined' && !!window.speechSynthesis;
if ((browserTTSExplicit || !speechAction.audioUrl) && browserTTSAvailable) {
this.playBrowserTTS(speechAction);
} else {
scheduleReadingTimer();
Expand Down
66 changes: 60 additions & 6 deletions lib/server/classroom-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ import {
} from '@/lib/generation/scene-generator';
import type { AICallFn } from '@/lib/generation/pipeline-types';
import type { AgentInfo } from '@/lib/generation/pipeline-types';
import type { PersistedAgent } from '@/lib/server/classroom-storage';
import { formatTeacherPersonaForPrompt } from '@/lib/generation/prompt-formatters';
import { getDefaultAgents } from '@/lib/orchestration/registry/store';
import {
getDefaultAgents,
getDefaultAgentsForPersistence,
} from '@/lib/orchestration/registry/store';
import { createLogger } from '@/lib/logger';
import { parseModelString } from '@/lib/ai/providers';
import { resolveApiKey, resolveWebSearchApiKey } from '@/lib/server/provider-config';
Expand Down Expand Up @@ -108,11 +112,37 @@ function stripCodeFences(text: string): string {
return cleaned.trim();
}

const AVATAR_POOL = [
'/avatars/teacher.png',
'/avatars/assist.png',
'/avatars/curious.png',
'/avatars/thinker.png',
'/avatars/note-taker.png',
'/avatars/teacher-2.png',
'/avatars/assist-2.png',
'/avatars/curious-2.png',
'/avatars/thinker-2.png',
'/avatars/note-taker-2.png',
];

const COLOR_PALETTE = [
'#3b82f6',
'#10b981',
'#f59e0b',
'#ec4899',
'#06b6d4',
'#8b5cf6',
'#f97316',
'#14b8a6',
'#e11d48',
'#6366f1',
];

async function generateAgentProfiles(
requirement: string,
language: string,
aiCall: AICallFn,
): Promise<AgentInfo[]> {
): Promise<PersistedAgent[]> {
const systemPrompt =
'You are an expert instructional designer. Generate agent profiles for a multi-agent classroom simulation. Return ONLY valid JSON, no markdown or explanation.';

Expand All @@ -122,24 +152,39 @@ ${requirement}
Requirements:
- Decide the appropriate number of agents based on the course content (typically 3-5)
- Exactly 1 agent must have role "teacher", the rest can be "assistant" or "student"
- Priority values: teacher=10 (highest), assistant=7, student=4-6
- Each agent needs: name, role, persona (2-3 sentences describing personality and teaching/learning style)
- Names and personas must be in language: ${language}
- Each agent must be assigned one avatar from this list: ${JSON.stringify(AVATAR_POOL)}
- Try to use different avatars for each agent
- Each agent must be assigned one color from this list: ${JSON.stringify(COLOR_PALETTE)}
- Each agent must have a different color

Return a JSON object with this exact structure:
{
"agents": [
{
"name": "string",
"role": "teacher" | "assistant" | "student",
"persona": "string (2-3 sentences)"
"persona": "string (2-3 sentences)",
"avatar": "string (from available list)",
"color": "string (hex color from palette)",
"priority": number (10 for teacher, 7 for assistant, 4-6 for student)
}
]
}`;

const response = await aiCall(systemPrompt, userPrompt);
const rawText = stripCodeFences(response);
const parsed = JSON.parse(rawText) as {
agents: Array<{ name: string; role: string; persona: string }>;
agents: Array<{
name: string;
role: string;
persona: string;
avatar?: string;
color?: string;
priority?: number;
}>;
};

if (!parsed.agents || !Array.isArray(parsed.agents) || parsed.agents.length < 2) {
Expand All @@ -156,6 +201,9 @@ Return a JSON object with this exact structure:
name: a.name,
role: a.role,
persona: a.persona,
avatar: a.avatar || AVATAR_POOL[i % AVATAR_POOL.length],
color: a.color || COLOR_PALETTE[i % COLOR_PALETTE.length],
priority: a.priority ?? (a.role === 'teacher' ? 10 : a.role === 'assistant' ? 7 : 5),
}));
}

Expand Down Expand Up @@ -212,18 +260,23 @@ export async function generateClassroom(

// Resolve agents based on agentMode
let agents: AgentInfo[];
let persistedAgents: PersistedAgent[] | undefined;
const agentMode = input.agentMode || 'default';
if (agentMode === 'generate') {
log.info('Generating custom agent profiles via LLM...');
try {
agents = await generateAgentProfiles(requirement, lang, aiCall);
log.info(`Generated ${agents.length} agent profiles`);
const fullAgents = await generateAgentProfiles(requirement, lang, aiCall);
log.info(`Generated ${fullAgents.length} agent profiles`);
persistedAgents = fullAgents;
agents = fullAgents;
} catch (e) {
log.warn('Agent profile generation failed, falling back to defaults:', e);
agents = getDefaultAgents();
persistedAgents = getDefaultAgentsForPersistence();
}
} else {
agents = getDefaultAgents();
persistedAgents = getDefaultAgentsForPersistence();
}
const teacherContext = formatTeacherPersonaForPrompt(agents);

Expand Down Expand Up @@ -412,6 +465,7 @@ export async function generateClassroom(
id: stageId,
stage,
scenes,
agents: persistedAgents,
},
options.baseUrl,
);
Expand Down
14 changes: 14 additions & 0 deletions lib/server/classroom-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ import path from 'path';
import type { NextRequest } from 'next/server';
import type { Scene, Stage } from '@/lib/types/stage';

/** Agent profile persisted alongside a classroom */
export interface PersistedAgent {
id: string;
name: string;
role: string;
persona: string;
avatar: string;
color: string;
priority: number;
}

export const CLASSROOMS_DIR = path.join(process.cwd(), 'data', 'classrooms');
export const CLASSROOM_JOBS_DIR = path.join(process.cwd(), 'data', 'classroom-jobs');

Expand Down Expand Up @@ -38,6 +49,7 @@ export interface PersistedClassroomData {
id: string;
stage: Stage;
scenes: Scene[];
agents?: PersistedAgent[];
createdAt: string;
}

Expand All @@ -63,13 +75,15 @@ export async function persistClassroom(
id: string;
stage: Stage;
scenes: Scene[];
agents?: PersistedAgent[];
},
baseUrl: string,
): Promise<PersistedClassroomData & { url: string }> {
const classroomData: PersistedClassroomData = {
id: data.id,
stage: data.stage,
scenes: data.scenes,
...(data.agents && data.agents.length > 0 ? { agents: data.agents } : {}),
createdAt: new Date().toISOString(),
};

Expand Down
Loading