Files
openmaic/OpenMAIC/lib/orchestration/registry/store.ts
2026-08-16 14:58:47 +08:00

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;
}