use-chat-sessions.ts 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492
  1. 'use client';
  2. import { useState, useCallback, useRef, useEffect } from 'react';
  3. import type {
  4. ChatSession,
  5. SessionType,
  6. SessionStatus,
  7. ChatMessageMetadata,
  8. DirectorState,
  9. } from '@/lib/types/chat';
  10. import type { DiscussionRequest } from '@/components/roundtable';
  11. import type { Action, SpotlightAction, DiscussionAction } from '@/lib/types/action';
  12. import type { UIMessage } from 'ai';
  13. import { useStageStore } from '@/lib/store';
  14. import { useCanvasStore } from '@/lib/store/canvas';
  15. import { useSettingsStore } from '@/lib/store/settings';
  16. import { useUserProfileStore } from '@/lib/store/user-profile';
  17. import { useAgentRegistry } from '@/lib/orchestration/registry/store';
  18. import { useI18n } from '@/lib/hooks/use-i18n';
  19. import { getCurrentModelConfig } from '@/lib/utils/model-config';
  20. import { USER_AVATAR } from '@/lib/types/roundtable';
  21. import { processSSEStream } from './process-sse-stream';
  22. import { StreamBuffer } from '@/lib/buffer/stream-buffer';
  23. import type { AgentStartItem, ActionItem } from '@/lib/buffer/stream-buffer';
  24. import { ActionEngine } from '@/lib/action/engine';
  25. import { toast } from 'sonner';
  26. import { createLogger } from '@/lib/logger';
  27. const log = createLogger('ChatSessions');
  28. interface UseChatSessionsOptions {
  29. onLiveSpeech?: (text: string | null, agentId?: string | null) => void;
  30. onSpeechProgress?: (ratio: number | null) => void;
  31. onThinking?: (state: { stage: string; agentId?: string } | null) => void;
  32. onCueUser?: (fromAgentId?: string, prompt?: string) => void;
  33. onActiveBubble?: (messageId: string | null) => void;
  34. onLiveSessionError?: () => void;
  35. /** Called when a QA/Discussion session completes naturally (director end). */
  36. onStopSession?: () => void;
  37. onSegmentSealed?: (
  38. messageId: string,
  39. partId: string,
  40. fullText: string,
  41. agentId: string | null,
  42. ) => void;
  43. /** When provided and returns true, StreamBuffer holds on the current text item after reveal. */
  44. shouldHoldAfterReveal?: () => { holding: boolean; segmentDone: number } | boolean;
  45. }
  46. export function useChatSessions(options: UseChatSessionsOptions = {}) {
  47. const onLiveSpeechRef = useRef(options.onLiveSpeech);
  48. const onSpeechProgressRef = useRef(options.onSpeechProgress);
  49. const onThinkingRef = useRef(options.onThinking);
  50. const onCueUserRef = useRef(options.onCueUser);
  51. const onActiveBubbleRef = useRef(options.onActiveBubble);
  52. const onLiveSessionErrorRef = useRef(options.onLiveSessionError);
  53. const onStopSessionRef = useRef(options.onStopSession);
  54. const onSegmentSealedRef = useRef(options.onSegmentSealed);
  55. const shouldHoldAfterRevealRef = useRef(options.shouldHoldAfterReveal);
  56. useEffect(() => {
  57. onLiveSpeechRef.current = options.onLiveSpeech;
  58. onSpeechProgressRef.current = options.onSpeechProgress;
  59. onThinkingRef.current = options.onThinking;
  60. onCueUserRef.current = options.onCueUser;
  61. onActiveBubbleRef.current = options.onActiveBubble;
  62. onLiveSessionErrorRef.current = options.onLiveSessionError;
  63. onStopSessionRef.current = options.onStopSession;
  64. onSegmentSealedRef.current = options.onSegmentSealed;
  65. shouldHoldAfterRevealRef.current = options.shouldHoldAfterReveal;
  66. }, [
  67. options.onLiveSpeech,
  68. options.onSpeechProgress,
  69. options.onThinking,
  70. options.onCueUser,
  71. options.onActiveBubble,
  72. options.onLiveSessionError,
  73. options.onStopSession,
  74. options.onSegmentSealed,
  75. options.shouldHoldAfterReveal,
  76. ]);
  77. const { t } = useI18n();
  78. // Track current stageId for data isolation
  79. const stageId = useStageStore((s) => s.stage?.id);
  80. const stageIdRef = useRef(stageId);
  81. const [sessions, setSessions] = useState<ChatSession[]>(() => {
  82. // Restore sessions from store (loaded from IndexedDB)
  83. const stored = useStageStore.getState().chats;
  84. return stored.map((s) =>
  85. s.status === 'active' ? { ...s, status: 'interrupted' as SessionStatus } : s,
  86. );
  87. });
  88. const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
  89. const [expandedSessionIds, setExpandedSessionIds] = useState<Set<string>>(new Set());
  90. const [isStreaming, setIsStreaming] = useState(false);
  91. const abortControllerRef = useRef<AbortController | null>(null);
  92. const streamingSessionIdRef = useRef<string | null>(null);
  93. const sessionsRef = useRef<ChatSession[]>(sessions);
  94. useEffect(() => {
  95. sessionsRef.current = sessions;
  96. }, [sessions]);
  97. // Per-loop-iteration state — tracks done event data and cue_user for the agent loop
  98. const loopDoneDataRef = useRef<{
  99. directorState?: DirectorState;
  100. totalAgents: number;
  101. agentHadContent?: boolean;
  102. cueUserReceived: boolean;
  103. } | null>(null);
  104. // Reload sessions when stage changes (course switch)
  105. // This synchronous setState is intentional: it resets derived state from
  106. // an external store (IndexedDB) when the stageId dependency changes.
  107. useEffect(() => {
  108. if (stageId === stageIdRef.current) return;
  109. stageIdRef.current = stageId;
  110. // Stage changed — reload sessions from store (already populated by loadFromStorage)
  111. const stored = useStageStore.getState().chats;
  112. setSessions(
  113. stored.map((s) =>
  114. s.status === 'active' ? { ...s, status: 'interrupted' as SessionStatus } : s,
  115. ),
  116. );
  117. setActiveSessionId(null);
  118. setExpandedSessionIds(new Set());
  119. }, [stageId]);
  120. // Sync sessions back to store for persistence (debounced via store's debouncedSave)
  121. // Guard: only write to the currently active stage
  122. useEffect(() => {
  123. if (stageIdRef.current && stageIdRef.current === useStageStore.getState().stage?.id) {
  124. useStageStore.getState().setChats(sessions);
  125. }
  126. }, [sessions]);
  127. // StreamBuffer instances per session (SSE + lecture share the same buffer model)
  128. const buffersRef = useRef<Map<string, StreamBuffer>>(new Map());
  129. // Abort active stream and destroy buffers on unmount
  130. useEffect(() => {
  131. const buffers = buffersRef.current;
  132. return () => {
  133. if (abortControllerRef.current) {
  134. abortControllerRef.current.abort();
  135. abortControllerRef.current = null;
  136. }
  137. buffers.forEach((buf) => buf.shutdown());
  138. buffers.clear();
  139. };
  140. }, []);
  141. // Session-scoped "paused intent" — survives buffer recreation across turns.
  142. // When true, newly created discussion/QA buffers are immediately paused.
  143. const livePausedRef = useRef(false);
  144. const clearLiveSessionAfterError = useCallback((sessionId: string, message: string) => {
  145. const now = Date.now();
  146. const errorMessageId = `error-${now}`;
  147. const buf = buffersRef.current.get(sessionId);
  148. if (buf) {
  149. buf.shutdown();
  150. buffersRef.current.delete(sessionId);
  151. }
  152. setSessions((prev) =>
  153. prev.map((s) =>
  154. s.id === sessionId
  155. ? {
  156. ...s,
  157. updatedAt: now,
  158. messages: [
  159. ...s.messages,
  160. {
  161. id: errorMessageId,
  162. role: 'assistant' as const,
  163. parts: [{ type: 'text', text: message }],
  164. metadata: {
  165. senderName: 'System',
  166. originalRole: 'agent' as const,
  167. createdAt: now,
  168. },
  169. },
  170. ],
  171. }
  172. : s,
  173. ),
  174. );
  175. onActiveBubbleRef.current?.(null);
  176. if (onLiveSessionErrorRef.current) {
  177. onLiveSessionErrorRef.current();
  178. } else {
  179. onSpeechProgressRef.current?.(null);
  180. onThinkingRef.current?.(null);
  181. onLiveSpeechRef.current?.(null, null);
  182. }
  183. }, []);
  184. // Tracks the single message ID per lecture session
  185. const lectureMessageIds = useRef<Map<string, string>>(new Map());
  186. // Tracks last action index per lecture session (avoids stale closure reads)
  187. const lectureLastActionIndexRef = useRef<Map<string, number>>(new Map());
  188. const toggleSessionExpand = useCallback((sessionId: string) => {
  189. setExpandedSessionIds((prev) => {
  190. const next = new Set(prev);
  191. if (next.has(sessionId)) {
  192. next.delete(sessionId);
  193. } else {
  194. next.add(sessionId);
  195. }
  196. return next;
  197. });
  198. }, []);
  199. /**
  200. * Create a StreamBuffer for a session and wire its callbacks to React state.
  201. * Returns the buffer instance (also stored in buffersRef).
  202. */
  203. const createBufferForSession = useCallback(
  204. (sessionId: string, type?: SessionType): StreamBuffer => {
  205. // Dispose previous buffer if any
  206. // Shutdown (not dispose) — avoids stale onLiveSpeech(null,null) callback
  207. const prev = buffersRef.current.get(sessionId);
  208. if (prev) prev.shutdown();
  209. // For discussion/QA sessions, add pacing delays so fast models don't
  210. // rush through text and actions. Lecture pacing is handled by PlaybackEngine.
  211. const pacingOptions = type === 'lecture' ? {} : { postTextDelayMs: 1200, actionDelayMs: 800 };
  212. const buffer = new StreamBuffer(
  213. {
  214. onAgentStart(data: AgentStartItem) {
  215. const now = Date.now();
  216. const agentConfig = useAgentRegistry.getState().getAgent(data.agentId);
  217. const newMsg: UIMessage<ChatMessageMetadata> = {
  218. id: data.messageId,
  219. role: 'assistant',
  220. parts: [],
  221. metadata: {
  222. senderName: agentConfig?.name || data.agentName,
  223. senderAvatar: data.avatar || agentConfig?.avatar,
  224. originalRole: 'agent',
  225. agentId: data.agentId,
  226. createdAt: now,
  227. },
  228. };
  229. setSessions((prev) =>
  230. prev.map((s) =>
  231. s.id === sessionId
  232. ? { ...s, messages: [...s.messages, newMsg], updatedAt: now }
  233. : s,
  234. ),
  235. );
  236. onActiveBubbleRef.current?.(data.messageId);
  237. },
  238. onAgentEnd() {
  239. // Remove empty assistant messages (agent started but produced no content)
  240. setSessions((prev) =>
  241. prev.map((s) => {
  242. if (s.id !== sessionId) return s;
  243. const msgs = s.messages.filter(
  244. (m) => !(m.role === 'assistant' && m.parts.length === 0),
  245. );
  246. return msgs.length !== s.messages.length ? { ...s, messages: msgs } : s;
  247. }),
  248. );
  249. },
  250. onTextReveal(
  251. messageId: string,
  252. partId: string,
  253. revealedText: string,
  254. _isComplete: boolean,
  255. ) {
  256. setSessions((prev) =>
  257. prev.map((s) => {
  258. if (s.id !== sessionId) return s;
  259. return {
  260. ...s,
  261. messages: s.messages.map((m) => {
  262. if (m.id !== messageId) return m;
  263. const parts = [...m.parts];
  264. // Match by _partId (supports multiple text parts per message, e.g. lecture)
  265. const existingIdx = parts.findIndex(
  266. (p) => (p as unknown as Record<string, unknown>)._partId === partId,
  267. );
  268. if (existingIdx >= 0) {
  269. parts[existingIdx] = {
  270. type: 'text',
  271. text: revealedText,
  272. _partId: partId,
  273. } as UIMessage<ChatMessageMetadata>['parts'][number];
  274. } else {
  275. parts.push({
  276. type: 'text',
  277. text: revealedText,
  278. _partId: partId,
  279. } as UIMessage<ChatMessageMetadata>['parts'][number]);
  280. }
  281. return { ...m, parts };
  282. }),
  283. // Don't update updatedAt on every tick — avoids thrashing persistence sync
  284. };
  285. }),
  286. );
  287. },
  288. onActionReady(messageId: string, data: ActionItem) {
  289. // Add action badge to message parts
  290. const actionPart = {
  291. type: `action-${data.actionName}`,
  292. actionId: data.actionId,
  293. actionName: data.actionName,
  294. input: data.params,
  295. state: 'result',
  296. output: { success: true },
  297. } as unknown as UIMessage<ChatMessageMetadata>['parts'][number];
  298. setSessions((prev) =>
  299. prev.map((s) => {
  300. if (s.id !== sessionId) return s;
  301. return {
  302. ...s,
  303. messages: s.messages.map((m) =>
  304. m.id === messageId ? { ...m, parts: [...m.parts, actionPart] } : m,
  305. ),
  306. updatedAt: Date.now(),
  307. };
  308. }),
  309. );
  310. // Execute the action via ActionEngine (fire-and-forget for visual effects)
  311. try {
  312. const actionEngine = new ActionEngine(useStageStore);
  313. const action = {
  314. id: data.actionId,
  315. type: data.actionName,
  316. ...data.params,
  317. } as Action;
  318. actionEngine.execute(action);
  319. } catch (err) {
  320. log.warn('[Buffer] Action execution error:', err);
  321. }
  322. },
  323. onLiveSpeech(text: string | null, agentId: string | null) {
  324. // Lecture sessions: roundtable text is managed by PlaybackEngine → setLectureSpeech
  325. // in stage.tsx. Buffer only drives chat area pacing for lectures.
  326. if (type === 'lecture') return;
  327. onLiveSpeechRef.current?.(text, agentId);
  328. },
  329. onSpeechProgress(ratio: number | null) {
  330. onSpeechProgressRef.current?.(ratio);
  331. },
  332. onThinking(data: { stage: string; agentId?: string } | null) {
  333. onThinkingRef.current?.(data);
  334. },
  335. onCueUser(fromAgentId?: string, prompt?: string) {
  336. // Track cue_user for agent loop
  337. if (loopDoneDataRef.current) {
  338. loopDoneDataRef.current.cueUserReceived = true;
  339. } else {
  340. loopDoneDataRef.current = {
  341. totalAgents: 0,
  342. cueUserReceived: true,
  343. };
  344. }
  345. onCueUserRef.current?.(fromAgentId, prompt);
  346. },
  347. onDone(data: {
  348. totalActions: number;
  349. totalAgents: number;
  350. agentHadContent?: boolean;
  351. directorState?: DirectorState;
  352. }) {
  353. // Store done data for agent loop consumption
  354. loopDoneDataRef.current = {
  355. directorState: data.directorState,
  356. totalAgents: data.totalAgents,
  357. agentHadContent: data.agentHadContent ?? true,
  358. cueUserReceived: loopDoneDataRef.current?.cueUserReceived ?? false,
  359. };
  360. // Session completion is handled by runAgentLoop, not here
  361. // (Lectures don't use the agent loop and complete via endSession)
  362. },
  363. onError(message: string) {
  364. log.error('[Buffer] Stream error:', message);
  365. },
  366. onSegmentSealed(
  367. messageId: string,
  368. partId: string,
  369. fullText: string,
  370. agentId: string | null,
  371. ) {
  372. onSegmentSealedRef.current?.(messageId, partId, fullText, agentId);
  373. },
  374. shouldHoldAfterReveal() {
  375. return shouldHoldAfterRevealRef.current?.() ?? (false as const);
  376. },
  377. },
  378. pacingOptions,
  379. );
  380. buffersRef.current.set(sessionId, buffer);
  381. buffer.start();
  382. // Inherit paused intent for discussion/QA sessions so new-turn buffers
  383. // don't start revealing text while the user has paused reading.
  384. if (type !== 'lecture' && livePausedRef.current) {
  385. buffer.pause();
  386. }
  387. return buffer;
  388. },
  389. [],
  390. );
  391. /**
  392. * Frontend-driven agent loop. Sends per-agent requests until:
  393. * - Director returns END (no agent spoke, no cue_user)
  394. * - Director returns USER (cue_user event received)
  395. * - maxTurns reached
  396. * - Request aborted
  397. *
  398. * Each iteration: POST /api/chat → process SSE → wait for buffer drain → check outcome.
  399. */
  400. const runAgentLoop = useCallback(
  401. async (
  402. sessionId: string,
  403. requestTemplate: {
  404. messages: UIMessage<ChatMessageMetadata>[];
  405. storeState: Record<string, unknown>;
  406. config: {
  407. agentIds: string[];
  408. sessionType?: string;
  409. agentConfigs?: Record<string, unknown>[];
  410. [key: string]: unknown;
  411. };
  412. userProfile?: { nickname?: string; bio?: string };
  413. apiKey: string;
  414. baseUrl?: string;
  415. model?: string;
  416. providerType?: string;
  417. },
  418. controller: AbortController,
  419. sessionType: SessionType,
  420. ): Promise<void> => {
  421. const settingsState = useSettingsStore.getState();
  422. // Attach full configs for generated (non-default) agents so the server can use them.
  423. // The server-side registry only has default agents; generated agents exist only client-side.
  424. const generatedConfigs = requestTemplate.config.agentIds
  425. .filter((id: string) => !id.startsWith('default-'))
  426. .map((id: string) => useAgentRegistry.getState().getAgent(id))
  427. .filter((agent): agent is NonNullable<typeof agent> => Boolean(agent))
  428. .map(({ createdAt: _c, updatedAt: _u, isDefault: _d, ...rest }) => rest);
  429. if (generatedConfigs.length > 0) {
  430. requestTemplate.config.agentConfigs = generatedConfigs;
  431. }
  432. const defaultMaxTurns = requestTemplate.config.agentIds.length <= 1 ? 1 : 10;
  433. const maxTurns = settingsState.maxTurns
  434. ? parseInt(settingsState.maxTurns, 10) || defaultMaxTurns
  435. : defaultMaxTurns;
  436. let directorState: DirectorState | undefined = undefined;
  437. let turnCount = 0;
  438. let currentMessages = requestTemplate.messages;
  439. let consecutiveEmptyTurns = 0;
  440. while (turnCount < maxTurns) {
  441. if (controller.signal.aborted) break;
  442. // Reset loop state for this iteration
  443. loopDoneDataRef.current = null;
  444. // Refresh store state each iteration — agent actions may have changed
  445. // whiteboard, scene, or mode between turns
  446. const freshState = useStageStore.getState();
  447. const freshStoreState = {
  448. stage: freshState.stage,
  449. scenes: freshState.scenes,
  450. currentSceneId: freshState.currentSceneId,
  451. mode: freshState.mode,
  452. whiteboardOpen: useCanvasStore.getState().whiteboardOpen,
  453. };
  454. const response = await fetch('/api/chat', {
  455. method: 'POST',
  456. headers: { 'Content-Type': 'application/json' },
  457. body: JSON.stringify({
  458. ...requestTemplate,
  459. messages: currentMessages,
  460. storeState: freshStoreState,
  461. directorState,
  462. }),
  463. signal: controller.signal,
  464. });
  465. if (!response.ok) {
  466. const errorText = await response.text();
  467. throw new Error(`API error: ${response.status} - ${errorText}`);
  468. }
  469. const buffer = createBufferForSession(sessionId, sessionType);
  470. await processSSEStream(response, sessionId, buffer, controller.signal);
  471. // Wait for buffer to finish playing all items (character animations, delays)
  472. try {
  473. await buffer.waitUntilDrained();
  474. } catch {
  475. // Buffer was disposed/shutdown (abort or session end) — exit loop
  476. break;
  477. }
  478. if (controller.signal.aborted) break;
  479. // Read loop outcome from done data.
  480. // loopDoneDataRef is mutated by StreamBuffer callbacks (onDone, onCueUser);
  481. // TypeScript's CFA can't track cross-callback mutations.
  482. const doneData = loopDoneDataRef.current as {
  483. directorState?: DirectorState;
  484. totalAgents: number;
  485. agentHadContent?: boolean;
  486. cueUserReceived: boolean;
  487. } | null;
  488. if (!doneData) break; // No done event — something went wrong
  489. // Update accumulated director state
  490. directorState = doneData.directorState;
  491. turnCount = directorState?.turnCount ?? turnCount + 1;
  492. // Check outcome
  493. if (doneData.cueUserReceived) {
  494. // Director said USER — stop loop, wait for user input
  495. break;
  496. }
  497. if (doneData.totalAgents === 0) {
  498. // Director said END — no agent spoke, conversation complete
  499. break;
  500. }
  501. // Track consecutive empty responses (agent dispatched but produced no content)
  502. if (doneData.agentHadContent === false) {
  503. consecutiveEmptyTurns++;
  504. if (consecutiveEmptyTurns >= 2) {
  505. log.warn(
  506. `[AgentLoop] ${consecutiveEmptyTurns} consecutive empty agent responses, stopping loop`,
  507. );
  508. break;
  509. }
  510. } else {
  511. consecutiveEmptyTurns = 0;
  512. }
  513. // Agent spoke — continue loop if under maxTurns
  514. // Refresh messages from latest session state for next iteration
  515. const currentSession = sessionsRef.current.find((s) => s.id === sessionId);
  516. if (currentSession) {
  517. currentMessages = currentSession.messages;
  518. }
  519. }
  520. // Handle loop completion
  521. const doneData = loopDoneDataRef.current;
  522. if (!controller.signal.aborted) {
  523. const wasCueUser = doneData?.cueUserReceived ?? false;
  524. if (!wasCueUser) {
  525. // Session completed normally (END or maxTurns reached)
  526. setSessions((prev) =>
  527. prev.map((s) =>
  528. s.id === sessionId
  529. ? {
  530. ...s,
  531. status: 'completed' as SessionStatus,
  532. updatedAt: Date.now(),
  533. }
  534. : s,
  535. ),
  536. );
  537. onStopSessionRef.current?.();
  538. }
  539. // If maxTurns reached, log it
  540. if (turnCount >= maxTurns && doneData && doneData.totalAgents > 0) {
  541. log.info(`[AgentLoop] Max turns (${maxTurns}) reached for session ${sessionId}`);
  542. }
  543. }
  544. },
  545. [createBufferForSession],
  546. );
  547. /**
  548. * Create a new chat session
  549. */
  550. const createSession = useCallback(async (type: SessionType, title: string): Promise<string> => {
  551. const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2)}`;
  552. const now = Date.now();
  553. const newSession: ChatSession = {
  554. id: sessionId,
  555. type,
  556. title,
  557. status: 'active',
  558. messages: [],
  559. config: {
  560. agentIds: ['default-1'],
  561. maxTurns: 0, // Not used for runtime — frontend loop manages maxTurns
  562. currentTurn: 0,
  563. defaultAgentId: 'default-1',
  564. },
  565. toolCalls: [],
  566. pendingToolCalls: [],
  567. createdAt: now,
  568. updatedAt: now,
  569. };
  570. setSessions((prev) => [...prev, newSession]);
  571. setActiveSessionId(sessionId);
  572. setExpandedSessionIds((prev) => new Set([...prev, sessionId]));
  573. log.info(`[ChatArea] Created session: ${sessionId} (${type})`);
  574. return sessionId;
  575. }, []);
  576. /**
  577. * End a chat session.
  578. * For QA/Discussion sessions with active streaming, appends "..." + interrupted marker.
  579. */
  580. const endSession = useCallback(
  581. async (sessionId: string): Promise<void> => {
  582. log.info(`[ChatArea] Ending session: ${sessionId}`);
  583. livePausedRef.current = false;
  584. const session = sessionsRef.current.find((s) => s.id === sessionId);
  585. const isLiveSession = session && (session.type === 'qa' || session.type === 'discussion');
  586. const wasStreaming = !!(
  587. abortControllerRef.current && streamingSessionIdRef.current === sessionId
  588. );
  589. // Only abort if this session owns the active stream
  590. if (wasStreaming) {
  591. abortControllerRef.current!.abort();
  592. abortControllerRef.current = null;
  593. streamingSessionIdRef.current = null;
  594. setIsStreaming(false);
  595. }
  596. // Destroy buffer — shutdown avoids firing stale onLiveSpeech(null,null)
  597. const buf = buffersRef.current.get(sessionId);
  598. if (buf) {
  599. buf.shutdown();
  600. buffersRef.current.delete(sessionId);
  601. }
  602. lectureMessageIds.current.delete(sessionId);
  603. lectureLastActionIndexRef.current.delete(sessionId);
  604. if (isLiveSession && wasStreaming) {
  605. // Append "..." + interrupted marker to last assistant message
  606. setSessions((prev) =>
  607. prev.map((s) => {
  608. if (s.id !== sessionId) return s;
  609. const messages = [...s.messages];
  610. for (let i = messages.length - 1; i >= 0; i--) {
  611. if (messages[i].role === 'assistant') {
  612. const parts = [...messages[i].parts];
  613. let appended = false;
  614. for (let j = parts.length - 1; j >= 0; j--) {
  615. if (parts[j].type === 'text') {
  616. const textPart = parts[j] as { type: 'text'; text: string };
  617. parts[j] = {
  618. type: 'text',
  619. text: (textPart.text || '') + '...',
  620. } as UIMessage<ChatMessageMetadata>['parts'][number];
  621. appended = true;
  622. break;
  623. }
  624. }
  625. if (!appended) {
  626. parts.push({
  627. type: 'text',
  628. text: '...',
  629. } as UIMessage<ChatMessageMetadata>['parts'][number]);
  630. }
  631. messages[i] = {
  632. ...messages[i],
  633. parts,
  634. metadata: { ...messages[i].metadata, interrupted: true },
  635. };
  636. break;
  637. }
  638. }
  639. return { ...s, messages, status: 'completed' as SessionStatus };
  640. }),
  641. );
  642. // Clear roundtable state via callbacks
  643. onLiveSpeechRef.current?.(null, null);
  644. onThinkingRef.current?.(null);
  645. } else {
  646. setSessions((prev) =>
  647. prev.map((s) =>
  648. s.id === sessionId ? { ...s, status: 'completed' as SessionStatus } : s,
  649. ),
  650. );
  651. }
  652. if (activeSessionId === sessionId) {
  653. setActiveSessionId(null);
  654. }
  655. },
  656. [activeSessionId],
  657. );
  658. /**
  659. * End the currently active QA/Discussion session (if any).
  660. */
  661. const endActiveSession = useCallback(async (): Promise<void> => {
  662. const active = sessionsRef.current.find(
  663. (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active',
  664. );
  665. if (active) {
  666. await endSession(active.id);
  667. }
  668. }, [endSession]);
  669. /**
  670. * Soft-pause the active QA/Discussion session.
  671. * Aborts SSE and appends "..." + interrupted marker, but keeps session 'active'
  672. * so the user can continue speaking in the same topic.
  673. */
  674. const softPauseSession = useCallback(async (sessionId: string): Promise<void> => {
  675. livePausedRef.current = false;
  676. const session = sessionsRef.current.find((s) => s.id === sessionId);
  677. if (!session) return;
  678. const isLiveSession = session.type === 'qa' || session.type === 'discussion';
  679. if (!isLiveSession || session.status !== 'active') return;
  680. const wasStreaming = !!(
  681. abortControllerRef.current && streamingSessionIdRef.current === sessionId
  682. );
  683. // Destroy buffer — no more ticks, no stale onDone/onLiveSpeech callbacks.
  684. // Resume will create a fresh buffer.
  685. const buf = buffersRef.current.get(sessionId);
  686. if (buf) {
  687. buf.shutdown();
  688. buffersRef.current.delete(sessionId);
  689. }
  690. // Abort SSE stream
  691. if (wasStreaming) {
  692. abortControllerRef.current!.abort();
  693. abortControllerRef.current = null;
  694. streamingSessionIdRef.current = null;
  695. setIsStreaming(false);
  696. }
  697. if (wasStreaming) {
  698. // Append "..." + interrupted marker to last assistant message, keep status 'active'
  699. setSessions((prev) =>
  700. prev.map((s) => {
  701. if (s.id !== sessionId) return s;
  702. const messages = [...s.messages];
  703. for (let i = messages.length - 1; i >= 0; i--) {
  704. if (messages[i].role === 'assistant') {
  705. const parts = [...messages[i].parts];
  706. let appended = false;
  707. for (let j = parts.length - 1; j >= 0; j--) {
  708. if (parts[j].type === 'text') {
  709. const textPart = parts[j] as { type: 'text'; text: string };
  710. parts[j] = {
  711. type: 'text',
  712. text: (textPart.text || '') + '...',
  713. } as UIMessage<ChatMessageMetadata>['parts'][number];
  714. appended = true;
  715. break;
  716. }
  717. }
  718. if (!appended) {
  719. parts.push({
  720. type: 'text',
  721. text: '...',
  722. } as UIMessage<ChatMessageMetadata>['parts'][number]);
  723. }
  724. messages[i] = {
  725. ...messages[i],
  726. parts,
  727. metadata: { ...messages[i].metadata, interrupted: true },
  728. };
  729. break;
  730. }
  731. }
  732. // Keep status 'active' — session continues when user speaks
  733. return { ...s, messages, updatedAt: Date.now() };
  734. }),
  735. );
  736. // Note: Do NOT call onLiveSpeech/onThinking here.
  737. // Caller (doSoftPause) manages roundtable state to keep the interrupted bubble visible.
  738. }
  739. log.info(`[ChatArea] Soft-paused session: ${sessionId}`);
  740. }, []);
  741. /**
  742. * Soft-pause the currently active QA/Discussion session (if any).
  743. */
  744. const softPauseActiveSession = useCallback(async (): Promise<void> => {
  745. const active = sessionsRef.current.find(
  746. (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active',
  747. );
  748. if (active) {
  749. await softPauseSession(active.id);
  750. }
  751. }, [softPauseSession]);
  752. /**
  753. * Resume a soft-paused session by re-calling /chat with existing messages.
  754. * The director will pick the next agent to continue the topic.
  755. */
  756. const resumeSession = useCallback(
  757. async (sessionId: string): Promise<void> => {
  758. const session = sessionsRef.current.find((s) => s.id === sessionId);
  759. if (!session || session.status !== 'active') return;
  760. const controller = new AbortController();
  761. abortControllerRef.current = controller;
  762. streamingSessionIdRef.current = sessionId;
  763. setIsStreaming(true);
  764. const currentState = useStageStore.getState();
  765. try {
  766. log.info(`[ChatArea] Resuming session: ${sessionId}`);
  767. const userProfileState = useUserProfileStore.getState();
  768. const mc = getCurrentModelConfig();
  769. const agentIds =
  770. useSettingsStore.getState().selectedAgentIds?.length > 0
  771. ? useSettingsStore.getState().selectedAgentIds
  772. : session.config.agentIds;
  773. await runAgentLoop(
  774. sessionId,
  775. {
  776. messages: session.messages,
  777. storeState: {
  778. stage: currentState.stage,
  779. scenes: currentState.scenes,
  780. currentSceneId: currentState.currentSceneId,
  781. mode: currentState.mode,
  782. whiteboardOpen: useCanvasStore.getState().whiteboardOpen,
  783. },
  784. config: {
  785. agentIds,
  786. sessionType: session.type,
  787. },
  788. userProfile: {
  789. nickname: userProfileState.nickname || undefined,
  790. bio: userProfileState.bio || undefined,
  791. },
  792. apiKey: mc.apiKey,
  793. baseUrl: mc.baseUrl,
  794. model: mc.modelString,
  795. providerType: mc.providerType,
  796. },
  797. controller,
  798. session.type,
  799. );
  800. } catch (error) {
  801. if (error instanceof DOMException && error.name === 'AbortError') {
  802. log.info('[ChatArea] Resume aborted');
  803. return;
  804. }
  805. log.error('[ChatArea] Resume error:', error);
  806. clearLiveSessionAfterError(
  807. sessionId,
  808. `Error: ${error instanceof Error ? error.message : String(error)}`,
  809. );
  810. } finally {
  811. if (abortControllerRef.current === controller) {
  812. abortControllerRef.current = null;
  813. streamingSessionIdRef.current = null;
  814. setIsStreaming(false);
  815. }
  816. }
  817. },
  818. [clearLiveSessionAfterError, runAgentLoop],
  819. );
  820. /**
  821. * Resume the currently active soft-paused session (if any).
  822. */
  823. const resumeActiveSession = useCallback(async (): Promise<void> => {
  824. const active = sessionsRef.current.find(
  825. (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active',
  826. );
  827. if (active) {
  828. await resumeSession(active.id);
  829. }
  830. }, [resumeSession]);
  831. /**
  832. * Send a message to the active session
  833. */
  834. const sendMessage = useCallback(
  835. async (content: string): Promise<void> => {
  836. let sessionId = activeSessionId;
  837. // Interrupt active generation: abort stream and append "..." to the last agent message
  838. if (isStreaming && abortControllerRef.current) {
  839. abortControllerRef.current.abort();
  840. abortControllerRef.current = null;
  841. if (sessionId) {
  842. setSessions((prev) =>
  843. prev.map((s) => {
  844. if (s.id !== sessionId) return s;
  845. const messages = [...s.messages];
  846. for (let i = messages.length - 1; i >= 0; i--) {
  847. if (messages[i].role === 'assistant') {
  848. const parts = [...messages[i].parts];
  849. for (let j = parts.length - 1; j >= 0; j--) {
  850. if (parts[j].type === 'text') {
  851. const textPart = parts[j] as {
  852. type: 'text';
  853. text: string;
  854. };
  855. parts[j] = {
  856. type: 'text',
  857. text: (textPart.text || '') + '...',
  858. } as UIMessage<ChatMessageMetadata>['parts'][number];
  859. messages[i] = { ...messages[i], parts };
  860. return { ...s, messages, updatedAt: Date.now() };
  861. }
  862. }
  863. break;
  864. }
  865. }
  866. return s;
  867. }),
  868. );
  869. }
  870. }
  871. // Validate model configuration before sending
  872. const modelConfig = getCurrentModelConfig();
  873. if (!modelConfig.modelId) {
  874. toast.error(t('settings.modelNotConfigured'));
  875. return;
  876. }
  877. if (modelConfig.requiresApiKey && !modelConfig.apiKey && !modelConfig.isServerConfigured) {
  878. toast.error(t('settings.setupNeeded'), {
  879. description: t('settings.apiKeyDesc'),
  880. });
  881. return;
  882. }
  883. // Create a new session when there's no active QA session to append to.
  884. // A completed session should NOT be reused — start a fresh one instead.
  885. const activeSession = sessionsRef.current.find((s) => s.id === sessionId);
  886. const needNewSession =
  887. !sessionId || activeSession?.type === 'lecture' || activeSession?.status === 'completed';
  888. if (needNewSession) {
  889. // End all active QA/Discussion sessions before creating new one
  890. const activeQAOrDiscussion = sessionsRef.current.filter(
  891. (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active',
  892. );
  893. for (const session of activeQAOrDiscussion) {
  894. await endSession(session.id);
  895. }
  896. sessionId = await createSession('qa', 'Q&A');
  897. }
  898. const controller = new AbortController();
  899. abortControllerRef.current = controller;
  900. streamingSessionIdRef.current = sessionId;
  901. setIsStreaming(true);
  902. const now = Date.now();
  903. const userMessageId = `user-${now}`;
  904. // Read all selected agent IDs from settings store
  905. const settingsState = useSettingsStore.getState();
  906. const agentIds: string[] =
  907. settingsState.selectedAgentIds?.length > 0 ? settingsState.selectedAgentIds : ['default-1'];
  908. const userMessage: UIMessage<ChatMessageMetadata> = {
  909. id: userMessageId,
  910. role: 'user',
  911. parts: [{ type: 'text', text: content }],
  912. metadata: {
  913. senderName: t('common.you'),
  914. senderAvatar: USER_AVATAR,
  915. originalRole: 'user',
  916. createdAt: now,
  917. },
  918. };
  919. // Read current session data from ref (avoids stale closure AND keeps updater pure)
  920. const existingSession = sessionsRef.current.find((s) => s.id === sessionId);
  921. const sessionMessages: UIMessage<ChatMessageMetadata>[] = existingSession
  922. ? [...existingSession.messages, userMessage]
  923. : [userMessage];
  924. const sessionType: SessionType = existingSession?.type || 'qa';
  925. // Pure updater — no side effects
  926. setSessions((prev) => {
  927. const exists = prev.some((s) => s.id === sessionId);
  928. if (exists) {
  929. return prev.map((s) =>
  930. s.id === sessionId
  931. ? {
  932. ...s,
  933. messages: [...s.messages, userMessage],
  934. status: 'active' as SessionStatus,
  935. updatedAt: now,
  936. }
  937. : s,
  938. );
  939. } else {
  940. const newSession: ChatSession = {
  941. id: sessionId!,
  942. type: 'qa',
  943. title: 'Q&A',
  944. status: 'active',
  945. messages: [userMessage],
  946. config: {
  947. agentIds,
  948. maxTurns: 0, // Not used for runtime — frontend loop manages maxTurns
  949. currentTurn: 0,
  950. defaultAgentId: agentIds[0],
  951. },
  952. toolCalls: [],
  953. pendingToolCalls: [],
  954. createdAt: now,
  955. updatedAt: now,
  956. };
  957. return [...prev, newSession];
  958. }
  959. });
  960. const currentState = useStageStore.getState();
  961. try {
  962. log.info(
  963. `[ChatArea] Sending message: "${content.slice(0, 50)}..." agents: ${agentIds.join(', ')}`,
  964. );
  965. const userProfileState = useUserProfileStore.getState();
  966. const mc = getCurrentModelConfig();
  967. await runAgentLoop(
  968. sessionId!,
  969. {
  970. messages: sessionMessages,
  971. storeState: {
  972. stage: currentState.stage,
  973. scenes: currentState.scenes,
  974. currentSceneId: currentState.currentSceneId,
  975. mode: currentState.mode,
  976. whiteboardOpen: useCanvasStore.getState().whiteboardOpen,
  977. },
  978. config: {
  979. agentIds,
  980. sessionType,
  981. },
  982. userProfile: {
  983. nickname: userProfileState.nickname || undefined,
  984. bio: userProfileState.bio || undefined,
  985. },
  986. apiKey: mc.apiKey,
  987. baseUrl: mc.baseUrl,
  988. model: mc.modelString,
  989. providerType: mc.providerType,
  990. },
  991. controller,
  992. sessionType,
  993. );
  994. } catch (error) {
  995. // Ignore AbortError — it's intentional (user interrupted)
  996. if (error instanceof DOMException && error.name === 'AbortError') {
  997. log.info('[ChatArea] Request aborted by user');
  998. return;
  999. }
  1000. log.error('[ChatArea] Error:', error);
  1001. clearLiveSessionAfterError(
  1002. sessionId!,
  1003. `Error: ${error instanceof Error ? error.message : String(error)}`,
  1004. );
  1005. } finally {
  1006. // Only clean up if this is still the active controller (avoid race with interrupt)
  1007. if (abortControllerRef.current === controller) {
  1008. abortControllerRef.current = null;
  1009. streamingSessionIdRef.current = null;
  1010. setIsStreaming(false);
  1011. }
  1012. }
  1013. },
  1014. [
  1015. activeSessionId,
  1016. clearLiveSessionAfterError,
  1017. isStreaming,
  1018. createSession,
  1019. endSession,
  1020. runAgentLoop,
  1021. t,
  1022. ],
  1023. );
  1024. /**
  1025. * Start a discussion with agent speaking first
  1026. */
  1027. const startDiscussion = useCallback(
  1028. async (request: DiscussionRequest): Promise<void> => {
  1029. log.info(`[ChatArea] Starting discussion: "${request.topic}"`);
  1030. // Explicitly clear buffer-pause intent (also cleared transitively via endSession,
  1031. // but being explicit guards against future refactors)
  1032. livePausedRef.current = false;
  1033. // Validate model configuration before starting discussion
  1034. const modelConfig = getCurrentModelConfig();
  1035. if (!modelConfig.modelId) {
  1036. toast.error(t('settings.modelNotConfigured'));
  1037. return;
  1038. }
  1039. if (modelConfig.requiresApiKey && !modelConfig.apiKey && !modelConfig.isServerConfigured) {
  1040. toast.error(t('settings.setupNeeded'), {
  1041. description: t('settings.apiKeyDesc'),
  1042. });
  1043. return;
  1044. }
  1045. // Auto-end previous active QA/Discussion sessions to ensure only one is active
  1046. const activeQAOrDiscussion = sessionsRef.current.filter(
  1047. (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active',
  1048. );
  1049. for (const session of activeQAOrDiscussion) {
  1050. await endSession(session.id);
  1051. }
  1052. const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2)}`;
  1053. const now = Date.now();
  1054. const agentId = request.agentId || 'default-1';
  1055. // Read all selected agent IDs from settings store
  1056. const settingsState = useSettingsStore.getState();
  1057. const agentIds: string[] =
  1058. settingsState.selectedAgentIds?.length > 0
  1059. ? [...settingsState.selectedAgentIds]
  1060. : [agentId];
  1061. // Ensure the trigger agent is included
  1062. if (!agentIds.includes(agentId)) {
  1063. agentIds.unshift(agentId);
  1064. }
  1065. // No pre-created assistant message — agent_start events create them dynamically
  1066. const newSession: ChatSession = {
  1067. id: sessionId,
  1068. type: 'discussion',
  1069. title: request.topic,
  1070. status: 'active',
  1071. messages: [],
  1072. config: {
  1073. agentIds,
  1074. maxTurns: 0, // Not used for runtime — frontend loop manages maxTurns
  1075. currentTurn: 0,
  1076. triggerAgentId: agentId,
  1077. },
  1078. toolCalls: [],
  1079. pendingToolCalls: [],
  1080. createdAt: now,
  1081. updatedAt: now,
  1082. };
  1083. setSessions((prev) => [...prev, newSession]);
  1084. setActiveSessionId(sessionId);
  1085. setExpandedSessionIds((prev) => new Set([...prev, sessionId]));
  1086. const controller = new AbortController();
  1087. abortControllerRef.current = controller;
  1088. streamingSessionIdRef.current = sessionId;
  1089. setIsStreaming(true);
  1090. const currentState = useStageStore.getState();
  1091. try {
  1092. const userProfileState = useUserProfileStore.getState();
  1093. const mc = getCurrentModelConfig();
  1094. await runAgentLoop(
  1095. sessionId,
  1096. {
  1097. messages: [],
  1098. storeState: {
  1099. stage: currentState.stage,
  1100. scenes: currentState.scenes,
  1101. currentSceneId: currentState.currentSceneId,
  1102. mode: currentState.mode,
  1103. whiteboardOpen: useCanvasStore.getState().whiteboardOpen,
  1104. },
  1105. config: {
  1106. agentIds,
  1107. sessionType: 'discussion',
  1108. discussionTopic: request.topic,
  1109. discussionPrompt: request.prompt,
  1110. triggerAgentId: agentId,
  1111. },
  1112. userProfile: {
  1113. nickname: userProfileState.nickname || undefined,
  1114. bio: userProfileState.bio || undefined,
  1115. },
  1116. apiKey: mc.apiKey,
  1117. baseUrl: mc.baseUrl,
  1118. model: mc.modelString,
  1119. providerType: mc.providerType,
  1120. },
  1121. controller,
  1122. 'discussion',
  1123. );
  1124. } catch (error) {
  1125. // Ignore AbortError — it's intentional (user interrupted)
  1126. if (error instanceof DOMException && error.name === 'AbortError') {
  1127. log.info('[ChatArea] Discussion aborted by user');
  1128. return;
  1129. }
  1130. log.error('[ChatArea] Discussion error:', error);
  1131. clearLiveSessionAfterError(
  1132. sessionId,
  1133. `Error starting discussion: ${error instanceof Error ? error.message : String(error)}`,
  1134. );
  1135. } finally {
  1136. // Only clean up if this is still the active controller (avoid race with interrupt)
  1137. if (abortControllerRef.current === controller) {
  1138. abortControllerRef.current = null;
  1139. streamingSessionIdRef.current = null;
  1140. setIsStreaming(false);
  1141. }
  1142. }
  1143. },
  1144. // eslint-disable-next-line react-hooks/exhaustive-deps -- t is stable from i18n context
  1145. [clearLiveSessionAfterError, endSession, runAgentLoop],
  1146. );
  1147. /**
  1148. * Handle interruption
  1149. */
  1150. const handleInterrupt = useCallback(() => {
  1151. if (!abortControllerRef.current) return;
  1152. log.info('[ChatArea] Interrupting active request');
  1153. abortControllerRef.current.abort();
  1154. abortControllerRef.current = null;
  1155. setIsStreaming(false);
  1156. streamingSessionIdRef.current = null;
  1157. }, []);
  1158. /**
  1159. * Start a lecture session for a scene.
  1160. * Creates a single assistant message that all actions will be appended to.
  1161. * Deduplicates: returns existing active lecture session for the same sceneId if found.
  1162. */
  1163. const startLecture = useCallback(
  1164. async (sceneId: string): Promise<string> => {
  1165. // Check for existing lecture session with same sceneId (active or completed)
  1166. const existing = sessions.find(
  1167. (s) =>
  1168. s.type === 'lecture' &&
  1169. s.sceneId === sceneId &&
  1170. (s.status === 'active' || s.status === 'completed'),
  1171. );
  1172. if (existing) {
  1173. // Reactivate a completed session so the chat panel shows it as active again.
  1174. // Actions won't be re-appended because lastActionIndex already covers them.
  1175. if (existing.status === 'completed') {
  1176. setSessions((prev) =>
  1177. prev.map((s) =>
  1178. s.id === existing.id ? { ...s, status: 'active' as SessionStatus } : s,
  1179. ),
  1180. );
  1181. // Restore lecture tracking refs (cleared by endSession)
  1182. const messageId = existing.messages[0]?.id;
  1183. if (messageId) {
  1184. lectureMessageIds.current.set(existing.id, messageId);
  1185. }
  1186. if (existing.lastActionIndex !== undefined) {
  1187. lectureLastActionIndexRef.current.set(existing.id, existing.lastActionIndex);
  1188. }
  1189. }
  1190. setActiveSessionId(existing.id);
  1191. setExpandedSessionIds((prev) => new Set([...prev, existing.id]));
  1192. return existing.id;
  1193. }
  1194. const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2)}`;
  1195. const now = Date.now();
  1196. const messageId = `lecture-msg-${now}`;
  1197. const scene = useStageStore.getState().scenes.find((s) => s.id === sceneId);
  1198. const title = scene?.title || t('chat.lecture');
  1199. const agentConfig = useAgentRegistry.getState().getAgent('default-1');
  1200. // Create session with a single assistant message (all actions append parts here)
  1201. const lectureMessage: UIMessage<ChatMessageMetadata> = {
  1202. id: messageId,
  1203. role: 'assistant',
  1204. parts: [],
  1205. metadata: {
  1206. senderName: agentConfig?.name || t('settings.agentNames.default-1'),
  1207. senderAvatar: agentConfig?.avatar,
  1208. originalRole: 'teacher',
  1209. agentId: 'default-1',
  1210. createdAt: now,
  1211. },
  1212. };
  1213. const newSession: ChatSession = {
  1214. id: sessionId,
  1215. type: 'lecture',
  1216. title,
  1217. status: 'active',
  1218. messages: [lectureMessage],
  1219. config: {
  1220. agentIds: ['default-1'],
  1221. maxTurns: 0,
  1222. currentTurn: 0,
  1223. },
  1224. toolCalls: [],
  1225. pendingToolCalls: [],
  1226. sceneId,
  1227. lastActionIndex: -1,
  1228. createdAt: now,
  1229. updatedAt: now,
  1230. };
  1231. lectureMessageIds.current.set(sessionId, messageId);
  1232. setSessions((prev) => [...prev, newSession]);
  1233. setActiveSessionId(sessionId);
  1234. setExpandedSessionIds((prev) => new Set([...prev, sessionId]));
  1235. log.info(`[ChatArea] Created lecture session: ${sessionId} for scene ${sceneId}`);
  1236. return sessionId;
  1237. },
  1238. [sessions, t],
  1239. );
  1240. /**
  1241. * Add a lecture action to the single message bubble via StreamBuffer.
  1242. * Speech → pushText + sealText (buffer handles pacing).
  1243. * Spotlight/laser/discussion → pushAction (badge appears after preceding text is revealed).
  1244. */
  1245. const addLectureMessage = useCallback(
  1246. (sessionId: string, action: Action, actionIndex: number) => {
  1247. const messageId = lectureMessageIds.current.get(sessionId);
  1248. if (!messageId) return;
  1249. // Skip if this action was already appended in a previous run
  1250. const lastIndex = lectureLastActionIndexRef.current.get(sessionId) ?? -1;
  1251. if (actionIndex <= lastIndex) return;
  1252. lectureLastActionIndexRef.current.set(sessionId, actionIndex);
  1253. // Update lastActionIndex in session
  1254. setSessions((prev) =>
  1255. prev.map((s) =>
  1256. s.id === sessionId ? { ...s, lastActionIndex: actionIndex, updatedAt: Date.now() } : s,
  1257. ),
  1258. );
  1259. // Get or create buffer for this lecture session
  1260. let buffer = buffersRef.current.get(sessionId);
  1261. if (!buffer || buffer.disposed) {
  1262. buffer = createBufferForSession(sessionId, 'lecture');
  1263. }
  1264. if (action.type === 'speech') {
  1265. buffer.pushText(messageId, action.text, 'default-1');
  1266. buffer.sealText(messageId);
  1267. } else if (
  1268. action.type === 'spotlight' ||
  1269. action.type === 'laser' ||
  1270. action.type === 'discussion'
  1271. ) {
  1272. const now = Date.now();
  1273. buffer.pushAction({
  1274. messageId,
  1275. actionId: `${action.type}-${now}`,
  1276. actionName: action.type,
  1277. params:
  1278. action.type === 'spotlight'
  1279. ? {
  1280. elementId: action.elementId,
  1281. dimOpacity: (action as SpotlightAction).dimOpacity,
  1282. }
  1283. : action.type === 'laser'
  1284. ? { elementId: action.elementId }
  1285. : {
  1286. topic: (action as DiscussionAction).topic,
  1287. prompt: (action as DiscussionAction).prompt,
  1288. },
  1289. agentId: 'default-1',
  1290. });
  1291. }
  1292. },
  1293. [createBufferForSession],
  1294. );
  1295. // Derive active session type for external consumers
  1296. const activeSession = sessions.find((s) => s.id === activeSessionId);
  1297. const activeSessionType = activeSession?.type ?? null;
  1298. const getLectureMessageId = useCallback((sessionId: string): string | null => {
  1299. return lectureMessageIds.current.get(sessionId) ?? null;
  1300. }, []);
  1301. /** Pause the buffer for a session (lecture pause support). */
  1302. const pauseBuffer = useCallback((sessionId: string) => {
  1303. const buf = buffersRef.current.get(sessionId);
  1304. if (buf) buf.pause();
  1305. }, []);
  1306. /** Resume the buffer for a session. */
  1307. const resumeBuffer = useCallback((sessionId: string) => {
  1308. const buf = buffersRef.current.get(sessionId);
  1309. if (buf) buf.resume();
  1310. }, []);
  1311. /** Pause the active live (QA/Discussion) buffer and set sticky intent. Returns true if paused. */
  1312. const pauseActiveLiveBuffer = useCallback((): boolean => {
  1313. const active = sessionsRef.current.find(
  1314. (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active',
  1315. );
  1316. if (!active) return false;
  1317. const buf = buffersRef.current.get(active.id);
  1318. if (!buf || buf.disposed) return false;
  1319. livePausedRef.current = true;
  1320. buf.pause();
  1321. log.info('[ChatArea] Buffer-paused discussion:', active.id);
  1322. return true;
  1323. }, []);
  1324. /** Resume the active live (QA/Discussion) buffer and clear sticky intent. */
  1325. const resumeActiveLiveBuffer = useCallback(() => {
  1326. const active = sessionsRef.current.find(
  1327. (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active',
  1328. );
  1329. if (!active) return;
  1330. livePausedRef.current = false;
  1331. const buf = buffersRef.current.get(active.id);
  1332. if (buf) buf.resume();
  1333. log.info('[ChatArea] Buffer-resumed discussion:', active.id);
  1334. }, []);
  1335. return {
  1336. sessions,
  1337. activeSessionId,
  1338. activeSessionType,
  1339. expandedSessionIds,
  1340. isStreaming,
  1341. createSession,
  1342. endSession,
  1343. endActiveSession,
  1344. softPauseActiveSession,
  1345. resumeActiveSession,
  1346. sendMessage,
  1347. startDiscussion,
  1348. startLecture,
  1349. addLectureMessage,
  1350. toggleSessionExpand,
  1351. handleInterrupt,
  1352. getLectureMessageId,
  1353. pauseBuffer,
  1354. resumeBuffer,
  1355. pauseActiveLiveBuffer,
  1356. resumeActiveLiveBuffer,
  1357. };
  1358. }