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>
This commit is contained in:
fanghanjun 2026-04-17 02:26:45 -07:00 committed by Ma
parent 2b195720f3
commit f359b55a32
4 changed files with 279 additions and 29 deletions

View file

@ -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("没有用户消息的 sessiontitle 保持 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,
);
});
});
});

View file

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

View file

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

View file

@ -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: [],