mirror of
https://github.com/Narcooo/inkos
synced 2026-04-21 14:37:16 +00:00
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:
parent
7dc6899d13
commit
8ccb4c8e45
4 changed files with 355 additions and 435 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { CreateState } from "../../types";
|
||||
|
||||
export const initialCreateState: CreateState = {
|
||||
pendingBookArgs: null,
|
||||
bookCreating: false,
|
||||
createProgress: "",
|
||||
bookDataVersion: 0,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue