Просмотр исходного кода

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>
fanghanjun 6 месяцев назад
Родитель
Сommit
8ccb4c8e45

+ 25 - 26
packages/studio/src/store/chat/slices/create/action.ts

@@ -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));

+ 0 - 1
packages/studio/src/store/chat/slices/create/initialState.ts

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

+ 283 - 387
packages/studio/src/store/chat/slices/message/action.ts

@@ -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";
+import { attachSessionStreamListeners } from "./stream-events";
+import {
+  bookKey,
+  createSessionRuntime,
+  deserializeMessages,
+  extractErrorMessage,
+  mergeSessionIds,
+  updateSession,
+  upsertSessionSummary,
+} from "./runtime";
 
-function extractErrorMessage(error: string | { code?: string; message?: string }): string {
-  if (typeof error === "string") return error;
-  return error.message ?? "Unknown error";
-}
-
-// -- Tool label helpers --
+export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions> = (set, get) => ({
+  activateSession: (sessionId) =>
+    set({ activeSessionId: sessionId }),
 
-const AGENT_LABELS: Record<string, string> = {
-  architect: "建书", writer: "写作", auditor: "审计",
-  reviser: "修订", exporter: "导出",
-};
-const TOOL_LABELS: Record<string, string> = {
-  read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
-};
+  setInput: (text) => set({ input: text }),
 
-function resolveToolLabel(tool: string, agent?: string): string {
-  if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
-  return TOOL_LABELS[tool] ?? tool;
-}
+  addUserMessage: (sessionId, content) =>
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, (session) => ({
+        messages: [...session.messages, { role: "user", content, timestamp: Date.now() }],
+        lastError: null,
+      })),
+    })),
+
+  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: (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: (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: (sessionId, errorMsg) =>
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, (session) => ({
+        messages: [...session.messages, { role: "assistant", content: `\u2717 ${errorMsg}`, timestamp: Date.now() }],
+        lastError: errorMsg,
+      })),
+    })),
+
+  loadSessionMessages: (sessionId, msgs) =>
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, (session) => {
+        if (session.messages.length > 0) return {};
+        return { messages: deserializeMessages(msgs) };
+      }),
+    })),
 
-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);
-}
+  setSelectedModel: (model, service) => set({ selectedModel: model, selectedService: service }),
 
-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) ?? "";
+  loadSessionList: async (bookId) => {
+    const query = bookId === null ? "null" : encodeURIComponent(bookId);
+    try {
+      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),
+          },
+        };
+      });
+    } catch {
+      // ignore
     }
-  }
-  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];
-}
+  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");
+    }
 
-/** Replace the last message with an updated version. */
-function replaceLast(messages: ReadonlyArray<Message>, updated: Message): ReadonlyArray<Message> {
-  return [...messages.slice(0, -1), updated];
-}
+    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,
+      };
+    });
 
-/** 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;
-}
+    return sessionId;
+  },
 
-/** 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[] = [];
+  renameSession: async (sessionId, title) => {
+    const previous = get().sessions[sessionId]?.title ?? null;
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, () => ({ title })),
+    }));
 
-  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);
+    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 })),
+      }));
     }
-  }
-
-  return {
-    content,
-    ...(thinking ? { thinking } : {}),
-    ...(thinkingStreaming ? { thinkingStreaming: true } : {}),
-    ...(toolExecutions.length > 0 ? { toolExecutions } : {}),
-  };
-}
-
-export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions> = (set, get) => ({
-  setInput: (text) => set({ input: text }),
-
-  addUserMessage: (content) => set((s) => ({
-    messages: [...s.messages, { role: "user" as const, content, timestamp: Date.now() }],
-  })),
+  },
 
-  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 }] };
+  deleteSession: async (sessionId) => {
+    const session = get().sessions[sessionId];
+    session?.stream?.close();
+    try {
+      await fetchJson(`/sessions/${sessionId}`, { method: "DELETE" });
+    } catch {
+      // ignore
     }
-    return { messages: [...s.messages, { role: "assistant" as const, 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 });
+    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 { ...m, 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() },
-    ],
-  })),
 
-  addErrorMessage: (errorMsg) => set((s) => ({
-    messages: [...s.messages, { role: "assistant" as const, content: `\u2717 ${errorMsg}`, timestamp: Date.now() }],
-  })),
-
-  setLoading: (loading) => set({ loading }),
-
-  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,
-          };
-        }),
-    };
-  }),
+      return {
+        sessions: rest,
+        sessionIdsByBook,
+        activeSessionId,
+      };
+    });
+  },
 
-  loadSession: async (bookId) => {
+  loadSessionDetail: async (sessionId) => {
     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<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],
+            ),
+          },
+        };
       });
-      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) => {
+  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);
-
-    get()._activeStream?.close();
+    set((state) => ({
+      input: "",
+      activeSessionId: sessionId,
+      sessions: updateSession(state.sessions, sessionId, () => ({
+        isStreaming: true,
+        lastError: null,
+      })),
+    }));
+
+    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,
+        })),
+      }));
     }
   },
 });

+ 42 - 16
packages/studio/src/store/chat/types.ts

@@ -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;