'use client'; /** * MAIC Agent v0 — client runtime. * * Drives an assistant-ui ExternalStore from the server's pi `AgentEvent` SSE * stream. A run produces multiple assistant turns (tool-call turn, wrap-up * turn); we keep each turn's ordered content as it streams (`turnsRef`) and * flatten chronologically via `mergeAssistantParts`, so tool cards and text * render in the order they actually happened. The assistant message carries a * streaming `status` (running → complete/error) for proper streaming UI. * * When a `regenerate_scene_actions` tool result arrives, its `details` payload * is applied to the editor's Dexie-backed stage store (guarded against empty * actions). Scene/stage context is read from `useStageStore` at send-time and * POSTed so the tool never relies on model-fabricated data. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { useExternalStoreRuntime, type AppendMessage, type ThreadMessageLike, } from '@assistant-ui/react'; import type { AgentEvent } from '@earendil-works/pi-agent-core'; import { useStageStore } from '@/lib/store/stage'; import { getCurrentModelConfig } from '@/lib/utils/model-config'; import type { SceneContextMap } from '@/app/api/agent/edit/route'; import { mergeAssistantParts, type PiPart } from './merge-assistant-parts'; import { resolveSceneOutline } from './resolve-scene-outline'; import { planRegenerateApply, type RegenerateDetails } from './apply-regenerate'; import { applyScenePatchInSync } from './apply-slide-content'; import { useRegenSnapshots } from './regen-snapshots'; import { createSession, saveSession, loadSession, listSessions, deleteSession, migrateLegacyThread, rememberActiveSession, recallActiveSession, } from './agent-thread-store'; import { deriveSessionTitle, type AgentEditSessionRecord } from './agent-edit-session-types'; import { serializeThread, deserializeThread, type SerializedMessage } from './serialize-thread'; export type { AssistantPart, PiPart } from './merge-assistant-parts'; import { toPiParts, type PiAssistantContent } from './to-pi-parts'; import { useThinkingTimers } from './thinking-timers'; import { useSceneRuntimeErrors } from '@/lib/store/scene-runtime-errors'; export interface UseAgentRuntimeOptions { scene?: { id: string; title: string }; isSendDisabled?: boolean; } /** A prior conversation turn sent to the server so the agent has memory. */ interface HistoryTurn { role: 'user' | 'assistant'; text: string; } function extractText(message: AppendMessage): string { if (typeof message.content === 'string') return message.content; return message.content .map((p) => (p.type === 'text' ? p.text : '')) .filter(Boolean) .join('\n'); } /** Plain text of a thread message's content (tool-call parts have no text). */ function messageText(content: ThreadMessageLike['content']): string { if (typeof content === 'string') return content.trim(); if (!Array.isArray(content)) return ''; return content .map((p) => (p && p.type === 'text' && typeof p.text === 'string' ? p.text : '')) .filter(Boolean) .join('\n') .trim(); } /** Project the rendered thread into text-only turns for server-side memory. */ function toHistory(messages: ThreadMessageLike[]): HistoryTurn[] { const out: HistoryTurn[] = []; for (const m of messages) { const text = messageText(m.content); if (!text) continue; out.push({ role: m.role === 'user' ? 'user' : 'assistant', text }); } return out; } /** * Re-seed reasoning durations into the (in-memory, lost-on-refresh) timer store * from a restored session so panels show "已思考 N s" instead of a blank label. */ function reseedReasoningTimers(saved: SerializedMessage[] | undefined): void { if (!Array.isArray(saved)) return; for (const m of saved) { if (m.role !== 'assistant' || !Array.isArray(m.content)) continue; let ord = 0; for (const p of m.content) { if (p.type === 'reasoning') { if (typeof p.durationMs === 'number') useThinkingTimers.getState().seed(`${m.id}:${ord}`, p.durationMs); ord++; } } } } export function useAgentRuntime(opts: UseAgentRuntimeOptions) { const [messages, setMessages] = useState([]); const [isRunning, setIsRunning] = useState(false); // Mirror the latest committed messages so `onNew` (which is not re-created on // every message change) can read the prior conversation to send as history. const messagesRef = useRef([]); useEffect(() => { messagesRef.current = messages; }, [messages]); // Multi-session persistence per stage (history list, IndexedDB-backed). The // active session is tracked in memory; on mount/course-switch we load the most // recent session (or start a fresh one) so a refresh no longer drops the chat. const stageId = useStageStore((s) => s.stage?.id); // The stage this effect last STARTED loading for (set synchronously). Used both // to dedup re-runs for the same stage and to detect a switch. We deliberately // do NOT gate on a "load committed" ref: the async pass bails via a stage-match // check, so under React StrictMode the first (uncancelled) pass hydrates while // the second is deduped, and a quick switch-away-and-back still reloads. const startedStageRef = useRef(undefined); const activeSessionIdRef = useRef(undefined); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(undefined); const refreshSessions = useCallback(async () => { const sid = useStageStore.getState().stage?.id; if (!sid) return; const list = await listSessions(sid); // The stage may have switched during the await — don't overwrite the new // panel's history with the previous stage's rows. if (useStageStore.getState().stage?.id !== sid) return; setSessions(list); }, []); useEffect(() => { if (!stageId || startedStageRef.current === stageId) return; // Switch = this effect previously started for a DIFFERENT stage, so any live // thread on screen belongs to that old stage and must be dropped — even if its // load never committed (e.g. a quick A→B→A before B settled). const isSwitch = startedStageRef.current !== undefined; startedStageRef.current = stageId; if (isSwitch) { // Drop the previous stage's thread synchronously BEFORE the async load, so // the old conversation can't be POSTed as history or archived under the new // stage, and the popover can't act on a foreign-stage session. abortRef.current?.abort(); abortRef.current = null; phaseRef.current = 'complete'; setIsRunning(false); messagesRef.current = []; setMessages([]); activeSessionIdRef.current = undefined; setActiveSessionId(undefined); setSessions([]); useRegenSnapshots.getState().clearAll(); useThinkingTimers.getState().clear(); } void (async () => { // One-time import of the old single-thread localStorage entry. await migrateLegacyThread(stageId); const list = await listSessions(stageId); // The user may have switched stages during the awaits — only commit if this // stage is still current (replaces a cancelled-flag, which left StrictMode // unable to hydrate). if (useStageStore.getState().stage?.id !== stageId) return; setSessions(list); // A conversation is already underway for THIS stage — either the user sent // before the stage id resolved (first resolve), or they started typing // during this async load window (after the synchronous switch-clear). Adopt // it as a new active session instead of clobbering the in-flight thread with // a stored one. if (messagesRef.current.length > 0) { // Reuse an id the settle-save effect may already have created/saved during // this load window; only mint a new one if none exists. Otherwise we'd // remember an unsaved id and strand the actually-saved conversation. const sid = activeSessionIdRef.current ?? createSession(stageId).id; activeSessionIdRef.current = sid; setActiveSessionId(sid); rememberActiveSession(stageId, sid); return; } // Prefer the session the user last had open (survives refresh), including a // freshly-created empty one after "new conversation" that has no row yet. const remembered = recallActiveSession(stageId); const rememberedRec = remembered ? list.find((s) => s.id === remembered) : undefined; if (rememberedRec) { activeSessionIdRef.current = rememberedRec.id; setActiveSessionId(rememberedRec.id); setMessages(deserializeThread(rememberedRec.messages)); reseedReasoningTimers(rememberedRec.messages); } else if (remembered) { // Remembered an empty/unsaved (or pruned) session → keep the clean slate. activeSessionIdRef.current = remembered; setActiveSessionId(remembered); setMessages([]); } else { const recent = list[0]; if (recent) { activeSessionIdRef.current = recent.id; setActiveSessionId(recent.id); setMessages(deserializeThread(recent.messages)); reseedReasoningTimers(recent.messages); } else { const s = createSession(stageId); activeSessionIdRef.current = s.id; setActiveSessionId(s.id); setMessages([]); } } })(); }, [stageId]); // Persist the thread whenever it settles (after a turn completes, not mid-run), // keyed by the current course. An effect — NOT an inline read after // setMessages — because a state updater does not run synchronously, so reading // "final" messages right after setMessages would serialize a stale/empty list. useEffect(() => { if (isRunning || messages.length === 0) return; const sid = useStageStore.getState().stage?.id; if (!sid) return; let sessionId = activeSessionIdRef.current; if (!sessionId) { const s = createSession(sid); sessionId = s.id; activeSessionIdRef.current = s.id; setActiveSessionId(s.id); } rememberActiveSession(sid, sessionId); // Supply each reasoning block's final duration (keyed messageId:ordinal) so // "已思考 N s" survives a refresh. const timers = useThinkingTimers.getState().timers; const getDuration = (messageId: string | undefined, ordinal: number): number | undefined => { const tmr = timers[`${messageId}:${ordinal}`]; return tmr && tmr.endedAt != null ? tmr.endedAt - tmr.startedAt : undefined; }; const serialized = serializeThread(messages, getDuration); const now = Date.now(); // saveSession preserves the persisted createdAt for an existing row, so a // fresh `now` here is only used when this session is first written. void saveSession({ id: sessionId, stageId: sid, // Empty title → UI renders the localized "untitled" label. title: deriveSessionTitle(serialized, ''), messages: serialized, createdAt: now, updatedAt: now, }).then(() => refreshSessions()); }, [messages, isRunning, refreshSessions]); // Per-run accumulated state: chronological turns + tool results. const turnsRef = useRef([]); const toolResultsRef = useRef>(new Map()); const errorRef = useRef(''); const phaseRef = useRef<'running' | 'complete' | 'error' | 'cancelled'>('complete'); // Aborts the in-flight run; closing the fetch body cancels the server stream // (the route's ReadableStream.cancel() calls agent.abort()). const abortRef = useRef(null); const clearThread = useCallback(() => { // Discard any in-flight run first — otherwise its late SSE events still pass // isCurrent() and could rewrite the cleared thread or apply tool patches to // the slide after the user reset. abortRef.current?.abort(); abortRef.current = null; phaseRef.current = 'complete'; setIsRunning(false); messagesRef.current = []; setMessages([]); useRegenSnapshots.getState().clearAll(); useThinkingTimers.getState().clear(); const sid = useStageStore.getState().stage?.id; if (sid) { // Archive-then-new: the prior session stays in Dexie; just start a fresh, // empty active session (persisted lazily once it has messages). Remember it // so a refresh keeps the clean slate instead of reloading the old chat. const s = createSession(sid); activeSessionIdRef.current = s.id; setActiveSessionId(s.id); rememberActiveSession(sid, s.id); } }, []); // Switch the panel to a stored session from the history list. const switchSession = useCallback( async (id: string) => { // Clicking the already-active session is a no-op: don't abort an in-flight // run and reload the last-persisted row, which would drop the current // prompt / a just-finished turn whose save hasn't settled yet. if (id === activeSessionIdRef.current) return; // Load and VALIDATE the target before doing anything destructive. A stale // row (deleted/pruned in another tab, or foreign-stage and left open across // a switch) must just refresh the list — without aborting the current run. const rec = await loadSession(id); if (!rec || rec.stageId !== useStageStore.getState().stage?.id) { await refreshSessions(); return; } // Target is valid — now abort the current run and switch to it. abortRef.current?.abort(); abortRef.current = null; phaseRef.current = 'complete'; setIsRunning(false); useRegenSnapshots.getState().clearAll(); useThinkingTimers.getState().clear(); activeSessionIdRef.current = rec.id; setActiveSessionId(rec.id); rememberActiveSession(rec.stageId, rec.id); const restored = deserializeThread(rec.messages); // Keep messagesRef in sync synchronously: onNew reads it to build the next // request's history, and it otherwise lags setMessages by a render — a prompt // sent right after switching would carry the OLD conversation as context. messagesRef.current = restored; setMessages(restored); reseedReasoningTimers(rec.messages); }, [refreshSessions], ); // Delete a session; if it was active, reset to a fresh empty session. const deleteSessionAndRefresh = useCallback( async (id: string) => { // Guard the same way as switchSession: a stale popover row from before a // stage switch must not delete a different stage's conversation. const rec = await loadSession(id); if (!rec || rec.stageId !== useStageStore.getState().stage?.id) { await refreshSessions(); return; } await deleteSession(id); await refreshSessions(); if (activeSessionIdRef.current === id) clearThread(); }, [refreshSessions, clearThread], ); const buildAssistant = useCallback((id: string): ThreadMessageLike => { const parts = mergeAssistantParts({ turns: turnsRef.current, toolResults: toolResultsRef.current, error: errorRef.current, }); const status: ThreadMessageLike['status'] = phaseRef.current === 'running' ? { type: 'running' } : phaseRef.current === 'error' ? { type: 'incomplete', reason: 'error' } : phaseRef.current === 'cancelled' ? { type: 'incomplete', reason: 'cancelled' } : { type: 'complete', reason: 'stop' }; return { role: 'assistant', id, content: parts as ThreadMessageLike['content'], status }; }, []); const handleEvent = useCallback((event: AgentEvent, refresh: () => void, assistantId: string) => { switch (event.type) { case 'message_start': { const msg = (event as { message?: { role?: string } }).message; if (msg?.role === 'assistant') { turnsRef.current.push([]); // a new assistant turn begins refresh(); } break; } case 'message_update': case 'message_end': { const msg = ( event as { message?: { role?: string; content?: PiAssistantContent[]; errorMessage?: string }; } ).message; if (msg?.role !== 'assistant') break; if (msg.errorMessage) errorRef.current = msg.errorMessage; if (Array.isArray(msg.content)) { if (turnsRef.current.length === 0) turnsRef.current.push([]); // Replace the CURRENT turn's content wholesale (pi re-emits the full // accumulated turn on each update) — order within the turn is kept. turnsRef.current[turnsRef.current.length - 1] = toPiParts(msg.content); } // Per-block reasoning timing for the thinking panels' durations. Compute // over the SAME merged view the UI renders so block ordinals align: each // reasoning block ends (freezes) once a later part follows it; the last // one stays open and ticks live until something follows or the run ends. const merged = mergeAssistantParts({ turns: turnsRef.current, toolResults: toolResultsRef.current, error: errorRef.current, }); const nowTs = Date.now(); let ord = 0; for (let i = 0; i < merged.length; i++) { if (merged[i].type === 'reasoning') { useThinkingTimers .getState() .observe(`${assistantId}:${ord}`, { end: i < merged.length - 1, now: nowTs }); ord++; } } refresh(); break; } case 'tool_execution_end': { const e = event as { toolCallId: string; toolName?: string; result?: { details?: unknown }; isError?: boolean; }; toolResultsRef.current.set(e.toolCallId, { result: e.result, isError: !!e.isError }); const details = (e.result?.details ?? {}) as RegenerateDetails; // Decide what to apply: regenerate_scene applies content (+actions) and // snapshots the pre-state for restore; regenerate_scene_actions applies // actions only. Empty actions are never applied (would wipe narration). const scene = details.sceneId ? useStageStore.getState().getSceneById(details.sceneId) : null; const { snapshot, patch } = planRegenerateApply(details, scene, e.toolName); // Capture the applied patch as the snapshot's `redo` so an undo can be // resumed (the Restore button toggles undo ↔ resume). if (snapshot) useRegenSnapshots .getState() .setSnapshot(e.toolCallId, patch ? { ...snapshot, redo: patch } : snapshot); if (patch && details.sceneId) { // Apply to the stage store and keep the OPEN slide edit session in // lockstep — else the canvas keeps rendering its stale history.present // and the next edit clobbers the regen. applyScenePatchInSync(details.sceneId, patch); } refresh(); break; } default: break; } }, []); const onNew = useCallback( async (message: AppendMessage) => { const userText = extractText(message); if (!userText) return; // Prior conversation (text turns) captured BEFORE this turn's messages are // appended — sent to the server so the agent has multi-turn memory. const history = toHistory(messagesRef.current); const turnId = `t-${Date.now()}`; const assistantId = `a-${turnId}`; turnsRef.current = []; toolResultsRef.current = new Map(); errorRef.current = ''; phaseRef.current = 'running'; const abort = new AbortController(); abortRef.current = abort; const userMsg: ThreadMessageLike = { role: 'user', id: `u-${turnId}`, content: [{ type: 'text', text: userText }], }; setMessages((prev) => [...prev, userMsg, buildAssistant(assistantId)]); setIsRunning(true); // This run is "current" only while it still owns abortRef. Once the user // stops and starts another run, a newer onNew takes abortRef — late SSE // events from this (superseded) run must not rewrite the new message. const isCurrent = () => abortRef.current === abort; const refresh = () => { if (!isCurrent()) return; setMessages((prev) => { const next = prev.slice(); next[next.length - 1] = buildAssistant(assistantId); return next; }); }; try { // Trusted scene context from the client store — the route injects it // into the tool deps so the model never fabricates outline/content. const storeState = useStageStore.getState(); const { scenes, outlines, stage } = storeState; // The full outline list in CURRENT scene order — each scene resolved by // its stable `outlineId` (reorder/insert/delete rebalances `order`, and // inserted/duplicated scenes have no persisted outline). The persisted // `outlines` array is the original generation plan, so handing it over // raw would give regenerate-scene/-actions a stale page index / order / // title list. See resolveSceneOutline. const allOutlines = scenes.map((s) => resolveSceneOutline(s, outlines)); const sceneContextMap: SceneContextMap = {}; for (const scene of scenes) { const outline = resolveSceneOutline(scene, outlines); sceneContextMap[scene.id] = { outline, allOutlines, content: scene.content, stageId: scene.stageId, languageDirective: stage?.languageDirective, // Runtime errors the interactive iframe reported, so read_scene_content // can show the agent why a page is blank instead of it guessing. runtimeErrors: useSceneRuntimeErrors.getState().errors[scene.id], }; } // Use the same active frontend model config as course generation // (sent via x-model/* headers). Without these the server falls back to // a server-side provider, so the agent would 500 when no server key is // configured even though generation works with the user's own config. const cfg = getCurrentModelConfig(); const res = await fetch('/api/agent/edit', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-model': cfg.modelString || '', 'x-api-key': cfg.apiKey || '', 'x-base-url': cfg.baseUrl || '', 'x-provider-type': cfg.providerType || '', }, body: JSON.stringify({ message: userText, scene: opts.scene, history, sceneContextMap, // The route reads per-request thinking config from the body (not // headers), same as generation — forward it so the agent honors the // user's active thinking budget/level too. ...(cfg.thinkingConfig ? { thinkingConfig: cfg.thinkingConfig } : {}), }), signal: abort.signal, }); if (!res.ok || !res.body) throw new Error(`agent request failed: ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; for (;;) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const chunks = buffer.split('\n\n'); buffer = chunks.pop() ?? ''; for (const chunk of chunks) { const line = chunk.split('\n').find((l) => l.startsWith('data:')); if (!line) continue; const json = line.slice(5).trim(); if (!json || json === '{}') continue; let event: AgentEvent; try { event = JSON.parse(json) as AgentEvent; } catch { continue; } // Skip late events from a superseded run (don't apply stale tool // results to the stage or rewrite the new run's message). if (isCurrent()) handleEvent(event, refresh, assistantId); } } } catch (err) { // User-initiated stop — keep whatever streamed, don't surface an error. if (abort.signal.aborted) { // Only finalize if this run still owns the state (a newer run may have // taken over). Mark any tool call that never produced a result as // stopped, so its card shows a clear "stopped" state instead of a // misleading green check, and flag the message as cancelled. if (abortRef.current === abort) { for (const turn of turnsRef.current) { for (const p of turn) { if (p.type === 'toolCall' && !toolResultsRef.current.has(p.id)) { toolResultsRef.current.set(p.id, { result: { __stopped: true }, isError: true }); } } } phaseRef.current = 'cancelled'; } } else { errorRef.current = `⚠️ ${err instanceof Error ? err.message : String(err)}`; phaseRef.current = 'error'; } } finally { // If the user stopped this run and immediately started a new one, a // newer onNew has already taken over abortRef and the message list. // This (superseded) run must NOT reset isRunning or rewrite the last // message, or it would clobber the new run. const superseded = abortRef.current !== abort; if (!superseded) { abortRef.current = null; if (phaseRef.current === 'running') phaseRef.current = 'complete'; // Close any reasoning block still open (e.g. the run ended with the // last block as its final phase) so its duration is final. useThinkingTimers.getState().endAll(`${assistantId}:`, Date.now()); setIsRunning(false); setMessages((prev) => { const next = prev.slice(); next[next.length - 1] = buildAssistant(assistantId); return next; }); // The persistence save runs via the [messages, isRunning] effect once // this update commits and isRunning flips false — see above. } } }, [buildAssistant, handleEvent, opts.scene], ); // Stop the current response: abort the fetch (cancels the server stream) and // drop the running flag immediately for a snappy UI; onNew's finally then // finalizes the assistant message with whatever had already streamed. const onCancel = useCallback(async () => { abortRef.current?.abort(); setIsRunning(false); }, []); const runtime = useExternalStoreRuntime({ messages, isRunning, isSendDisabled: opts.isSendDisabled, onNew, onCancel, convertMessage: (m) => m, }); return { runtime, clearThread, hasMessages: messages.length > 0, isRunning, sessions, activeSessionId, switchSession, deleteSessionAndRefresh, refreshSessions, }; }