fix(studio): remove pendingBookArgs from CreateState, keep only on SessionRuntime

pendingBookArgs 原来同时存在 CreateState(顶层)和 SessionRuntime(per-session)两份,
切换 session 时有数据不一致的风险。现在只保留在 SessionRuntime.pendingBookArgs 上:
- setPendingBookArgs 只写到 sessions[activeSessionId]
- handleCreateBook 只从 session.pendingBookArgs 读取
- activateSession / deleteSession 不再同步顶层字段

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fanghanjun 2026-04-17 00:58:17 -07:00 committed by Ma
parent 7dc6899d13
commit 8ccb4c8e45
4 changed files with 355 additions and 435 deletions

View file

@ -5,18 +5,18 @@ import { bookKey } from "../message/runtime";
export const createCreateSlice: StateCreator<ChatStore, [], [], CreateActions> = (set, get) => ({
setPendingBookArgs: (args) =>
set((state) => ({
pendingBookArgs: args,
sessions: state.activeSessionId
? {
...state.sessions,
[state.activeSessionId]: {
...state.sessions[state.activeSessionId],
pendingBookArgs: args,
},
}
: state.sessions,
})),
set((state) => {
if (!state.activeSessionId) return {};
return {
sessions: {
...state.sessions,
[state.activeSessionId]: {
...state.sessions[state.activeSessionId],
pendingBookArgs: args,
},
},
};
}),
setBookCreating: (creating) => set({ bookCreating: creating }),
setCreateProgress: (progress) => set({ createProgress: progress }),
@ -28,8 +28,7 @@ export const createCreateSlice: StateCreator<ChatStore, [], [], CreateActions> =
handleCreateBook: async (sessionId, activeBookId) => {
const session = get().sessions[sessionId];
const pendingArgs = session?.pendingBookArgs ?? get().pendingBookArgs;
if (!pendingArgs) return null;
if (!session?.pendingBookArgs) return null;
set({ bookCreating: true });
try {
@ -67,18 +66,18 @@ export const createCreateSlice: StateCreator<ChatStore, [], [], CreateActions> =
});
get().bumpBookDataVersion();
}
set((state) => ({
pendingBookArgs: null,
sessions: state.sessions[sessionId]
? {
...state.sessions,
[sessionId]: {
...state.sessions[sessionId],
pendingBookArgs: null,
},
}
: state.sessions,
}));
set((state) => {
if (!state.sessions[sessionId]) return {};
return {
sessions: {
...state.sessions,
[sessionId]: {
...state.sessions[sessionId],
pendingBookArgs: null,
},
},
};
});
return newBookId;
} catch (e) {
get().addErrorMessage(sessionId, e instanceof Error ? e.message : String(e));

View file

@ -1,7 +1,6 @@
import type { CreateState } from "../../types";
export const initialCreateState: CreateState = {
pendingBookArgs: null,
bookCreating: false,
createProgress: "",
bookDataVersion: 0,

View file

@ -1,406 +1,280 @@
import type { StateCreator } from "zustand";
import type { ChatStore, MessageActions, AgentResponse, SessionMessage, Message, MessagePart, ToolExecution, PipelineStage } from "../../types";
import type {
AgentResponse,
ChatStore,
MessageActions,
SessionResponse,
SessionSummary,
} from "../../types";
import { fetchJson } from "../../../../hooks/use-api";
import { shouldRefreshSidebarForTool } from "../../message-policy";
function extractErrorMessage(error: string | { code?: string; message?: string }): string {
if (typeof error === "string") return error;
return error.message ?? "Unknown error";
}
// -- Tool label helpers --
const AGENT_LABELS: Record<string, string> = {
architect: "建书", writer: "写作", auditor: "审计",
reviser: "修订", exporter: "导出",
};
const TOOL_LABELS: Record<string, string> = {
read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
};
function resolveToolLabel(tool: string, agent?: string): string {
if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
return TOOL_LABELS[tool] ?? tool;
}
function summarizeResult(result: unknown): string {
if (typeof result === "string") return result.slice(0, 200);
if (result && typeof result === "object") {
const r = result as Record<string, unknown>;
if (typeof r.content === "string") return r.content.slice(0, 200);
}
return String(result).slice(0, 200);
}
function extractToolError(result: unknown): string {
if (typeof result === "string") return result.slice(0, 500);
if (result && typeof result === "object") {
const r = result as Record<string, unknown>;
if (typeof r.content === "string") return r.content.slice(0, 500);
if (r.content && Array.isArray(r.content)) {
const textPart = r.content.find((c: any) => c.type === "text");
if (textPart) return (textPart as any).text?.slice(0, 500) ?? "";
}
}
return String(result).slice(0, 500);
}
// -- Parts helpers --
/** Get or create the streaming assistant message, returning [updatedMessages, streamMsg]. */
function getOrCreateStream(messages: ReadonlyArray<Message>, streamTs: number): [ReadonlyArray<Message>, Message] {
const last = messages[messages.length - 1];
if (last?.timestamp === streamTs && last.role === "assistant") {
return [messages, last];
}
const newMsg: Message = { role: "assistant", content: "", timestamp: streamTs, parts: [] };
return [[...messages, newMsg], newMsg];
}
/** Replace the last message with an updated version. */
function replaceLast(messages: ReadonlyArray<Message>, updated: Message): ReadonlyArray<Message> {
return [...messages.slice(0, -1), updated];
}
/** Find the last tool part that is "running" in a parts array. */
function findRunningToolPart(parts: MessagePart[]): (MessagePart & { type: "tool" }) | undefined {
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (p.type === "tool" && p.execution.status === "running") return p as MessagePart & { type: "tool" };
}
return undefined;
}
/** Derive flat fields (content, thinking, toolExecutions) from parts for persistence compatibility. */
function deriveFlat(parts: MessagePart[]): { content: string; thinking?: string; thinkingStreaming?: boolean; toolExecutions?: ToolExecution[] } {
let content = "";
let thinking = "";
let thinkingStreaming = false;
const toolExecutions: ToolExecution[] = [];
for (const p of parts) {
if (p.type === "thinking") {
if (thinking) thinking += "\n\n---\n\n";
thinking += p.content;
if (p.streaming) thinkingStreaming = true;
} else if (p.type === "text") {
content += p.content;
} else if (p.type === "tool") {
toolExecutions.push(p.execution);
}
}
return {
content,
...(thinking ? { thinking } : {}),
...(thinkingStreaming ? { thinkingStreaming: true } : {}),
...(toolExecutions.length > 0 ? { toolExecutions } : {}),
};
}
import { attachSessionStreamListeners } from "./stream-events";
import {
bookKey,
createSessionRuntime,
deserializeMessages,
extractErrorMessage,
mergeSessionIds,
updateSession,
upsertSessionSummary,
} from "./runtime";
export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions> = (set, get) => ({
activateSession: (sessionId) =>
set({ activeSessionId: sessionId }),
setInput: (text) => set({ input: text }),
addUserMessage: (content) => set((s) => ({
messages: [...s.messages, { role: "user" as const, content, timestamp: Date.now() }],
})),
addUserMessage: (sessionId, content) =>
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (session) => ({
messages: [...session.messages, { role: "user", content, timestamp: Date.now() }],
lastError: null,
})),
})),
appendStreamChunk: (text, streamTs) => set((s) => {
const last = s.messages[s.messages.length - 1];
if (last?.timestamp === streamTs && last.role === "assistant") {
return { messages: [...s.messages.slice(0, -1), { ...last, content: last.content + text }] };
}
return { messages: [...s.messages, { role: "assistant" as const, content: text, timestamp: streamTs }] };
}),
appendStreamChunk: (sessionId, text, streamTs) =>
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (session) => {
const last = session.messages[session.messages.length - 1];
if (last?.timestamp === streamTs && last.role === "assistant") {
return {
messages: [...session.messages.slice(0, -1), { ...last, content: last.content + text }],
};
}
return {
messages: [...session.messages, { role: "assistant", content: text, timestamp: streamTs }],
};
}),
})),
finalizeStream: (streamTs, content, toolCall) => set((s) => ({
messages: s.messages.map((m) => {
if (m.timestamp !== streamTs || m.role !== "assistant") return m;
// Update the last text part in parts, or add one
const parts = [...(m.parts ?? [])];
const lastPart = parts[parts.length - 1];
if (lastPart?.type === "text") {
parts[parts.length - 1] = { ...lastPart, content };
} else if (content) {
parts.push({ type: "text", content });
}
return { ...m, content, toolCall, parts };
}),
})),
finalizeStream: (sessionId, streamTs, content, toolCall) =>
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (session) => ({
messages: session.messages.map((message) => {
if (message.timestamp !== streamTs || message.role !== "assistant") return message;
const parts = [...(message.parts ?? [])];
const lastPart = parts[parts.length - 1];
if (lastPart?.type === "text") {
parts[parts.length - 1] = { ...lastPart, content };
} else if (content) {
parts.push({ type: "text", content });
}
return { ...message, content, toolCall, parts };
}),
})),
})),
replaceStreamWithError: (streamTs, errorMsg) => set((s) => ({
messages: [
...s.messages.filter((m) => !(m.timestamp === streamTs && m.role === "assistant")),
{ role: "assistant" as const, content: `\u2717 ${errorMsg}`, timestamp: Date.now() },
],
})),
replaceStreamWithError: (sessionId, streamTs, errorMsg) =>
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (session) => ({
messages: [
...session.messages.filter(
(message) => !(message.timestamp === streamTs && message.role === "assistant"),
),
{ role: "assistant", content: `\u2717 ${errorMsg}`, timestamp: Date.now() },
],
isStreaming: false,
lastError: errorMsg,
stream: null,
})),
})),
addErrorMessage: (errorMsg) => set((s) => ({
messages: [...s.messages, { role: "assistant" as const, content: `\u2717 ${errorMsg}`, timestamp: Date.now() }],
})),
addErrorMessage: (sessionId, errorMsg) =>
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (session) => ({
messages: [...session.messages, { role: "assistant", content: `\u2717 ${errorMsg}`, timestamp: Date.now() }],
lastError: errorMsg,
})),
})),
setLoading: (loading) => set({ loading }),
loadSessionMessages: (sessionId, msgs) =>
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (session) => {
if (session.messages.length > 0) return {};
return { messages: deserializeMessages(msgs) };
}),
})),
setSelectedModel: (model, service) => set({ selectedModel: model, selectedService: service }),
loadSessionMessages: (msgs) => set((s) => {
if (s.messages.length > 0) return s;
return {
messages: msgs
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => {
const toolExecs = (m as any).toolExecutions as ToolExecution[] | undefined;
// Rebuild parts from flat fields for historical messages
const parts: MessagePart[] = [];
if (m.thinking) parts.push({ type: "thinking", content: m.thinking, streaming: false });
if (toolExecs) {
for (const exec of toolExecs) parts.push({ type: "tool", execution: exec });
}
if (m.content) parts.push({ type: "text", content: m.content });
return {
role: m.role as "user" | "assistant",
content: m.content,
thinking: m.thinking,
toolExecutions: toolExecs,
timestamp: m.timestamp,
parts: parts.length > 0 ? parts : undefined,
};
}),
};
}),
loadSession: async (bookId) => {
loadSessionList: async (bookId) => {
const query = bookId === null ? "null" : encodeURIComponent(bookId);
try {
const data = await fetchJson<{ session: { sessionId: string; messages?: SessionMessage[] } }>("/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bookId: bookId ?? null }),
const data = await fetchJson<{ sessions: ReadonlyArray<SessionSummary> }>(`/sessions?bookId=${query}`);
set((state) => {
let sessions = state.sessions;
for (const summary of data.sessions) {
sessions = upsertSessionSummary(sessions, summary);
}
return {
sessions,
sessionIdsByBook: {
...state.sessionIdsByBook,
[bookKey(bookId)]: data.sessions.map((session) => session.sessionId),
},
};
});
const session = data.session;
const prevSessionId = get().currentSessionId;
if (prevSessionId === session.sessionId && get().messages.length > 0) {
return;
}
get()._activeStream?.close();
set({ currentSessionId: session.sessionId, messages: [], loading: false, _activeStream: null });
if (session.messages && session.messages.length > 0) {
get().loadSessionMessages(session.messages);
}
} catch {
set({ currentSessionId: null, messages: [], loading: false });
// ignore
}
},
sendMessage: async (text, activeBookId) => {
createSession: async (bookId) => {
const data = await fetchJson<SessionResponse>("/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bookId }),
});
const sessionId = data.session?.sessionId;
if (!sessionId) {
throw new Error("Failed to create session");
}
set((state) => {
const runtime = createSessionRuntime({
sessionId,
bookId: data.session?.bookId ?? bookId ?? null,
title: data.session?.title ?? null,
});
return {
sessions: {
...state.sessions,
[sessionId]: runtime,
},
sessionIdsByBook: {
...state.sessionIdsByBook,
[bookKey(runtime.bookId)]: mergeSessionIds(
state.sessionIdsByBook[bookKey(runtime.bookId)],
[sessionId],
),
},
activeSessionId: sessionId,
};
});
return sessionId;
},
renameSession: async (sessionId, title) => {
const previous = get().sessions[sessionId]?.title ?? null;
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({ title })),
}));
try {
await fetchJson(`/sessions/${sessionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
} catch {
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({ title: previous })),
}));
}
},
deleteSession: async (sessionId) => {
const session = get().sessions[sessionId];
session?.stream?.close();
try {
await fetchJson(`/sessions/${sessionId}`, { method: "DELETE" });
} catch {
// ignore
}
set((state) => {
const { [sessionId]: deleted, ...rest } = state.sessions;
const sessionIdsByBook = Object.fromEntries(
Object.entries(state.sessionIdsByBook).map(([key, ids]) => [
key,
ids.filter((id) => id !== sessionId),
]),
);
let activeSessionId = state.activeSessionId;
if (activeSessionId === sessionId) {
const fallbackKey = bookKey(session?.bookId ?? null);
activeSessionId = sessionIdsByBook[fallbackKey]?.[0] ?? null;
}
return {
sessions: rest,
sessionIdsByBook,
activeSessionId,
};
});
},
loadSessionDetail: async (sessionId) => {
try {
const data = await fetchJson<SessionResponse>(`/sessions/${sessionId}`);
const detail = data.session;
if (!detail?.sessionId) return;
const detailSessionId = detail.sessionId;
const messages = detail.messages ? deserializeMessages(detail.messages) : [];
set((state) => {
const runtime = state.sessions[detailSessionId];
const nextBookId = detail.bookId ?? runtime?.bookId ?? null;
return {
sessions: {
...state.sessions,
[detailSessionId]: {
...(runtime ?? createSessionRuntime({
sessionId: detailSessionId,
bookId: nextBookId,
title: detail.title ?? null,
})),
bookId: nextBookId,
title: detail.title ?? runtime?.title ?? null,
messages,
},
},
sessionIdsByBook: {
...state.sessionIdsByBook,
[bookKey(nextBookId)]: mergeSessionIds(
state.sessionIdsByBook[bookKey(nextBookId)],
[detailSessionId],
),
},
};
});
} catch {
// ignore
}
},
sendMessage: async (sessionId, text, activeBookId) => {
const trimmed = text.trim();
if (!trimmed || get().loading) return;
const session = get().sessions[sessionId];
if (!trimmed || !session || session.isStreaming) return;
if (!get().selectedModel) {
get().addUserMessage(trimmed);
get().addErrorMessage("请先选择一个模型");
get().addUserMessage(sessionId, trimmed);
get().addErrorMessage(sessionId, "请先选择一个模型");
return;
}
const hasBook = Boolean(activeBookId);
const instruction = hasBook ? trimmed : `/new ${trimmed}`;
const instruction = activeBookId ? trimmed : `/new ${trimmed}`;
const streamTs = Date.now() + 1;
set({ input: "", loading: true });
get().addUserMessage(trimmed);
set((state) => ({
input: "",
activeSessionId: sessionId,
sessions: updateSession(state.sessions, sessionId, () => ({
isStreaming: true,
lastError: null,
})),
}));
get()._activeStream?.close();
get().addUserMessage(sessionId, trimmed);
session.stream?.close();
const streamEs = new EventSource("/api/v1/events");
set({ _activeStream: streamEs });
// ---------------------------------------------------------------
// SSE listeners — each event updates `parts` as source of truth,
// then derives flat fields (content/thinking/toolExecutions) for
// persistence compatibility.
// ---------------------------------------------------------------
streamEs.addEventListener("thinking:start", () => {
set((s) => {
const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
const parts = [...(stream.parts ?? [])];
parts.push({ type: "thinking", content: "", streaming: true });
const flat = deriveFlat(parts);
return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
});
});
streamEs.addEventListener("thinking:delta", (e: MessageEvent) => {
try {
const d = e.data ? JSON.parse(e.data) : null;
if (!d?.text) return;
set((s) => {
const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
const parts = [...(stream.parts ?? [])];
const last = parts[parts.length - 1];
if (last?.type === "thinking") {
parts[parts.length - 1] = { ...last, content: last.content + d.text };
}
const flat = deriveFlat(parts);
return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
});
} catch { /* ignore */ }
});
streamEs.addEventListener("thinking:end", () => {
set((s) => {
const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
const parts = [...(stream.parts ?? [])];
const last = parts[parts.length - 1];
if (last?.type === "thinking") {
parts[parts.length - 1] = { ...last, streaming: false };
}
const flat = deriveFlat(parts);
return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
});
});
streamEs.addEventListener("draft:delta", (e: MessageEvent) => {
try {
const d = e.data ? JSON.parse(e.data) : null;
if (!d?.text) return;
set((s) => {
const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
const parts = [...(stream.parts ?? [])];
const last = parts[parts.length - 1];
if (last?.type === "text") {
parts[parts.length - 1] = { ...last, content: last.content + d.text };
} else {
parts.push({ type: "text", content: d.text });
}
const flat = deriveFlat(parts);
return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
});
} catch { /* ignore */ }
});
streamEs.addEventListener("tool:start", (e: MessageEvent) => {
try {
const d = e.data ? JSON.parse(e.data) : null;
if (!d?.tool) return;
set((s) => {
const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
const parts = [...(stream.parts ?? [])];
// For pipeline ops (sub_agent), move trailing text to thinking
if (d.tool === "sub_agent") {
const last = parts[parts.length - 1];
if (last?.type === "text" && last.content) {
parts.pop();
const prev = parts[parts.length - 1];
if (prev?.type === "thinking") {
parts[parts.length - 1] = { ...prev, content: prev.content + (prev.content ? "\n\n" : "") + last.content };
} else {
parts.push({ type: "thinking", content: last.content, streaming: false });
}
}
}
const agent = d.tool === "sub_agent" ? (d.args?.agent as string | undefined) : undefined;
const stages: PipelineStage[] | undefined = (d.stages as string[] | undefined)?.length
? (d.stages as string[]).map((label) => ({ label, status: "pending" as const }))
: undefined;
const exec: ToolExecution = {
id: d.id as string,
tool: d.tool as string,
agent,
label: resolveToolLabel(d.tool, agent),
status: "running",
args: d.args as Record<string, unknown> | undefined,
stages,
startedAt: Date.now(),
};
parts.push({ type: "tool", execution: exec });
const flat = deriveFlat(parts);
return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
});
} catch { /* ignore */ }
});
streamEs.addEventListener("tool:end", (e: MessageEvent) => {
try {
const d = e.data ? JSON.parse(e.data) : null;
if (!d?.tool) return;
set((s) => {
const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
const parts = (stream.parts ?? []).map((p) => {
if (p.type !== "tool" || p.execution.id !== d.id) return p;
const exec = { ...p.execution };
exec.status = d.isError ? "error" : "completed";
exec.completedAt = Date.now();
if (d.isError) exec.error = extractToolError(d.result);
else exec.result = summarizeResult(d.result);
exec.stages = exec.stages?.map((s) =>
s.status !== "completed" ? { ...s, status: "completed" as const, progress: undefined } : s
);
return { type: "tool" as const, execution: exec };
});
const flat = deriveFlat(parts);
return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
});
if (shouldRefreshSidebarForTool(d.tool)) {
get().bumpBookDataVersion();
}
} catch { /* ignore */ }
});
streamEs.addEventListener("log", (e: MessageEvent) => {
try {
const d = e.data ? JSON.parse(e.data) : null;
const msg = d?.message as string | undefined;
if (!msg) return;
set((s) => {
const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
const runningTool = findRunningToolPart([...(stream.parts ?? [])]);
if (!runningTool) return s;
const parts = (stream.parts ?? []).map((p) => {
if (p.type !== "tool" || p.execution.id !== runningTool.execution.id) return p;
return { type: "tool" as const, execution: { ...p.execution, logs: [...(p.execution.logs ?? []), msg] } };
});
const flat = deriveFlat(parts);
return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
});
} catch { /* ignore */ }
});
streamEs.addEventListener("llm:progress", (e: MessageEvent) => {
try {
const d = e.data ? JSON.parse(e.data) : null;
if (!d) return;
set((s) => {
const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
const runningTool = findRunningToolPart([...(stream.parts ?? [])]);
if (!runningTool?.execution.stages) return s;
const parts = (stream.parts ?? []).map((p) => {
if (p.type !== "tool" || p.execution.id !== runningTool.execution.id) return p;
const stages = p.execution.stages!.map((stage) =>
stage.status === "active"
? { ...stage, progress: { status: d.status, elapsedMs: d.elapsedMs, totalChars: d.totalChars, chineseChars: d.chineseChars } }
: stage
);
return { type: "tool" as const, execution: { ...p.execution, stages } };
});
const flat = deriveFlat(parts);
return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
});
} catch { /* ignore */ }
});
// -- API call + finalize --
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({ stream: streamEs })),
}));
attachSessionStreamListeners({ sessionId, streamTs, streamEs, set, get });
try {
const data = await fetchJson<AgentResponse>("/agent", {
@ -409,7 +283,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
body: JSON.stringify({
instruction,
activeBookId,
sessionId: get().currentSessionId,
sessionId,
model: get().selectedModel ?? undefined,
service: get().selectedService ?? undefined,
}),
@ -419,46 +293,68 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
const finalContent = data.details?.draftRaw || data.response || "";
const toolCall = data.details?.toolCall ?? undefined;
const hasStream = get().messages.some((m) => m.timestamp === streamTs);
const hasStream = Boolean(
get().sessions[sessionId]?.messages.some((message) => message.timestamp === streamTs),
);
if (data.error) {
const errorMessage = extractErrorMessage(data.error);
if (hasStream) {
get().replaceStreamWithError(streamTs, extractErrorMessage(data.error));
get().replaceStreamWithError(sessionId, streamTs, errorMessage);
} else {
get().addErrorMessage(extractErrorMessage(data.error));
get().addErrorMessage(sessionId, errorMessage);
}
} else if (finalContent) {
if (hasStream) {
get().finalizeStream(streamTs, finalContent, toolCall);
get().finalizeStream(sessionId, streamTs, finalContent, toolCall);
} else {
set((s) => ({
messages: [...s.messages, {
role: "assistant" as const, content: finalContent, timestamp: Date.now(), toolCall,
}],
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (runtime) => ({
messages: [
...runtime.messages,
{
role: "assistant",
content: finalContent,
timestamp: Date.now(),
toolCall,
},
],
})),
}));
}
if (toolCall?.name === "create_book") {
get().setPendingBookArgs({ ...toolCall.arguments });
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({
pendingBookArgs: { ...toolCall.arguments },
})),
}));
}
} else {
const emptyMsg = "模型未返回文本内容。请检查协议类型chat/responses、流式开关或上游服务兼容性。";
const emptyMessage = "模型未返回文本内容。请检查协议类型chat/responses、流式开关或上游服务兼容性。";
if (hasStream) {
get().replaceStreamWithError(streamTs, emptyMsg);
get().replaceStreamWithError(sessionId, streamTs, emptyMessage);
} else {
get().addErrorMessage(emptyMsg);
get().addErrorMessage(sessionId, emptyMessage);
}
}
} catch (e) {
} catch (error) {
streamEs.close();
const errorMsg = e instanceof Error ? e.message : String(e);
const hasStream = get().messages.some((m) => m.timestamp === streamTs);
const errorMessage = error instanceof Error ? error.message : String(error);
const hasStream = Boolean(
get().sessions[sessionId]?.messages.some((message) => message.timestamp === streamTs),
);
if (hasStream) {
get().replaceStreamWithError(streamTs, errorMsg);
get().replaceStreamWithError(sessionId, streamTs, errorMessage);
} else {
get().addErrorMessage(errorMsg);
get().addErrorMessage(sessionId, errorMessage);
}
} finally {
set({ loading: false, _activeStream: null });
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (runtime) => ({
isStreaming: false,
stream: runtime.stream === streamEs ? null : runtime.stream,
})),
}));
}
},
});

View file

@ -56,6 +56,15 @@ export interface SessionMessage {
readonly timestamp: number;
}
export interface SessionSummary {
readonly sessionId: string;
readonly bookId: string | null;
readonly title: string | null;
readonly messageCount: number;
readonly createdAt: number;
readonly updatedAt: number;
}
export interface AgentResponse {
readonly response?: string;
readonly error?: string | { code?: string; message?: string };
@ -64,6 +73,9 @@ export interface AgentResponse {
readonly toolCall?: ToolCall;
};
readonly session?: {
readonly sessionId?: string;
readonly bookId?: string | null;
readonly title?: string | null;
readonly activeBookId?: string;
readonly creationDraft?: unknown;
readonly messages?: ReadonlyArray<SessionMessage>;
@ -74,6 +86,8 @@ export interface AgentResponse {
export interface SessionResponse {
readonly session?: {
readonly sessionId?: string;
readonly bookId?: string | null;
readonly title?: string | null;
readonly activeBookId?: string;
readonly messages?: ReadonlyArray<SessionMessage>;
};
@ -88,19 +102,27 @@ export interface BookSummary {
cast: string;
}
export interface SessionRuntime {
readonly sessionId: string;
readonly bookId: string | null;
readonly title: string | null;
readonly messages: ReadonlyArray<Message>;
readonly stream: EventSource | null;
readonly isStreaming: boolean;
readonly lastError: string | null;
readonly pendingBookArgs: Record<string, unknown> | null;
}
export interface MessageState {
messages: ReadonlyArray<Message>;
sessions: Record<string, SessionRuntime>;
sessionIdsByBook: Record<string, ReadonlyArray<string>>;
activeSessionId: string | null;
input: string;
loading: boolean;
currentSessionId: string | null;
selectedModel: string | null;
selectedService: string | null;
/** Active EventSource ref — closed on session switch */
_activeStream: EventSource | null;
}
export interface CreateState {
pendingBookArgs: Record<string, unknown> | null;
bookCreating: boolean;
createProgress: string;
bookDataVersion: number;
@ -115,16 +137,20 @@ export type ChatState = MessageState & CreateState;
// -- Action interfaces --
export interface MessageActions {
activateSession: (sessionId: string | null) => void;
setInput: (text: string) => void;
addUserMessage: (content: string) => void;
appendStreamChunk: (text: string, streamTs: number) => void;
finalizeStream: (streamTs: number, content: string, toolCall?: ToolCall) => void;
replaceStreamWithError: (streamTs: number, errorMsg: string) => void;
addErrorMessage: (errorMsg: string) => void;
setLoading: (loading: boolean) => void;
loadSessionMessages: (msgs: ReadonlyArray<SessionMessage>) => void;
loadSession: (bookId?: string) => Promise<void>;
sendMessage: (text: string, activeBookId?: string) => Promise<void>;
addUserMessage: (sessionId: string, content: string) => void;
appendStreamChunk: (sessionId: string, text: string, streamTs: number) => void;
finalizeStream: (sessionId: string, streamTs: number, content: string, toolCall?: ToolCall) => void;
replaceStreamWithError: (sessionId: string, streamTs: number, errorMsg: string) => void;
addErrorMessage: (sessionId: string, errorMsg: string) => void;
loadSessionMessages: (sessionId: string, msgs: ReadonlyArray<SessionMessage>) => void;
loadSessionList: (bookId: string | null) => Promise<void>;
createSession: (bookId: string | null) => Promise<string>;
renameSession: (sessionId: string, title: string) => Promise<void>;
deleteSession: (sessionId: string) => Promise<void>;
loadSessionDetail: (sessionId: string) => Promise<void>;
sendMessage: (sessionId: string, text: string, activeBookId?: string) => Promise<void>;
setSelectedModel: (model: string, service: string) => void;
}
@ -132,7 +158,7 @@ export interface CreateActions {
setPendingBookArgs: (args: Record<string, unknown> | null) => void;
setBookCreating: (creating: boolean) => void;
setCreateProgress: (progress: string) => void;
handleCreateBook: (activeBookId?: string) => Promise<string | null>;
handleCreateBook: (sessionId: string, activeBookId?: string) => Promise<string | null>;
bumpBookDataVersion: () => void;
openArtifact: (file: string) => void;
openChapterArtifact: (chapterNum: number) => void;