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

refactor(studio): extract session SSE listeners from App.tsx into use-session-events hook

session:title 和 book:created 的 SSE 事件处理原来写在 App.tsx 里(70 行),
现在抽到 hooks/use-session-events.ts 里,App.tsx 只调 useSessionEvents()。
同时去掉 create/action.ts 里重复定义的 bookKey,统一从 runtime.ts 导入。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fanghanjun 6 месяцев назад
Родитель
Сommit
7dc6899d13

+ 3 - 0
packages/studio/src/App.tsx

@@ -19,6 +19,7 @@ import { DoctorView } from "./pages/DoctorView";
 import { LanguageSelector } from "./pages/LanguageSelector";
 import { BookSidebar, BookSidebarToggle } from "./components/chat/BookSidebar";
 import { useSSE } from "./hooks/use-sse";
+import { useSessionEvents } from "./hooks/use-session-events";
 import { useTheme } from "./hooks/use-theme";
 import { useI18n } from "./hooks/use-i18n";
 import { postApi, putApi, useApi } from "./hooks/use-api";
@@ -56,6 +57,8 @@ export function App() {
     }
   }, [project]);
 
+  useSessionEvents(sse, route, setRoute);
+
   const nav = {
     toDashboard: () => setRoute({ page: "dashboard" }),
     toBook: (bookId: string) => setRoute({ page: "book", bookId }),

+ 64 - 0
packages/studio/src/hooks/use-session-events.ts

@@ -0,0 +1,64 @@
+import { useEffect } from "react";
+import type { SSEMessage } from "./use-sse";
+import type { HashRoute } from "./use-hash-route";
+import { useChatStore } from "../store/chat";
+import { bookKey, mergeSessionIds, updateSession } from "../store/chat/slices/message/runtime";
+import { clearBookCreateSessionId, getBookCreateSessionId } from "../pages/chat-page-state";
+
+/**
+ * 监听全局 SSE 事件中与 session 有关的两类消息:
+ * - session:title — AI 自动生成标题后推送,更新侧边栏显示
+ * - book:created  — 新建书籍成功后推送,把 session 从 null 迁移到新书籍、清 localStorage、跳转
+ */
+export function useSessionEvents(
+  sse: { messages: ReadonlyArray<SSEMessage> },
+  route: HashRoute,
+  setRoute: (route: HashRoute) => void,
+): void {
+  useEffect(() => {
+    const recent = sse.messages.at(-1);
+    if (!recent) return;
+
+    if (recent.event === "session:title") {
+      const data = recent.data as { sessionId?: string; title?: string } | null;
+      if (!data?.sessionId || !data.title) return;
+      const { sessionId, title } = data;
+      useChatStore.setState((state) => {
+        const session = state.sessions[sessionId];
+        if (!session) return {};
+        return {
+          sessions: updateSession(state.sessions, sessionId, () => ({ title })),
+        };
+      });
+      return;
+    }
+
+    if (recent.event === "book:created") {
+      const data = recent.data as { sessionId?: string; bookId?: string } | null;
+      if (!data?.sessionId || !data.bookId) return;
+      const { sessionId, bookId } = data;
+
+      useChatStore.setState((state) => {
+        const session = state.sessions[sessionId];
+        if (!session) return {};
+        const previousKey = bookKey(session.bookId);
+        const nextKey = bookKey(bookId);
+        return {
+          sessions: updateSession(state.sessions, sessionId, () => ({ bookId })),
+          sessionIdsByBook: {
+            ...state.sessionIdsByBook,
+            [previousKey]: (state.sessionIdsByBook[previousKey] ?? []).filter((id) => id !== sessionId),
+            [nextKey]: mergeSessionIds(state.sessionIdsByBook[nextKey], [sessionId]),
+          },
+        };
+      });
+
+      if (getBookCreateSessionId() === sessionId) {
+        clearBookCreateSessionId();
+        if (route.page === "book-create") {
+          setRoute({ page: "book", bookId });
+        }
+      }
+    }
+  }, [route.page, setRoute, sse.messages]);
+}

+ 60 - 6
packages/studio/src/store/chat/slices/create/action.ts

@@ -1,9 +1,22 @@
 import type { StateCreator } from "zustand";
 import type { ChatStore, CreateActions, AgentResponse } from "../../types";
 import { fetchJson } from "../../../../hooks/use-api";
+import { bookKey } from "../message/runtime";
 
 export const createCreateSlice: StateCreator<ChatStore, [], [], CreateActions> = (set, get) => ({
-  setPendingBookArgs: (args) => set({ pendingBookArgs: args }),
+  setPendingBookArgs: (args) =>
+    set((state) => ({
+      pendingBookArgs: args,
+      sessions: state.activeSessionId
+        ? {
+            ...state.sessions,
+            [state.activeSessionId]: {
+              ...state.sessions[state.activeSessionId],
+              pendingBookArgs: args,
+            },
+          }
+        : state.sessions,
+    })),
   setBookCreating: (creating) => set({ bookCreating: creating }),
   setCreateProgress: (progress) => set({ createProgress: progress }),
 
@@ -13,21 +26,62 @@ export const createCreateSlice: StateCreator<ChatStore, [], [], CreateActions> =
   closeArtifact: () => set({ sidebarView: "panel", artifactFile: null, artifactChapter: null }),
   setBookSummary: (summary) => set({ bookSummary: summary }),
 
-  handleCreateBook: async (activeBookId) => {
-    if (!get().pendingBookArgs) return null;
+  handleCreateBook: async (sessionId, activeBookId) => {
+    const session = get().sessions[sessionId];
+    const pendingArgs = session?.pendingBookArgs ?? get().pendingBookArgs;
+    if (!pendingArgs) return null;
 
     set({ bookCreating: true });
     try {
       const data = await fetchJson<AgentResponse>("/agent", {
         method: "POST",
         headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ instruction: "/create", activeBookId }),
+        body: JSON.stringify({ instruction: "/create", activeBookId, sessionId }),
       });
       const newBookId = data.session?.activeBookId ?? null;
-      if (newBookId) get().bumpBookDataVersion();
+      if (newBookId) {
+        set((state) => {
+          const session = state.sessions[sessionId];
+          if (!session) {
+            return {};
+          }
+
+          const previousIds = state.sessionIdsByBook[bookKey(session.bookId)] ?? [];
+          const nextIds = state.sessionIdsByBook[newBookId] ?? [];
+
+          return {
+            sessions: {
+              ...state.sessions,
+              [sessionId]: {
+                ...session,
+                bookId: newBookId,
+                pendingBookArgs: null,
+              },
+            },
+            sessionIdsByBook: {
+              ...state.sessionIdsByBook,
+              [bookKey(session.bookId)]: previousIds.filter((id) => id !== sessionId),
+              [newBookId]: nextIds.includes(sessionId) ? nextIds : [sessionId, ...nextIds],
+            },
+          };
+        });
+        get().bumpBookDataVersion();
+      }
+      set((state) => ({
+        pendingBookArgs: null,
+        sessions: state.sessions[sessionId]
+          ? {
+              ...state.sessions,
+              [sessionId]: {
+                ...state.sessions[sessionId],
+                pendingBookArgs: null,
+              },
+            }
+          : state.sessions,
+      }));
       return newBookId;
     } catch (e) {
-      get().addErrorMessage(e instanceof Error ? e.message : String(e));
+      get().addErrorMessage(sessionId, e instanceof Error ? e.message : String(e));
       return null;
     } finally {
       set({ bookCreating: false });