|
|
|
|
| import { useState, useCallback, useRef, useEffect } from 'react'; |
| import type { |
| ChatSession, |
| SessionType, |
| SessionStatus, |
| ChatMessageMetadata, |
| DirectorState, |
| } from '@/lib/types/chat'; |
| import type { DiscussionRequest } from '@/components/roundtable'; |
| import type { Action, SpotlightAction, DiscussionAction } from '@/lib/types/action'; |
| import type { UIMessage } from 'ai'; |
| import type { ThinkingConfig } from '@/lib/types/provider'; |
| import { useStageStore } from '@/lib/store'; |
| import { useCanvasStore } from '@/lib/store/canvas'; |
| import { useSettingsStore } from '@/lib/store/settings'; |
| import { useUserProfileStore } from '@/lib/store/user-profile'; |
| import { useAgentRegistry } from '@/lib/orchestration/registry/store'; |
| import { useI18n } from '@/lib/hooks/use-i18n'; |
| import { getCurrentModelConfig } from '@/lib/utils/model-config'; |
| import { USER_AVATAR } from '@/lib/types/roundtable'; |
| import { StreamBuffer } from '@/lib/buffer/stream-buffer'; |
| import type { AgentStartItem, ActionItem } from '@/lib/buffer/stream-buffer'; |
| import { runAgentLoop, type AgentLoopStoreState } from '@/lib/chat/agent-loop'; |
| import { ActionEngine } from '@/lib/action/engine'; |
| import { toast } from 'sonner'; |
| import { createLogger } from '@/lib/logger'; |
|
|
| const log = createLogger('ChatSessions'); |
|
|
| interface UseChatSessionsOptions { |
| onLiveSpeech?: (text: string | null, agentId?: string | null) => void; |
| onSpeechProgress?: (ratio: number | null) => void; |
| onThinking?: (state: { stage: string; agentId?: string } | null) => void; |
| onCueUser?: (fromAgentId?: string, prompt?: string) => void; |
| onActiveBubble?: (messageId: string | null) => void; |
| onLiveSessionError?: () => void; |
| |
| onStopSession?: () => void; |
| onSegmentSealed?: ( |
| messageId: string, |
| partId: string, |
| fullText: string, |
| agentId: string | null, |
| ) => void; |
| |
| shouldHoldAfterReveal?: () => { holding: boolean; segmentDone: number } | boolean; |
| } |
|
|
| export function useChatSessions(options: UseChatSessionsOptions = {}) { |
| const onLiveSpeechRef = useRef(options.onLiveSpeech); |
| const onSpeechProgressRef = useRef(options.onSpeechProgress); |
| const onThinkingRef = useRef(options.onThinking); |
| const onCueUserRef = useRef(options.onCueUser); |
| const onActiveBubbleRef = useRef(options.onActiveBubble); |
| const onLiveSessionErrorRef = useRef(options.onLiveSessionError); |
| const onStopSessionRef = useRef(options.onStopSession); |
| const onSegmentSealedRef = useRef(options.onSegmentSealed); |
| const shouldHoldAfterRevealRef = useRef(options.shouldHoldAfterReveal); |
| useEffect(() => { |
| onLiveSpeechRef.current = options.onLiveSpeech; |
| onSpeechProgressRef.current = options.onSpeechProgress; |
| onThinkingRef.current = options.onThinking; |
| onCueUserRef.current = options.onCueUser; |
| onActiveBubbleRef.current = options.onActiveBubble; |
| onLiveSessionErrorRef.current = options.onLiveSessionError; |
| onStopSessionRef.current = options.onStopSession; |
| onSegmentSealedRef.current = options.onSegmentSealed; |
| shouldHoldAfterRevealRef.current = options.shouldHoldAfterReveal; |
| }, [ |
| options.onLiveSpeech, |
| options.onSpeechProgress, |
| options.onThinking, |
| options.onCueUser, |
| options.onActiveBubble, |
| options.onLiveSessionError, |
| options.onStopSession, |
| options.onSegmentSealed, |
| options.shouldHoldAfterReveal, |
| ]); |
| const { t } = useI18n(); |
|
|
| |
| const stageId = useStageStore((s) => s.stage?.id); |
| const stageIdRef = useRef(stageId); |
|
|
| const [sessions, setSessions] = useState<ChatSession[]>(() => { |
| |
| const stored = useStageStore.getState().chats; |
| return stored.map((s) => |
| s.status === 'active' ? { ...s, status: 'interrupted' as SessionStatus } : s, |
| ); |
| }); |
| const [activeSessionId, setActiveSessionId] = useState<string | null>(null); |
| const [expandedSessionIds, setExpandedSessionIds] = useState<Set<string>>(new Set()); |
| const [isStreaming, setIsStreaming] = useState(false); |
| const abortControllerRef = useRef<AbortController | null>(null); |
| const streamingSessionIdRef = useRef<string | null>(null); |
| const sessionsRef = useRef<ChatSession[]>(sessions); |
| useEffect(() => { |
| sessionsRef.current = sessions; |
| }, [sessions]); |
|
|
| |
| const loopDoneDataRef = useRef<{ |
| directorState?: DirectorState; |
| totalAgents: number; |
| agentHadContent?: boolean; |
| cueUserReceived: boolean; |
| } | null>(null); |
|
|
| |
| |
| |
| useEffect(() => { |
| if (stageId === stageIdRef.current) return; |
| stageIdRef.current = stageId; |
| |
| const stored = useStageStore.getState().chats; |
| setSessions( |
| stored.map((s) => |
| s.status === 'active' ? { ...s, status: 'interrupted' as SessionStatus } : s, |
| ), |
| ); |
| setActiveSessionId(null); |
| setExpandedSessionIds(new Set()); |
| }, [stageId]); |
|
|
| |
| |
| useEffect(() => { |
| if (stageIdRef.current && stageIdRef.current === useStageStore.getState().stage?.id) { |
| useStageStore.getState().setChats(sessions); |
| } |
| }, [sessions]); |
|
|
| |
| const buffersRef = useRef<Map<string, StreamBuffer>>(new Map()); |
|
|
| |
| useEffect(() => { |
| const buffers = buffersRef.current; |
| return () => { |
| if (abortControllerRef.current) { |
| abortControllerRef.current.abort(); |
| abortControllerRef.current = null; |
| } |
| buffers.forEach((buf) => buf.shutdown()); |
| buffers.clear(); |
| }; |
| }, []); |
|
|
| |
| |
| const livePausedRef = useRef(false); |
|
|
| const clearLiveSessionAfterError = useCallback((sessionId: string, message: string) => { |
| const now = Date.now(); |
| const errorMessageId = `error-${now}`; |
|
|
| const buf = buffersRef.current.get(sessionId); |
| if (buf) { |
| buf.shutdown(); |
| buffersRef.current.delete(sessionId); |
| } |
|
|
| setSessions((prev) => |
| prev.map((s) => |
| s.id === sessionId |
| ? { |
| ...s, |
| updatedAt: now, |
| messages: [ |
| ...s.messages, |
| { |
| id: errorMessageId, |
| role: 'assistant' as const, |
| parts: [{ type: 'text', text: message }], |
| metadata: { |
| senderName: 'System', |
| originalRole: 'agent' as const, |
| createdAt: now, |
| }, |
| }, |
| ], |
| } |
| : s, |
| ), |
| ); |
|
|
| onActiveBubbleRef.current?.(null); |
| if (onLiveSessionErrorRef.current) { |
| onLiveSessionErrorRef.current(); |
| } else { |
| onSpeechProgressRef.current?.(null); |
| onThinkingRef.current?.(null); |
| onLiveSpeechRef.current?.(null, null); |
| } |
| }, []); |
|
|
| |
| const lectureMessageIds = useRef<Map<string, string>>(new Map()); |
|
|
| |
| const lectureLastActionIndexRef = useRef<Map<string, number>>(new Map()); |
|
|
| const toggleSessionExpand = useCallback((sessionId: string) => { |
| setExpandedSessionIds((prev) => { |
| const next = new Set(prev); |
| if (next.has(sessionId)) { |
| next.delete(sessionId); |
| } else { |
| next.add(sessionId); |
| } |
| return next; |
| }); |
| }, []); |
|
|
| |
| |
| |
| |
| const createBufferForSession = useCallback( |
| (sessionId: string, type?: SessionType): StreamBuffer => { |
| |
| |
| const prev = buffersRef.current.get(sessionId); |
| if (prev) prev.shutdown(); |
|
|
| |
| |
| const pacingOptions = type === 'lecture' ? {} : { postTextDelayMs: 1200, actionDelayMs: 800 }; |
|
|
| const buffer = new StreamBuffer( |
| { |
| onAgentStart(data: AgentStartItem) { |
| const now = Date.now(); |
| const agentConfig = useAgentRegistry.getState().getAgent(data.agentId); |
| const newMsg: UIMessage<ChatMessageMetadata> = { |
| id: data.messageId, |
| role: 'assistant', |
| parts: [], |
| metadata: { |
| senderName: agentConfig?.name || data.agentName, |
| senderAvatar: data.avatar || agentConfig?.avatar, |
| originalRole: 'agent', |
| agentId: data.agentId, |
| createdAt: now, |
| }, |
| }; |
| setSessions((prev) => |
| prev.map((s) => |
| s.id === sessionId |
| ? { ...s, messages: [...s.messages, newMsg], updatedAt: now } |
| : s, |
| ), |
| ); |
| onActiveBubbleRef.current?.(data.messageId); |
| }, |
|
|
| onAgentEnd() { |
| |
| setSessions((prev) => |
| prev.map((s) => { |
| if (s.id !== sessionId) return s; |
| const msgs = s.messages.filter( |
| (m) => !(m.role === 'assistant' && m.parts.length === 0), |
| ); |
| return msgs.length !== s.messages.length ? { ...s, messages: msgs } : s; |
| }), |
| ); |
| }, |
|
|
| onTextReveal( |
| messageId: string, |
| partId: string, |
| revealedText: string, |
| _isComplete: boolean, |
| ) { |
| setSessions((prev) => |
| prev.map((s) => { |
| if (s.id !== sessionId) return s; |
| return { |
| ...s, |
| messages: s.messages.map((m) => { |
| if (m.id !== messageId) return m; |
| const parts = [...m.parts]; |
| |
| const existingIdx = parts.findIndex( |
| (p) => (p as unknown as Record<string, unknown>)._partId === partId, |
| ); |
| if (existingIdx >= 0) { |
| parts[existingIdx] = { |
| type: 'text', |
| text: revealedText, |
| _partId: partId, |
| } as UIMessage<ChatMessageMetadata>['parts'][number]; |
| } else { |
| parts.push({ |
| type: 'text', |
| text: revealedText, |
| _partId: partId, |
| } as UIMessage<ChatMessageMetadata>['parts'][number]); |
| } |
| return { ...m, parts }; |
| }), |
| |
| }; |
| }), |
| ); |
| }, |
|
|
| onActionReady(messageId: string, data: ActionItem) { |
| |
| const actionPart = { |
| type: `action-${data.actionName}`, |
| actionId: data.actionId, |
| actionName: data.actionName, |
| input: data.params, |
| state: 'result', |
| output: { success: true }, |
| } as unknown as UIMessage<ChatMessageMetadata>['parts'][number]; |
|
|
| setSessions((prev) => |
| prev.map((s) => { |
| if (s.id !== sessionId) return s; |
| return { |
| ...s, |
| messages: s.messages.map((m) => |
| m.id === messageId ? { ...m, parts: [...m.parts, actionPart] } : m, |
| ), |
| updatedAt: Date.now(), |
| }; |
| }), |
| ); |
|
|
| |
| try { |
| const actionEngine = new ActionEngine(useStageStore); |
| const action = { |
| id: data.actionId, |
| type: data.actionName, |
| ...data.params, |
| } as Action; |
| actionEngine.execute(action); |
| } catch (err) { |
| log.warn('[Buffer] Action execution error:', err); |
| } |
| }, |
|
|
| onLiveSpeech(text: string | null, agentId: string | null) { |
| |
| |
| if (type === 'lecture') return; |
| onLiveSpeechRef.current?.(text, agentId); |
| }, |
|
|
| onSpeechProgress(ratio: number | null) { |
| onSpeechProgressRef.current?.(ratio); |
| }, |
|
|
| onThinking(data: { stage: string; agentId?: string } | null) { |
| onThinkingRef.current?.(data); |
| }, |
|
|
| onCueUser(fromAgentId?: string, prompt?: string) { |
| |
| if (loopDoneDataRef.current) { |
| loopDoneDataRef.current.cueUserReceived = true; |
| } else { |
| loopDoneDataRef.current = { |
| totalAgents: 0, |
| cueUserReceived: true, |
| }; |
| } |
| onCueUserRef.current?.(fromAgentId, prompt); |
| }, |
|
|
| onDone(data: { |
| totalActions: number; |
| totalAgents: number; |
| agentHadContent?: boolean; |
| directorState?: DirectorState; |
| }) { |
| |
| loopDoneDataRef.current = { |
| directorState: data.directorState, |
| totalAgents: data.totalAgents, |
| agentHadContent: data.agentHadContent ?? true, |
| cueUserReceived: loopDoneDataRef.current?.cueUserReceived ?? false, |
| }; |
| |
| |
| }, |
|
|
| onError(message: string) { |
| log.error('[Buffer] Stream error:', message); |
| }, |
|
|
| onSegmentSealed( |
| messageId: string, |
| partId: string, |
| fullText: string, |
| agentId: string | null, |
| ) { |
| onSegmentSealedRef.current?.(messageId, partId, fullText, agentId); |
| }, |
|
|
| shouldHoldAfterReveal() { |
| return shouldHoldAfterRevealRef.current?.() ?? (false as const); |
| }, |
| }, |
| pacingOptions, |
| ); |
|
|
| buffersRef.current.set(sessionId, buffer); |
| buffer.start(); |
|
|
| |
| |
| if (type !== 'lecture' && livePausedRef.current) { |
| buffer.pause(); |
| } |
|
|
| return buffer; |
| }, |
| [], |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| const runAgentLoopFn = useCallback( |
| async ( |
| sessionId: string, |
| requestTemplate: { |
| messages: UIMessage<ChatMessageMetadata>[]; |
| storeState: Record<string, unknown>; |
| config: { |
| agentIds: string[]; |
| sessionType?: string; |
| agentConfigs?: Record<string, unknown>[]; |
| [key: string]: unknown; |
| }; |
| userProfile?: { nickname?: string; bio?: string }; |
| apiKey: string; |
| baseUrl?: string; |
| model?: string; |
| providerType?: string; |
| thinkingConfig?: ThinkingConfig; |
| }, |
| controller: AbortController, |
| sessionType: SessionType, |
| ): Promise<void> => { |
| const settingsState = useSettingsStore.getState(); |
|
|
| |
| |
| const generatedConfigs = requestTemplate.config.agentIds |
| .filter((id: string) => !id.startsWith('default-')) |
| .map((id: string) => useAgentRegistry.getState().getAgent(id)) |
| .filter((agent): agent is NonNullable<typeof agent> => Boolean(agent)) |
| .map(({ createdAt: _c, updatedAt: _u, isDefault: _d, ...rest }) => rest); |
| if (generatedConfigs.length > 0) { |
| requestTemplate.config.agentConfigs = generatedConfigs; |
| } |
|
|
| const defaultMaxTurns = requestTemplate.config.agentIds.length <= 1 ? 1 : 10; |
| const maxTurns = settingsState.maxTurns |
| ? parseInt(settingsState.maxTurns, 10) || defaultMaxTurns |
| : defaultMaxTurns; |
|
|
| |
| let currentBuffer: StreamBuffer | null = null; |
| |
| |
| let currentMessageId: string | null = null; |
|
|
| const outcome = await runAgentLoop( |
| { |
| config: requestTemplate.config, |
| userProfile: requestTemplate.userProfile, |
| apiKey: requestTemplate.apiKey, |
| baseUrl: requestTemplate.baseUrl, |
| model: requestTemplate.model, |
| providerType: requestTemplate.providerType, |
| thinkingConfig: requestTemplate.thinkingConfig, |
| }, |
| { |
| getStoreState: (): AgentLoopStoreState => { |
| const freshState = useStageStore.getState(); |
| return { |
| stage: freshState.stage, |
| scenes: freshState.scenes, |
| currentSceneId: freshState.currentSceneId, |
| mode: freshState.mode, |
| whiteboardOpen: useCanvasStore.getState().whiteboardOpen, |
| }; |
| }, |
|
|
| getMessages: () => { |
| const currentSession = sessionsRef.current.find((s) => s.id === sessionId); |
| return currentSession?.messages ?? requestTemplate.messages; |
| }, |
|
|
| fetchChat: (body, signal) => |
| fetch('/api/chat', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body), |
| signal, |
| }), |
|
|
| onEvent: (event) => { |
| |
| if (!currentBuffer) { |
| currentBuffer = createBufferForSession(sessionId, sessionType); |
| } |
|
|
| |
| switch (event.type) { |
| case 'agent_start': { |
| const { messageId, agentId, agentName, agentAvatar, agentColor } = event.data; |
| currentMessageId = messageId; |
| currentBuffer.pushAgentStart({ |
| messageId, |
| agentId, |
| agentName, |
| avatar: agentAvatar, |
| color: agentColor, |
| }); |
| break; |
| } |
| case 'agent_end': { |
| currentBuffer.pushAgentEnd({ |
| messageId: event.data.messageId, |
| agentId: event.data.agentId, |
| }); |
| break; |
| } |
| case 'text_delta': { |
| const targetId = event.data.messageId ?? currentMessageId; |
| if (!targetId) break; |
| currentBuffer.pushText(targetId, event.data.content); |
| break; |
| } |
| case 'action': { |
| const targetId = event.data.messageId ?? currentMessageId; |
| if (!targetId) break; |
| if (controller.signal.aborted) break; |
| currentBuffer.pushAction({ |
| actionId: event.data.actionId, |
| actionName: event.data.actionName, |
| params: event.data.params, |
| messageId: targetId, |
| agentId: event.data.agentId, |
| }); |
| break; |
| } |
| case 'thinking': |
| currentBuffer.pushThinking(event.data); |
| break; |
| case 'cue_user': |
| currentBuffer.pushCueUser(event.data); |
| break; |
| case 'done': |
| currentBuffer.pushDone(event.data); |
| break; |
| case 'error': |
| |
| |
| currentBuffer.pushError(event.data.message); |
| throw new Error(event.data.message); |
| } |
| }, |
|
|
| onIterationEnd: async () => { |
| if (!currentBuffer) return null; |
|
|
| |
| try { |
| await currentBuffer.waitUntilDrained(); |
| } catch { |
| |
| currentBuffer = null; |
| return null; |
| } |
|
|
| currentBuffer = null; |
|
|
| |
| |
| const doneData = loopDoneDataRef.current; |
| loopDoneDataRef.current = null; |
|
|
| if (!doneData) return null; |
| return { |
| directorState: doneData.directorState, |
| totalAgents: doneData.totalAgents, |
| agentHadContent: doneData.agentHadContent ?? true, |
| cueUserReceived: doneData.cueUserReceived, |
| }; |
| }, |
| }, |
| controller.signal, |
| maxTurns, |
| ); |
|
|
| |
| if (!controller.signal.aborted) { |
| if (outcome.reason !== 'cue_user') { |
| setSessions((prev) => |
| prev.map((s) => |
| s.id === sessionId |
| ? { |
| ...s, |
| status: 'completed' as SessionStatus, |
| updatedAt: Date.now(), |
| } |
| : s, |
| ), |
| ); |
| onStopSessionRef.current?.(); |
| } |
| } |
| }, |
| [createBufferForSession], |
| ); |
|
|
| |
| |
| |
| const createSession = useCallback(async (type: SessionType, title: string): Promise<string> => { |
| const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2)}`; |
| const now = Date.now(); |
|
|
| const newSession: ChatSession = { |
| id: sessionId, |
| type, |
| title, |
| status: 'active', |
| messages: [], |
| config: { |
| agentIds: ['default-1'], |
| maxTurns: 0, |
| currentTurn: 0, |
| defaultAgentId: 'default-1', |
| }, |
| toolCalls: [], |
| pendingToolCalls: [], |
| createdAt: now, |
| updatedAt: now, |
| }; |
|
|
| setSessions((prev) => [...prev, newSession]); |
| setActiveSessionId(sessionId); |
| setExpandedSessionIds((prev) => new Set([...prev, sessionId])); |
|
|
| log.info(`[ChatArea] Created session: ${sessionId} (${type})`); |
| return sessionId; |
| }, []); |
|
|
| |
| |
| |
| |
| const endSession = useCallback( |
| async (sessionId: string): Promise<void> => { |
| log.info(`[ChatArea] Ending session: ${sessionId}`); |
| livePausedRef.current = false; |
|
|
| const session = sessionsRef.current.find((s) => s.id === sessionId); |
| const isLiveSession = session && (session.type === 'qa' || session.type === 'discussion'); |
| const wasStreaming = !!( |
| abortControllerRef.current && streamingSessionIdRef.current === sessionId |
| ); |
|
|
| |
| if (wasStreaming) { |
| abortControllerRef.current!.abort(); |
| abortControllerRef.current = null; |
| streamingSessionIdRef.current = null; |
| setIsStreaming(false); |
| } |
|
|
| |
| const buf = buffersRef.current.get(sessionId); |
| if (buf) { |
| buf.shutdown(); |
| buffersRef.current.delete(sessionId); |
| } |
| lectureMessageIds.current.delete(sessionId); |
| lectureLastActionIndexRef.current.delete(sessionId); |
|
|
| if (isLiveSession && wasStreaming) { |
| |
| setSessions((prev) => |
| prev.map((s) => { |
| if (s.id !== sessionId) return s; |
| const messages = [...s.messages]; |
| for (let i = messages.length - 1; i >= 0; i--) { |
| if (messages[i].role === 'assistant') { |
| const parts = [...messages[i].parts]; |
| let appended = false; |
| for (let j = parts.length - 1; j >= 0; j--) { |
| if (parts[j].type === 'text') { |
| const textPart = parts[j] as { type: 'text'; text: string }; |
| parts[j] = { |
| type: 'text', |
| text: (textPart.text || '') + '...', |
| } as UIMessage<ChatMessageMetadata>['parts'][number]; |
| appended = true; |
| break; |
| } |
| } |
| if (!appended) { |
| parts.push({ |
| type: 'text', |
| text: '...', |
| } as UIMessage<ChatMessageMetadata>['parts'][number]); |
| } |
| messages[i] = { |
| ...messages[i], |
| parts, |
| metadata: { ...messages[i].metadata, interrupted: true }, |
| }; |
| break; |
| } |
| } |
| return { ...s, messages, status: 'completed' as SessionStatus }; |
| }), |
| ); |
| |
| onLiveSpeechRef.current?.(null, null); |
| onThinkingRef.current?.(null); |
| } else { |
| setSessions((prev) => |
| prev.map((s) => |
| s.id === sessionId ? { ...s, status: 'completed' as SessionStatus } : s, |
| ), |
| ); |
| } |
|
|
| if (activeSessionId === sessionId) { |
| setActiveSessionId(null); |
| } |
| }, |
| [activeSessionId], |
| ); |
|
|
| |
| |
| |
| const endActiveSession = useCallback(async (): Promise<void> => { |
| const active = sessionsRef.current.find( |
| (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active', |
| ); |
| if (active) { |
| await endSession(active.id); |
| } |
| }, [endSession]); |
|
|
| |
| |
| |
| |
| |
| const softPauseSession = useCallback(async (sessionId: string): Promise<void> => { |
| livePausedRef.current = false; |
| const session = sessionsRef.current.find((s) => s.id === sessionId); |
| if (!session) return; |
| const isLiveSession = session.type === 'qa' || session.type === 'discussion'; |
| if (!isLiveSession || session.status !== 'active') return; |
|
|
| const wasStreaming = !!( |
| abortControllerRef.current && streamingSessionIdRef.current === sessionId |
| ); |
|
|
| |
| |
| const buf = buffersRef.current.get(sessionId); |
| if (buf) { |
| buf.shutdown(); |
| buffersRef.current.delete(sessionId); |
| } |
|
|
| |
| if (wasStreaming) { |
| abortControllerRef.current!.abort(); |
| abortControllerRef.current = null; |
| streamingSessionIdRef.current = null; |
| setIsStreaming(false); |
| } |
|
|
| if (wasStreaming) { |
| |
| setSessions((prev) => |
| prev.map((s) => { |
| if (s.id !== sessionId) return s; |
| const messages = [...s.messages]; |
| for (let i = messages.length - 1; i >= 0; i--) { |
| if (messages[i].role === 'assistant') { |
| const parts = [...messages[i].parts]; |
| let appended = false; |
| for (let j = parts.length - 1; j >= 0; j--) { |
| if (parts[j].type === 'text') { |
| const textPart = parts[j] as { type: 'text'; text: string }; |
| parts[j] = { |
| type: 'text', |
| text: (textPart.text || '') + '...', |
| } as UIMessage<ChatMessageMetadata>['parts'][number]; |
| appended = true; |
| break; |
| } |
| } |
| if (!appended) { |
| parts.push({ |
| type: 'text', |
| text: '...', |
| } as UIMessage<ChatMessageMetadata>['parts'][number]); |
| } |
| messages[i] = { |
| ...messages[i], |
| parts, |
| metadata: { ...messages[i].metadata, interrupted: true }, |
| }; |
| break; |
| } |
| } |
| |
| return { ...s, messages, updatedAt: Date.now() }; |
| }), |
| ); |
| |
| |
| } |
|
|
| log.info(`[ChatArea] Soft-paused session: ${sessionId}`); |
| }, []); |
|
|
| |
| |
| |
| const softPauseActiveSession = useCallback(async (): Promise<void> => { |
| const active = sessionsRef.current.find( |
| (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active', |
| ); |
| if (active) { |
| await softPauseSession(active.id); |
| } |
| }, [softPauseSession]); |
|
|
| |
| |
| |
| |
| const resumeSession = useCallback( |
| async (sessionId: string): Promise<void> => { |
| const session = sessionsRef.current.find((s) => s.id === sessionId); |
| if (!session || session.status !== 'active') return; |
|
|
| const controller = new AbortController(); |
| abortControllerRef.current = controller; |
| streamingSessionIdRef.current = sessionId; |
| setIsStreaming(true); |
|
|
| const currentState = useStageStore.getState(); |
|
|
| try { |
| log.info(`[ChatArea] Resuming session: ${sessionId}`); |
|
|
| const userProfileState = useUserProfileStore.getState(); |
| const mc = getCurrentModelConfig(); |
|
|
| const agentIds = |
| useSettingsStore.getState().selectedAgentIds?.length > 0 |
| ? useSettingsStore.getState().selectedAgentIds |
| : session.config.agentIds; |
|
|
| await runAgentLoopFn( |
| sessionId, |
| { |
| messages: session.messages, |
| storeState: { |
| stage: currentState.stage, |
| scenes: currentState.scenes, |
| currentSceneId: currentState.currentSceneId, |
| mode: currentState.mode, |
| whiteboardOpen: useCanvasStore.getState().whiteboardOpen, |
| }, |
| config: { |
| agentIds, |
| sessionType: session.type, |
| }, |
| userProfile: { |
| nickname: userProfileState.nickname || undefined, |
| bio: userProfileState.bio || undefined, |
| }, |
| apiKey: mc.apiKey, |
| baseUrl: mc.baseUrl, |
| model: mc.modelString, |
| providerType: mc.providerType, |
| thinkingConfig: mc.thinkingConfig, |
| }, |
| controller, |
| session.type, |
| ); |
| } catch (error) { |
| if (error instanceof DOMException && error.name === 'AbortError') { |
| log.info('[ChatArea] Resume aborted'); |
| return; |
| } |
| log.error('[ChatArea] Resume error:', error); |
| clearLiveSessionAfterError( |
| sessionId, |
| `Error: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| } finally { |
| if (abortControllerRef.current === controller) { |
| abortControllerRef.current = null; |
| streamingSessionIdRef.current = null; |
| setIsStreaming(false); |
| } |
| } |
| }, |
| [clearLiveSessionAfterError, runAgentLoopFn], |
| ); |
|
|
| |
| |
| |
| const resumeActiveSession = useCallback(async (): Promise<void> => { |
| const active = sessionsRef.current.find( |
| (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active', |
| ); |
| if (active) { |
| await resumeSession(active.id); |
| } |
| }, [resumeSession]); |
|
|
| |
| |
| |
| const sendMessage = useCallback( |
| async (content: string): Promise<void> => { |
| let sessionId = activeSessionId; |
|
|
| |
| if (isStreaming && abortControllerRef.current) { |
| abortControllerRef.current.abort(); |
| abortControllerRef.current = null; |
|
|
| if (sessionId) { |
| setSessions((prev) => |
| prev.map((s) => { |
| if (s.id !== sessionId) return s; |
| const messages = [...s.messages]; |
| for (let i = messages.length - 1; i >= 0; i--) { |
| if (messages[i].role === 'assistant') { |
| const parts = [...messages[i].parts]; |
| for (let j = parts.length - 1; j >= 0; j--) { |
| if (parts[j].type === 'text') { |
| const textPart = parts[j] as { |
| type: 'text'; |
| text: string; |
| }; |
| parts[j] = { |
| type: 'text', |
| text: (textPart.text || '') + '...', |
| } as UIMessage<ChatMessageMetadata>['parts'][number]; |
| messages[i] = { ...messages[i], parts }; |
| return { ...s, messages, updatedAt: Date.now() }; |
| } |
| } |
| break; |
| } |
| } |
| return s; |
| }), |
| ); |
| } |
| } |
|
|
| |
| const modelConfig = getCurrentModelConfig(); |
| if (!modelConfig.modelId) { |
| toast.error(t('settings.modelNotConfigured')); |
| return; |
| } |
| if (modelConfig.requiresApiKey && !modelConfig.apiKey && !modelConfig.isServerConfigured) { |
| toast.error(t('settings.setupNeeded'), { |
| description: t('settings.apiKeyDesc'), |
| }); |
| return; |
| } |
|
|
| |
| |
| const activeSession = sessionsRef.current.find((s) => s.id === sessionId); |
| const needNewSession = |
| !sessionId || activeSession?.type === 'lecture' || activeSession?.status === 'completed'; |
|
|
| if (needNewSession) { |
| |
| const activeQAOrDiscussion = sessionsRef.current.filter( |
| (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active', |
| ); |
| for (const session of activeQAOrDiscussion) { |
| await endSession(session.id); |
| } |
| sessionId = await createSession('qa', 'Q&A'); |
| } |
|
|
| const controller = new AbortController(); |
| abortControllerRef.current = controller; |
| streamingSessionIdRef.current = sessionId; |
| setIsStreaming(true); |
|
|
| const now = Date.now(); |
| const userMessageId = `user-${now}`; |
|
|
| |
| const settingsState = useSettingsStore.getState(); |
| const agentIds: string[] = |
| settingsState.selectedAgentIds?.length > 0 ? settingsState.selectedAgentIds : ['default-1']; |
|
|
| const userMessage: UIMessage<ChatMessageMetadata> = { |
| id: userMessageId, |
| role: 'user', |
| parts: [{ type: 'text', text: content }], |
| metadata: { |
| senderName: t('common.you'), |
| senderAvatar: USER_AVATAR, |
| originalRole: 'user', |
| createdAt: now, |
| }, |
| }; |
|
|
| |
| const existingSession = sessionsRef.current.find((s) => s.id === sessionId); |
| const sessionMessages: UIMessage<ChatMessageMetadata>[] = existingSession |
| ? [...existingSession.messages, userMessage] |
| : [userMessage]; |
| const sessionType: SessionType = existingSession?.type || 'qa'; |
|
|
| |
| setSessions((prev) => { |
| const exists = prev.some((s) => s.id === sessionId); |
| if (exists) { |
| return prev.map((s) => |
| s.id === sessionId |
| ? { |
| ...s, |
| messages: [...s.messages, userMessage], |
| status: 'active' as SessionStatus, |
| updatedAt: now, |
| } |
| : s, |
| ); |
| } else { |
| const newSession: ChatSession = { |
| id: sessionId!, |
| type: 'qa', |
| title: 'Q&A', |
| status: 'active', |
| messages: [userMessage], |
| config: { |
| agentIds, |
| maxTurns: 0, |
| currentTurn: 0, |
| defaultAgentId: agentIds[0], |
| }, |
| toolCalls: [], |
| pendingToolCalls: [], |
| createdAt: now, |
| updatedAt: now, |
| }; |
| return [...prev, newSession]; |
| } |
| }); |
|
|
| const currentState = useStageStore.getState(); |
|
|
| try { |
| log.info( |
| `[ChatArea] Sending message: "${content.slice(0, 50)}..." agents: ${agentIds.join(', ')}`, |
| ); |
|
|
| const userProfileState = useUserProfileStore.getState(); |
| const mc = getCurrentModelConfig(); |
|
|
| await runAgentLoopFn( |
| sessionId!, |
| { |
| messages: sessionMessages, |
| storeState: { |
| stage: currentState.stage, |
| scenes: currentState.scenes, |
| currentSceneId: currentState.currentSceneId, |
| mode: currentState.mode, |
| whiteboardOpen: useCanvasStore.getState().whiteboardOpen, |
| }, |
| config: { |
| agentIds, |
| sessionType, |
| }, |
| userProfile: { |
| nickname: userProfileState.nickname || undefined, |
| bio: userProfileState.bio || undefined, |
| }, |
| apiKey: mc.apiKey, |
| baseUrl: mc.baseUrl, |
| model: mc.modelString, |
| providerType: mc.providerType, |
| thinkingConfig: mc.thinkingConfig, |
| }, |
| controller, |
| sessionType, |
| ); |
| } catch (error) { |
| |
| if (error instanceof DOMException && error.name === 'AbortError') { |
| log.info('[ChatArea] Request aborted by user'); |
| return; |
| } |
|
|
| log.error('[ChatArea] Error:', error); |
| clearLiveSessionAfterError( |
| sessionId!, |
| `Error: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| } finally { |
| |
| if (abortControllerRef.current === controller) { |
| abortControllerRef.current = null; |
| streamingSessionIdRef.current = null; |
| setIsStreaming(false); |
| } |
| } |
| }, |
| [ |
| activeSessionId, |
| clearLiveSessionAfterError, |
| isStreaming, |
| createSession, |
| endSession, |
| runAgentLoopFn, |
| t, |
| ], |
| ); |
|
|
| |
| |
| |
| const startDiscussion = useCallback( |
| async (request: DiscussionRequest): Promise<void> => { |
| log.info(`[ChatArea] Starting discussion: "${request.topic}"`); |
| |
| |
| livePausedRef.current = false; |
|
|
| |
| const modelConfig = getCurrentModelConfig(); |
| if (!modelConfig.modelId) { |
| toast.error(t('settings.modelNotConfigured')); |
| return; |
| } |
| if (modelConfig.requiresApiKey && !modelConfig.apiKey && !modelConfig.isServerConfigured) { |
| toast.error(t('settings.setupNeeded'), { |
| description: t('settings.apiKeyDesc'), |
| }); |
| return; |
| } |
|
|
| |
| const activeQAOrDiscussion = sessionsRef.current.filter( |
| (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active', |
| ); |
| for (const session of activeQAOrDiscussion) { |
| await endSession(session.id); |
| } |
|
|
| const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2)}`; |
| const now = Date.now(); |
| const agentId = request.agentId || 'default-1'; |
|
|
| |
| const settingsState = useSettingsStore.getState(); |
| const agentIds: string[] = |
| settingsState.selectedAgentIds?.length > 0 |
| ? [...settingsState.selectedAgentIds] |
| : [agentId]; |
| |
| if (!agentIds.includes(agentId)) { |
| agentIds.unshift(agentId); |
| } |
|
|
| |
| const newSession: ChatSession = { |
| id: sessionId, |
| type: 'discussion', |
| title: request.topic, |
| status: 'active', |
| messages: [], |
| config: { |
| agentIds, |
| maxTurns: 0, |
| currentTurn: 0, |
| triggerAgentId: agentId, |
| }, |
| toolCalls: [], |
| pendingToolCalls: [], |
| createdAt: now, |
| updatedAt: now, |
| }; |
|
|
| setSessions((prev) => [...prev, newSession]); |
| setActiveSessionId(sessionId); |
| setExpandedSessionIds((prev) => new Set([...prev, sessionId])); |
|
|
| const controller = new AbortController(); |
| abortControllerRef.current = controller; |
| streamingSessionIdRef.current = sessionId; |
| setIsStreaming(true); |
|
|
| const currentState = useStageStore.getState(); |
|
|
| try { |
| const userProfileState = useUserProfileStore.getState(); |
| const mc = getCurrentModelConfig(); |
|
|
| await runAgentLoopFn( |
| sessionId, |
| { |
| messages: [], |
| storeState: { |
| stage: currentState.stage, |
| scenes: currentState.scenes, |
| currentSceneId: currentState.currentSceneId, |
| mode: currentState.mode, |
| whiteboardOpen: useCanvasStore.getState().whiteboardOpen, |
| }, |
| config: { |
| agentIds, |
| sessionType: 'discussion', |
| discussionTopic: request.topic, |
| discussionPrompt: request.prompt, |
| triggerAgentId: agentId, |
| }, |
| userProfile: { |
| nickname: userProfileState.nickname || undefined, |
| bio: userProfileState.bio || undefined, |
| }, |
| apiKey: mc.apiKey, |
| baseUrl: mc.baseUrl, |
| model: mc.modelString, |
| providerType: mc.providerType, |
| thinkingConfig: mc.thinkingConfig, |
| }, |
| controller, |
| 'discussion', |
| ); |
| } catch (error) { |
| |
| if (error instanceof DOMException && error.name === 'AbortError') { |
| log.info('[ChatArea] Discussion aborted by user'); |
| return; |
| } |
|
|
| log.error('[ChatArea] Discussion error:', error); |
| clearLiveSessionAfterError( |
| sessionId, |
| `Error starting discussion: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| } finally { |
| |
| if (abortControllerRef.current === controller) { |
| abortControllerRef.current = null; |
| streamingSessionIdRef.current = null; |
| setIsStreaming(false); |
| } |
| } |
| }, |
| |
| [clearLiveSessionAfterError, endSession, runAgentLoopFn], |
| ); |
|
|
| |
| |
| |
| const handleInterrupt = useCallback(() => { |
| if (!abortControllerRef.current) return; |
|
|
| log.info('[ChatArea] Interrupting active request'); |
| abortControllerRef.current.abort(); |
| abortControllerRef.current = null; |
| setIsStreaming(false); |
| streamingSessionIdRef.current = null; |
| }, []); |
|
|
| |
| |
| |
| |
| |
| const startLecture = useCallback( |
| async (sceneId: string): Promise<string> => { |
| |
| const existing = sessions.find( |
| (s) => |
| s.type === 'lecture' && |
| s.sceneId === sceneId && |
| (s.status === 'active' || s.status === 'completed'), |
| ); |
| if (existing) { |
| |
| |
| if (existing.status === 'completed') { |
| setSessions((prev) => |
| prev.map((s) => |
| s.id === existing.id ? { ...s, status: 'active' as SessionStatus } : s, |
| ), |
| ); |
| |
| const messageId = existing.messages[0]?.id; |
| if (messageId) { |
| lectureMessageIds.current.set(existing.id, messageId); |
| } |
| if (existing.lastActionIndex !== undefined) { |
| lectureLastActionIndexRef.current.set(existing.id, existing.lastActionIndex); |
| } |
| } |
| setActiveSessionId(existing.id); |
| setExpandedSessionIds((prev) => new Set([...prev, existing.id])); |
| return existing.id; |
| } |
|
|
| const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2)}`; |
| const now = Date.now(); |
| const messageId = `lecture-msg-${now}`; |
|
|
| const scene = useStageStore.getState().scenes.find((s) => s.id === sceneId); |
| const title = scene?.title || t('chat.lecture'); |
|
|
| const agentConfig = useAgentRegistry.getState().getAgent('default-1'); |
|
|
| |
| const lectureMessage: UIMessage<ChatMessageMetadata> = { |
| id: messageId, |
| role: 'assistant', |
| parts: [], |
| metadata: { |
| senderName: agentConfig?.name || t('settings.agentNames.default-1'), |
| senderAvatar: agentConfig?.avatar, |
| originalRole: 'teacher', |
| agentId: 'default-1', |
| createdAt: now, |
| }, |
| }; |
|
|
| const newSession: ChatSession = { |
| id: sessionId, |
| type: 'lecture', |
| title, |
| status: 'active', |
| messages: [lectureMessage], |
| config: { |
| agentIds: ['default-1'], |
| maxTurns: 0, |
| currentTurn: 0, |
| }, |
| toolCalls: [], |
| pendingToolCalls: [], |
| sceneId, |
| lastActionIndex: -1, |
| createdAt: now, |
| updatedAt: now, |
| }; |
|
|
| lectureMessageIds.current.set(sessionId, messageId); |
|
|
| setSessions((prev) => [...prev, newSession]); |
| setActiveSessionId(sessionId); |
| setExpandedSessionIds((prev) => new Set([...prev, sessionId])); |
|
|
| log.info(`[ChatArea] Created lecture session: ${sessionId} for scene ${sceneId}`); |
| return sessionId; |
| }, |
| [sessions, t], |
| ); |
|
|
| |
| |
| |
| |
| |
| const addLectureMessage = useCallback( |
| (sessionId: string, action: Action, actionIndex: number) => { |
| const messageId = lectureMessageIds.current.get(sessionId); |
| if (!messageId) return; |
|
|
| |
| const lastIndex = lectureLastActionIndexRef.current.get(sessionId) ?? -1; |
| if (actionIndex <= lastIndex) return; |
| lectureLastActionIndexRef.current.set(sessionId, actionIndex); |
|
|
| |
| setSessions((prev) => |
| prev.map((s) => |
| s.id === sessionId ? { ...s, lastActionIndex: actionIndex, updatedAt: Date.now() } : s, |
| ), |
| ); |
|
|
| |
| let buffer = buffersRef.current.get(sessionId); |
| if (!buffer || buffer.disposed) { |
| buffer = createBufferForSession(sessionId, 'lecture'); |
| } |
|
|
| if (action.type === 'speech') { |
| buffer.pushText(messageId, action.text, 'default-1'); |
| buffer.sealText(messageId); |
| } else if ( |
| action.type === 'spotlight' || |
| action.type === 'laser' || |
| action.type === 'discussion' |
| ) { |
| const now = Date.now(); |
| buffer.pushAction({ |
| messageId, |
| actionId: `${action.type}-${now}`, |
| actionName: action.type, |
| params: |
| action.type === 'spotlight' |
| ? { |
| elementId: action.elementId, |
| dimOpacity: (action as SpotlightAction).dimOpacity, |
| } |
| : action.type === 'laser' |
| ? { elementId: action.elementId } |
| : { |
| topic: (action as DiscussionAction).topic, |
| prompt: (action as DiscussionAction).prompt, |
| }, |
| agentId: 'default-1', |
| }); |
| } |
| }, |
| [createBufferForSession], |
| ); |
|
|
| |
| const activeSession = sessions.find((s) => s.id === activeSessionId); |
| const activeSessionType = activeSession?.type ?? null; |
|
|
| const getLectureMessageId = useCallback((sessionId: string): string | null => { |
| return lectureMessageIds.current.get(sessionId) ?? null; |
| }, []); |
|
|
| |
| const pauseBuffer = useCallback((sessionId: string) => { |
| const buf = buffersRef.current.get(sessionId); |
| if (buf) buf.pause(); |
| }, []); |
|
|
| |
| const resumeBuffer = useCallback((sessionId: string) => { |
| const buf = buffersRef.current.get(sessionId); |
| if (buf) buf.resume(); |
| }, []); |
|
|
| |
| const pauseActiveLiveBuffer = useCallback((): boolean => { |
| const active = sessionsRef.current.find( |
| (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active', |
| ); |
| if (!active) return false; |
| const buf = buffersRef.current.get(active.id); |
| if (!buf || buf.disposed) return false; |
| livePausedRef.current = true; |
| buf.pause(); |
| log.info('[ChatArea] Buffer-paused discussion:', active.id); |
| return true; |
| }, []); |
|
|
| |
| const resumeActiveLiveBuffer = useCallback(() => { |
| const active = sessionsRef.current.find( |
| (s) => (s.type === 'qa' || s.type === 'discussion') && s.status === 'active', |
| ); |
| if (!active) return; |
| livePausedRef.current = false; |
| const buf = buffersRef.current.get(active.id); |
| if (buf) buf.resume(); |
| log.info('[ChatArea] Buffer-resumed discussion:', active.id); |
| }, []); |
|
|
| return { |
| sessions, |
| activeSessionId, |
| activeSessionType, |
| expandedSessionIds, |
| isStreaming, |
| createSession, |
| endSession, |
| endActiveSession, |
| softPauseActiveSession, |
| resumeActiveSession, |
| sendMessage, |
| startDiscussion, |
| startLecture, |
| addLectureMessage, |
| toggleSessionExpand, |
| handleInterrupt, |
| getLectureMessageId, |
| pauseBuffer, |
| resumeBuffer, |
| pauseActiveLiveBuffer, |
| resumeActiveLiveBuffer, |
| }; |
| } |
|
|