From d14049aa6391a6a67ec0991512b52eef40d857c3 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Thu, 19 Mar 2026 15:30:44 +0800 Subject: [PATCH 1/5] fix(generate): persist and load generated agents in API-generated classrooms When agentMode=generate was used with /api/generate-classroom, the LLM-generated agent profiles were created but never persisted to the classroom JSON file. This meant the classroom page couldn't display custom agents. - Add PersistedAgent type and agents field to PersistedClassroomData - Enrich server-side generateAgentProfiles to produce full agent data (avatar, color, priority) - Pass generated agents through to persistClassroom - Add registerPersistedAgents() to load agents from server data without IndexedDB - Update classroom page to register agents from API response Co-Authored-By: Claude Opus 4.6 (1M context) --- app/classroom/[id]/page.tsx | 15 +++++++- lib/orchestration/registry/store.ts | 41 ++++++++++++++++++++ lib/server/classroom-generation.ts | 59 ++++++++++++++++++++++++++--- lib/server/classroom-storage.ts | 14 +++++++ 4 files changed, 123 insertions(+), 6 deletions(-) diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index 523e23211..725f46af5 100644 --- a/app/classroom/[id]/page.tsx +++ b/app/classroom/[id]/page.tsx @@ -44,13 +44,26 @@ 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/lib/orchestration/registry/store.ts b/lib/orchestration/registry/store.ts index b5e7b8600..4b188deba 100644 --- a/lib/orchestration/registry/store.ts +++ b/lib/orchestration/registry/store.ts @@ -363,6 +363,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/server/classroom-generation.ts b/lib/server/classroom-generation.ts index eda67b4c4..7fefb979e 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -13,6 +13,7 @@ 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 { createLogger } from '@/lib/logger'; @@ -108,11 +109,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 +149,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 +163,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 +174,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 +198,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,12 +257,15 @@ 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(); @@ -412,6 +460,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(), }; From 58f813fc9a25f8fd61cae4da7b5f699580954b8b Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Thu, 19 Mar 2026 15:32:43 +0800 Subject: [PATCH 2/5] style: fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- app/classroom/[id]/page.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index 725f46af5..4973f332f 100644 --- a/app/classroom/[id]/page.tsx +++ b/app/classroom/[id]/page.tsx @@ -54,9 +54,8 @@ export default function ClassroomDetailPage() { // Register server-persisted agents into the agent registry if (agents && agents.length > 0) { - const { registerPersistedAgents } = await import( - '@/lib/orchestration/registry/store' - ); + const { registerPersistedAgents } = + await import('@/lib/orchestration/registry/store'); const agentIds = registerPersistedAgents(classroomId, agents); if (agentIds.length > 0) { const { useSettingsStore } = await import('@/lib/store/settings'); From 4e6fae1c372f0b1d44d08feb4cbf34fd710a7189 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Thu, 19 Mar 2026 18:28:48 +0800 Subject: [PATCH 3/5] fix(generate): persist default agents in API-generated classrooms When using default agents (agentMode != 'generate'), agent profiles were not persisted to the classroom JSON. This meant the classroom page showed no agents for default-mode classrooms. - Add getDefaultAgentsForPersistence() that exports full agent data - Always persist agents (default or generated) to the classroom file Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/orchestration/registry/store.ts | 21 +++++++++++++++++++++ lib/server/classroom-generation.ts | 7 ++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/orchestration/registry/store.ts b/lib/orchestration/registry/store.ts index 4b188deba..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) => ({ diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index 7fefb979e..8d167d9d7 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -15,7 +15,10 @@ 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'; @@ -269,9 +272,11 @@ export async function generateClassroom( } 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); From c5a046b9eb46a3a0332ff9cf2ab1959cde0b6614 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Thu, 19 Mar 2026 18:47:33 +0800 Subject: [PATCH 4/5] fix(roundtable): show i18n description for teacher instead of full persona The teacher hover card was displaying the raw English persona prompt while other agents correctly showed short i18n descriptions. Apply the same i18n-first fallback logic used by student/assistant agents. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/roundtable/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/roundtable/index.tsx b/components/roundtable/index.tsx index 0d08a71b6..86e3caa46 100644 --- a/components/roundtable/index.tsx +++ b/components/roundtable/index.tsx @@ -519,7 +519,12 @@ export function Roundtable({ {teacherConfig?.persona && (

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

)} From b577c33f04672cc3eaa431487611bef71b4f4fda Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Thu, 19 Mar 2026 19:16:44 +0800 Subject: [PATCH 5/5] feat(playback): auto-fallback to browser TTS when no server audio When a speech action has no pre-generated audioUrl (server TTS not configured), automatically use the browser's native SpeechSynthesis API as a fallback instead of requiring the user to manually enable browser-native-tts in settings. Reading timer remains the final fallback when browser TTS is also unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/playback/engine.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/playback/engine.ts b/lib/playback/engine.ts index 36fd569b9..870618556 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();