256 lines
12 KiB
TypeScript
256 lines
12 KiB
TypeScript
/**
|
|
* 会话与消息持久化。
|
|
*
|
|
* 存储:userData/sessions.json(单文件,MVP 规模足够)
|
|
* 真实业务后续应换成 SQLite;此处刻意把接口收敛成 Store 形态,便于替换实现。
|
|
*/
|
|
|
|
import { app } from 'electron'
|
|
import { promises as fs } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { randomUUID } from 'node:crypto'
|
|
import type { ChatMessage, SessionMeta } from '@shared/types'
|
|
import { applyAssistantEvent } from '@shared/message-parts'
|
|
import type { SessionContext } from '@shared/workspaces'
|
|
import { migrateImportedWorkBuddyMessages, type WorkBuddyImportProvenance } from '@shared/workbuddy-import'
|
|
import { agentMode, agentModeContextPatch } from './agent/mode-policy'
|
|
|
|
interface SessionRecord extends SessionMeta {
|
|
messages: ChatMessage[]
|
|
pendingNativeBranchId?: string
|
|
importProvenance?: WorkBuddyImportProvenance
|
|
}
|
|
|
|
interface StoreShape {
|
|
sessions: SessionRecord[]
|
|
}
|
|
|
|
let cache: StoreShape | null = null
|
|
let loadPromise: Promise<StoreShape> | null = null
|
|
let persistQueue: Promise<void> = Promise.resolve()
|
|
|
|
function filePath(): string {
|
|
return join(app.getPath('userData'), 'sessions.json')
|
|
}
|
|
|
|
async function load(): Promise<StoreShape> {
|
|
if (cache) return cache
|
|
if (!loadPromise) {
|
|
loadPromise = (async () => {
|
|
try {
|
|
const raw = await fs.readFile(filePath(), 'utf8')
|
|
const parsed = JSON.parse(raw) as StoreShape
|
|
cache = { sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [] }
|
|
let recovered = false
|
|
for (const session of cache.sessions) {
|
|
const migrated = migrateImportedWorkBuddyMessages(session.messages ?? [], session.importProvenance)
|
|
if (migrated.changed) { session.messages = migrated.messages; recovered = true }
|
|
session.messages = (session.messages ?? []).map((message) => {
|
|
if (message.status !== 'streaming') return message
|
|
recovered = true
|
|
return { ...applyAssistantEvent(message, { runId: message.requestId ?? '', type: 'cancelled' }), interrupted: true }
|
|
})
|
|
}
|
|
if (recovered) await persist()
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { cache = null; throw error }
|
|
cache = { sessions: [] }
|
|
}
|
|
return cache as StoreShape
|
|
})()
|
|
}
|
|
|
|
const pending = loadPromise
|
|
try {
|
|
return await pending
|
|
} finally {
|
|
if (loadPromise === pending) loadPromise = null
|
|
}
|
|
}
|
|
|
|
async function persist(): Promise<void> {
|
|
if (!cache) return
|
|
const snapshot = JSON.stringify(cache, null, 2)
|
|
const userData = app.getPath('userData')
|
|
const file = filePath()
|
|
const temporaryFile = join(userData, `.sessions-${randomUUID()}.tmp`)
|
|
const write = persistQueue.then(async () => {
|
|
await fs.mkdir(userData, { recursive: true })
|
|
try {
|
|
await fs.writeFile(temporaryFile, snapshot, 'utf8')
|
|
await fs.rename(temporaryFile, file)
|
|
} catch (error) {
|
|
await fs.rm(temporaryFile, { force: true }).catch(() => undefined)
|
|
throw error
|
|
}
|
|
})
|
|
|
|
// A failed write reaches its caller but does not reject subsequent writes.
|
|
persistQueue = write.catch(() => undefined)
|
|
await write
|
|
}
|
|
|
|
export async function listSessions(): Promise<SessionMeta[]> {
|
|
const store = await load()
|
|
return [...store.sessions]
|
|
// 置顶任务优先(最近置顶在前),其余按更新时间倒序。
|
|
.sort((a, b) => Number(b.pinnedAt ?? 0) - Number(a.pinnedAt ?? 0) || b.updatedAt - a.updatedAt)
|
|
.map(({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, pinnedAt, archivedAt }) => ({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, pinnedAt, archivedAt }))
|
|
}
|
|
|
|
/** 侧栏可见任务:默认排除已归档;`includeArchived` 供归档视图使用。 */
|
|
export async function listVisibleSessions(includeArchived = false): Promise<SessionMeta[]> {
|
|
const sessions = await listSessions()
|
|
return includeArchived ? sessions : sessions.filter((session) => session.archivedAt === undefined)
|
|
}
|
|
|
|
export async function setSessionPinned(sessionId: string, pinned: boolean): Promise<SessionMeta> {
|
|
const store = await load()
|
|
const session = store.sessions.find((item) => item.id === sessionId)
|
|
if (!session) throw new Error('会话不存在')
|
|
const previous = session.pinnedAt
|
|
session.pinnedAt = pinned ? Date.now() : undefined
|
|
try { await persist() } catch (error) { session.pinnedAt = previous; throw error }
|
|
return meta(session)
|
|
}
|
|
|
|
export async function setSessionArchived(sessionId: string, archived: boolean): Promise<SessionMeta> {
|
|
const store = await load()
|
|
const session = store.sessions.find((item) => item.id === sessionId)
|
|
if (!session) throw new Error('会话不存在')
|
|
const previous = session.archivedAt
|
|
session.archivedAt = archived ? Date.now() : undefined
|
|
try { await persist() } catch (error) { session.archivedAt = previous; throw error }
|
|
return meta(session)
|
|
}
|
|
|
|
function meta(session: SessionRecord): SessionMeta {
|
|
const { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, pinnedAt, archivedAt } = session
|
|
return { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, pinnedAt, archivedAt }
|
|
}
|
|
|
|
export async function createSession(title = '新对话', context: SessionContext = {}): Promise<SessionMeta> {
|
|
context = agentModeContextPatch({}, context)
|
|
const store = await load()
|
|
const now = Date.now()
|
|
const record: SessionRecord = { ...context, id: randomUUID(), title, createdAt: now, updatedAt: now, messages: [] }
|
|
store.sessions.push(record)
|
|
await persist()
|
|
return { ...context, id: record.id, title: record.title, createdAt: now, updatedAt: now }
|
|
}
|
|
|
|
export async function getSessionContext(id: string): Promise<SessionContext> {
|
|
const session = (await load()).sessions.find((item) => item.id === id)
|
|
if (!session) throw new Error('会话不存在')
|
|
return { workspaceId: session.workspaceId, projectId: session.projectId, cwd: session.cwd, modelId: session.modelId, expertId: session.expertId, agentMode: agentMode(session.agentMode), thinkingLevel: session.thinkingLevel }
|
|
}
|
|
export async function updateSessionContext(id: string, patch: SessionContext): Promise<SessionMeta> {
|
|
const session = (await load()).sessions.find((item) => item.id === id)
|
|
if (!session) throw new Error('会话不存在')
|
|
const previous = { ...session }
|
|
Object.assign(session, agentModeContextPatch(session, patch))
|
|
try { await persist() } catch (error) {
|
|
for (const key of Object.keys(session)) if (!Object.hasOwn(previous, key)) delete (session as unknown as Record<string, unknown>)[key]
|
|
Object.assign(session, previous); throw error
|
|
}
|
|
const { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level } = session
|
|
return { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level }
|
|
}
|
|
|
|
export async function getMessages(sessionId: string): Promise<ChatMessage[]> {
|
|
const store = await load()
|
|
return store.sessions.find((s) => s.id === sessionId)?.messages ?? []
|
|
}
|
|
|
|
export async function getPendingNativeBranch(sessionId: string): Promise<string | undefined> {
|
|
return (await load()).sessions.find((session) => session.id === sessionId)?.pendingNativeBranchId
|
|
}
|
|
export async function setPendingNativeBranch(sessionId: string, entryId: string | undefined): Promise<void> {
|
|
const session = (await load()).sessions.find((item) => item.id === sessionId)
|
|
if (!session) throw new Error('会话不存在')
|
|
const previous = session.pendingNativeBranchId
|
|
session.pendingNativeBranchId = entryId
|
|
try { await persist() } catch (error) { session.pendingNativeBranchId = previous; throw error }
|
|
}
|
|
|
|
export async function appendMessage(sessionId: string, message: ChatMessage): Promise<void> {
|
|
const store = await load()
|
|
const session = store.sessions.find((s) => s.id === sessionId)
|
|
if (!session) return
|
|
session.messages.push(message)
|
|
session.updatedAt = Date.now()
|
|
await persist()
|
|
}
|
|
|
|
export async function patchMessage(
|
|
sessionId: string,
|
|
messageId: string,
|
|
patch: Partial<ChatMessage>,
|
|
options: { touchSession?: boolean } = {}
|
|
): Promise<void> {
|
|
const store = await load()
|
|
const session = store.sessions.find((s) => s.id === sessionId)
|
|
if (!session) return
|
|
const message = session.messages.find((m) => m.id === messageId)
|
|
if (!message) return
|
|
Object.assign(message, patch)
|
|
if(options.touchSession!==false)session.updatedAt = Date.now()
|
|
await persist()
|
|
}
|
|
|
|
export async function renameSession(sessionId: string, title: string): Promise<void> {
|
|
const store = await load()
|
|
const session = store.sessions.find((s) => s.id === sessionId)
|
|
if (!session) return
|
|
session.title = title.trim() || session.title
|
|
session.updatedAt = Date.now()
|
|
await persist()
|
|
}
|
|
|
|
export async function deleteSession(sessionId: string): Promise<void> {
|
|
const store = await load()
|
|
store.sessions = store.sessions.filter((s) => s.id !== sessionId)
|
|
await persist()
|
|
}
|
|
|
|
/** Keep the abandoned branch on disk before replacing the active conversation. */
|
|
export async function replaceMessages(sessionId: string, messages: ChatMessage[], reason: string, options?: { pendingNativeBranchId?: string }): Promise<void> {
|
|
const store = await load()
|
|
const session = store.sessions.find((item) => item.id === sessionId)
|
|
if (!session) throw new Error('会话不存在')
|
|
const directory = join(app.getPath('userData'), 'session-branches', encodeURIComponent(sessionId))
|
|
await fs.mkdir(directory, { recursive: true })
|
|
await fs.writeFile(join(directory, `${Date.now()}-${randomUUID()}.json`), JSON.stringify({ reason, session }, null, 2), 'utf8')
|
|
const previous = session.messages
|
|
const updatedAt = session.updatedAt
|
|
const previousBranch = session.pendingNativeBranchId
|
|
session.messages = messages
|
|
if (options) session.pendingNativeBranchId = options.pendingNativeBranchId
|
|
session.updatedAt = Date.now()
|
|
try { await persist() } catch (error) { session.messages = previous; session.updatedAt = updatedAt; session.pendingNativeBranchId = previousBranch; throw error }
|
|
}
|
|
|
|
/** A turn and its selected native branch are one atomic session-store mutation. */
|
|
export async function startTurn(sessionId: string, user: ChatMessage, assistant: ChatMessage, prefix: ChatMessage[], reason?: string, nativeBranchId?: string): Promise<void> {
|
|
const store = await load()
|
|
const session = store.sessions.find((item) => item.id === sessionId)
|
|
if (!session) throw new Error('会话不存在')
|
|
if (reason) {
|
|
const directory = join(app.getPath('userData'), 'session-branches', encodeURIComponent(sessionId))
|
|
await fs.mkdir(directory, { recursive: true })
|
|
await fs.writeFile(join(directory, `${Date.now()}-${randomUUID()}.json`), JSON.stringify({ reason, session }, null, 2), 'utf8')
|
|
}
|
|
const previous = { messages: session.messages, title: session.title, updatedAt: session.updatedAt, pendingNativeBranchId: session.pendingNativeBranchId }
|
|
session.messages = [...prefix, user, assistant]
|
|
if (!prefix.length) session.title = deriveTitle(user.content)
|
|
if (nativeBranchId) session.pendingNativeBranchId = nativeBranchId
|
|
session.updatedAt = Date.now()
|
|
try { await persist() } catch (error) { Object.assign(session, previous); throw error }
|
|
}
|
|
|
|
/** 用首条用户消息生成标题 */
|
|
export function deriveTitle(text: string): string {
|
|
const flat = text.replace(/\s+/g, ' ').trim()
|
|
return flat.length > 24 ? `${flat.slice(0, 24)}…` : flat || '新对话'
|
|
}
|