chore: establish YCode replication baseline and TODO

This commit is contained in:
xiaohan.yuan committed 2026-10-17 02:43:49 +08:00
commit 6dc59f89ba
1118 files changed
+196002

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
/**
* 会话与消息持久化。
*
* 存储: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) => b.updatedAt - a.updatedAt)
.map(({ id, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level }) => ({ id, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level }))
}
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, 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, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level } = session
return { id: sessionId, title, createdAt, updatedAt, workspaceId, 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>
): 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)
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 || '新对话'
}