diff --git a/src/config.ts b/src/config.ts index 23af3be..30a4022 100644 --- a/src/config.ts +++ b/src/config.ts @@ -65,6 +65,8 @@ const envSchema = z SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"), // conversation metadata 持久化目录。 CONVERSATION_STORAGE_DIR: z.string().default("./data/conversations"), + // conversation UI state 持久化目录。 + CONVERSATION_STATE_STORAGE_DIR: z.string().default("./data/conversation-states"), // 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。 SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce .number() diff --git a/src/conversations/stateStore.ts b/src/conversations/stateStore.ts new file mode 100644 index 0000000..fddaf83 --- /dev/null +++ b/src/conversations/stateStore.ts @@ -0,0 +1,41 @@ +import { join } from "node:path"; + +import { config } from "../config.js"; +import { + atomicWriteJson, + ensureDirectory, + readJsonFile, + removeFileIfExists, +} from "../utils/fileStore.js"; + +export type ConversationStateRecord = { + sessionId: string; + isTitleManuallyEdited?: boolean; + messages: unknown[]; + branchGroups: unknown[]; +}; + +export class ConversationStateStore { + constructor(private readonly baseDir = config.CONVERSATION_STATE_STORAGE_DIR) {} + + async initialize() { + await ensureDirectory(this.baseDir); + } + + async read(sessionScopeKey: string) { + return await readJsonFile(this.filePath(sessionScopeKey)); + } + + async write(sessionScopeKey: string, state: ConversationStateRecord) { + await atomicWriteJson(this.filePath(sessionScopeKey), state); + return state; + } + + async remove(sessionScopeKey: string) { + await removeFileIfExists(this.filePath(sessionScopeKey)); + } + + private filePath(sessionScopeKey: string) { + return join(this.baseDir, `${sessionScopeKey}.json`); + } +} diff --git a/src/conversations/store.ts b/src/conversations/store.ts index f7ec26b..2e5f3c8 100644 --- a/src/conversations/store.ts +++ b/src/conversations/store.ts @@ -2,7 +2,13 @@ import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { config } from "../config.js"; -import { atomicWriteJson, ensureDirectory, readJsonFile } from "../utils/fileStore.js"; +import { + atomicWriteJson, + ensureDirectory, + listJsonFiles, + readJsonFile, + removeFileIfExists, +} from "../utils/fileStore.js"; import { toConversationScopeKey } from "../utils/fileStore.js"; export type ConversationStatus = "active" | "archived"; @@ -81,7 +87,10 @@ export class ConversationStore { ); } - async touch(record: ConversationRecord, updates: Partial> = {}) { + async touch( + record: ConversationRecord, + updates: Partial> = {}, + ) { const next: ConversationRecord = { ...record, ...normalizeConversationUpdates(updates), @@ -91,6 +100,25 @@ export class ConversationStore { return next; } + async list(context: ConversationContext) { + const files = await listJsonFiles(this.baseDir); + const records = await Promise.all( + files.map((file) => readJsonFile(file)), + ); + return records + .filter((record): record is ConversationRecord => Boolean(record)) + .filter( + (record) => + record.actorKey === context.actorKey && + record.projectKey === context.projectKey, + ) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + } + + async remove(record: ConversationRecord) { + await removeFileIfExists(this.filePath(record.sessionScopeKey)); + } + private filePath(sessionScopeKey: string) { return join(this.baseDir, `${sessionScopeKey}.json`); } @@ -113,7 +141,7 @@ const normalizeConversationUpdates = ( if (typeof updates.title === "string") { const trimmed = updates.title.trim(); if (trimmed) { - normalized.title = trimmed; + normalized.title = trimmed.slice(0, 120); } } return normalized; diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 9f568cf..d3f0e51 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -5,6 +5,7 @@ import { type LearningOrchestrator } from "../learning/orchestrator.js"; import { type SessionHistoryStore } from "../history/store.js"; import { logger } from "../logger.js"; import { MemoryStore } from "../memory/store.js"; +import { type ConversationStateStore } from "../conversations/stateStore.js"; import { type ConversationStore } from "../conversations/store.js"; import { type ResultReferenceResolver } from "../results/resolver.js"; import { RESULT_REFERENCE_KIND } from "../results/store.js"; @@ -14,6 +15,7 @@ import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { buildPromptWithLearningContext, generateSessionTitle, + shouldGenerateSessionTitle, } from "./chatSession.js"; import { collectTextContent, @@ -42,10 +44,18 @@ const forkPayloadSchema = z.object({ keep_message_count: z.coerce.number().int().min(0), }); +const conversationStateSchema = z.object({ + title: z.string().max(120).optional(), + is_title_manually_edited: z.boolean().optional(), + messages: z.array(z.unknown()).default([]), + branch_groups: z.array(z.unknown()).default([]), +}); + export const buildChatRouter = ( sessionBridge: ChatSessionBridge, runtime: OpencodeRuntimeAdapter, conversationStore: ConversationStore, + conversationStateStore: ConversationStateStore, memoryStore: MemoryStore, sessionHistoryStore: SessionHistoryStore, learningOrchestrator: LearningOrchestrator, @@ -87,6 +97,178 @@ export const buildChatRouter = ( }); }); + chatRouter.get("/sessions", async (req, res) => { + const projectId = req.header("x-project-id") ?? undefined; + const userId = req.header("x-user-id") ?? undefined; + const actorKey = toActorKey(userId); + const projectKey = toProjectKey(projectId); + const records = await conversationStore.list({ + actorKey, + projectId, + projectKey, + userId, + }); + res.json({ + sessions: records.map((record) => ({ + id: record.sessionId, + title: record.title ?? "新对话", + created_at: record.createdAt, + updated_at: record.updatedAt, + status: record.status, + parent_session_id: record.parentSessionId, + })), + }); + }); + + chatRouter.get("/session/:sessionId", async (req, res) => { + const sessionId = req.params.sessionId?.trim(); + const projectId = req.header("x-project-id") ?? undefined; + const userId = req.header("x-user-id") ?? undefined; + const actorKey = toActorKey(userId); + const projectKey = toProjectKey(projectId); + if (!sessionId) { + res.status(400).json({ message: "session_id is required" }); + return; + } + + const conversation = await conversationStore.get( + { + actorKey, + projectId, + projectKey, + userId, + }, + sessionId, + ); + if (!conversation) { + res.status(404).json({ message: "session not found" }); + return; + } + + const state = await conversationStateStore.read(conversation.sessionScopeKey); + res.json({ + id: conversation.sessionId, + title: conversation.title ?? "新对话", + is_title_manually_edited: state?.isTitleManuallyEdited ?? false, + created_at: conversation.createdAt, + updated_at: conversation.updatedAt, + status: conversation.status, + session_id: conversation.sessionId, + messages: state?.messages ?? [], + branch_groups: state?.branchGroups ?? [], + parent_session_id: conversation.parentSessionId, + }); + }); + + chatRouter.put("/session/:sessionId", async (req, res) => { + const sessionId = req.params.sessionId?.trim(); + const parsed = conversationStateSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + res.status(400).json({ + message: "invalid request payload", + detail: parsed.error.flatten(), + }); + return; + } + + const projectId = req.header("x-project-id") ?? undefined; + const userId = req.header("x-user-id") ?? undefined; + const actorKey = toActorKey(userId); + const projectKey = toProjectKey(projectId); + if (!sessionId) { + res.status(400).json({ message: "session_id is required" }); + return; + } + + const { record } = await conversationStore.ensure({ + actorKey, + projectId, + projectKey, + sessionId, + userId, + }); + const nextRecord = await conversationStore.touch(record, { + ...(parsed.data.title ? { title: parsed.data.title } : {}), + }); + await conversationStateStore.write(nextRecord.sessionScopeKey, { + sessionId: nextRecord.sessionId, + isTitleManuallyEdited: parsed.data.is_title_manually_edited, + messages: parsed.data.messages, + branchGroups: parsed.data.branch_groups, + }); + res.json({ + id: nextRecord.sessionId, + title: nextRecord.title ?? "新对话", + created_at: nextRecord.createdAt, + updated_at: nextRecord.updatedAt, + status: nextRecord.status, + session_id: nextRecord.sessionId, + }); + }); + + chatRouter.patch("/session/:sessionId/title", async (req, res) => { + const sessionId = req.params.sessionId?.trim(); + const title = + typeof req.body?.title === "string" ? req.body.title.trim() : ""; + const isTitleManuallyEdited = + typeof req.body?.is_title_manually_edited === "boolean" + ? req.body.is_title_manually_edited + : undefined; + const projectId = req.header("x-project-id") ?? undefined; + const userId = req.header("x-user-id") ?? undefined; + const actorKey = toActorKey(userId); + const projectKey = toProjectKey(projectId); + if (!sessionId || !title) { + res.status(400).json({ message: "session_id and title are required" }); + return; + } + const conversation = await conversationStore.get( + { actorKey, projectId, projectKey, userId }, + sessionId, + ); + if (!conversation) { + res.status(404).json({ message: "session not found" }); + return; + } + const nextConversation = await conversationStore.touch(conversation, { title }); + const state = await conversationStateStore.read(nextConversation.sessionScopeKey); + if (state) { + await conversationStateStore.write(nextConversation.sessionScopeKey, { + ...state, + isTitleManuallyEdited: + isTitleManuallyEdited ?? state.isTitleManuallyEdited, + }); + } + res.json({ + id: nextConversation.sessionId, + title: nextConversation.title, + updated_at: nextConversation.updatedAt, + }); + }); + + chatRouter.delete("/session/:sessionId", async (req, res) => { + const sessionId = req.params.sessionId?.trim(); + const projectId = req.header("x-project-id") ?? undefined; + const userId = req.header("x-user-id") ?? undefined; + const actorKey = toActorKey(userId); + const projectKey = toProjectKey(projectId); + if (!sessionId) { + res.status(400).json({ message: "session_id is required" }); + return; + } + const conversation = await conversationStore.get( + { actorKey, projectId, projectKey, userId }, + sessionId, + ); + if (!conversation) { + res.status(204).end(); + return; + } + await conversationStateStore.remove(conversation.sessionScopeKey); + await conversationStore.remove(conversation); + res.status(204).end(); + }); + chatRouter.get("/render-ref/:renderRef", async (req, res) => { const renderRef = req.params.renderRef?.trim(); const userId = req.header("x-user-id")?.trim(); @@ -364,9 +546,21 @@ export const buildChatRouter = ( .reverse() .find((message) => message.info.role === "assistant"); const assistantText = collectTextContent(assistantMessage?.parts ?? []); - const existingSessionTitle = activeConversation.title; + const latestConversation = + (await conversationStore.get( + { actorKey, projectId, projectKey, userId }, + activeConversation.sessionId, + )) ?? activeConversation; + const latestConversationState = await conversationStateStore.read( + latestConversation.sessionScopeKey, + ); + const existingSessionTitle = latestConversation.title; let sessionTitle = existingSessionTitle; - const shouldGenerateTitle = recentTurns.length <= 1; + const shouldGenerateTitle = shouldGenerateSessionTitle({ + recentTurnCount: recentTurns.length, + isTitleManuallyEdited: + latestConversationState?.isTitleManuallyEdited ?? false, + }); if (shouldGenerateTitle) { sessionTitle = await generateSessionTitle(runtime, { sessionId: binding.sessionId, @@ -374,7 +568,7 @@ export const buildChatRouter = ( fallbackTitle: existingSessionTitle, }); } - const nextConversation = await conversationStore.touch(activeConversation, { + const nextConversation = await conversationStore.touch(latestConversation, { ...(sessionTitle && sessionTitle !== existingSessionTitle ? { title: sessionTitle } : {}), diff --git a/src/routes/chatSession.ts b/src/routes/chatSession.ts index 9bc3758..f3102bd 100644 --- a/src/routes/chatSession.ts +++ b/src/routes/chatSession.ts @@ -75,6 +75,11 @@ const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => { return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized; }; +export const shouldGenerateSessionTitle = (options: { + recentTurnCount: number; + isTitleManuallyEdited: boolean; +}) => options.recentTurnCount <= 1 && !options.isTitleManuallyEdited; + export const generateSessionTitle = async ( runtime: OpencodeRuntimeAdapter, options: { diff --git a/src/server.ts b/src/server.ts index d56080a..24bcc46 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,7 @@ import express from "express"; import { SessionHistoryStore } from "./history/store.js"; import { ChatSessionBridge } from "./chat/sessionBridge.js"; import { config } from "./config.js"; +import { ConversationStateStore } from "./conversations/stateStore.js"; import { ConversationStore } from "./conversations/store.js"; import { logger } from "./logger.js"; import { LearningOrchestrator } from "./learning/orchestrator.js"; @@ -19,6 +20,7 @@ import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js"; const app = express(); const sessionBridge = new ChatSessionBridge(opencodeRuntime); const conversationStore = new ConversationStore(); +const conversationStateStore = new ConversationStateStore(); const memoryStore = new MemoryStore(); const sessionHistoryStore = new SessionHistoryStore(); const toolContextStore = new ToolSessionContextStore(); @@ -246,6 +248,7 @@ app.use( sessionBridge, opencodeRuntime, conversationStore, + conversationStateStore, memoryStore, sessionHistoryStore, learningOrchestrator, @@ -256,6 +259,7 @@ app.use( const bootstrap = async () => { await Promise.all([ conversationStore.initialize(), + conversationStateStore.initialize(), learningOrchestrator.initialize(), memoryStore.initialize(), resultReferenceStore.initialize(), diff --git a/tests/routes/chatSession.test.ts b/tests/routes/chatSession.test.ts new file mode 100644 index 0000000..cad6ea2 --- /dev/null +++ b/tests/routes/chatSession.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; + +import { shouldGenerateSessionTitle } from "../../src/routes/chatSession.js"; + +describe("shouldGenerateSessionTitle", () => { + it("allows auto-title generation for the first turn when the title was not edited", () => { + expect( + shouldGenerateSessionTitle({ + recentTurnCount: 0, + isTitleManuallyEdited: false, + }), + ).toBe(true); + }); + + it("blocks auto-title generation after the user edits the title manually", () => { + expect( + shouldGenerateSessionTitle({ + recentTurnCount: 0, + isTitleManuallyEdited: true, + }), + ).toBe(false); + }); + + it("only allows auto-title generation during the first two turns", () => { + expect( + shouldGenerateSessionTitle({ + recentTurnCount: 1, + isTitleManuallyEdited: false, + }), + ).toBe(true); + expect( + shouldGenerateSessionTitle({ + recentTurnCount: 2, + isTitleManuallyEdited: false, + }), + ).toBe(false); + }); +});