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

修复 Studio 建书事件和侧边栏增量刷新

fanghanjun 6 месяцев назад
Родитель
Сommit
3cfd725a02

+ 9 - 4
packages/core/src/agent/agent-tools.ts

@@ -12,8 +12,10 @@ import { writeExportArtifact } from "../interaction/export-artifact.js";
 // Helpers
 // ---------------------------------------------------------------------------
 
-function textResult(text: string): AgentToolResult<undefined> {
-  return { content: [{ type: "text", text }], details: undefined };
+function textResult(text: string): AgentToolResult<undefined>;
+function textResult<T>(text: string, details: T): AgentToolResult<T>;
+function textResult<T = undefined>(text: string, details?: T): AgentToolResult<T> {
+  return { content: [{ type: "text", text }], details: details as T };
 }
 
 /**
@@ -125,7 +127,7 @@ export function createSubAgentTool(
       params: Static<typeof SubAgentParams>,
       _signal?: AbortSignal,
       onUpdate?: AgentToolUpdateCallback,
-    ): Promise<AgentToolResult<undefined>> {
+    ): Promise<AgentToolResult<unknown>> {
       const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, revise, feedback, mode, format, approvedOnly } = params;
 
       const progress = (msg: string) => {
@@ -172,7 +174,10 @@ export function createSubAgentTool(
               { externalContext: instruction },
             );
             progress(`Architect finished — book "${id}" foundation created.`);
-            return textResult(`Book "${resolvedTitle}" (${id}) initialised successfully. Foundation files are ready.`);
+            return textResult(
+              `Book "${resolvedTitle}" (${id}) initialised successfully. Foundation files are ready.`,
+              { kind: "book_created", bookId: id, title: resolvedTitle },
+            );
           }
 
           case "writer": {

+ 78 - 3
packages/studio/src/api/server.test.ts

@@ -130,8 +130,8 @@ vi.mock("@actalk/inkos-core", () => {
       return [];
     }
 
-    async loadBookConfig(): Promise<never> {
-      return await loadBookConfigMock() as never;
+    async loadBookConfig(bookId?: string): Promise<never> {
+      return await loadBookConfigMock(bookId) as never;
     }
 
     async loadChapterIndex(bookId: string): Promise<[]> {
@@ -146,7 +146,7 @@ vi.mock("@actalk/inkos-core", () => {
       return (await rollbackToChapterMock(bookId, chapterNumber)) as number[];
     }
 
-    async getNextChapterNumber(): Promise<number> {
+    async getNextChapterNumber(_bookId?: string): Promise<number> {
       return 1;
     }
 
@@ -1924,6 +1924,81 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("migrates and exposes a book created by architect even when the final agent text is empty", async () => {
+    const orphanSession = {
+      sessionId: "agent-session-1",
+      bookId: null,
+      title: null,
+      messages: [],
+      events: [],
+      draftRounds: [],
+      createdAt: 1,
+      updatedAt: 1,
+    };
+    loadBookSessionMock.mockResolvedValue(orphanSession);
+    appendBookSessionMessageMock.mockImplementation((session: unknown) => session);
+    migrateBookSessionMock.mockResolvedValue({
+      ...orphanSession,
+      bookId: "new-book",
+    });
+    loadBookConfigMock.mockImplementation(async (bookId?: string) => ({
+      id: bookId ?? "new-book",
+      title: "New Book",
+      platform: "qidian",
+      genre: "urban",
+      status: "outlining",
+      targetChapters: 100,
+      chapterWordCount: 3000,
+      createdAt: "2026-04-12T00:00:00.000Z",
+      updatedAt: "2026-04-12T00:00:00.000Z",
+    }));
+    runAgentSessionMock.mockImplementationOnce(async (config: { onEvent?: (event: unknown) => void }) => {
+      config.onEvent?.({
+        type: "tool_execution_start",
+        toolCallId: "tool-1",
+        toolName: "sub_agent",
+        args: { agent: "architect", title: "New Book" },
+      });
+      config.onEvent?.({
+        type: "tool_execution_end",
+        toolCallId: "tool-1",
+        toolName: "sub_agent",
+        isError: false,
+        result: {
+          content: [{ type: "text", text: "Book created." }],
+          details: { kind: "book_created", bookId: "new-book", title: "New Book" },
+        },
+      });
+      return {
+        responseText: "",
+        messages: [{ role: "user", content: "/new New Book" }],
+      };
+    });
+    chatCompletionMock.mockResolvedValueOnce({
+      content: "建书完成。",
+      usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "写一本都市商战", sessionId: "agent-session-1" }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(migrateBookSessionMock).toHaveBeenCalledWith(root, "agent-session-1", "new-book");
+    await expect(response.json()).resolves.toMatchObject({
+      response: "建书完成。",
+      session: {
+        sessionId: "agent-session-1",
+        activeBookId: "new-book",
+      },
+    });
+  });
+
   it("rejects /api/v1/agent requests without sessionId", async () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);

+ 126 - 30
packages/studio/src/api/server.ts

@@ -136,12 +136,22 @@ interface CollectedToolExec {
   status: "running" | "completed" | "error";
   args?: Record<string, unknown>;
   result?: string;
+  details?: unknown;
   error?: string;
   stages?: Array<{ label: string; status: "pending" | "completed" }>;
   startedAt: number;
   completedAt?: number;
 }
 
+interface StudioBookListSummary {
+  readonly id: string;
+  readonly title: string;
+  readonly genre: string;
+  readonly status: string;
+  readonly chaptersWritten: number;
+  readonly [key: string]: unknown;
+}
+
 // --- Event bus for SSE ---
 
 type EventHandler = (event: string, data: unknown) => void;
@@ -194,6 +204,50 @@ function broadcast(event: string, data: unknown): void {
   }
 }
 
+function deriveBookIdFromTitle(title: string): string {
+  return title
+    .trim()
+    .toLowerCase()
+    .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
+    .replace(/-+/g, "-")
+    .replace(/^-+|-+$/g, "")
+    .slice(0, 30);
+}
+
+function resolveArchitectBookIdFromArgs(args?: Record<string, unknown>): string | null {
+  if (!args || args.agent !== "architect" || args.revise === true) return null;
+  if (typeof args.bookId === "string" && args.bookId.trim()) return args.bookId.trim();
+  if (typeof args.title === "string" && args.title.trim()) {
+    return deriveBookIdFromTitle(args.title) || null;
+  }
+  return null;
+}
+
+function resolveCreatedBookIdFromToolExecs(execs: ReadonlyArray<CollectedToolExec>): string | null {
+  for (let i = execs.length - 1; i >= 0; i -= 1) {
+    const exec = execs[i];
+    if (exec.tool !== "sub_agent" || exec.agent !== "architect" || exec.status !== "completed") continue;
+
+    const details = exec.details as { kind?: unknown; bookId?: unknown } | undefined;
+    if (details?.kind === "book_created" && typeof details.bookId === "string" && details.bookId.trim()) {
+      return details.bookId.trim();
+    }
+
+    const fromArgs = resolveArchitectBookIdFromArgs(exec.args);
+    if (fromArgs) return fromArgs;
+  }
+  return null;
+}
+
+async function loadStudioBookListSummary(
+  state: StateManager,
+  bookId: string,
+): Promise<StudioBookListSummary> {
+  const book = await state.loadBookConfig(bookId);
+  const nextChapter = await state.getNextChapterNumber(bookId);
+  return { ...book, chaptersWritten: nextChapter - 1 };
+}
+
 function isCustomServiceId(serviceId: string): boolean {
   return serviceId === "custom" || serviceId.startsWith("custom:");
 }
@@ -728,13 +782,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
   app.get("/api/v1/books", async (c) => {
     const bookIds = await state.listBooks();
-    const books = await Promise.all(
-      bookIds.map(async (id) => {
-        const book = await state.loadBookConfig(id);
-        const nextChapter = await state.getNextChapterNumber(id);
-        return { ...book, chaptersWritten: nextChapter - 1 };
-      }),
-    );
+    const books = await Promise.all(bookIds.map((id) => loadStudioBookListSummary(state, id)));
     return c.json({ books });
   });
 
@@ -811,13 +859,14 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       },
       tools,
     }).then(
-      (result: {
+      async (result: {
         readonly session: { readonly activeBookId?: string };
         readonly details?: Readonly<Record<string, unknown>>;
       }) => {
         const createdBookId = (result.details?.bookId as string | undefined) ?? result.session.activeBookId ?? bookId;
+        const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
         bookCreateStatus.delete(createdBookId);
-        broadcast("book:created", { bookId: createdBookId });
+        broadcast("book:created", { bookId: createdBookId, ...(book ? { book } : {}) });
       },
       (e: unknown) => {
         const error = e instanceof Error ? e.message : String(e);
@@ -1780,6 +1829,17 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
                 startedAt: Date.now(),
               });
 
+              if (!activeBookId && event.toolName === "sub_agent" && agent === "architect") {
+                const bookId = resolveArchitectBookIdFromArgs(args);
+                if (bookId) {
+                  const title = typeof args?.title === "string" && args.title.trim()
+                    ? args.title.trim()
+                    : bookId;
+                  bookCreateStatus.set(bookId, { status: "creating" });
+                  broadcast("book:creating", { bookId, title, sessionId: streamSessionId });
+                }
+              }
+
               broadcast("tool:start", {
                 sessionId: streamSessionId,
                 id: event.toolCallId,
@@ -1803,6 +1863,20 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
                 exec.stages = exec.stages?.map(s => ({ ...s, status: "completed" as const }));
                 if (event.isError) exec.error = extractToolError(event.result);
                 else exec.result = summarizeResult(event.result);
+                exec.details = (event.result as { details?: unknown } | undefined)?.details;
+                if (
+                  event.isError &&
+                  !activeBookId &&
+                  exec.tool === "sub_agent" &&
+                  exec.agent === "architect"
+                ) {
+                  const bookId = resolveArchitectBookIdFromArgs(exec.args);
+                  if (bookId) {
+                    const error = exec.error ?? "Book creation failed";
+                    bookCreateStatus.set(bookId, { status: "error", error });
+                    broadcast("book:error", { bookId, sessionId: streamSessionId, error });
+                  }
+                }
               }
               broadcast("tool:end", {
                 sessionId: streamSessionId,
@@ -1818,6 +1892,35 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         initialMessages,
       );
 
+      let broadcastedCreatedBookId: string | null = null;
+      const finalizeCreatedBook = async (): Promise<string | null> => {
+        if (activeBookId) return null;
+        const createdBookId = resolveCreatedBookIdFromToolExecs(collectedToolExecs);
+        if (!createdBookId) return null;
+        if (broadcastedCreatedBookId === createdBookId) return createdBookId;
+
+        try {
+          const migratedSession = await migrateBookSession(root, bookSession.sessionId, createdBookId);
+          if (migratedSession) {
+            bookSession = migratedSession;
+          }
+        } catch (e) {
+          if (!(e instanceof SessionAlreadyMigratedError)) {
+            throw e;
+          }
+        }
+
+        const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
+        bookCreateStatus.delete(createdBookId);
+        broadcast("book:created", {
+          bookId: createdBookId,
+          sessionId: bookSession.sessionId,
+          ...(book ? { book } : {}),
+        });
+        broadcastedCreatedBookId = createdBookId;
+        return createdBookId;
+      };
+
       // Persist user + assistant messages to BookSession
       bookSession = appendBookSessionMessage(bookSession, {
         role: "user",
@@ -1872,9 +1975,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
               timestamp: Date.now() + 1,
             });
             await persistBookSession(root, bookSession);
+            const createdBookId = await finalizeCreatedBook();
             return c.json({
               response: fallback.content,
-              session: { sessionId: bookSession.sessionId },
+              session: {
+                sessionId: bookSession.sessionId,
+                ...(createdBookId ? { activeBookId: createdBookId } : {}),
+              },
             });
           }
         } catch {
@@ -1899,6 +2006,10 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
           );
         } catch (probeError) {
           const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
+          if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
+            await persistBookSession(root, bookSession);
+            await finalizeCreatedBook();
+          }
           return c.json({
             error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
             response: probeMessage,
@@ -1906,35 +2017,20 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         }
 
         const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
+        if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
+          await persistBookSession(root, bookSession);
+          await finalizeCreatedBook();
+        }
         return c.json({
           error: { code: "AGENT_EMPTY_RESPONSE", message: emptyMessage },
           response: emptyMessage,
         }, 502);
       }
       await persistBookSession(root, bookSession);
+      await finalizeCreatedBook();
 
       broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId });
 
-      // If a sub_agent created a new book during this session, broadcast book:created
-      // so the sidebar refreshes.
-      if (!activeBookId && collectedToolExecs.some((t) => t.agent === "architect" && t.status === "completed")) {
-        const books = await state.listBooks();
-        const latestBook = books.at(-1);
-        if (latestBook) {
-          try {
-            const migratedSession = await migrateBookSession(root, bookSession.sessionId, latestBook);
-            if (migratedSession) {
-              bookSession = migratedSession;
-            }
-          } catch (e) {
-            if (!(e instanceof SessionAlreadyMigratedError)) {
-              throw e;
-            }
-          }
-          broadcast("book:created", { bookId: latestBook, sessionId: bookSession.sessionId });
-        }
-      }
-
       return c.json({
         response: result.responseText,
         session: {

+ 13 - 3
packages/studio/src/components/Sidebar.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useMemo, useState } from "react";
 import { useApi } from "../hooks/use-api";
 import type { SSEMessage } from "../hooks/use-sse";
-import { shouldRefetchBookCollections, shouldRefetchDaemonStatus } from "../hooks/use-book-activity";
+import { applyBookCollectionEvent, shouldRefetchBookCollections, shouldRefetchDaemonStatus } from "../hooks/use-book-activity";
 import type { TFunction } from "../hooks/use-i18n";
 import { useChatStore } from "../store/chat";
 import { ConfirmDialog } from "./ConfirmDialog";
@@ -65,7 +65,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
   sse: { messages: ReadonlyArray<SSEMessage> };
   t: TFunction;
 }) {
-  const { data, refetch: refetchBooks } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
+  const { data, refetch: refetchBooks, mutate: mutateBooks } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
   const { data: daemon, refetch: refetchDaemon } = useApi<{ running: boolean }>("/daemon");
   const sessions = useChatStore((s) => s.sessions);
   const sessionIdsByBook = useChatStore((s) => s.sessionIdsByBook);
@@ -88,12 +88,22 @@ export function Sidebar({ nav, activePage, sse, t }: {
     const recent = sse.messages.at(-1);
     if (!recent) return;
     if (shouldRefetchBookCollections(recent)) {
+      let appliedIncrementally = false;
+      mutateBooks((current) => {
+        const updatedBooks = applyBookCollectionEvent(current?.books ?? [], recent);
+        if (!updatedBooks) return current;
+        appliedIncrementally = true;
+        return { books: updatedBooks };
+      });
+      if (appliedIncrementally) {
+        return;
+      }
       refetchBooks();
     }
     if (shouldRefetchDaemonStatus(recent)) {
       refetchDaemon();
     }
-  }, [refetchBooks, refetchDaemon, sse.messages]);
+  }, [mutateBooks, refetchBooks, refetchDaemon, sse.messages]);
 
   // bookDataVersion 变化(外部数据信号)时才重拉当前已展开书的 session 列表;
   // 展开/折叠本身不触发请求(展开由 toggleBook 驱动,已带"首次加载"判断)。

+ 1 - 1
packages/studio/src/hooks/use-api.ts

@@ -164,7 +164,7 @@ export function useApi<T>(path: string) {
     };
   }, [path, refetch]);
 
-  return { data, loading, error, refetch };
+  return { data, loading, error, refetch, mutate: setData };
 }
 
 export async function postApi<T>(path: string, body?: unknown): Promise<T> {

+ 21 - 0
packages/studio/src/hooks/use-book-activity.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest";
 import type { SSEMessage } from "./use-sse";
 import {
+  applyBookCollectionEvent,
   deriveActiveBookIds,
   deriveBookActivity,
   shouldRefetchBookCollections,
@@ -106,3 +107,23 @@ describe("shouldRefetchDaemonStatus", () => {
     expect(shouldRefetchDaemonStatus(msg("daemon:chapter", {}, 1))).toBe(false);
   });
 });
+
+describe("applyBookCollectionEvent", () => {
+  it("upserts a created book from the event payload without requiring a refetch", () => {
+    const books = [
+      { id: "alpha", title: "Alpha", genre: "urban", status: "active", chaptersWritten: 3 },
+    ];
+
+    expect(applyBookCollectionEvent(books, msg("book:created", {
+      bookId: "beta",
+      book: { id: "beta", title: "Beta", genre: "xuanhuan", status: "outlining", chaptersWritten: 0 },
+    }, 1))).toEqual([
+      { id: "alpha", title: "Alpha", genre: "urban", status: "active", chaptersWritten: 3 },
+      { id: "beta", title: "Beta", genre: "xuanhuan", status: "outlining", chaptersWritten: 0 },
+    ]);
+  });
+
+  it("returns null when a collection event lacks enough data for incremental update", () => {
+    expect(applyBookCollectionEvent([], msg("book:created", { bookId: "beta" }, 1))).toBeNull();
+  });
+});

+ 56 - 0
packages/studio/src/hooks/use-book-activity.ts

@@ -43,11 +43,42 @@ export interface BookActivity {
   readonly lastError: string | null;
 }
 
+export interface SidebarBookSummary {
+  readonly id: string;
+  readonly title: string;
+  readonly genre: string;
+  readonly status: string;
+  readonly chaptersWritten: number;
+}
+
 function getBookId(message: SSEMessage): string | null {
   const data = message.data as { bookId?: unknown } | null;
   return typeof data?.bookId === "string" ? data.bookId : null;
 }
 
+function getBookSummary(message: SSEMessage): SidebarBookSummary | null {
+  const data = message.data as { book?: unknown } | null;
+  const book = data?.book;
+  if (!book || typeof book !== "object") return null;
+  const candidate = book as Partial<SidebarBookSummary>;
+  if (
+    typeof candidate.id !== "string" ||
+    typeof candidate.title !== "string" ||
+    typeof candidate.genre !== "string" ||
+    typeof candidate.status !== "string" ||
+    typeof candidate.chaptersWritten !== "number"
+  ) {
+    return null;
+  }
+  return {
+    id: candidate.id,
+    title: candidate.title,
+    genre: candidate.genre,
+    status: candidate.status,
+    chaptersWritten: candidate.chaptersWritten,
+  };
+}
+
 export function deriveActiveBookIds(messages: ReadonlyArray<SSEMessage>): ReadonlySet<string> {
   const active = new Set<string>();
 
@@ -122,3 +153,28 @@ export function shouldRefetchBookCollections(message: SSEMessage | undefined): b
 export function shouldRefetchDaemonStatus(message: SSEMessage | undefined): boolean {
   return Boolean(message && DAEMON_STATUS_REFRESH_EVENTS.has(message.event));
 }
+
+export function applyBookCollectionEvent(
+  books: ReadonlyArray<SidebarBookSummary>,
+  message: SSEMessage | undefined,
+): ReadonlyArray<SidebarBookSummary> | null {
+  if (!message) return null;
+
+  if (message.event === "book:created") {
+    const book = getBookSummary(message);
+    if (!book) return null;
+    const existingIndex = books.findIndex((candidate) => candidate.id === book.id);
+    if (existingIndex < 0) {
+      return [...books, book];
+    }
+    return books.map((candidate, index) => index === existingIndex ? book : candidate);
+  }
+
+  if (message.event === "book:deleted") {
+    const bookId = getBookId(message);
+    if (!bookId) return null;
+    return books.filter((book) => book.id !== bookId);
+  }
+
+  return null;
+}