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

refactor(core): session title 简化为"第一条消息即标题" + 老数据 lazy migration

之前的 session title 逻辑用 LLM 生成 + titleSource 三态(draft/ai/manual)过于复杂。
简化为:
- 用户发第一条消息时直接把消息内容(截断≤20 字)写入 title,之后不再覆盖
- 用户手动改名仍然走 renameBookSession
- 删掉 updateSessionTitle/setDraftSessionTitle/titleSource 字段

listBookSessions 加一次性 lazy migration:读到 title=null 但已有用户消息的
老 session,把第一条消息补写为 title 并 persist 回磁盘,下次不再迁移。

createBookSession / createAndPersistBookSession 新增可选 sessionId 参数,
让客户端可以传入预生成的 id,为"新建会话延迟持久化(draft session)"流程
做准备(用户点新建不落盘,发消息时才 POST /sessions 用同一 id 写入)。

测试:
- extractFirstUserMessageTitle 7 个 helper 单测(空数组、缺 user 消息、
  挑首条、空白合并、超长截断、全空白、非数组输入)
- listBookSessions lazy migration 4 个场景(迁移、已有 title 不覆盖、
  无用户消息保持 null、多条同时迁移)

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

+ 218 - 12
packages/core/src/__tests__/book-session-store.test.ts

@@ -6,9 +6,14 @@ import {
   loadBookSession,
   persistBookSession,
   listBookSessions,
-  findOrCreateBookSession,
+  renameBookSession,
+  deleteBookSession,
+  migrateBookSession,
+  extractFirstUserMessageTitle,
+  SessionAlreadyMigratedError,
 } from "../interaction/book-session-store.js";
 import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
+import { mkdir, writeFile } from "node:fs/promises";
 
 describe("book-session-store", () => {
   let tempDir: string;
@@ -44,6 +49,39 @@ describe("book-session-store", () => {
       expect(loaded!.messages).toHaveLength(1);
       expect(loaded!.messages[0].content).toBe("test");
     });
+
+    it("createBookSession initializes title as null", () => {
+      const session = createBookSession("book");
+      expect(session.title).toBeNull();
+    });
+
+    it("parses old session files without title field", async () => {
+      const oldFormat = {
+        sessionId: "old-session",
+        bookId: "book",
+        messages: [],
+        draftRounds: [],
+        events: [],
+        createdAt: 1000,
+        updatedAt: 1000,
+      };
+      const dir = join(tempDir, ".inkos", "sessions");
+      await mkdir(dir, { recursive: true });
+      await writeFile(join(dir, "old-session.json"), JSON.stringify(oldFormat));
+
+      const loaded = await loadBookSession(tempDir, "old-session");
+      expect(loaded).not.toBeNull();
+      expect(loaded!.title).toBeNull();
+    });
+
+    it("round-trips title through persist/load", async () => {
+      let session = createBookSession("book");
+      session = { ...session, title: "测试标题" };
+      await persistBookSession(tempDir, session);
+
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded!.title).toBe("测试标题");
+    });
   });
 
   describe("listBookSessions", () => {
@@ -90,20 +128,188 @@ describe("book-session-store", () => {
     });
   });
 
-  describe("findOrCreateBookSession", () => {
-    it("creates new if none exist", async () => {
-      const session = await findOrCreateBookSession(tempDir, "new-book");
-      expect(session.bookId).toBe("new-book");
-      // Verify it was persisted
+  describe("renameBookSession", () => {
+    it("sets title and updates updatedAt", async () => {
+      const session = createBookSession("book");
+      await persistBookSession(tempDir, session);
+      const oldUpdatedAt = session.updatedAt;
+
+      await new Promise((resolve) => setTimeout(resolve, 5));
+      await renameBookSession(tempDir, session.sessionId, "新标题");
+
       const loaded = await loadBookSession(tempDir, session.sessionId);
-      expect(loaded).not.toBeNull();
+      expect(loaded!.title).toBe("新标题");
+      expect(loaded!.updatedAt).toBeGreaterThan(oldUpdatedAt);
     });
 
-    it("returns existing if found", async () => {
-      const existing = createBookSession("book");
-      await persistBookSession(tempDir, existing);
-      const found = await findOrCreateBookSession(tempDir, "book");
-      expect(found.sessionId).toBe(existing.sessionId);
+    it("returns null for non-existent session", async () => {
+      const result = await renameBookSession(tempDir, "nonexistent", "title");
+      expect(result).toBeNull();
+    });
+  });
+
+  describe("deleteBookSession", () => {
+    it("removes session file", async () => {
+      const session = createBookSession("book");
+      await persistBookSession(tempDir, session);
+
+      await deleteBookSession(tempDir, session.sessionId);
+
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded).toBeNull();
+    });
+
+    it("does nothing for non-existent session", async () => {
+      await expect(deleteBookSession(tempDir, "nonexistent")).resolves.toBeUndefined();
+    });
+  });
+
+  describe("extractFirstUserMessageTitle", () => {
+    it("returns null when messages array is empty", () => {
+      expect(extractFirstUserMessageTitle([])).toBeNull();
+    });
+
+    it("returns null when no user message exists", () => {
+      expect(extractFirstUserMessageTitle([
+        { role: "assistant", content: "hi" },
+        { role: "system", content: "prompt" },
+      ])).toBeNull();
+    });
+
+    it("picks the first user message content", () => {
+      expect(extractFirstUserMessageTitle([
+        { role: "system", content: "sys" },
+        { role: "user", content: "第一条提问" },
+        { role: "assistant", content: "回答" },
+        { role: "user", content: "第二条提问" },
+      ])).toBe("第一条提问");
+    });
+
+    it("collapses whitespace into single spaces", () => {
+      expect(extractFirstUserMessageTitle([
+        { role: "user", content: "多行\n\n内容   有空格" },
+      ])).toBe("多行 内容 有空格");
+    });
+
+    it("truncates content longer than 20 chars with ellipsis", () => {
+      expect(extractFirstUserMessageTitle([
+        { role: "user", content: "这是一段超过二十个字符的很长的提问内容会被截断" },
+      ])).toBe("这是一段超过二十个字符的很长的提问内容会…");
+    });
+
+    it("returns null when content is only whitespace", () => {
+      expect(extractFirstUserMessageTitle([
+        { role: "user", content: "   \n\t   " },
+      ])).toBeNull();
+    });
+
+    it("returns null for non-array input", () => {
+      expect(extractFirstUserMessageTitle(null)).toBeNull();
+      expect(extractFirstUserMessageTitle(undefined)).toBeNull();
+      expect(extractFirstUserMessageTitle("not array")).toBeNull();
+    });
+  });
+
+  describe("listBookSessions: 老 session lazy migration", () => {
+    it("把 title 为 null 但已有用户消息的老 session 补写 title 并持久化", async () => {
+      const session = {
+        ...createBookSession("book-a"),
+        title: null,
+        messages: [
+          { role: "user" as const, content: "帮我写下一章", timestamp: 100 },
+          { role: "assistant" as const, content: "好的,正在构思...", timestamp: 200 },
+        ],
+      };
+      await persistBookSession(tempDir, session);
+
+      // 触发 list → 应该迁移
+      const list = await listBookSessions(tempDir, "book-a");
+      expect(list).toHaveLength(1);
+      expect(list[0].title).toBe("帮我写下一章");
+
+      // 验证已经落盘
+      const reloaded = await loadBookSession(tempDir, session.sessionId);
+      expect(reloaded!.title).toBe("帮我写下一章");
+    });
+
+    it("不覆盖已有 title 的 session", async () => {
+      let session = createBookSession("book-a");
+      session = {
+        ...session,
+        title: "原有标题",
+        messages: [
+          { role: "user" as const, content: "后来的消息", timestamp: 100 },
+        ],
+      };
+      await persistBookSession(tempDir, session);
+
+      const list = await listBookSessions(tempDir, "book-a");
+      expect(list[0].title).toBe("原有标题");
+
+      const reloaded = await loadBookSession(tempDir, session.sessionId);
+      expect(reloaded!.title).toBe("原有标题");
+    });
+
+    it("没有用户消息的 session:title 保持 null,不 persist", async () => {
+      const session = createBookSession("book-a");
+      await persistBookSession(tempDir, session);
+      const originalUpdatedAt = session.updatedAt;
+
+      const list = await listBookSessions(tempDir, "book-a");
+      expect(list[0].title).toBeNull();
+
+      const reloaded = await loadBookSession(tempDir, session.sessionId);
+      expect(reloaded!.title).toBeNull();
+      expect(reloaded!.updatedAt).toBe(originalUpdatedAt);
+    });
+
+    it("多条老 session 同时迁移", async () => {
+      const s1 = {
+        ...createBookSession("book-b"),
+        title: null,
+        messages: [{ role: "user" as const, content: "问题一", timestamp: 1 }],
+      };
+      const s2 = {
+        ...createBookSession("book-b"),
+        title: null,
+        messages: [{ role: "user" as const, content: "问题二", timestamp: 1 }],
+      };
+      await persistBookSession(tempDir, s1);
+      await persistBookSession(tempDir, s2);
+
+      const list = await listBookSessions(tempDir, "book-b");
+      expect(list).toHaveLength(2);
+      const titles = new Set(list.map((s) => s.title));
+      expect(titles).toEqual(new Set(["问题一", "问题二"]));
+    });
+  });
+
+  describe("migrateBookSession", () => {
+    it("binds an orphan session to a book", async () => {
+      const session = createBookSession(null);
+      await persistBookSession(tempDir, session);
+      const oldUpdatedAt = session.updatedAt;
+
+      await new Promise((resolve) => setTimeout(resolve, 5));
+      const migrated = await migrateBookSession(tempDir, session.sessionId, "book-1");
+
+      expect(migrated).not.toBeNull();
+      expect(migrated!.bookId).toBe("book-1");
+      expect(migrated!.updatedAt).toBeGreaterThan(oldUpdatedAt);
+    });
+
+    it("returns null for non-existent session", async () => {
+      const result = await migrateBookSession(tempDir, "nonexistent", "book-1");
+      expect(result).toBeNull();
+    });
+
+    it("throws when session is already bound to a book", async () => {
+      const session = createBookSession("book-1");
+      await persistBookSession(tempDir, session);
+
+      await expect(migrateBookSession(tempDir, session.sessionId, "book-2")).rejects.toBeInstanceOf(
+        SessionAlreadyMigratedError,
+      );
     });
   });
 });

+ 10 - 1
packages/core/src/index.ts

@@ -122,7 +122,16 @@ export {
   loadGlobalSession,
   persistGlobalSession,
 } from "./interaction/project-session-store.js";
-export { loadBookSession, persistBookSession, listBookSessions, findOrCreateBookSession } from "./interaction/book-session-store.js";
+export {
+  loadBookSession,
+  persistBookSession,
+  listBookSessions,
+  renameBookSession,
+  deleteBookSession,
+  migrateBookSession,
+  createAndPersistBookSession,
+  SessionAlreadyMigratedError,
+} from "./interaction/book-session-store.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export {
   routeNaturalLanguageIntent,

+ 47 - 14
packages/core/src/interaction/book-session-store.ts

@@ -13,6 +13,24 @@ function sessionPath(projectRoot: string, sessionId: string): string {
   return join(sessionsDir(projectRoot), `${sessionId}.json`);
 }
 
+/**
+ * 从 messages 数组里取第一条 user 消息,裁剪成 ≤20 字的单行字符串。
+ * 用于把用户首条提问作为会话标题。
+ */
+export function extractFirstUserMessageTitle(messages: unknown): string | null {
+  if (!Array.isArray(messages)) return null;
+  for (const message of messages) {
+    if (!message || typeof message !== "object") continue;
+    if ((message as { role?: unknown }).role !== "user") continue;
+    const content = (message as { content?: unknown }).content;
+    if (typeof content !== "string") return null;
+    const oneLine = content.trim().replace(/\s+/g, " ");
+    if (oneLine.length === 0) return null;
+    return oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
+  }
+  return null;
+}
+
 export class SessionAlreadyMigratedError extends Error {
   constructor(sessionId: string, currentBookId: string) {
     super(`Session "${sessionId}" is already bound to book "${currentBookId}"`);
@@ -83,10 +101,31 @@ export async function listBookSessions(
           ? (data.bookId as string | null)
           : null;
         if (parsedBookId !== bookId) return null;
+
+        let persistedTitle = typeof data.title === "string" ? data.title : null;
+
+        // Lazy migration:老 session 的 title 字段是 null 但已经有用户消息的,
+        // 一次性把第一条用户消息补写成 title 并 persist 回磁盘。用户在新流程中
+        // 发消息时会立即写 title,此 migration 只对历史数据生效一次。
+        if (persistedTitle === null) {
+          const recoveredTitle = extractFirstUserMessageTitle(data.messages);
+          if (recoveredTitle) {
+            try {
+              const fullSession = await loadBookSession(projectRoot, data.sessionId);
+              if (fullSession && fullSession.title === null) {
+                await persistBookSession(projectRoot, { ...fullSession, title: recoveredTitle });
+                persistedTitle = recoveredTitle;
+              }
+            } catch {
+              // 读不出完整 session 就忽略;下次再试
+            }
+          }
+        }
+
         return {
           sessionId: data.sessionId,
           bookId: parsedBookId,
-          title: typeof data.title === "string" ? data.title : null,
+          title: persistedTitle,
           messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
           createdAt: typeof data.createdAt === "number" ? data.createdAt : 0,
           updatedAt: typeof data.updatedAt === "number" ? data.updatedAt : 0,
@@ -114,18 +153,6 @@ export async function renameBookSession(
   return updated;
 }
 
-export async function updateSessionTitle(
-  projectRoot: string,
-  sessionId: string,
-  title: string,
-): Promise<BookSession | null> {
-  const session = await loadBookSession(projectRoot, sessionId);
-  if (!session || session.title !== null) return session;
-  const updated = { ...session, title, updatedAt: Date.now() };
-  await persistBookSession(projectRoot, updated);
-  return updated;
-}
-
 export async function deleteBookSession(
   projectRoot: string,
   sessionId: string,
@@ -160,8 +187,14 @@ export async function migrateBookSession(
 export async function createAndPersistBookSession(
   projectRoot: string,
   bookId: string | null,
+  sessionId?: string,
 ): Promise<BookSession> {
-  const session = createBookSession(bookId);
+  // 如果指定了 sessionId 且对应文件已存在,视为幂等操作直接返回(支持"用户发消息时才持久化 draft"流程)
+  if (sessionId) {
+    const existing = await loadBookSession(projectRoot, sessionId);
+    if (existing) return existing;
+  }
+  const session = createBookSession(bookId, sessionId);
   await persistBookSession(projectRoot, session);
   return session;
 }

+ 4 - 2
packages/core/src/interaction/session.ts

@@ -101,6 +101,7 @@ export type InteractionSession = z.infer<typeof InteractionSessionSchema>;
 export const BookSessionSchema = z.object({
   sessionId: z.string().min(1),
   bookId: z.string().nullable(),
+  title: z.string().nullable().default(null),
   messages: z.array(InteractionMessageSchema).default([]),
   creationDraft: BookCreationDraftSchema.optional(),
   draftRounds: z.array(DraftRoundSchema).default([]),
@@ -121,11 +122,12 @@ export const GlobalSessionSchema = z.object({
 
 export type GlobalSession = z.infer<typeof GlobalSessionSchema>;
 
-export function createBookSession(bookId: string | null): BookSession {
+export function createBookSession(bookId: string | null, sessionId?: string): BookSession {
   const now = Date.now();
   return {
-    sessionId: `${now}-${Math.random().toString(36).slice(2, 8)}`,
+    sessionId: sessionId ?? `${now}-${Math.random().toString(36).slice(2, 8)}`,
     bookId,
+    title: null,
     messages: [],
     draftRounds: [],
     events: [],