Explorar el Código

feat(studio): 删掉 LLM 生成 title 逻辑,第一条消息直接写入 title

- 删除 scheduleSessionTitleGeneration / normalizeGeneratedSessionTitle,
  不再异步调 LLM 生成 session 标题
- POST /agent 处理用户消息时,bookSession.title 为 null 就用 instruction
  内容(单行、≤20 字)作为 title,通过 session:title SSE 广播给前端
- POST /sessions 支持接受客户端预生成的 sessionId(格式校验后透传到 core
  的 createAndPersistBookSession),为 draft session 流程做准备
- /services/:id/models 加 10 分钟内存缓存(modelListCache),避免每次
  sidebar 挂载都向真实 LLM 服务发送请求;支持 ?refresh=1 手动刷新
- 测试:修正 createAndPersistBookSession mock 期望参数列表

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fanghanjun hace 6 meses
padre
commit
8a15f7ae50
Se han modificado 2 ficheros con 165 adiciones y 67 borrados
  1. 133 11
      packages/studio/src/api/server.test.ts
  2. 32 56
      packages/studio/src/api/server.ts

+ 133 - 11
packages/studio/src/api/server.test.ts

@@ -23,10 +23,14 @@ const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
 const loadProjectSessionMock = vi.fn();
 const resolveSessionActiveBookMock = vi.fn();
 const runAgentSessionMock = vi.fn();
-const findOrCreateBookSessionMock = vi.fn();
+const createAndPersistBookSessionMock = vi.fn();
 const loadBookSessionMock = vi.fn();
 const persistBookSessionMock = vi.fn();
 const appendBookSessionMessageMock = vi.fn();
+const renameBookSessionMock = vi.fn();
+const updateSessionTitleMock = vi.fn();
+const deleteBookSessionMock = vi.fn();
+const migrateBookSessionMock = vi.fn();
 const resolveServiceModelMock = vi.fn();
 const loadSecretsMock = vi.fn();
 const saveSecretsMock = vi.fn();
@@ -80,6 +84,13 @@ const logger = {
 };
 
 vi.mock("@actalk/inkos-core", () => {
+  class MockSessionAlreadyMigratedError extends Error {
+    constructor(message = "Session already migrated") {
+      super(message);
+      this.name = "SessionAlreadyMigratedError";
+    }
+  }
+
   class MockStateManager {
     constructor(private readonly root: string) {}
 
@@ -163,10 +174,15 @@ vi.mock("@actalk/inkos-core", () => {
     resolveSessionActiveBook: resolveSessionActiveBookMock,
     runAgentSession: runAgentSessionMock,
     buildAgentSystemPrompt: vi.fn(() => "You are helpful."),
-    findOrCreateBookSession: findOrCreateBookSessionMock,
+    createAndPersistBookSession: createAndPersistBookSessionMock,
     loadBookSession: loadBookSessionMock,
     persistBookSession: persistBookSessionMock,
     appendBookSessionMessage: appendBookSessionMessageMock,
+    renameBookSession: renameBookSessionMock,
+    updateSessionTitle: updateSessionTitleMock,
+    deleteBookSession: deleteBookSessionMock,
+    migrateBookSession: migrateBookSessionMock,
+    SessionAlreadyMigratedError: MockSessionAlreadyMigratedError,
     resolveServicePreset: resolveServicePresetMock,
     resolveServiceProviderFamily: resolveServiceProviderFamilyMock,
     resolveServiceModelsBaseUrl: resolveServiceModelsBaseUrlMock,
@@ -326,10 +342,14 @@ describe("createStudioServer daemon lifecycle", () => {
     rollbackToChapterMock.mockResolvedValue([]);
     pipelineConfigs.length = 0;
     runAgentSessionMock.mockReset();
-    findOrCreateBookSessionMock.mockReset();
+    createAndPersistBookSessionMock.mockReset();
     loadBookSessionMock.mockReset();
     persistBookSessionMock.mockReset();
     appendBookSessionMessageMock.mockReset();
+    renameBookSessionMock.mockReset();
+    updateSessionTitleMock.mockReset();
+    deleteBookSessionMock.mockReset();
+    migrateBookSessionMock.mockReset();
     resolveServiceModelMock.mockReset();
     loadSecretsMock.mockReset();
     saveSecretsMock.mockReset();
@@ -341,17 +361,30 @@ describe("createStudioServer daemon lifecycle", () => {
     // Default BookSession for agent tests
     const defaultBookSession = {
       sessionId: "agent-session-1",
-      projectRoot: root,
-      activeBookId: "demo-book",
+      bookId: "demo-book",
+      title: null,
       messages: [],
       events: [],
+      draftRounds: [],
+      createdAt: 1,
+      updatedAt: 1,
     };
-    findOrCreateBookSessionMock.mockResolvedValue(defaultBookSession);
-    loadBookSessionMock.mockResolvedValue(null);
+    createAndPersistBookSessionMock.mockResolvedValue(defaultBookSession);
+    loadBookSessionMock.mockResolvedValue(defaultBookSession);
     persistBookSessionMock.mockResolvedValue(undefined);
     appendBookSessionMessageMock.mockImplementation(
       (session: unknown, _msg: unknown) => session,
     );
+    renameBookSessionMock.mockResolvedValue(null);
+    updateSessionTitleMock.mockImplementation(async (_root: string, _sessionId: string, title: string) => ({
+      ...defaultBookSession,
+      title,
+    }));
+    deleteBookSessionMock.mockResolvedValue(undefined);
+    migrateBookSessionMock.mockImplementation(async (_root: string, _sessionId: string, bookId: string) => ({
+      ...defaultBookSession,
+      bookId,
+    }));
     runAgentSessionMock.mockResolvedValue({
       responseText: "Agent response.",
       messages: [],
@@ -1223,6 +1256,75 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("creates a fresh book session on POST /api/v1/sessions", async () => {
+    createAndPersistBookSessionMock.mockResolvedValueOnce({
+      sessionId: "fresh-session",
+      bookId: "demo-book",
+      title: null,
+      messages: [],
+      events: [],
+      draftRounds: [],
+      createdAt: 10,
+      updatedAt: 10,
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/sessions", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ bookId: "demo-book" }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(createAndPersistBookSessionMock).toHaveBeenCalledWith(root, "demo-book", undefined);
+    await expect(response.json()).resolves.toMatchObject({
+      session: { sessionId: "fresh-session", bookId: "demo-book", title: null },
+    });
+  });
+
+  it("renames a session through PUT /api/v1/sessions/:sessionId", async () => {
+    renameBookSessionMock.mockResolvedValueOnce({
+      sessionId: "agent-session-1",
+      bookId: "demo-book",
+      title: "新标题",
+      messages: [],
+      events: [],
+      draftRounds: [],
+      createdAt: 1,
+      updatedAt: 2,
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/sessions/agent-session-1", {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ title: "  新标题  " }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(renameBookSessionMock).toHaveBeenCalledWith(root, "agent-session-1", "新标题");
+    await expect(response.json()).resolves.toMatchObject({
+      session: { sessionId: "agent-session-1", title: "新标题" },
+    });
+  });
+
+  it("deletes a session through DELETE /api/v1/sessions/:sessionId", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/sessions/agent-session-1", {
+      method: "DELETE",
+    });
+
+    expect(response.status).toBe(200);
+    expect(deleteBookSessionMock).toHaveBeenCalledWith(root, "agent-session-1");
+    await expect(response.json()).resolves.toEqual({ ok: true });
+  });
+
   it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
     runAgentSessionMock.mockResolvedValueOnce({
       responseText: "Completed write_next for demo-book.",
@@ -1238,7 +1340,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/agent", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
+      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book", sessionId: "agent-session-1" }),
     });
 
     expect(response.status).toBe(200);
@@ -1307,6 +1409,7 @@ describe("createStudioServer daemon lifecycle", () => {
         instruction: "nihao",
         service: "custom:CodexForMe",
         model: "gpt-5.4",
+        sessionId: "agent-session-1",
       }),
     });
 
@@ -1325,7 +1428,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/agent", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
+      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book", sessionId: "agent-session-1" }),
     });
 
     expect(response.status).toBe(500);
@@ -1350,7 +1453,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/agent", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book" }),
+      body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book", sessionId: "agent-session-1" }),
     });
 
     expect(response.status).toBe(502);
@@ -1379,7 +1482,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/agent", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book" }),
+      body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book", sessionId: "agent-session-1" }),
     });
 
     expect(response.status).toBe(200);
@@ -1389,6 +1492,25 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("rejects /api/v1/agent requests without sessionId", async () => {
+    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: "continue", activeBookId: "demo-book" }),
+    });
+
+    expect(response.status).toBe(400);
+    await expect(response.json()).resolves.toEqual({
+      error: {
+        code: "SESSION_ID_REQUIRED",
+        message: "sessionId is required",
+      },
+    });
+  });
+
   it("returns the shared interaction session state", async () => {
     loadProjectSessionMock.mockResolvedValue({
       sessionId: "session-2",

+ 32 - 56
packages/studio/src/api/server.ts

@@ -19,7 +19,6 @@ import {
   appendBookSessionMessage,
   createAndPersistBookSession,
   renameBookSession,
-  updateSessionTitle,
   deleteBookSession,
   migrateBookSession,
   SessionAlreadyMigratedError,
@@ -103,15 +102,6 @@ function extractToolError(result: unknown): string {
   return String(result).slice(0, 500);
 }
 
-function normalizeGeneratedSessionTitle(raw: string): string | null {
-  const compact = raw
-    .replace(/[\r\n]+/g, " ")
-    .replace(/[“”"「」]/g, "")
-    .trim()
-    .slice(0, 20);
-  return compact.length > 0 ? compact : null;
-}
-
 interface CollectedToolExec {
   id: string;
   tool: string;
@@ -132,6 +122,9 @@ type EventHandler = (event: string, data: unknown) => void;
 const subscribers = new Set<EventHandler>();
 const bookCreateStatus = new Map<string, { status: "creating" | "error"; error?: string }>();
 
+// 内存缓存:service -> 模型列表 + 更新时间戳;避免每次 sidebar 挂载时都打真实 LLM /models
+const modelListCache = new Map<string, { models: Array<{ id: string; name: string }>; at: number }>();
+
 interface ServiceConfigEntry {
   service: string;
   name?: string;
@@ -1064,17 +1057,27 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
   app.get("/api/v1/services/:service/models", async (c) => {
     const service = c.req.param("service");
+    const refresh = c.req.query("refresh") === "1";
     const apiKey = c.req.query("apiKey") || await getServiceApiKey(root, service);
 
     // No key = no models
     if (!apiKey) return c.json({ models: [] });
 
+    // Cache by service + apiKey fingerprint; valid for 10 min unless ?refresh=1
+    const cacheKey = `${service}::${apiKey.slice(-8)}`;
+    if (!refresh) {
+      const cached = modelListCache.get(cacheKey);
+      if (cached && Date.now() - cached.at < 10 * 60 * 1000) {
+        return c.json({ models: cached.models });
+      }
+    }
+
     // Fast path: services with knownModels return immediately
     const preset = resolveServicePreset(isCustomServiceId(service) ? "custom" : service);
     if (preset?.knownModels && preset.knownModels.length > 0) {
-      return c.json({
-        models: preset.knownModels.map((id) => ({ id, name: id })),
-      });
+      const models = preset.knownModels.map((id) => ({ id, name: id }));
+      modelListCache.set(cacheKey, { models, at: Date.now() });
+      return c.json({ models });
     }
 
     // Simple /models API call + fallback to pi-ai built-in list (no slow probe)
@@ -1098,6 +1101,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       const builtIn = await listModelsForService(service, apiKey);
       models = builtIn.map((m) => ({ id: m.id, name: m.name }));
     }
+    modelListCache.set(cacheKey, { models, at: Date.now() });
     return c.json({ models });
   });
 
@@ -1276,9 +1280,12 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   });
 
   app.post("/api/v1/sessions", async (c) => {
-    const body = await c.req.json<{ bookId?: string | null }>().catch(() => ({}));
+    const body = await c.req.json<{ bookId?: string | null; sessionId?: string }>().catch(() => ({}));
     const bookId = (body as { bookId?: string | null }).bookId ?? null;
-    const session = await createAndPersistBookSession(root, bookId);
+    const sessionId = (body as { sessionId?: string }).sessionId;
+    // sessionId 只允许 timestamp-random 格式;防止注入任意文件名
+    const safeSessionId = sessionId && /^[0-9]+-[a-z0-9]+$/.test(sessionId) ? sessionId : undefined;
+    const session = await createAndPersistBookSession(root, bookId, safeSessionId);
     return c.json({ session });
   });
 
@@ -1331,45 +1338,6 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       }
       let bookSession = loadedBookSession;
       const streamSessionId = loadedBookSession.sessionId;
-      const scheduleSessionTitleGeneration = (resolvedSessionId: string) => {
-        void (async () => {
-          try {
-            const freshSession = await loadBookSession(root, resolvedSessionId);
-            if (!freshSession || freshSession.title !== null) return;
-
-            const firstUserMessage = freshSession.messages.find((message) => message.role === "user");
-            const firstAssistantMessage = freshSession.messages.find((message) => message.role === "assistant");
-            if (!firstUserMessage || !firstAssistantMessage) return;
-
-            const freshConfig = await loadCurrentProjectConfig({ requireApiKey: false });
-            const titleClient = createLLMClient(freshConfig.llm);
-            const titleResult = await chatCompletion(
-              titleClient,
-              freshConfig.llm.defaultModel ?? freshConfig.llm.model,
-              [
-                {
-                  role: "user",
-                  content:
-                    "用 6 个中文字以内概括这段对话的主题,只返回标题文本。\n\n"
-                    + `用户: ${firstUserMessage.content.slice(0, 200)}\n`
-                    + `助手: ${firstAssistantMessage.content.slice(0, 200)}`,
-                },
-              ],
-              { maxTokens: 32 },
-            );
-
-            const title = normalizeGeneratedSessionTitle(titleResult.content ?? "");
-            if (!title) return;
-
-            const updated = await updateSessionTitle(root, resolvedSessionId, title);
-            if (!updated || updated.title !== title) return;
-
-            broadcast("session:title", { sessionId: resolvedSessionId, title });
-          } catch {
-            // ignore title generation errors and try again after the next exchange
-          }
-        })();
-      };
 
       // Build initial message context from persisted session
       const initialMessages = bookSession.messages
@@ -1570,6 +1538,16 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         content: instruction,
         timestamp: Date.now(),
       });
+      // 第一条用户消息就是 session 的标题:如果 title 还是 null,用消息内容(单行、≤20字)写入。
+      // 后续消息不覆盖;用户手动改名通过 renameBookSession 覆盖。
+      if (bookSession.title === null) {
+        const oneLine = instruction.trim().replace(/\s+/g, " ");
+        const title = oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
+        if (title) {
+          bookSession = { ...bookSession, title };
+          broadcast("session:title", { sessionId: bookSession.sessionId, title });
+        }
+      }
       if (result.responseText) {
         const lastAssistant = result.messages?.filter((m: any) => m.role === "assistant").pop();
         const thinking = lastAssistant?.thinking;
@@ -1608,7 +1586,6 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
               timestamp: Date.now() + 1,
             });
             await persistBookSession(root, bookSession);
-            scheduleSessionTitleGeneration(bookSession.sessionId);
             return c.json({
               response: fallback.content,
               session: { sessionId: bookSession.sessionId },
@@ -1651,7 +1628,6 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       await persistBookSession(root, bookSession);
 
       broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId });
-      scheduleSessionTitleGeneration(bookSession.sessionId);
 
       // If a sub_agent created a new book during this session, broadcast book:created
       // so the sidebar refreshes.