Files
openmaic/OpenMAIC/components/chat/use-chat-sessions.ts

2206 lines
76 KiB
TypeScript

'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import {
nextChatUpdatedAt,
withChatSegmentReveal,
withChatSessionStatus,
type ChatSession,
type SessionType,
type SessionStatus,
type ChatMessageMetadata,
type DirectorState,
type PiSessionBoundaryContext,
type StatelessEvent,
} 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 { fetchMakeloreLearningAgent } from '@/lib/makelore-runtime/browser-bridge';
import { ActionEngine } from '@/lib/action/engine';
import {
buildQuizResultsForStoreState,
didActiveSceneRemainUnchanged,
} from '@/lib/chat/quiz-results-for-store-state';
import { toast } from 'sonner';
import { createLogger } from '@/lib/logger';
import { isPiChatEnabled } from '@/lib/config/feature-flags';
import type { CleanupSource } from '@/lib/playback/auto-resume';
import { nanoid } from 'nanoid';
const log = createLogger('ChatSessions');
const SOFT_CLOSE_TIMEOUT_MS = 15_000;
export interface SoftCloseRegistration {
token: string;
deadline: number;
timer: ReturnType<typeof setTimeout>;
endReason?: string;
directorState?: DirectorState;
}
export function takeSoftCloseRegistration(
registrations: Map<string, SoftCloseRegistration>,
sessionId: string,
expectedToken?: string,
): SoftCloseRegistration | undefined {
const registration = registrations.get(sessionId);
if (!registration || (expectedToken && registration.token !== expectedToken)) return undefined;
registrations.delete(sessionId);
clearTimeout(registration.timer);
return registration;
}
/** Context used by playback cleanup and optional lecture auto-resume. */
export interface SessionCleanupPayload {
sessionId: string;
endReason?: string;
source: CleanupSource;
}
export interface EndSessionOptions {
source?: CleanupSource;
}
export const MANUAL_STOP_END_OPTIONS: EndSessionOptions = { source: 'manual_stop' };
export interface PreviousLiveSessionContext {
endSource?: CleanupSource;
sceneId?: string;
}
export function createPreviousLiveSessionContext(
session: Pick<ChatSession, 'sceneId'>,
currentSceneId: string | null | undefined,
endSource?: CleanupSource,
): PreviousLiveSessionContext {
return {
endSource,
sceneId: session.sceneId ?? currentSceneId ?? undefined,
};
}
export function createPiSessionBoundaryContext(
previous: PreviousLiveSessionContext | undefined,
currentSceneId: string | null | undefined,
): PiSessionBoundaryContext {
return {
isFirstRequestInLiveSession: true,
previousEndSource: previous?.endSource,
sameSceneAsPrevious:
previous?.sceneId && currentSceneId ? previous.sceneId === currentSceneId : undefined,
};
}
export function getPiSessionBoundaryContext(
contexts: Map<string, PiSessionBoundaryContext>,
sessionId: string,
): PiSessionBoundaryContext | undefined {
return contexts.get(sessionId);
}
export function consumePiSessionBoundaryContext(
contexts: Map<string, PiSessionBoundaryContext>,
sessionId: string,
expected: PiSessionBoundaryContext,
): boolean {
if (contexts.get(sessionId) !== expected) return false;
return contexts.delete(sessionId);
}
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;
/** Called immediately when the server semantically closes a QA/Discussion session. */
onSoftCloseSession?: (payload: SessionCleanupPayload) => void;
/** Called when a QA/Discussion session completes naturally (director end). */
onStopSession?: (payload: SessionCleanupPayload) => void;
onSegmentSealed?: (
messageId: string,
partId: string,
fullText: string,
agentId: string | null,
) => void;
/** When provided and returns true, StreamBuffer holds on the current text item after reveal. */
shouldHoldAfterReveal?: () => { holding: boolean; segmentDone: number } | boolean;
}
export type ChatRequestTemplate = {
messages: UIMessage<ChatMessageMetadata>[];
config: {
agentIds: string[];
sessionType?: string;
agentConfigs?: Record<string, unknown>[];
piEnableWhiteboardTools?: boolean;
[key: string]: unknown;
};
userProfile?: { nickname?: string; bio?: string };
apiKey: string;
baseUrl?: string;
model?: string;
providerType?: string;
thinkingConfig?: ThinkingConfig;
directorState?: DirectorState;
piSessionBoundary?: PiSessionBoundaryContext;
};
/**
* One fresh store-state snapshot for an outgoing chat request. Quiz results
* come from the async RuntimeStore read; the before/after scene compare drops
* them when a scene transition raced the read (a stale scene's results must
* not leak into the next scene's request).
*/
async function buildFreshAgentLoopStoreState(): Promise<AgentLoopStoreState> {
const stateBeforeQuizRead = useStageStore.getState();
const quizResults = await buildQuizResultsForStoreState(
stateBeforeQuizRead.scenes,
stateBeforeQuizRead.currentSceneId,
);
const freshState = useStageStore.getState();
return {
stage: freshState.stage,
scenes: freshState.scenes,
outlines: freshState.outlines,
currentSceneId: freshState.currentSceneId,
mode: freshState.mode,
whiteboardOpen: useCanvasStore.getState().whiteboardOpen,
quizResults: didActiveSceneRemainUnchanged(
stateBeforeQuizRead.scenes,
stateBeforeQuizRead.currentSceneId,
freshState.scenes,
freshState.currentSceneId,
)
? quizResults
: undefined,
};
}
export function withPiInclassWhiteboardTools<T extends ChatRequestTemplate>(requestTemplate: T): T {
return {
...requestTemplate,
config: {
...requestTemplate.config,
piEnableWhiteboardTools: true,
},
};
}
export function shouldAwaitPresentationAction(actionName: string): boolean {
return actionName.startsWith('wb_');
}
export async function retireLiveRequestResources<
T extends { shutdown(): void; waitForCurrentAction?(): Promise<void> },
>(
controller: AbortController | null,
sessionId: string | null,
buffers: Map<string, T>,
): Promise<void> {
controller?.abort();
if (!sessionId) return;
const buffer = buffers.get(sessionId);
buffer?.shutdown();
buffers.delete(sessionId);
await buffer?.waitForCurrentAction?.();
}
type StatelessStreamConsumerFactory = (
sessionId: string,
controller: AbortController,
sessionType: SessionType,
) => {
onEvent: (event: StatelessEvent) => void;
onIterationEnd: () => Promise<{
directorState: DirectorState | undefined;
totalAgents: number;
agentHadContent: boolean;
cueUserReceived?: boolean;
sessionClosed?: boolean;
endReason?: string;
} | null>;
};
export function normalizeStoredSessionsForRestore(sessions: ChatSession[]): ChatSession[] {
return sessions.map((session) => {
if (session.status === 'active') {
return {
...withChatSessionStatus(session, 'interrupted'),
softCloseDeadline: undefined,
};
}
if (session.status === 'soft-closing') {
return {
...withChatSessionStatus(session, 'completed'),
softCloseDeadline: undefined,
};
}
return session.softCloseDeadline ? { ...session, softCloseDeadline: undefined } : session;
});
}
function isLiveSessionType(session: Pick<ChatSession, 'type'>): boolean {
return session.type === 'qa' || session.type === 'discussion';
}
export function isOpenLiveSession(session: Pick<ChatSession, 'type' | 'status'>): boolean {
return (
isLiveSessionType(session) && (session.status === 'active' || session.status === 'soft-closing')
);
}
export function resumeSoftClosingSessionForFollowUp(
session: ChatSession,
userMessage: UIMessage<ChatMessageMetadata>,
now: number,
): ChatSession {
return {
...session,
messages: [...session.messages, userMessage],
status: 'active' as SessionStatus,
endReason: undefined,
softCloseDeadline: undefined,
updatedAt: nextChatUpdatedAt(session, now),
};
}
export function resumeSoftClosingSessionWithoutMessage(
session: ChatSession,
now: number,
): ChatSession | undefined {
if (session.status !== 'soft-closing') return undefined;
return {
...session,
status: 'active',
endReason: undefined,
softCloseDeadline: undefined,
updatedAt: nextChatUpdatedAt(session, now),
};
}
export type PiSingleRequestOutcome =
| { type: 'error'; messageKey: 'chat.error.streamInterrupted' }
| { type: 'soft_closing'; endReason?: string; directorState?: DirectorState }
| { type: 'cue_user'; directorState?: DirectorState }
| { type: 'completed'; directorState?: DirectorState };
export function getPiSingleRequestOutcome(
doneData: Awaited<ReturnType<ReturnType<StatelessStreamConsumerFactory>['onIterationEnd']>>,
): PiSingleRequestOutcome {
if (!doneData) {
return { type: 'error', messageKey: 'chat.error.streamInterrupted' };
}
if (doneData.sessionClosed) {
return {
type: 'soft_closing',
endReason: doneData.endReason,
directorState: doneData.directorState,
};
}
if (doneData.totalAgents === 0 || doneData.agentHadContent === false) {
return { type: 'error', messageKey: 'chat.error.streamInterrupted' };
}
if (doneData.cueUserReceived) {
return { type: 'cue_user', directorState: doneData.directorState };
}
return { type: 'completed', directorState: doneData.directorState };
}
export async function runPiSingleRequest(
sessionId: string,
requestTemplate: ChatRequestTemplate & { storeState: AgentLoopStoreState },
controller: AbortController,
sessionType: SessionType,
createConsumer: StatelessStreamConsumerFactory,
clearLiveSessionAfterError: (sessionId: string, message: string) => void,
enterSoftClosing: (
sessionId: string,
data: { endReason?: string; directorState?: DirectorState },
) => void,
markSessionCompleted: (
sessionId: string,
data?: { endReason?: string; directorState?: DirectorState; source?: CleanupSource },
) => void,
storeDirectorState: (sessionId: string, directorState?: DirectorState) => void,
onStopSessionRef: { current?: ((payload: SessionCleanupPayload) => void) | undefined },
t: (key: string) => string,
onResponseAccepted?: () => void,
): Promise<void> {
const consumer = createConsumer(sessionId, controller, sessionType);
const response = await fetch('/api/chat/pi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestTemplate),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Pi chat request failed: ${response.status}`);
}
if (!response.body) {
throw new Error('Pi chat response body is empty');
}
onResponseAccepted?.();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let sseBuffer = '';
let sawDoneEvent = false;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
sseBuffer += decoder.decode(value, { stream: true });
const parts = sseBuffer.split('\n\n');
sseBuffer = parts.pop() || '';
for (const part of parts) {
if (!part.trim() || part.startsWith(':')) continue;
const dataLine = part.split('\n').find((line) => line.startsWith('data: '));
if (!dataLine) continue;
const event = JSON.parse(dataLine.slice(6)) as StatelessEvent;
if (event.type === 'done') sawDoneEvent = true;
consumer.onEvent(event);
}
}
const doneData = sawDoneEvent ? await consumer.onIterationEnd() : null;
if (controller.signal.aborted) return;
const outcome = getPiSingleRequestOutcome(doneData);
if (outcome.type === 'error') {
clearLiveSessionAfterError(sessionId, t(outcome.messageKey));
onStopSessionRef.current?.({ sessionId, source: 'error' });
return;
}
if (outcome.type === 'soft_closing') {
enterSoftClosing(sessionId, {
endReason: outcome.endReason,
directorState: outcome.directorState,
});
return;
}
if (outcome.type === 'cue_user') {
storeDirectorState(sessionId, outcome.directorState);
return;
}
// The Pi endpoint owns the whole server-side loop; one successful done closes
// this classroom turn instead of asking the frontend to POST another turn.
markSessionCompleted(sessionId, {
directorState: outcome.directorState,
source: 'turn_complete',
});
onStopSessionRef.current?.({ sessionId, source: 'turn_complete' });
}
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 onSoftCloseSessionRef = useRef(options.onSoftCloseSession);
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;
onSoftCloseSessionRef.current = options.onSoftCloseSession;
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.onSoftCloseSession,
options.onStopSession,
options.onSegmentSealed,
options.shouldHoldAfterReveal,
]);
const { t } = useI18n();
// Track current stageId for data isolation
const stageId = useStageStore((s) => s.stage?.id);
const stageIdRef = useRef(stageId);
const currentSceneId = useStageStore((s) => s.currentSceneId);
const currentSceneIdRef = useRef(currentSceneId);
const [sessions, setSessions] = useState<ChatSession[]>(() => {
// Restore sessions from store (loaded from IndexedDB)
const stored = useStageStore.getState().chats;
return normalizeStoredSessionsForRestore(stored);
});
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 softCloseRegistrationsRef = useRef<Map<string, SoftCloseRegistration>>(new Map());
const softCloseLifecycleRef = useRef<Map<string, 'soft-closing' | 'active' | 'completed'>>(
new Map(),
);
const sessionsRef = useRef<ChatSession[]>(sessions);
const previousLiveSessionRef = useRef<PreviousLiveSessionContext | undefined>(undefined);
const piSessionBoundariesRef = useRef<Map<string, PiSessionBoundaryContext>>(new Map());
useEffect(() => {
sessionsRef.current = sessions;
}, [sessions]);
// Per-loop-iteration state — tracks done event data and cue_user for the agent loop
const loopDoneDataRef = useRef<{
directorState?: DirectorState;
totalAgents: number;
agentHadContent?: boolean;
cueUserReceived: boolean;
sessionClosed?: boolean;
endReason?: string;
} | null>(null);
// Reload sessions when stage changes (course switch)
// This synchronous setState is intentional: it resets derived state from
// an external store (IndexedDB) when the stageId dependency changes.
useEffect(() => {
if (stageId === stageIdRef.current) return;
stageIdRef.current = stageId;
softCloseRegistrationsRef.current.forEach(({ timer }) => clearTimeout(timer));
softCloseRegistrationsRef.current.clear();
softCloseLifecycleRef.current.clear();
// Stage changed — reload sessions from store (already populated by loadFromStorage)
const stored = useStageStore.getState().chats;
setSessions(normalizeStoredSessionsForRestore(stored));
setActiveSessionId(null);
setExpandedSessionIds(new Set());
previousLiveSessionRef.current = undefined;
piSessionBoundariesRef.current.clear();
}, [stageId]);
useEffect(() => {
if (currentSceneId === currentSceneIdRef.current) return;
currentSceneIdRef.current = currentSceneId;
}, [currentSceneId]);
const registerFirstPiRequest = useCallback((sessionId: string) => {
piSessionBoundariesRef.current.set(
sessionId,
createPiSessionBoundaryContext(previousLiveSessionRef.current, currentSceneIdRef.current),
);
}, []);
const getFirstPiRequestContext = useCallback((sessionId: string) => {
return getPiSessionBoundaryContext(piSessionBoundariesRef.current, sessionId);
}, []);
// Sync sessions back to store for persistence (debounced via store's debouncedSave)
// Guard: only write to the currently active stage
useEffect(() => {
if (stageIdRef.current && stageIdRef.current === useStageStore.getState().stage?.id) {
useStageStore.getState().setChats(sessions);
}
}, [sessions]);
// StreamBuffer instances per session (SSE + lecture share the same buffer model)
const buffersRef = useRef<Map<string, StreamBuffer>>(new Map());
const pendingRetirementRef = useRef<Promise<void>>(Promise.resolve());
const retireActiveLiveRequest = useCallback(
(fallbackSessionId?: string | null): string | null => {
const interruptedSessionId = streamingSessionIdRef.current ?? fallbackSessionId ?? null;
const previousRetirement = pendingRetirementRef.current;
const retirement = retireLiveRequestResources(
abortControllerRef.current,
interruptedSessionId,
buffersRef.current,
);
pendingRetirementRef.current = Promise.all([previousRetirement, retirement]).then(
() => undefined,
);
abortControllerRef.current = null;
streamingSessionIdRef.current = null;
setIsStreaming(false);
return interruptedSessionId;
},
[],
);
// Abort active stream and destroy buffers on unmount
useEffect(() => {
const buffers = buffersRef.current;
const softCloseRegistrations = softCloseRegistrationsRef.current;
const softCloseLifecycle = softCloseLifecycleRef.current;
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
softCloseRegistrations.forEach(({ timer }) => clearTimeout(timer));
softCloseRegistrations.clear();
softCloseLifecycle.clear();
buffers.forEach((buf) => buf.shutdown());
buffers.clear();
};
}, []);
const claimSoftCloseRegistration = useCallback(
(sessionId: string, expectedToken?: string): SoftCloseRegistration | undefined => {
return takeSoftCloseRegistration(softCloseRegistrationsRef.current, sessionId, expectedToken);
},
[],
);
const clearSoftCloseRegistration = useCallback(
(sessionId: string): boolean => {
return Boolean(claimSoftCloseRegistration(sessionId));
},
[claimSoftCloseRegistration],
);
const markSessionCompleted = useCallback(
(
sessionId: string,
data?: { endReason?: string; directorState?: DirectorState; source?: CleanupSource },
) => {
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
if (session && isLiveSessionType(session)) {
previousLiveSessionRef.current = createPreviousLiveSessionContext(
session,
currentSceneIdRef.current,
data?.source,
);
}
piSessionBoundariesRef.current.delete(sessionId);
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? {
...s,
status: 'completed' as SessionStatus,
updatedAt: Date.now(),
endReason: data?.endReason ?? s.endReason,
softCloseDeadline: undefined,
directorState: data?.directorState ?? s.directorState,
}
: s,
),
);
},
[],
);
const finishSoftCloseTimeout = useCallback(
(sessionId: string, expectedToken?: string): boolean => {
const registration = claimSoftCloseRegistration(sessionId, expectedToken);
if (!registration || softCloseLifecycleRef.current.get(sessionId) !== 'soft-closing') {
return false;
}
softCloseLifecycleRef.current.set(sessionId, 'completed');
const retirement = retireLiveRequestResources(null, sessionId, buffersRef.current);
pendingRetirementRef.current = Promise.all([pendingRetirementRef.current, retirement]).then(
() => undefined,
);
markSessionCompleted(sessionId, {
endReason: registration.endReason,
directorState: registration.directorState,
source: 'soft_close_timeout',
});
onStopSessionRef.current?.({
sessionId,
endReason: registration.endReason,
source: 'soft_close_timeout',
});
return true;
},
[claimSoftCloseRegistration, markSessionCompleted],
);
const storeDirectorState = useCallback((sessionId: string, directorState?: DirectorState) => {
if (!directorState) return;
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? {
...s,
directorState,
updatedAt: nextChatUpdatedAt(s),
}
: s,
),
);
}, []);
const enterSoftClosing = useCallback(
(sessionId: string, data: { endReason?: string; directorState?: DirectorState }) => {
clearSoftCloseRegistration(sessionId);
const token = nanoid();
const deadline = Date.now() + SOFT_CLOSE_TIMEOUT_MS;
softCloseLifecycleRef.current.set(sessionId, 'soft-closing');
// Entering the soft-closing window is NOT the timeout — only the 15s timer
// firing below counts as soft_close_timeout (the auto-resume trigger).
onSoftCloseSessionRef.current?.({
sessionId,
endReason: data.endReason,
source: 'soft_close_enter',
});
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? {
...s,
status: 'soft-closing' as SessionStatus,
updatedAt: Date.now(),
endReason: data.endReason ?? s.endReason,
softCloseDeadline: deadline,
directorState: data.directorState ?? s.directorState,
}
: s,
),
);
const timer = setTimeout(() => {
finishSoftCloseTimeout(sessionId, token);
}, SOFT_CLOSE_TIMEOUT_MS);
softCloseRegistrationsRef.current.set(sessionId, {
token,
deadline,
timer,
endReason: data.endReason,
directorState: data.directorState,
});
},
[clearSoftCloseRegistration, finishSoftCloseTimeout],
);
useEffect(() => {
const reconcileExpiredSoftClose = () => {
if (document.visibilityState !== 'visible') return;
const now = Date.now();
for (const [sessionId, registration] of softCloseRegistrationsRef.current) {
if (now >= registration.deadline) {
finishSoftCloseTimeout(sessionId, registration.token);
}
}
};
document.addEventListener('visibilitychange', reconcileExpiredSoftClose);
return () => document.removeEventListener('visibilitychange', reconcileExpiredSoftClose);
}, [finishSoftCloseTimeout]);
// Session-scoped "paused intent" — survives buffer recreation across turns.
// When true, newly created discussion/QA buffers are immediately paused.
const livePausedRef = useRef(false);
const clearLiveSessionAfterError = useCallback(
(sessionId: string, message: string) => {
const now = Date.now();
const errorMessageId = `error-${now}`;
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
if (session && isLiveSessionType(session)) {
previousLiveSessionRef.current = createPreviousLiveSessionContext(
session,
currentSceneIdRef.current,
'error',
);
}
if (streamingSessionIdRef.current === sessionId) {
retireActiveLiveRequest(sessionId);
} else {
const retirement = retireLiveRequestResources(null, sessionId, buffersRef.current);
pendingRetirementRef.current = Promise.all([pendingRetirementRef.current, retirement]).then(
() => undefined,
);
}
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? {
...s,
status: 'error' as SessionStatus,
softCloseDeadline: undefined,
updatedAt: nextChatUpdatedAt(s, 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);
}
},
[retireActiveLiveRequest],
);
// Tracks the single message ID per lecture session
const lectureMessageIds = useRef<Map<string, string>>(new Map());
// Tracks last action index per lecture session (avoids stale closure reads)
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;
});
}, []);
/**
* Create a StreamBuffer for a session and wire its callbacks to React state.
* Returns the buffer instance (also stored in buffersRef).
*/
const createBufferForSession = useCallback(
(sessionId: string, type?: SessionType): StreamBuffer => {
// Dispose previous buffer if any
// Shutdown (not dispose) — avoids stale onLiveSpeech(null,null) callback
const prev = buffersRef.current.get(sessionId);
if (prev) prev.shutdown();
// For discussion/QA sessions, add pacing delays so fast models don't
// rush through text and actions. Lecture pacing is handled by PlaybackEngine.
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: nextChatUpdatedAt(s, now),
}
: s,
),
);
onActiveBubbleRef.current?.(data.messageId);
},
onAgentEnd() {
// Remove empty assistant messages (agent started but produced no content)
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, updatedAt: nextChatUpdatedAt(s) }
: s;
}),
);
},
onTextReveal(
messageId: string,
partId: string,
revealedText: string,
isComplete: boolean,
) {
setSessions((prev) =>
prev.map((s) => {
if (s.id !== sessionId) return s;
const revealed = {
...s,
messages: s.messages.map((m) => {
if (m.id !== messageId) return m;
const parts = [...m.parts];
// Match by _partId (supports multiple text parts per message, e.g. lecture)
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 };
}),
// Don't update updatedAt on every tick — avoids thrashing persistence sync
};
return withChatSegmentReveal(revealed, isComplete);
}),
);
},
async onActionReady(messageId: string, data: ActionItem, signal: AbortSignal) {
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: nextChatUpdatedAt(s),
};
}),
);
// Whiteboard effects mutate shared stage state after animation delays,
// so keep them ordered. Long-running media playback stays interruptible.
try {
const actionEngine = new ActionEngine(useStageStore);
const action = {
id: data.actionId,
type: data.actionName,
...data.params,
} as Action;
const execution = actionEngine.execute(action, { signal });
if (shouldAwaitPresentationAction(data.actionName)) {
await execution;
} else {
void execution.catch((err) => log.warn('[Buffer] Action execution error:', err));
}
} catch (err) {
log.warn('[Buffer] Action execution error:', err);
}
},
onLiveSpeech(text: string | null, agentId: string | null) {
// Lecture sessions: roundtable text is managed by PlaybackEngine → setLectureSpeech
// in stage.tsx. Buffer only drives chat area pacing for lectures.
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) {
// Track cue_user for agent loop
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;
cueUserReceived?: boolean;
sessionClosed?: boolean;
endReason?: string;
directorState?: DirectorState;
}) {
// Store done data for agent loop consumption
loopDoneDataRef.current = {
directorState: data.directorState,
totalAgents: data.totalAgents,
agentHadContent: data.agentHadContent ?? true,
cueUserReceived:
data.cueUserReceived ?? loopDoneDataRef.current?.cueUserReceived ?? false,
sessionClosed: data.sessionClosed,
endReason: data.endReason,
};
// Session completion is handled by runAgentLoopFn, not here
// (Lectures don't use the agent loop and complete via endSession)
},
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();
// Inherit paused intent for discussion/QA sessions so new-turn buffers
// don't start revealing text while the user has paused reading.
if (type !== 'lecture' && livePausedRef.current) {
buffer.pause();
}
return buffer;
},
[],
);
const createStatelessStreamConsumer = useCallback(
(sessionId: string, controller: AbortController, sessionType: SessionType) => {
let currentBuffer: StreamBuffer | null = null;
let currentMessageId: string | null = null;
const onEvent = (event: StatelessEvent) => {
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);
}
};
const 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,
sessionClosed: doneData.sessionClosed,
endReason: doneData.endReason,
};
};
return { onEvent, onIterationEnd };
},
[createBufferForSession],
);
/**
* Frontend-driven agent loop. Delegates to the shared runAgentLoop
* from lib/chat/agent-loop.ts, wiring StreamBuffer for UI pacing.
*
* Each iteration: POST /api/chat → process SSE → wait for buffer drain → check outcome.
*/
const runAgentLoopFn = useCallback(
async (
sessionId: string,
requestTemplate: ChatRequestTemplate,
controller: AbortController,
sessionType: SessionType,
): Promise<void> => {
// Attach full configs for generated (non-default) agents so the server can use them.
// The server-side registry only has default agents; generated agents exist only client-side.
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;
}
if (window.__MAKELORE_OFFLINE_PLAYER__) {
const streamConsumer = createStatelessStreamConsumer(sessionId, controller, sessionType);
const storeState = await buildFreshAgentLoopStoreState();
const response = await fetchMakeloreLearningAgent({
messages: requestTemplate.messages,
storeState,
}, controller.signal);
if (!response.ok) throw new Error(await response.text());
const reader = response.body?.getReader();
if (!reader) throw new Error('助教没有返回内容');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() || '';
for (const frame of frames) {
const data = frame.split('\n').find((line) => line.startsWith('data: '));
if (data) streamConsumer.onEvent(JSON.parse(data.slice(6)) as StatelessEvent);
}
}
await streamConsumer.onIterationEnd();
return;
}
if (isPiChatEnabled()) {
// Pi bypasses runAgentLoop's per-iteration getStoreState, so its single
// request needs the snapshot built here — /api/chat/pi rejects bodies
// without storeState.
const storeState = await buildFreshAgentLoopStoreState();
const firstRequestContext = getFirstPiRequestContext(sessionId);
const piRequestTemplate = firstRequestContext
? { ...requestTemplate, storeState, piSessionBoundary: firstRequestContext }
: { ...requestTemplate, storeState };
await runPiSingleRequest(
sessionId,
withPiInclassWhiteboardTools(piRequestTemplate),
controller,
sessionType,
createStatelessStreamConsumer,
clearLiveSessionAfterError,
enterSoftClosing,
markSessionCompleted,
storeDirectorState,
onStopSessionRef,
t,
firstRequestContext
? () => {
consumePiSessionBoundaryContext(
piSessionBoundariesRef.current,
sessionId,
firstRequestContext,
);
}
: undefined,
);
return;
}
const streamConsumer = createStatelessStreamConsumer(sessionId, controller, sessionType);
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: buildFreshAgentLoopStoreState,
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: streamConsumer.onEvent,
onIterationEnd: streamConsumer.onIterationEnd,
},
controller.signal,
);
// Handle loop completion (UI-specific). Map each outcome.reason to a
// distinct session state — don't conflate error paths with completion.
if (!controller.signal.aborted) {
switch (outcome.reason) {
case 'cue_user':
// Session stays active; UI waits for the next user message.
break;
case 'end':
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? {
...s,
status: 'completed' as SessionStatus,
updatedAt: nextChatUpdatedAt(s),
}
: s,
),
);
onStopSessionRef.current?.({ sessionId, source: 'turn_complete' });
break;
case 'empty_turns':
clearLiveSessionAfterError(sessionId, t('chat.error.emptyAgentResponses'));
onStopSessionRef.current?.({ sessionId, source: 'error' });
break;
case 'no_done':
clearLiveSessionAfterError(sessionId, t('chat.error.streamInterrupted'));
onStopSessionRef.current?.({ sessionId, source: 'error' });
break;
case 'aborted':
// Already handled elsewhere via abort signal.
break;
}
}
},
[
clearLiveSessionAfterError,
createStatelessStreamConsumer,
enterSoftClosing,
markSessionCompleted,
storeDirectorState,
t,
getFirstPiRequestContext,
],
);
/**
* Create a new chat session
*/
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();
if (type === 'qa' || type === 'discussion') registerFirstPiRequest(sessionId);
const newSession: ChatSession = {
id: sessionId,
type,
title,
status: 'active',
messages: [],
config: {
agentIds: ['default-1'],
defaultAgentId: 'default-1',
},
toolCalls: [],
pendingToolCalls: [],
sceneId: currentSceneIdRef.current ?? undefined,
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;
},
[registerFirstPiRequest],
);
/**
* End a chat session.
* For QA/Discussion sessions with active streaming, appends "..." + interrupted marker.
*/
const endSession = useCallback(
async (sessionId: string, options: EndSessionOptions = {}): Promise<void> => {
log.info(`[ChatArea] Ending session: ${sessionId}`);
clearSoftCloseRegistration(sessionId);
softCloseLifecycleRef.current.set(sessionId, 'completed');
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
);
// Retire presentation work before callers start another session. An action
// already inside ActionEngine cannot be cancelled, so wait for it to settle.
if (wasStreaming) {
retireActiveLiveRequest(sessionId);
} else {
const retirement = retireLiveRequestResources(null, sessionId, buffersRef.current);
pendingRetirementRef.current = Promise.all([pendingRetirementRef.current, retirement]).then(
() => undefined,
);
}
await pendingRetirementRef.current;
piSessionBoundariesRef.current.delete(sessionId);
if (isLiveSession && session) {
previousLiveSessionRef.current = createPreviousLiveSessionContext(
session,
currentSceneIdRef.current,
options.source,
);
}
lectureMessageIds.current.delete(sessionId);
lectureLastActionIndexRef.current.delete(sessionId);
if (isLiveSession && wasStreaming) {
// Append "..." + interrupted marker to last assistant message
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 {
...withChatSessionStatus(s, 'completed'),
messages,
softCloseDeadline: undefined,
};
}),
);
// Clear roundtable state via callbacks
onLiveSpeechRef.current?.(null, null);
onThinkingRef.current?.(null);
} else {
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? {
...withChatSessionStatus(s, 'completed'),
softCloseDeadline: undefined,
}
: s,
),
);
}
if (activeSessionId === sessionId) {
setActiveSessionId(null);
}
},
[activeSessionId, clearSoftCloseRegistration, retireActiveLiveRequest],
);
const continueSoftClosingSession = useCallback(
(sessionId: string): boolean => {
if (
softCloseLifecycleRef.current.get(sessionId) !== 'soft-closing' ||
!claimSoftCloseRegistration(sessionId)
) {
return false;
}
softCloseLifecycleRef.current.set(sessionId, 'active');
const now = Date.now();
setSessions((prev) =>
prev.map((session) =>
session.id === sessionId
? (resumeSoftClosingSessionWithoutMessage(session, now) ?? session)
: session,
),
);
return true;
},
[claimSoftCloseRegistration],
);
const confirmSoftClosingSession = useCallback(
async (sessionId: string): Promise<SessionCleanupPayload | undefined> => {
const registration = claimSoftCloseRegistration(sessionId);
if (!registration || softCloseLifecycleRef.current.get(sessionId) !== 'soft-closing') {
return undefined;
}
softCloseLifecycleRef.current.set(sessionId, 'completed');
const payload: SessionCleanupPayload = {
sessionId,
endReason: registration.endReason,
source: 'soft_close_confirmed',
};
await endSession(sessionId, { source: 'soft_close_confirmed' });
return payload;
},
[claimSoftCloseRegistration, endSession],
);
/**
* End the currently active QA/Discussion session (if any).
*/
const endActiveSession = useCallback(
async (options: EndSessionOptions = {}): Promise<void> => {
const active = sessionsRef.current.find(isOpenLiveSession);
if (active) {
await endSession(active.id, options);
}
},
[endSession],
);
/**
* Soft-pause the active QA/Discussion session.
* Aborts SSE and appends "..." + interrupted marker, but keeps session 'active'
* so the user can continue speaking in the same topic.
*/
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
);
if (wasStreaming) {
retireActiveLiveRequest(sessionId);
} else {
const retirement = retireLiveRequestResources(null, sessionId, buffersRef.current);
pendingRetirementRef.current = Promise.all([pendingRetirementRef.current, retirement]).then(
() => undefined,
);
}
await pendingRetirementRef.current;
if (wasStreaming) {
// Append "..." + interrupted marker to last assistant message, keep status 'active'
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;
}
}
// Keep status 'active' — session continues when user speaks
return { ...s, messages, updatedAt: nextChatUpdatedAt(s) };
}),
);
// Note: Do NOT call onLiveSpeech/onThinking here.
// Caller (doSoftPause) manages roundtable state to keep the interrupted bubble visible.
}
log.info(`[ChatArea] Soft-paused session: ${sessionId}`);
},
[retireActiveLiveRequest],
);
/**
* Soft-pause the currently active QA/Discussion session (if any).
*/
const softPauseActiveSession = useCallback(async (): Promise<void> => {
const active = sessionsRef.current.find(isOpenLiveSession);
if (active) {
await softPauseSession(active.id);
}
}, [softPauseSession]);
/**
* Resume a soft-paused session by re-calling /chat with existing messages.
* The director will pick the next agent to continue the topic.
*/
const resumeSession = useCallback(
async (sessionId: string): Promise<void> => {
const session = sessionsRef.current.find((s) => s.id === sessionId);
if (!session || session.status !== 'active') return;
await pendingRetirementRef.current;
const controller = new AbortController();
abortControllerRef.current = controller;
streamingSessionIdRef.current = sessionId;
setIsStreaming(true);
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,
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,
directorState: session.directorState,
},
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],
);
/**
* Resume the currently active soft-paused session (if any).
*/
const resumeActiveSession = useCallback(async (): Promise<void> => {
const active = sessionsRef.current.find(isOpenLiveSession);
if (active) {
await resumeSession(active.id);
}
}, [resumeSession]);
/**
* Send a message to the active session
*/
const sendMessage = useCallback(
async (content: string): Promise<void> => {
let sessionId = activeSessionId;
// Interrupt active generation: abort stream and append "..." to the last agent message
if (abortControllerRef.current) {
const interruptedSessionId = retireActiveLiveRequest(sessionId);
const interruptedMessageSessionId = interruptedSessionId ?? sessionId;
if (interruptedMessageSessionId) {
setSessions((prev) =>
prev.map((s) => {
if (s.id !== interruptedMessageSessionId) 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: nextChatUpdatedAt(s) };
}
}
break;
}
}
return s;
}),
);
}
}
// An interrupted action may still be finishing a delayed state mutation.
// Capture the next request only after that mutation has settled.
await pendingRetirementRef.current;
// Validate model configuration before sending
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;
}
// Create a new session when there's no active QA session to append to.
// A completed session should NOT be reused — start a fresh one instead.
const activeSession = sessionsRef.current.find((s) => s.id === sessionId);
const needNewSession =
!sessionId || activeSession?.type === 'lecture' || activeSession?.status === 'completed';
if (needNewSession) {
// End all active QA/Discussion sessions before creating new one
const activeQAOrDiscussion = sessionsRef.current.filter(isOpenLiveSession);
for (const session of activeQAOrDiscussion) {
await endSession(session.id);
}
sessionId = await createSession('qa', 'Q&A');
} else if (sessionId && activeSession?.status === 'soft-closing') {
if (claimSoftCloseRegistration(sessionId)) {
softCloseLifecycleRef.current.set(sessionId, 'active');
} else {
const lifecycle = softCloseLifecycleRef.current.get(sessionId);
if (lifecycle === 'completed') {
sessionId = await createSession('qa', 'Q&A');
} else if (lifecycle !== 'active') {
return;
}
}
}
const controller = new AbortController();
abortControllerRef.current = controller;
streamingSessionIdRef.current = sessionId;
setIsStreaming(true);
const now = Date.now();
const userMessageId = `user-${now}`;
// Read all selected agent IDs from settings store
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,
},
};
// Read current session data from ref (avoids stale closure AND keeps updater pure)
const existingSession = sessionsRef.current.find((s) => s.id === sessionId);
const sessionMessages: UIMessage<ChatMessageMetadata>[] = existingSession
? [...existingSession.messages, userMessage]
: [userMessage];
const sessionType: SessionType = existingSession?.type || 'qa';
// Pure updater — no side effects
setSessions((prev) => {
const exists = prev.some((s) => s.id === sessionId);
if (exists) {
return prev.map((s) =>
s.id === sessionId ? resumeSoftClosingSessionForFollowUp(s, userMessage, now) : s,
);
} else {
const newSession: ChatSession = {
id: sessionId!,
type: 'qa',
title: 'Q&A',
status: 'active',
messages: [userMessage],
config: {
agentIds,
defaultAgentId: agentIds[0],
},
toolCalls: [],
pendingToolCalls: [],
sceneId: currentSceneIdRef.current ?? undefined,
createdAt: now,
updatedAt: now,
};
return [...prev, newSession];
}
});
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,
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,
directorState: existingSession?.directorState,
},
controller,
sessionType,
);
} catch (error) {
// Ignore AbortError — it's intentional (user interrupted)
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 {
// Only clean up if this is still the active controller (avoid race with interrupt)
if (abortControllerRef.current === controller) {
abortControllerRef.current = null;
streamingSessionIdRef.current = null;
setIsStreaming(false);
}
}
},
[
activeSessionId,
clearLiveSessionAfterError,
createSession,
endSession,
claimSoftCloseRegistration,
retireActiveLiveRequest,
runAgentLoopFn,
t,
],
);
/**
* Start a discussion with agent speaking first
*/
const startDiscussion = useCallback(
async (request: DiscussionRequest): Promise<void> => {
log.info(`[ChatArea] Starting discussion: "${request.topic}"`);
// Explicitly clear buffer-pause intent (also cleared transitively via endSession,
// but being explicit guards against future refactors)
livePausedRef.current = false;
// Validate model configuration before starting discussion
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;
}
// Auto-end previous active QA/Discussion sessions to ensure only one is active
const activeQAOrDiscussion = sessionsRef.current.filter(isOpenLiveSession);
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';
registerFirstPiRequest(sessionId);
// Read all selected agent IDs from settings store
const settingsState = useSettingsStore.getState();
const agentIds: string[] =
settingsState.selectedAgentIds?.length > 0
? [...settingsState.selectedAgentIds]
: [agentId];
// Ensure the trigger agent is included
if (!agentIds.includes(agentId)) {
agentIds.unshift(agentId);
}
// No pre-created assistant message — agent_start events create them dynamically
const newSession: ChatSession = {
id: sessionId,
type: 'discussion',
title: request.topic,
status: 'active',
messages: [],
config: {
agentIds,
triggerAgentId: agentId,
},
toolCalls: [],
pendingToolCalls: [],
sceneId: currentSceneIdRef.current ?? undefined,
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);
try {
const userProfileState = useUserProfileStore.getState();
const mc = getCurrentModelConfig();
await runAgentLoopFn(
sessionId,
{
messages: [],
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) {
// Ignore AbortError — it's intentional (user interrupted)
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 {
// Only clean up if this is still the active controller (avoid race with interrupt)
if (abortControllerRef.current === controller) {
abortControllerRef.current = null;
streamingSessionIdRef.current = null;
setIsStreaming(false);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- t is stable from i18n context
[clearLiveSessionAfterError, endSession, registerFirstPiRequest, runAgentLoopFn],
);
/**
* Handle interruption
*/
const handleInterrupt = useCallback(() => {
if (!abortControllerRef.current) return;
log.info('[ChatArea] Interrupting active request');
retireActiveLiveRequest(activeSessionId);
}, [activeSessionId, retireActiveLiveRequest]);
/**
* Start a lecture session for a scene.
* Creates a single assistant message that all actions will be appended to.
* Deduplicates: returns existing active lecture session for the same sceneId if found.
*/
const startLecture = useCallback(
async (sceneId: string): Promise<string> => {
// Check for existing lecture session with same sceneId (active or completed)
const existing = sessions.find(
(s) =>
s.type === 'lecture' &&
s.sceneId === sceneId &&
(s.status === 'active' || s.status === 'completed'),
);
if (existing) {
// Reactivate a completed session so the chat panel shows it as active again.
// Actions won't be re-appended because lastActionIndex already covers them.
if (existing.status === 'completed') {
setSessions((prev) =>
prev.map((s) => (s.id === existing.id ? withChatSessionStatus(s, 'active') : s)),
);
// Restore lecture tracking refs (cleared by endSession)
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');
// Create session with a single assistant message (all actions append parts here)
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'],
},
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],
);
/**
* Add a lecture action to the single message bubble via StreamBuffer.
* Speech → pushText + sealText (buffer handles pacing).
* Spotlight/laser/discussion → pushAction (badge appears after preceding text is revealed).
*/
const addLectureMessage = useCallback(
(sessionId: string, action: Action, actionIndex: number) => {
const messageId = lectureMessageIds.current.get(sessionId);
if (!messageId) return;
// Skip if this action was already appended in a previous run
const lastIndex = lectureLastActionIndexRef.current.get(sessionId) ?? -1;
if (actionIndex <= lastIndex) return;
lectureLastActionIndexRef.current.set(sessionId, actionIndex);
// Update lastActionIndex in session
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? { ...s, lastActionIndex: actionIndex, updatedAt: nextChatUpdatedAt(s) }
: s,
),
);
// Get or create buffer for this lecture session
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],
);
// Derive active session type for external consumers
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;
}, []);
/** Pause the buffer for a session (lecture pause support). */
const pauseBuffer = useCallback((sessionId: string) => {
const buf = buffersRef.current.get(sessionId);
if (buf) buf.pause();
}, []);
/** Resume the buffer for a session. */
const resumeBuffer = useCallback((sessionId: string) => {
const buf = buffersRef.current.get(sessionId);
if (buf) buf.resume();
}, []);
/** Pause the active live (QA/Discussion) buffer and set sticky intent. Returns true if paused. */
const pauseActiveLiveBuffer = useCallback((): boolean => {
const active = sessionsRef.current.find(isOpenLiveSession);
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;
}, []);
/** Resume the active live (QA/Discussion) buffer and clear sticky intent. */
const resumeActiveLiveBuffer = useCallback(() => {
const active = sessionsRef.current.find(isOpenLiveSession);
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,
continueSoftClosingSession,
confirmSoftClosingSession,
softPauseActiveSession,
resumeActiveSession,
sendMessage,
startDiscussion,
startLecture,
addLectureMessage,
toggleSessionExpand,
handleInterrupt,
getLectureMessageId,
pauseBuffer,
resumeBuffer,
pauseActiveLiveBuffer,
resumeActiveLiveBuffer,
};
}