312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
/**
|
|
* 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<string, AgentConfig>; // Map of agentId -> config
|
|
|
|
// Actions
|
|
addAgent: (agent: AgentConfig) => void;
|
|
updateAgent: (id: string, updates: Partial<AgentConfig>) => 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<string, AgentConfig> = {
|
|
'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<AgentRegistryState>()(
|
|
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<string, unknown> | undefined;
|
|
const persistedAgents = (persisted?.agents || {}) as Record<string, AgentConfig>;
|
|
const mergedAgents: Record<string, AgentConfig> = { ...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<GeneratedAgentConfig>,
|
|
): 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;
|
|
}
|