mirror of
https://github.com/Narcooo/inkos
synced 2026-04-21 14:37:16 +00:00
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:
parent
2b195720f3
commit
f359b55a32
4 changed files with 279 additions and 29 deletions
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
Loading…
Reference in a new issue