/** * Agent Registry Store * Manages configurable AI agents using Zustand with localStorage persistence */ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { AgentConfig } from './types'; import { getActionsForRole } from './types'; import { isKnownTTSProviderId } from '@/lib/audio/constants'; import type { GeneratedAgentConfig } from '@/lib/types/stage'; import { USER_AVATAR } from '@/lib/types/roundtable'; import type { Participant, ParticipantRole } from '@/lib/types/roundtable'; import { useUserProfileStore } from '@/lib/store/user-profile'; import type { AgentInfo } from '@openmaic/generation'; interface AgentRegistryState { agents: Record; // Map of agentId -> config // Actions addAgent: (agent: AgentConfig) => void; updateAgent: (id: string, updates: Partial) => void; deleteAgent: (id: string) => void; getAgent: (id: string) => AgentConfig | undefined; listAgents: () => AgentConfig[]; } // Action types available to agents const WHITEBOARD_ACTIONS = [ 'wb_open', 'wb_close', 'wb_draw_text', 'wb_draw_shape', 'wb_draw_chart', 'wb_draw_latex', 'wb_draw_table', 'wb_draw_line', 'wb_draw_code', 'wb_edit_code', 'wb_clear', 'wb_delete', ]; const SLIDE_ACTIONS = ['spotlight', 'laser', 'play_video']; // Default agents - always available on both server and client const DEFAULT_AGENTS: Record = { 'default-1': { id: 'default-1', name: 'AI teacher', role: 'teacher', persona: `You are the lead teacher of this classroom. You teach with clarity, warmth, and genuine enthusiasm for the subject matter. Your teaching style: - Explain concepts step by step, building from what students already know - Use vivid analogies, real-world examples, and visual aids to make abstract ideas concrete - Pause to check understanding — ask questions, not just lecture - Adapt your pace: slow down for difficult parts, move briskly through familiar ground - Encourage students by name when they contribute, and gently correct mistakes without embarrassment You can spotlight or laser-point at slide elements, and use the whiteboard for hand-drawn explanations. Use these actions naturally as part of your teaching flow. Never announce your actions; just teach. Tone: Professional yet approachable. Patient. Encouraging. You genuinely care about whether students understand.`, avatar: '/avatars/teacher.png', color: '#3b82f6', allowedActions: [...SLIDE_ACTIONS, ...WHITEBOARD_ACTIONS], priority: 10, createdAt: new Date(), updatedAt: new Date(), isDefault: true, }, }; /** * Return the built-in default agents as lightweight AgentInfo objects * suitable for the generation pipeline (no UI-only fields like avatar/color). */ export function getDefaultAgents(): AgentInfo[] { return Object.values(DEFAULT_AGENTS).map((a) => ({ id: a.id, name: a.name, role: a.role, persona: a.persona, })); } /** * Portable snapshot of the built-in roster for persisted/server-generated * classrooms. Unlike AgentInfo this keeps persona presentation and voice * fields, so frozen bundles do not depend on whatever defaults a future app * version happens to ship. */ export function getDefaultGeneratedAgentConfigs( agentIds?: readonly string[], ): GeneratedAgentConfig[] { const selected = agentIds ? agentIds.map((id) => DEFAULT_AGENTS[id]).filter((agent): agent is AgentConfig => !!agent) : Object.values(DEFAULT_AGENTS); return selected.map((agent) => ({ id: agent.id, name: agent.name, role: agent.role, persona: agent.persona, avatar: agent.avatar, color: agent.color, priority: agent.priority, ...(agent.voiceConfig ? { voiceConfig: { providerId: agent.voiceConfig.providerId, voiceId: agent.voiceConfig.voiceId, }, } : {}), ...(agent.voiceDesign ? { voiceDesign: agent.voiceDesign } : {}), })); } export const useAgentRegistry = create()( persist( (set, get) => ({ // Initialize with default agents so they're available on server agents: { ...DEFAULT_AGENTS }, addAgent: (agent) => set((state) => ({ agents: { ...state.agents, [agent.id]: agent }, })), updateAgent: (id, updates) => set((state) => ({ agents: { ...state.agents, [id]: { ...state.agents[id], ...updates, updatedAt: new Date() }, }, })), deleteAgent: (id) => set((state) => { const { [id]: _removed, ...rest } = state.agents; return { agents: rest }; }), getAgent: (id) => get().agents[id], listAgents: () => Object.values(get().agents), }), { name: 'agent-registry-storage', version: 11, // Bumped: add voiceOverrides field to AgentConfig migrate: (persistedState: unknown) => persistedState, // Generated agents are single-sourced on the stage document and rebuilt // from it on every classroom load — keep them out of the localStorage // snapshot entirely. The merge filter below stays as defense in depth // for snapshots written before this partialize existed. partialize: (state) => ({ agents: Object.fromEntries( Object.entries(state.agents).filter(([, agent]) => !agent.isGenerated), ), }), // Merge persisted state with default agents // Default agents always use code-defined values (not cached) // Custom agents use persisted values merge: (persistedState: unknown, currentState) => { const persisted = persistedState as Record | undefined; const persistedAgents = (persisted?.agents || {}) as Record; const mergedAgents: Record = { ...DEFAULT_AGENTS }; // Only preserve non-default, non-generated (custom) agents from cache // Generated agents are loaded on-demand from IndexedDB per stage for (const [id, agent] of Object.entries(persistedAgents)) { const agentConfig = agent as AgentConfig; if (!id.startsWith('default-') && !agentConfig.isGenerated) { mergedAgents[id] = agentConfig; } } return { ...currentState, agents: mergedAgents, }; }, }, ), ); /** * Convert agents to roundtable participants * Maps agent roles to participant roles for the UI * @param t - i18n translation function for localized display names */ export function agentsToParticipants( agentIds: string[], t?: (key: string) => string, ): Participant[] { const registry = useAgentRegistry.getState(); const participants: Participant[] = []; let hasTeacher = false; // Resolve agents and sort: teacher first (by role then priority desc) const resolved = agentIds .map((id) => registry.getAgent(id)) .filter((a): a is AgentConfig => a != null); resolved.sort((a, b) => { if (a.role === 'teacher' && b.role !== 'teacher') return -1; if (a.role !== 'teacher' && b.role === 'teacher') return 1; return (b.priority ?? 0) - (a.priority ?? 0); }); for (const agent of resolved) { // Map agent role to participant role: // The first agent with role "teacher" becomes the left-side teacher. // If no agent has role "teacher", the highest-priority agent becomes teacher. let role: ParticipantRole = 'student'; if (!hasTeacher) { role = 'teacher'; hasTeacher = true; } // Use i18n name for default agents, fall back to registry name const i18nName = t?.(`settings.agentNames.${agent.id}`); const displayName = i18nName && i18nName !== `settings.agentNames.${agent.id}` ? i18nName : agent.name; participants.push({ id: agent.id, name: displayName, role, avatar: agent.avatar, isOnline: true, isSpeaking: false, }); } // Always add user participant — use profile store when available const userProfile = useUserProfileStore.getState(); const userName = userProfile.nickname || t?.('common.you') || 'You'; const userAvatar = userProfile.avatar || USER_AVATAR; participants.push({ id: 'user-1', name: userName, role: 'user', avatar: userAvatar, isOnline: true, isSpeaking: false, }); return participants; } /** * Replace the registry's generated agents with the given stage roster. * * In-memory registry side effect: the persisted source of truth for the * roster is `stage.generatedAgentConfigs` on the stage document, and callers * persist it through the document path — the registry's own localStorage * snapshot excludes generated agents (see the persist `partialize` above), so * nothing written here becomes durable. * Clears previously loaded generated agents first (even when the new roster is * empty) so a prior classroom's roster cannot leak into the current one. * The contract keeps `voiceConfig.providerId` an open string; a binding whose * provider is not registered in this app is dropped here (the agent keeps its * voiceDesign, and the TTS path falls back at call time). * Returns the applied agent IDs. */ export function applyGeneratedAgentsToRegistry( stageId: string, agents: ReadonlyArray, ): string[] { const registry = useAgentRegistry.getState(); for (const agent of registry.listAgents()) { if (agent.isGenerated) registry.deleteAgent(agent.id); } const now = Date.now(); const ids: string[] = []; for (const agent of agents) { const { voiceConfig, ...rest } = agent; registry.addAgent({ ...rest, allowedActions: getActionsForRole(agent.role), isDefault: false, isGenerated: true, boundStageId: stageId, createdAt: new Date(now), updatedAt: new Date(now), ...(voiceConfig && isKnownTTSProviderId(voiceConfig.providerId) ? { voiceConfig: { providerId: voiceConfig.providerId, voiceId: voiceConfig.voiceId, }, } : {}), }); ids.push(agent.id); } // Eager warm-up: pre-register each generated agent's auto voice so the first // spoken line is already stable. Same idempotent ensure as the TTS path; // fire-and-forget. Dynamic import keeps this client-only dep out of the // server-importable store module. if (ids.length > 0 && typeof window !== 'undefined') { void import('@/lib/audio/agent-voice') .then((m) => m.warmUpAgentVoices(registry.listAgents().filter((a) => a.isGenerated))) .catch(() => undefined); } return ids; }