chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -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 || '新对话'
|
||||
}
|
||||
Reference in new issue
Block a user