diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index 523e23211..4973f332f 100644 --- a/app/classroom/[id]/page.tsx +++ b/app/classroom/[id]/page.tsx @@ -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) { diff --git a/components/roundtable/index.tsx b/components/roundtable/index.tsx index 9ab09939b..ce7e79b35 100644 --- a/components/roundtable/index.tsx +++ b/components/roundtable/index.tsx @@ -560,7 +560,12 @@ export function Roundtable({ {teacherConfig?.persona && (

- {teacherConfig.persona} + {(() => { + const i18nDesc = t(`settings.agentDescriptions.${teacherConfig.id}`); + return i18nDesc !== `settings.agentDescriptions.${teacherConfig.id}` + ? i18nDesc + : teacherConfig.persona; + })()}

)} diff --git a/lib/orchestration/registry/store.ts b/lib/orchestration/registry/store.ts index b5e7b8600..970cec78f 100644 --- a/lib/orchestration/registry/store.ts +++ b/lib/orchestration/registry/store.ts @@ -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()( persist( (set, get) => ({ @@ -363,6 +384,47 @@ export async function loadGeneratedAgentsForStage(stageId: string): Promise, +): 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. diff --git a/lib/playback/engine.ts b/lib/playback/engine.ts index c9c5c8bf6..408d61b71 100644 --- a/lib/playback/engine.ts +++ b/lib/playback/engine.ts @@ -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(); diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index eda67b4c4..8d167d9d7 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -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'; @@ -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 { +): Promise { 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.'; @@ -122,8 +152,13 @@ ${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: { @@ -131,7 +166,10 @@ Return a JSON object with this exact structure: { "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) } ] }`; @@ -139,7 +177,14 @@ Return a JSON object with this exact structure: 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) { @@ -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), })); } @@ -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); @@ -412,6 +465,7 @@ export async function generateClassroom( id: stageId, stage, scenes, + agents: persistedAgents, }, options.baseUrl, ); diff --git a/lib/server/classroom-storage.ts b/lib/server/classroom-storage.ts index 41e3e8c9e..631bec8af 100644 --- a/lib/server/classroom-storage.ts +++ b/lib/server/classroom-storage.ts @@ -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'); @@ -38,6 +49,7 @@ export interface PersistedClassroomData { id: string; stage: Stage; scenes: Scene[]; + agents?: PersistedAgent[]; createdAt: string; } @@ -63,6 +75,7 @@ export async function persistClassroom( id: string; stage: Stage; scenes: Scene[]; + agents?: PersistedAgent[]; }, baseUrl: string, ): Promise { @@ -70,6 +83,7 @@ export async function persistClassroom( id: data.id, stage: data.stage, scenes: data.scenes, + ...(data.agents && data.agents.length > 0 ? { agents: data.agents } : {}), createdAt: new Date().toISOString(), };