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(),
};