Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

1886 lines
62 KiB
TypeScript

"use client";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
} from "react";
import {
LANGUAGE_EVENT,
LANGUAGE_STORAGE_KEY,
normalizeLanguage,
readStoredChatResponseTimeout,
readStoredLanguage,
writeStoredActiveSessionId,
} from "@/context/app-shell-storage";
import type { StreamEvent, ChatMessage, LLMSelection } from "@/lib/unified-ws";
import { UnifiedWSClient } from "@/lib/unified-ws";
import {
getSession,
deleteMessage,
updateBranchSelection,
updateSessionTitle,
type SessionMessage,
} from "@/lib/session-api";
import { normalizeMarkdownForDisplay } from "@/lib/markdown-display";
import { normalizeMessageContent } from "@/lib/message-content";
import { buildVisiblePath, tipMessageId } from "@/lib/message-branches";
import {
isNarrationMarker,
recomputeAnswerContent,
shouldAppendEventContent,
} from "@/lib/stream";
import { hasPendingAskUserInMessages } from "@/lib/ask-user-state";
import { notify } from "@/lib/notifications";
import i18n from "i18next";
import {
normalizeBookReferences,
type BookReferencePayload,
} from "@/lib/book-references";
type SessionRuntimeStatus =
| "idle"
| "running"
| "completed"
| "failed"
| "cancelled"
| "rejected";
interface OutgoingAttachment {
type: string;
url?: string;
base64?: string;
filename?: string;
mime_type?: string;
}
interface NotebookReferencePayload {
notebook_id: string;
record_ids: string[];
}
type HistoryReferencePayload = string[];
type QuestionNotebookReferencePayload = number[];
type MemoryReferencePayload = Array<"summary" | "profile">;
export interface SendMessageOptions {
displayUserMessage?: boolean;
persistUserMessage?: boolean;
requestSnapshotOverride?: MessageRequestSnapshot;
bookReferences?: BookReferencePayload[];
/** Edit-branching: when set, the new user message is inserted as a
* sibling under this parent rather than appended to the session tail.
* ``null`` means "explicitly attach to the session root". */
parentMessageId?: number | null;
}
export interface ChatState {
sessionId: string | null;
sessionTitle: string;
enabledTools: string[];
activeCapability: string | null;
knowledgeBases: string[];
llmSelection: LLMSelection | null;
/** Session-level persona preference; "" = Default (no persona). Applies
* to every following message until changed (persisted on the session). */
personaSelection: string;
messages: MessageItem[];
isStreaming: boolean;
currentStage: string;
language: string;
/** Edit-branching: keyed by stringified parent_message_id (or "null"
* for the root). Empty means "default to latest sibling everywhere". */
selectedBranches: Record<string, number>;
}
interface SessionStatusSnapshot {
sessionId: string;
status: SessionRuntimeStatus;
activeTurnId: string | null;
updatedAt: number;
}
export interface MessageAttachment {
type: string;
filename?: string;
base64?: string;
url?: string;
mime_type?: string;
/** Stable per-attachment id; matches the URL segment served by /api/attachments. */
id?: string;
/** Plain-text rendering of office docs, populated by the backend extractor.
* Used by the preview drawer to show "what the LLM saw" for binary docs. */
extracted_text?: string;
/** Set on files the assistant produced this turn (exec/code_execution
* artifacts) rather than files the user uploaded. Rendered as openable
* cards under the assistant message. */
generated?: boolean;
/** Byte size of the generated file, for the card's subtitle. */
size_bytes?: number;
}
export interface MessageRequestSnapshot {
content: string;
capability?: string | null;
enabledTools: string[];
knowledgeBases: string[];
language: string;
attachments?: MessageAttachment[];
config?: Record<string, unknown>;
notebookReferences?: NotebookReferencePayload[];
historyReferences?: HistoryReferencePayload;
questionNotebookReferences?: QuestionNotebookReferencePayload;
bookReferences?: BookReferencePayload[];
persona?: string;
memoryReferences?: MemoryReferencePayload;
llmSelection?: LLMSelection | null;
}
export interface MessageItem {
id?: number;
role: "user" | "assistant" | "system";
content: string;
capability?: string;
events?: StreamEvent[];
attachments?: MessageAttachment[];
requestSnapshot?: MessageRequestSnapshot;
/** Edit-branching: id of the message this row continues. */
parentMessageId?: number | null;
}
interface SessionEntry extends ChatState {
key: string;
status: SessionRuntimeStatus;
activeTurnId: string | null;
lastSeq: number;
updatedAt: number;
/** Edit-branching: maps a parent_message_id (stringified, or "null" for
* the session root) to the chosen child id at that branch point. */
selectedBranches: Record<string, number>;
}
interface ProviderState {
selectedKey: string | null;
sessions: Record<string, SessionEntry>;
sidebarRefreshToken: number;
}
type Action =
| { type: "SET_TOOLS"; tools: string[] }
| { type: "SET_CAPABILITY"; cap: string | null }
| { type: "SET_KB"; kbs: string[] }
| { type: "SET_LLM_SELECTION"; selection: LLMSelection | null }
| { type: "SET_PERSONA_SELECTION"; persona: string }
| { type: "SET_LANGUAGE"; lang: string }
| {
type: "ADD_USER_MSG";
key: string;
content: string;
capability?: string | null;
attachments?: MessageAttachment[];
requestSnapshot?: MessageRequestSnapshot;
parentMessageId?: number | null;
}
| { type: "POP_LAST_ASSISTANT"; key: string }
| { type: "RESTORE_ASSISTANT"; key: string; message: MessageItem }
| { type: "STREAM_START"; key: string }
| { type: "STREAM_EVENT"; key: string; event: StreamEvent }
| {
type: "STREAM_END";
key: string;
status?: SessionRuntimeStatus;
turnId?: string | null;
}
| {
type: "BIND_SERVER_SESSION";
key: string;
sessionId: string;
turnId?: string | null;
}
| {
type: "LOAD_SESSION";
key: string;
sessionId: string;
title?: string;
messages: MessageItem[];
activeTurnId?: string | null;
status?: SessionRuntimeStatus;
tools?: string[];
capability?: string | null;
knowledgeBases?: string[];
llmSelection?: LLMSelection | null;
personaSelection?: string;
language?: string;
selectedBranches?: Record<string, number>;
}
| { type: "SET_SESSION_TITLE"; key: string; title: string }
| { type: "DELETE_TURN"; key: string; messageId: number }
| { type: "NEW_SESSION"; key: string }
| {
type: "SET_SELECTED_BRANCH";
key: string;
parentKey: string;
childId: number;
}
| {
type: "REPLACE_SELECTED_BRANCHES";
key: string;
selectedBranches: Record<string, number>;
}
| { type: "BUMP_SIDEBAR_REFRESH" };
function createSessionEntry(
key: string,
sessionId: string | null = null,
): SessionEntry {
return {
key,
sessionId,
sessionTitle: "",
enabledTools: [],
activeCapability: null,
knowledgeBases: [],
llmSelection: null,
personaSelection: "",
messages: [],
isStreaming: false,
currentStage: "",
language: typeof window === "undefined" ? "en" : readStoredLanguage(),
status: "idle",
activeTurnId: null,
lastSeq: 0,
updatedAt: Date.now(),
selectedBranches: {},
};
}
function ensureSelectedSession(state: ProviderState): SessionEntry {
if (state.selectedKey && state.sessions[state.selectedKey]) {
return state.sessions[state.selectedKey];
}
return createSessionEntry("draft");
}
function updateSelectedSession(
state: ProviderState,
updater: (session: SessionEntry) => SessionEntry,
): ProviderState {
const current = ensureSelectedSession(state);
const key = state.selectedKey || current.key;
const nextSession = updater(current);
return {
...state,
selectedKey: key,
sessions: {
...state.sessions,
[key]: nextSession,
},
};
}
function isSameTurnEvent(a: StreamEvent, b: StreamEvent): boolean {
const aSeq = Number(a.seq || 0);
const bSeq = Number(b.seq || 0);
if (aSeq <= 0 || bSeq <= 0 || aSeq !== bSeq) return false;
const aTurn = a.turn_id || "";
const bTurn = b.turn_id || "";
return Boolean(aTurn && bTurn && aTurn === bTurn);
}
function reducer(state: ProviderState, action: Action): ProviderState {
switch (action.type) {
case "SET_TOOLS":
return updateSelectedSession(state, (session) => ({
...session,
enabledTools: action.tools,
}));
case "SET_CAPABILITY":
return updateSelectedSession(state, (session) => ({
...session,
activeCapability: action.cap,
}));
case "SET_KB":
return updateSelectedSession(state, (session) => ({
...session,
knowledgeBases: action.kbs,
}));
case "SET_LLM_SELECTION":
return updateSelectedSession(state, (session) => ({
...session,
llmSelection: action.selection,
}));
case "SET_PERSONA_SELECTION":
return updateSelectedSession(state, (session) => ({
...session,
personaSelection: action.persona,
}));
case "SET_LANGUAGE":
return updateSelectedSession(state, (session) => ({
...session,
language: action.lang,
}));
case "ADD_USER_MSG": {
const session =
state.sessions[action.key] ?? createSessionEntry(action.key);
return {
...state,
sessions: {
...state.sessions,
[action.key]: {
...session,
messages: [
...session.messages,
{
id: -Date.now(),
role: "user",
content: action.content,
capability: action.capability || "",
parentMessageId:
action.parentMessageId === undefined
? null
: action.parentMessageId,
...(action.attachments?.length
? { attachments: action.attachments }
: {}),
...(action.requestSnapshot
? { requestSnapshot: action.requestSnapshot }
: {}),
},
],
updatedAt: Date.now(),
},
},
};
}
case "POP_LAST_ASSISTANT": {
const session = state.sessions[action.key];
if (!session || session.messages.length === 0) return state;
const last = session.messages[session.messages.length - 1];
if (last.role !== "assistant") return state;
return {
...state,
sessions: {
...state.sessions,
[action.key]: {
...session,
messages: session.messages.slice(0, -1),
updatedAt: Date.now(),
},
},
};
}
case "RESTORE_ASSISTANT": {
// Revert an optimistic POP_LAST_ASSISTANT when the server rejects a
// regenerate request (e.g. ``regenerate_busy``), so the user doesn't
// silently lose their last reply.
const session = state.sessions[action.key];
if (!session) return state;
const messages = [...session.messages];
// Drop any placeholder STREAM_START assistant bubble before restoring.
while (
messages.length > 0 &&
messages[messages.length - 1].role === "assistant" &&
(messages[messages.length - 1].content ?? "") === "" &&
(messages[messages.length - 1].events?.length ?? 0) === 0
) {
messages.pop();
}
messages.push(action.message);
return {
...state,
sessions: {
...state.sessions,
[action.key]: {
...session,
messages,
updatedAt: Date.now(),
},
},
};
}
case "STREAM_START": {
const session =
state.sessions[action.key] ?? createSessionEntry(action.key);
const existing = session.messages ?? [];
// Chain the placeholder assistant onto whatever message currently
// sits at the tip — this is normally the user row just added by
// ADD_USER_MSG (possibly an optimistic negative id during an edit).
const tip = existing.length > 0 ? existing[existing.length - 1] : null;
return {
...state,
sessions: {
...state.sessions,
[action.key]: {
...session,
isStreaming: true,
status: "running",
messages: [
...existing,
{
id: -Date.now(),
role: "assistant",
content: "",
events: [],
capability: session.activeCapability || "",
parentMessageId: tip?.id ?? null,
},
],
updatedAt: Date.now(),
},
},
};
}
case "STREAM_EVENT": {
// If the session entry has been removed (e.g., BIND_SERVER_SESSION
// just renamed ``draft_X`` to a real id but a stray event still
// targets the old key), drop the event rather than synthesise an
// orphan session with no user message — that would scrub the
// user's just-sent bubble from view.
if (!state.sessions[action.key]) return state;
const session = state.sessions[action.key];
const msgs = [...session.messages];
let last = msgs[msgs.length - 1];
if (last?.role !== "assistant") {
msgs.push({
id: -Date.now(),
role: "assistant",
content: "",
events: [],
capability: session.activeCapability || "",
parentMessageId: last?.id ?? null,
});
last = msgs[msgs.length - 1];
}
if (
(last?.events || []).some((event) =>
isSameTurnEvent(event, action.event),
)
) {
return state;
}
const events = [...(last?.events || []), action.event];
let content = last?.content || "";
if (isNarrationMarker(action.event)) {
// A round just resolved as narration (preamble before a tool call):
// drop its already-streamed text from the answer — it stays in the
// trace. Recomputing is cheap here (only fires per narration round).
content = recomputeAnswerContent(events);
} else if (shouldAppendEventContent(action.event)) {
content += action.event.content;
}
const capability = last?.capability || session.activeCapability || "";
msgs[msgs.length - 1] = {
...(last || { role: "assistant", content: "" }),
content,
events,
capability,
};
return {
...state,
sessions: {
...state.sessions,
[action.key]: {
...session,
messages: msgs,
currentStage:
action.event.type === "stage_start"
? action.event.stage
: action.event.type === "stage_end"
? ""
: session.currentStage,
activeTurnId: action.event.turn_id || session.activeTurnId,
lastSeq: Math.max(session.lastSeq, action.event.seq || 0),
updatedAt: Date.now(),
},
},
};
}
case "STREAM_END":
return {
...state,
sessions: {
...state.sessions,
[action.key]: {
...(state.sessions[action.key] ?? createSessionEntry(action.key)),
isStreaming: false,
currentStage: "",
status: action.status ?? "completed",
activeTurnId:
action.status === "running"
? action.turnId ||
state.sessions[action.key]?.activeTurnId ||
null
: null,
updatedAt: Date.now(),
},
},
sidebarRefreshToken: state.sidebarRefreshToken + 1,
};
case "BIND_SERVER_SESSION": {
const current =
state.sessions[action.key] ?? createSessionEntry(action.key);
const targetKey = action.sessionId;
const existing = state.sessions[targetKey];
const merged: SessionEntry = {
...(existing ?? current),
...current,
key: targetKey,
sessionId: action.sessionId,
sessionTitle: current.sessionTitle || existing?.sessionTitle || "",
activeTurnId: action.turnId || current.activeTurnId,
status: current.isStreaming ? "running" : current.status,
updatedAt: Date.now(),
};
const nextSessions = { ...state.sessions };
delete nextSessions[action.key];
nextSessions[targetKey] = merged;
return {
...state,
selectedKey:
state.selectedKey === action.key ? targetKey : state.selectedKey,
sessions: nextSessions,
sidebarRefreshToken: state.sidebarRefreshToken + 1,
};
}
case "LOAD_SESSION": {
const existing =
state.sessions[action.key] ??
createSessionEntry(action.key, action.sessionId);
return {
...state,
selectedKey: action.key,
sessions: {
...state.sessions,
[action.key]: {
...existing,
key: action.key,
sessionId: action.sessionId,
sessionTitle:
action.title !== undefined ? action.title : existing.sessionTitle,
enabledTools: action.tools ?? existing.enabledTools,
activeCapability:
action.capability !== undefined
? action.capability
: existing.activeCapability,
knowledgeBases: action.knowledgeBases ?? existing.knowledgeBases,
llmSelection:
action.llmSelection !== undefined
? action.llmSelection
: existing.llmSelection,
personaSelection:
action.personaSelection !== undefined
? action.personaSelection
: existing.personaSelection,
messages: action.messages,
isStreaming: (action.status || "idle") === "running",
currentStage: "",
activeTurnId: action.activeTurnId || null,
status: action.status || "idle",
language: action.language ?? existing.language,
selectedBranches:
action.selectedBranches ?? existing.selectedBranches,
updatedAt: Date.now(),
},
},
};
}
case "SET_SESSION_TITLE": {
const session = state.sessions[action.key];
if (!session) return state;
return {
...state,
sessions: {
...state.sessions,
[action.key]: {
...session,
sessionTitle: action.title,
updatedAt: Date.now(),
},
},
sidebarRefreshToken: state.sidebarRefreshToken + 1,
};
}
case "SET_SELECTED_BRANCH": {
const session = state.sessions[action.key];
if (!session) return state;
return {
...state,
sessions: {
...state.sessions,
[action.key]: {
...session,
selectedBranches: {
...session.selectedBranches,
[action.parentKey]: action.childId,
},
updatedAt: Date.now(),
},
},
};
}
case "REPLACE_SELECTED_BRANCHES": {
const session = state.sessions[action.key];
if (!session) return state;
return {
...state,
sessions: {
...state.sessions,
[action.key]: {
...session,
selectedBranches: { ...action.selectedBranches },
updatedAt: Date.now(),
},
},
};
}
case "DELETE_TURN": {
const session = state.sessions[action.key];
if (!session) return state;
const idx = session.messages.findIndex((m) => m.id === action.messageId);
if (idx === -1) return state;
const msg = session.messages[idx];
const toRemove = new Set<number>();
toRemove.add(idx);
if (msg.role === "user") {
if (
idx + 1 < session.messages.length &&
session.messages[idx + 1].role === "assistant"
) {
toRemove.add(idx + 1);
}
} else if (msg.role === "assistant") {
if (idx - 1 >= 0 && session.messages[idx - 1].role === "user") {
toRemove.add(idx - 1);
}
}
const nextMessages = session.messages.filter((_, i) => !toRemove.has(i));
return {
...state,
sessions: {
...state.sessions,
[action.key]: {
...session,
messages: nextMessages,
isStreaming: false,
status: "idle",
updatedAt: Date.now(),
},
},
sidebarRefreshToken: state.sidebarRefreshToken + 1,
};
}
case "BUMP_SIDEBAR_REFRESH":
return {
...state,
sidebarRefreshToken: state.sidebarRefreshToken + 1,
};
case "NEW_SESSION": {
const MAX_CACHED_SESSIONS = 20;
let nextSessions = {
...state.sessions,
[action.key]: createSessionEntry(action.key),
};
const keys = Object.keys(nextSessions);
if (keys.length > MAX_CACHED_SESSIONS) {
const evictable = keys
.filter(
(k) => k !== action.key && nextSessions[k].status !== "running",
)
.sort(
(a, b) => nextSessions[a].updatedAt - nextSessions[b].updatedAt,
);
const toRemove = evictable.slice(0, keys.length - MAX_CACHED_SESSIONS);
for (const k of toRemove) delete nextSessions[k];
}
return { ...state, selectedKey: action.key, sessions: nextSessions };
}
default:
return state;
}
}
const initialState: ProviderState = {
selectedKey: null,
sessions: {},
sidebarRefreshToken: 0,
};
// Grace window between the orchestrator's ``done`` event and the actual
// WS disconnect. Keeps the connection alive long enough for post-turn
// pushes like the LLM-generated ``session_meta`` title update to land.
const POST_DONE_DISCONNECT_DELAY_MS = 15_000;
interface ChatContextValue {
state: ChatState;
setTools: (tools: string[]) => void;
setCapability: (cap: string | null) => void;
setKBs: (kbs: string[]) => void;
setLLMSelection: (selection: LLMSelection | null) => void;
setPersonaSelection: (persona: string) => void;
setLanguage: (lang: string) => void;
sendMessage: (
content: string,
attachments?: OutgoingAttachment[],
config?: Record<string, unknown>,
notebookReferences?: NotebookReferencePayload[],
historyReferences?: HistoryReferencePayload,
options?: SendMessageOptions,
questionNotebookReferences?: QuestionNotebookReferencePayload,
persona?: string,
memoryReferences?: MemoryReferencePayload,
) => void;
cancelStreamingTurn: () => void;
/**
* Deliver the user's reply for a turn that is paused on an
* ``ask_user`` tool call. Sends the reply via the unified WS so the
* backend can substitute it into the matching ``role=tool`` message
* and resume the agentic loop on the **same** turn. No-op when the
* active session has no live turn waiting on input.
*
* Accepts a plain string (legacy single-question reply) or a
* structured object with ``answers`` (v2 multi-question reply).
*/
submitUserReply: (
reply:
| string
| {
text?: string;
answers?: Array<{ questionId: string; text: string }>;
},
) => void;
regenerateLastMessage: () => void;
deleteTurn: (messageId: number) => Promise<void>;
/** Re-send a user message under a new branch (sibling of the original).
* Uses the composer's current capability / refs — only the text is
* taken from ``newContent``. Re-runs the turn from the original's
* parent context. */
editMessage: (messageId: number, newContent: string) => Promise<void>;
/** Switch which sibling is currently visible at a branch point. */
switchBranch: (parentMessageId: number | null, childId: number) => void;
renameSessionTitle: (title: string) => Promise<void>;
newSession: () => void;
loadSession: (sessionId: string, signal?: AbortSignal) => Promise<void>;
selectedSessionId: string | null;
sessionStatuses: Record<string, SessionStatusSnapshot>;
sidebarRefreshToken: number;
}
const ChatCtx = createContext<ChatContextValue | null>(null);
function hydrateMessageAttachments(
attachments: SessionMessage["attachments"],
): MessageAttachment[] {
return Array.isArray(attachments)
? attachments.map((item) => ({
type: item.type,
filename: item.filename,
base64: item.base64,
url: item.url,
mime_type: item.mime_type,
id: item.id,
extracted_text: item.extracted_text,
generated: item.generated,
size_bytes: item.size_bytes,
}))
: [];
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter(
(item): item is string => typeof item === "string" && item.length > 0,
)
: [];
}
function asLLMSelection(value: unknown): LLMSelection | null {
const record = asRecord(value);
const profileId =
typeof record?.profile_id === "string" ? record.profile_id.trim() : "";
const modelId =
typeof record?.model_id === "string" ? record.model_id.trim() : "";
return profileId && modelId
? { profile_id: profileId, model_id: modelId }
: null;
}
function normalizeSelectedBranches(value: unknown): Record<string, number> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const result: Record<string, number> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const n = typeof v === "number" ? v : Number(v);
if (Number.isInteger(n) && n > 0) result[k] = n;
}
return result;
}
function asMemoryReferences(value: unknown): MemoryReferencePayload {
return asStringArray(value).filter(
(item): item is "summary" | "profile" =>
item === "summary" || item === "profile",
);
}
function asNotebookReferences(value: unknown): NotebookReferencePayload[] {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
const ref = asRecord(item);
const notebookId =
typeof ref?.notebook_id === "string" ? ref.notebook_id : "";
const recordIds = asStringArray(ref?.record_ids);
return notebookId && recordIds.length
? [{ notebook_id: notebookId, record_ids: recordIds }]
: [];
});
}
function asQuestionReferences(
value: unknown,
): QuestionNotebookReferencePayload {
return Array.isArray(value)
? value
.map((item) => (typeof item === "number" ? item : Number(item)))
.filter((item) => Number.isInteger(item))
: [];
}
function hydrateRequestSnapshot(
message: SessionMessage,
content: string,
attachments: MessageAttachment[],
): MessageRequestSnapshot | undefined {
const metadata = asRecord(message.metadata);
const stored = asRecord(
metadata?.request_snapshot ?? metadata?.requestSnapshot,
);
if (!stored) return undefined;
const snapshot: MessageRequestSnapshot = {
content: typeof stored.content === "string" ? stored.content : content,
capability:
typeof stored.capability === "string"
? stored.capability
: message.capability || "",
enabledTools: asStringArray(stored.enabledTools),
knowledgeBases: asStringArray(stored.knowledgeBases),
language: typeof stored.language === "string" ? stored.language : "en",
...(attachments.length ? { attachments } : {}),
};
const config = asRecord(stored.config);
const notebookReferences = asNotebookReferences(stored.notebookReferences);
const historyReferences = asStringArray(stored.historyReferences);
const questionNotebookReferences = asQuestionReferences(
stored.questionNotebookReferences,
);
const persona =
typeof stored.persona === "string" && stored.persona.length > 0
? stored.persona
: "";
const memoryReferences = asMemoryReferences(stored.memoryReferences);
const bookReferences = normalizeBookReferences(stored.bookReferences);
const llmSelection = asLLMSelection(stored.llmSelection);
if (config && Object.keys(config).length) snapshot.config = config;
if (notebookReferences.length)
snapshot.notebookReferences = notebookReferences;
if (historyReferences.length) snapshot.historyReferences = historyReferences;
if (questionNotebookReferences.length) {
snapshot.questionNotebookReferences = questionNotebookReferences;
}
if (bookReferences.length) snapshot.bookReferences = bookReferences;
if (persona) snapshot.persona = persona;
if (memoryReferences.length) snapshot.memoryReferences = memoryReferences;
if (llmSelection) snapshot.llmSelection = llmSelection;
return snapshot;
}
export function UnifiedChatProvider({
children,
}: {
children: React.ReactNode;
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const stateRef = useRef(initialState);
const runnersRef = useRef<
Map<
string,
{
key: string;
client: UnifiedWSClient;
}
>
>(new Map());
const draftCounterRef = useRef(0);
const retryTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
// Tracks in-flight regenerate requests so we can restore the popped
// assistant message if the server rejects the request (e.g. ``regenerate_busy``
// or ``nothing_to_regenerate``). Keyed by session entry key.
const pendingRegenerateRef = useRef<Map<string, MessageItem>>(new Map());
// Forward-declared so ``handleRunnerEvent`` (created above
// ``loadSession`` in source order) can trigger a server refresh after
// a turn finishes without taking a stale closure of ``loadSession``.
const loadSessionRef = useRef<((sessionId: string) => Promise<void>) | null>(
null,
);
useLayoutEffect(() => {
stateRef.current = state;
}, [state]);
useEffect(
() => () => {
runnersRef.current.forEach(({ client }) => client.disconnect());
runnersRef.current.clear();
retryTimersRef.current.forEach((id) => clearTimeout(id));
retryTimersRef.current.clear();
},
[],
);
const makeDraftKey = useCallback(() => {
draftCounterRef.current += 1;
return `draft_${Date.now()}_${draftCounterRef.current}`;
}, []);
const hydrateMessages = useCallback(
(messages: SessionMessage[]): MessageItem[] => {
return messages
.filter((message) => message.role !== "system")
.map((message) => {
const raw = normalizeMessageContent(message.content as unknown);
const attachments = hydrateMessageAttachments(message.attachments);
const requestSnapshot = hydrateRequestSnapshot(
message,
raw,
attachments,
);
return {
id: message.id,
role: message.role,
content:
message.role === "assistant"
? normalizeMarkdownForDisplay(raw)
: raw,
capability: message.capability || "",
events: Array.isArray(message.events) ? message.events : [],
attachments,
parentMessageId:
message.parent_message_id === undefined
? null
: message.parent_message_id,
...(requestSnapshot ? { requestSnapshot } : {}),
};
});
},
[],
);
const moveRunner = useCallback((oldKey: string, newKey: string) => {
if (oldKey === newKey) return;
const runner = runnersRef.current.get(oldKey);
if (!runner) return;
runnersRef.current.delete(oldKey);
runner.key = newKey;
runnersRef.current.set(newKey, runner);
}, []);
const handleRunnerEvent = useCallback(
(runnerKey: string, event: StreamEvent) => {
const runner = runnersRef.current.get(runnerKey);
const effectiveKey = runner?.key || runnerKey;
if (event.type === "session") {
const sessionId =
(event.metadata as { session_id?: string } | undefined)?.session_id ||
event.session_id ||
"";
const turnId =
(event.metadata as { turn_id?: string } | undefined)?.turn_id ||
event.turn_id ||
null;
if (sessionId) {
dispatch({
type: "BIND_SERVER_SESSION",
key: effectiveKey,
sessionId,
turnId,
});
moveRunner(effectiveKey, sessionId);
}
return;
}
if (event.type === "session_meta") {
// Post-turn metadata push (currently only used for the
// LLM-generated session title). The backend writes the new
// title to its store *before* sending this event. Update the
// active header immediately and bump the sidebar so history
// rows refresh to the generated title without a flicker.
const title = String(
(event.metadata as { title?: string } | undefined)?.title || "",
).trim();
if (title) {
dispatch({
type: "SET_SESSION_TITLE",
key: effectiveKey,
title,
});
} else {
dispatch({ type: "BUMP_SIDEBAR_REFRESH" });
}
return;
}
if (event.type === "done") {
const status = String(
(event.metadata as { status?: string } | undefined)?.status ||
"completed",
);
dispatch({
type: "STREAM_END",
key: effectiveKey,
status: (status as SessionRuntimeStatus) || "completed",
turnId: event.turn_id || null,
});
pendingRegenerateRef.current.delete(effectiveKey);
const runner = runnersRef.current.get(effectiveKey);
// Hold the WS open briefly so post-turn ``session_meta`` events
// (e.g. the LLM-generated title for the first user/assistant
// pair) can still reach us. The backend generates the title
// before its finally block sends the subscriber sentinel, but
// the title model can take a couple of seconds — disconnecting
// synchronously on ``done`` would race that publish.
if (runner) {
runnersRef.current.delete(effectiveKey);
window.setTimeout(() => {
runner.client.disconnect();
}, POST_DONE_DISCONNECT_DELAY_MS);
}
// Reconcile optimistic client-side message ids with the
// server's real ids after the turn finishes. Without this the
// Edit button (which needs a real id to attach the new branch
// under) and branch navigation (which keys off real ids) would
// stay disabled until the user navigates away and back.
if (status === "completed") {
const finishedSession = stateRef.current.sessions[effectiveKey];
const sessionId = finishedSession?.sessionId;
if (sessionId) {
loadSessionRef.current?.(sessionId).catch(() => {
/* non-fatal — local state remains usable */
});
}
}
return;
}
dispatch({ type: "STREAM_EVENT", key: effectiveKey, event });
if (
event.type === "error" &&
Boolean(
(event.metadata as { turn_terminal?: boolean } | undefined)
?.turn_terminal,
)
) {
const reason = String(
(event.metadata as { reason?: string } | undefined)?.reason || "",
);
// Pre-flight regenerate rejections never mutate server state, so we
// roll back the optimistic POP_LAST_ASSISTANT/STREAM_START placeholder
// to keep the transcript in sync with the server.
if (
reason === "regenerate_busy" ||
reason === "nothing_to_regenerate"
) {
const stash = pendingRegenerateRef.current.get(effectiveKey);
if (stash) {
dispatch({
type: "RESTORE_ASSISTANT",
key: effectiveKey,
message: stash,
});
}
}
pendingRegenerateRef.current.delete(effectiveKey);
const status = String(
(event.metadata as { status?: string } | undefined)?.status ||
"failed",
);
dispatch({
type: "STREAM_END",
key: effectiveKey,
status: status as SessionRuntimeStatus,
turnId: event.turn_id || null,
});
}
},
[moveRunner],
);
const ensureRunner = useCallback(
(key: string) => {
const existing = runnersRef.current.get(key);
if (existing) {
const session = stateRef.current.sessions[key];
if (session) {
existing.client.setResumeState(session.activeTurnId, session.lastSeq);
}
if (!existing.client.connected) existing.client.connect();
return existing;
}
const record = {
key,
client: new UnifiedWSClient(
(event) => handleRunnerEvent(record.key, event),
() => {
const session = stateRef.current.sessions[record.key];
if (session?.isStreaming) {
if (
hasPendingAskUserInMessages(
session.messages,
session.activeTurnId,
)
) {
return;
}
dispatch({
type: "STREAM_END",
key: record.key,
status: "failed",
});
// Surface the disconnect to the user. The WS client already
// logs to console — we add a toast so non-debugging users
// don't see streaming silently flatline.
notify(
i18n.t(
"Connection lost while generating. Please retry your message.",
),
{ tone: "error", durationMs: 6000 },
);
}
},
),
};
runnersRef.current.set(key, record);
const session = stateRef.current.sessions[key];
if (session?.activeTurnId) {
record.client.setResumeState(session.activeTurnId, session.lastSeq);
}
record.client.connect();
return record;
},
[handleRunnerEvent],
);
const sendThroughRunner = useCallback(
function dispatchToRunner(key: string, msg: ChatMessage, attempt = 0) {
const runner = ensureRunner(key);
if (!runner.client.connected) {
if (attempt >= 10) {
console.error("WebSocket failed to connect after retries");
dispatch({ type: "STREAM_END", key, status: "failed" });
// Surfaces the dead-after-N-retries case (different code path
// from the close-while-streaming handler above). Same user
// mental model, so same toast copy.
notify(
i18n.t(
"Couldn't reach the server. Please check your connection and retry.",
),
{ tone: "error", durationMs: 6000 },
);
return;
}
const timerId = setTimeout(() => {
retryTimersRef.current.delete(timerId);
dispatchToRunner(key, msg, attempt + 1);
}, 200);
retryTimersRef.current.add(timerId);
return;
}
runner.client.send(msg);
},
[ensureRunner],
);
const loadSession = useCallback(
async (sessionId: string, signal?: AbortSignal) => {
const session = await getSession(sessionId, signal);
const activeTurn = Array.isArray(session.active_turns)
? session.active_turns[0]
: undefined;
dispatch({
type: "LOAD_SESSION",
key: session.session_id || session.id,
sessionId: session.session_id || session.id,
title: session.title || "",
messages: hydrateMessages(session.messages ?? []),
activeTurnId: activeTurn?.turn_id || activeTurn?.id || null,
status:
(session.status as SessionRuntimeStatus | undefined) ||
(activeTurn ? "running" : "idle"),
tools: Array.isArray(session.preferences?.tools)
? session.preferences.tools
: [],
capability: session.preferences?.capability || null,
knowledgeBases: Array.isArray(session.preferences?.knowledge_bases)
? session.preferences.knowledge_bases
: [],
llmSelection: asLLMSelection(session.preferences?.llm_selection),
personaSelection:
typeof session.preferences?.persona === "string"
? session.preferences.persona
: "",
// The Settings language is global UI state. Historical sessions may
// have stale persisted preferences, so new turns follow the current
// app language rather than the language saved when the session began.
language: readStoredLanguage(),
selectedBranches: normalizeSelectedBranches(
session.preferences?.selected_branches,
),
});
if (activeTurn?.turn_id || activeTurn?.id) {
const key = session.session_id || session.id;
sendThroughRunner(key, {
type: "subscribe_turn",
turn_id: activeTurn.turn_id || activeTurn.id,
after_seq: 0,
});
}
},
[hydrateMessages, sendThroughRunner],
);
useLayoutEffect(() => {
loadSessionRef.current = loadSession;
}, [loadSession]);
useEffect(() => {
if (typeof window === "undefined") return;
const current = state.selectedKey
? state.sessions[state.selectedKey]
: null;
writeStoredActiveSessionId(current?.sessionId ?? null);
}, [state.selectedKey, state.sessions]);
useEffect(() => {
if (typeof window === "undefined") return;
const syncLanguage = (language: string | null | undefined) => {
dispatch({ type: "SET_LANGUAGE", lang: normalizeLanguage(language) });
};
const onLanguage = (event: Event) => {
const detail = (event as CustomEvent<{ language?: string }>).detail;
syncLanguage(detail?.language);
};
const onStorage = (event: StorageEvent) => {
if (event.key === LANGUAGE_STORAGE_KEY) syncLanguage(event.newValue);
};
window.addEventListener(LANGUAGE_EVENT, onLanguage);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(LANGUAGE_EVENT, onLanguage);
window.removeEventListener("storage", onStorage);
};
}, []);
// URL is now the source of truth for session loading.
// Chat pages load sessions based on URL params; no sessionStorage restore needed.
// Initialize a draft session so the provider always has a selected key.
useEffect(() => {
if (typeof window === "undefined") return;
if (!state.selectedKey) {
dispatch({ type: "NEW_SESSION", key: makeDraftKey() });
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Idle timeout: if a streaming session receives no events for the configured
// window (default 180s, set in Settings > Network), auto-fail it. Read per
// tick so a settings change applies without remounting.
useEffect(() => {
const CHECK_INTERVAL_MS = 10_000;
const timer = setInterval(() => {
const timeoutSeconds = readStoredChatResponseTimeout();
const idleTimeoutMs = timeoutSeconds * 1000;
const current = stateRef.current;
for (const [key, session] of Object.entries(current.sessions)) {
if (!session.isStreaming) continue;
if (
hasPendingAskUserInMessages(session.messages, session.activeTurnId)
) {
continue;
}
if (Date.now() - session.updatedAt <= idleTimeoutMs) continue;
dispatch({
type: "STREAM_EVENT",
key,
event: {
type: "error",
source: "client",
stage: "",
content: `Connection timed out — no response received for ${timeoutSeconds} seconds.`,
metadata: { turn_terminal: true, status: "failed" },
timestamp: Date.now() / 1000,
},
});
dispatch({ type: "STREAM_END", key, status: "failed" });
const runner = runnersRef.current.get(key);
if (runner) {
runner.client.disconnect();
runnersRef.current.delete(key);
}
}
}, CHECK_INTERVAL_MS);
return () => clearInterval(timer);
}, []);
const sendMessage = useCallback(
(
content: string,
attachments?: OutgoingAttachment[],
config?: Record<string, unknown>,
notebookReferences?: NotebookReferencePayload[],
historyReferences?: HistoryReferencePayload,
options?: SendMessageOptions,
questionNotebookReferences?: QuestionNotebookReferencePayload,
persona?: string,
memoryReferences?: MemoryReferencePayload,
) => {
const msgAttachments = attachments?.map((a) => ({
type: a.type,
filename: a.filename,
base64: a.base64,
url: a.url,
mime_type: a.mime_type,
}));
const currentState = stateRef.current;
let key = currentState.selectedKey;
if (!key) {
key = makeDraftKey();
dispatch({ type: "NEW_SESSION", key });
}
const session = currentState.sessions[key] ?? createSessionEntry(key);
const replaySnapshot = options?.requestSnapshotOverride;
const effectiveCapability =
replaySnapshot?.capability ?? session.activeCapability;
const effectiveTools =
replaySnapshot?.enabledTools ?? session.enabledTools;
const effectiveKnowledgeBases =
replaySnapshot?.knowledgeBases ?? session.knowledgeBases;
const effectiveLLMSelection =
replaySnapshot && "llmSelection" in replaySnapshot
? (replaySnapshot.llmSelection ?? null)
: session.llmSelection;
const effectiveLanguage =
replaySnapshot?.language ?? readStoredLanguage();
// Persona resolution: replay snapshot wins; then an explicit per-call
// persona (quiz follow-up surface); then the session-level preference.
// Always a string — "" means Default / no persona.
const effectivePersona =
replaySnapshot?.persona ?? persona ?? session.personaSelection ?? "";
const effectiveMemoryReferences =
replaySnapshot?.memoryReferences ?? memoryReferences;
const effectiveBookReferences =
replaySnapshot?.bookReferences ?? options?.bookReferences;
const effectiveAttachments =
replaySnapshot?.attachments?.map((a) => ({
type: a.type,
filename: a.filename,
base64: a.base64,
url: a.url,
mime_type: a.mime_type,
})) ?? msgAttachments;
const effectiveConfig = config ?? replaySnapshot?.config;
const effectiveNotebookReferences =
replaySnapshot?.notebookReferences ?? notebookReferences;
const effectiveHistoryReferences =
replaySnapshot?.historyReferences ?? historyReferences;
const effectiveQuestionNotebookReferences =
replaySnapshot?.questionNotebookReferences ??
questionNotebookReferences;
const requestSnapshot: MessageRequestSnapshot = replaySnapshot ?? {
content,
capability: effectiveCapability,
enabledTools: [...effectiveTools],
knowledgeBases: [...effectiveKnowledgeBases],
language: effectiveLanguage,
...(effectiveAttachments?.length
? { attachments: effectiveAttachments }
: {}),
...(effectiveConfig && Object.keys(effectiveConfig).length > 0
? { config: effectiveConfig }
: {}),
...(effectiveNotebookReferences?.length
? { notebookReferences: effectiveNotebookReferences }
: {}),
...(effectiveHistoryReferences?.length
? { historyReferences: [...effectiveHistoryReferences] }
: {}),
...(effectiveQuestionNotebookReferences?.length
? {
questionNotebookReferences: [
...effectiveQuestionNotebookReferences,
],
}
: {}),
...(effectiveBookReferences?.length
? { bookReferences: effectiveBookReferences }
: {}),
...(effectivePersona ? { persona: effectivePersona } : {}),
...(effectiveMemoryReferences?.length
? { memoryReferences: [...effectiveMemoryReferences] }
: {}),
...(effectiveLLMSelection
? { llmSelection: effectiveLLMSelection }
: {}),
};
// Default the new message's parent to the tip of the currently-
// visible path so the local chat tree stays connected during
// streaming. The wire-level ``parent_message_id`` is computed
// separately further down: only persisted (positive) ids or an
// explicit ``null`` (root edit) are sent — optimistic negative ids
// would be meaningless to the server.
const visible = buildVisiblePath(
session.messages,
session.selectedBranches,
).messages;
const tipId = tipMessageId(visible);
const localParentId =
options?.parentMessageId !== undefined
? options.parentMessageId
: tipId;
const wireParentId: number | null | undefined =
options?.parentMessageId !== undefined
? options.parentMessageId
: tipId !== null && tipId > 0
? tipId
: undefined;
if (options?.displayUserMessage !== false) {
dispatch({
type: "ADD_USER_MSG",
key,
content,
capability: effectiveCapability,
attachments: effectiveAttachments,
requestSnapshot,
parentMessageId: localParentId,
});
}
dispatch({ type: "STREAM_START", key });
const effectiveTurnConfig =
options?.persistUserMessage === false
? { ...(effectiveConfig || {}), _persist_user_message: false }
: effectiveConfig;
sendThroughRunner(key, {
type: "start_turn",
content,
tools: effectiveTools,
capability: effectiveCapability,
knowledge_bases: effectiveKnowledgeBases,
session_id: session.sessionId,
attachments: effectiveAttachments,
language: effectiveLanguage,
...(effectiveNotebookReferences?.length
? { notebook_references: effectiveNotebookReferences }
: {}),
...(effectiveHistoryReferences?.length
? { history_references: effectiveHistoryReferences }
: {}),
...(effectiveQuestionNotebookReferences?.length
? {
question_notebook_references: effectiveQuestionNotebookReferences,
}
: {}),
...(effectiveBookReferences?.length
? { book_references: effectiveBookReferences }
: {}),
// Always sent (possibly ""): an explicit key is the backend's signal
// to persist the value into session.preferences — "" clears back to
// Default. Omitting the key would make the backend fall back to the
// stored preference, so a clear could never propagate.
persona: effectivePersona,
...(effectiveMemoryReferences?.length
? { memory_references: effectiveMemoryReferences }
: {}),
...(effectiveLLMSelection
? { llm_selection: effectiveLLMSelection }
: {}),
...(effectiveTurnConfig && Object.keys(effectiveTurnConfig).length > 0
? { config: effectiveTurnConfig }
: {}),
// Send ``parent_message_id`` only when we have a real (positive)
// server id to chain under, or when the caller explicitly pinned
// a parent (incl. ``null`` for editing the session's first
// message). When the visible tip is still an optimistic
// negative id, omit the key and let the backend auto-append to
// the latest persisted row.
...(wireParentId !== undefined
? { parent_message_id: wireParentId }
: {}),
});
},
[makeDraftKey, sendThroughRunner],
);
const cancelStreamingTurn = useCallback(() => {
const currentState = stateRef.current;
const key = currentState.selectedKey;
if (!key) return;
const session = currentState.sessions[key];
if (!session) return;
const turnId = session.activeTurnId;
const runner = runnersRef.current.get(key);
if (runner?.client.connected) {
if (turnId) {
runner.client.send({ type: "cancel_turn", turn_id: turnId });
}
runner.client.disconnect();
runnersRef.current.delete(key);
}
if (session.isStreaming) {
dispatch({ type: "STREAM_END", key, status: "cancelled" });
}
}, []);
const submitUserReply = useCallback(
(
reply:
| string
| {
text?: string;
answers?: Array<{ questionId: string; text: string }>;
},
) => {
const currentState = stateRef.current;
const key = currentState.selectedKey;
if (!key) return;
const session = currentState.sessions[key];
const turnId = session?.activeTurnId;
const pendingAskUser = session
? hasPendingAskUserInMessages(session.messages, turnId)
: false;
// Only meaningful while a turn is live. A paused ask_user turn can be
// silent long enough for the socket to reconnect, so allow submission
// whenever the unresolved card and active turn id are still present.
if (!session || !turnId || (!session.isStreaming && !pendingAskUser)) {
return;
}
const message: import("@/lib/unified-ws").SubmitUserReplyMessage = {
type: "submit_user_reply",
turn_id: turnId,
};
if (typeof reply === "string") {
message.text = reply;
} else {
if (typeof reply.text === "string") message.text = reply.text;
if (Array.isArray(reply.answers)) message.answers = reply.answers;
}
sendThroughRunner(key, message);
},
[sendThroughRunner],
);
const regenerateLastMessage = useCallback(() => {
const currentState = stateRef.current;
const key = currentState.selectedKey;
if (!key) return;
const session = currentState.sessions[key];
if (!session || !session.sessionId) return;
if (session.isStreaming) return;
const lastUser = [...session.messages]
.reverse()
.find((m) => m.role === "user");
if (!lastUser) return;
// Snapshot the trailing assistant (if any) so we can put it back when the
// server rejects the request. We intentionally keep events/attachments so
// the restored bubble round-trips identically.
const lastMessage = session.messages[session.messages.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
pendingRegenerateRef.current.set(key, { ...lastMessage });
} else {
pendingRegenerateRef.current.delete(key);
}
dispatch({ type: "POP_LAST_ASSISTANT", key });
dispatch({ type: "STREAM_START", key });
sendThroughRunner(key, {
type: "regenerate",
session_id: session.sessionId,
overrides: {
language: readStoredLanguage(),
},
});
}, [sendThroughRunner]);
const derivedState = useMemo<ChatState>(() => {
const current = ensureSelectedSession(state);
return {
sessionId: current.sessionId,
sessionTitle: current.sessionTitle,
enabledTools: current.enabledTools,
activeCapability: current.activeCapability,
knowledgeBases: current.knowledgeBases,
llmSelection: current.llmSelection,
personaSelection: current.personaSelection,
messages: current.messages,
isStreaming: current.isStreaming,
currentStage: current.currentStage,
language: current.language,
selectedBranches: current.selectedBranches,
};
}, [state]);
const sessionStatuses = useMemo<Record<string, SessionStatusSnapshot>>(() => {
const entries: Record<string, SessionStatusSnapshot> = {};
for (const session of Object.values(state.sessions)) {
if (!session.sessionId || session.status !== "running") continue;
entries[session.sessionId] = {
sessionId: session.sessionId,
status: session.status,
activeTurnId: session.activeTurnId,
updatedAt: session.updatedAt,
};
}
return entries;
}, [state.sessions]);
const setTools = useCallback((tools: string[]) => {
dispatch({ type: "SET_TOOLS", tools });
}, []);
const setCapability = useCallback((cap: string | null) => {
dispatch({ type: "SET_CAPABILITY", cap });
}, []);
const setKBs = useCallback((kbs: string[]) => {
dispatch({ type: "SET_KB", kbs });
}, []);
const setLLMSelection = useCallback((selection: LLMSelection | null) => {
dispatch({ type: "SET_LLM_SELECTION", selection });
}, []);
const setPersonaSelection = useCallback((persona: string) => {
dispatch({ type: "SET_PERSONA_SELECTION", persona });
}, []);
const setLanguage = useCallback((lang: string) => {
dispatch({ type: "SET_LANGUAGE", lang });
}, []);
const renameSessionTitle = useCallback(async (title: string) => {
const trimmed = title.trim();
if (!trimmed) return;
const currentState = stateRef.current;
const key = currentState.selectedKey;
if (!key) return;
const session = currentState.sessions[key];
const sessionId = session?.sessionId;
if (!sessionId) return;
const updated = await updateSessionTitle(sessionId, trimmed);
dispatch({
type: "SET_SESSION_TITLE",
key,
title: updated.title || trimmed,
});
}, []);
const newSession = useCallback(() => {
dispatch({ type: "NEW_SESSION", key: makeDraftKey() });
}, [makeDraftKey]);
const editMessage = useCallback(
async (messageId: number, newContent: string) => {
const trimmed = newContent.trim();
if (!trimmed) return;
const currentState = stateRef.current;
const key = currentState.selectedKey;
if (!key) return;
const session = currentState.sessions[key];
if (!session) return;
// Edits create a new branch via a fresh turn — block while one is
// already running so we don't queue against an in-flight stream
// (matches the delete-turn guard).
if (session.isStreaming) return;
const idx = session.messages.findIndex(
(m) => m.id === messageId && m.role === "user",
);
if (idx === -1) return;
let original = session.messages[idx];
// Optimistic in-flight rows have a negative client-side id — we
// need a real server id to hang the new sibling under. Refresh
// from the server, then re-resolve the row by its position in the
// (now-persisted) thread before continuing.
if (typeof original.id === "number" && original.id < 0) {
if (!session.sessionId) return;
try {
await loadSession(session.sessionId);
} catch {
return;
}
const refreshed = stateRef.current.sessions[key];
const candidate = refreshed?.messages[idx];
if (
!candidate ||
candidate.role !== "user" ||
typeof candidate.id !== "number" ||
candidate.id < 0
) {
return;
}
original = candidate;
}
if (typeof original.id !== "number" || original.id < 0) return;
const parentId = original.parentMessageId ?? null;
sendMessage(
trimmed,
undefined,
undefined,
undefined,
undefined,
{ parentMessageId: parentId },
undefined,
undefined,
undefined,
);
},
[loadSession, sendMessage],
);
const switchBranch = useCallback(
(parentMessageId: number | null, childId: number) => {
const currentState = stateRef.current;
const key = currentState.selectedKey;
if (!key) return;
const session = currentState.sessions[key];
if (!session) return;
const parentKey =
parentMessageId == null ? "null" : String(parentMessageId);
dispatch({
type: "SET_SELECTED_BRANCH",
key,
parentKey,
childId,
});
const sessionId = session.sessionId;
if (!sessionId) return;
const nextSelections = {
...session.selectedBranches,
[parentKey]: childId,
};
// Fire-and-forget — local state is the source of truth for the UI;
// the server copy only matters for reload-time hydration.
updateBranchSelection(sessionId, nextSelections).catch((err) => {
console.warn("Failed to persist branch selection:", err);
});
},
[],
);
const deleteTurn = useCallback(
async (messageId: number) => {
const currentState = stateRef.current;
const key = currentState.selectedKey;
if (!key) return;
const session = currentState.sessions[key];
if (!session || !session.sessionId) return;
if (session.isStreaming) return;
let effectiveId = messageId;
if (messageId < 0) {
const origIdx = session.messages.findIndex((m) => m.id === messageId);
if (origIdx === -1) return;
try {
await loadSession(session.sessionId);
} catch {
return;
}
const refreshed = stateRef.current.sessions[key];
const realId = refreshed?.messages[origIdx]?.id;
if (realId == null || realId < 0) return;
effectiveId = realId;
}
try {
await deleteMessage(session.sessionId, effectiveId);
dispatch({ type: "DELETE_TURN", key, messageId: effectiveId });
} catch (err) {
console.error("Failed to delete turn:", err);
}
},
[loadSession],
);
// Memoize the context value so consumers don't re-render on every render of
// this provider. Without this wrap, every stream-event-driven reducer
// dispatch produced a fresh object identity, cascading a re-render through
// every `useUnifiedChat()` consumer (chat page, composer, sidebar) on each
// token. The callbacks below are already stable via useCallback; the only
// things that should change identity are derivedState, sessionStatuses,
// and sidebarRefreshToken.
const value = useMemo<ChatContextValue>(
() => ({
state: derivedState,
setTools,
setCapability,
setKBs,
setLLMSelection,
setPersonaSelection,
setLanguage,
sendMessage,
cancelStreamingTurn,
submitUserReply,
regenerateLastMessage,
deleteTurn,
editMessage,
switchBranch,
renameSessionTitle,
newSession,
loadSession,
selectedSessionId: derivedState.sessionId,
sessionStatuses,
sidebarRefreshToken: state.sidebarRefreshToken,
}),
[
derivedState,
setTools,
setCapability,
setKBs,
setLLMSelection,
setPersonaSelection,
setLanguage,
sendMessage,
cancelStreamingTurn,
submitUserReply,
regenerateLastMessage,
deleteTurn,
editMessage,
switchBranch,
renameSessionTitle,
newSession,
loadSession,
sessionStatuses,
state.sidebarRefreshToken,
],
);
return <ChatCtx.Provider value={value}>{children}</ChatCtx.Provider>;
}
export function useUnifiedChat() {
const ctx = useContext(ChatCtx);
if (!ctx)
throw new Error("useUnifiedChat must be inside UnifiedChatProvider");
return ctx;
}