import { randomUUID } from "node:crypto"; import { appendFileSync, existsSync, readFileSync, readdirSync, statSync, unlinkSync, } from "node:fs"; import { join, resolve } from "node:path"; import type { WebUiPaths } from "../config/paths.ts"; import { isPathInsideRoot } from "../utils/paths.ts"; import { prunePinnedSessionPaths, removePinnedSessionPath, setSessionPinned } from "../db/index.ts"; function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean { const t = (text ?? "").trim(); if (!t) return true; if (sessionHeaderId && t === sessionHeaderId) return true; if (/^[0-9a-f]{8,}$/i.test(t)) return true; if (/^[0-9]{10,}$/.test(t)) return true; return false; } function titleFromFirstUserMessage(text: string, maxChars = 56): string { const cleaned = String(text ?? "").replace(/\s+/g, " ").trim(); if (!cleaned) return ""; const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/); let candidate = sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned; if (candidate.length > maxChars) { candidate = `${candidate.slice(0, maxChars).trimEnd()}…`; } return candidate; } function extractPreview(msg: any): string { const c = msg.content; if (!c) return ""; if (typeof c === "string") return c.slice(0, 200); if (Array.isArray(c)) { const text = c .filter((x: any) => x.type === "text") .map((x: any) => x.text) .join("") .slice(0, 200); if (text) return text; const imageCount = c.filter((x: any) => x.type === "image").length; if (imageCount > 0) return `[${imageCount} 张图片]`; } return ""; } export function listSessionFiles(paths: WebUiPaths): string[] { if (!existsSync(paths.sessionsDir)) return []; const files: string[] = []; const visit = (dir: string) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { visit(fullPath); } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { files.push(fullPath); } } }; visit(paths.sessionsDir); return files.sort().reverse(); } export function readSessionSummary(paths: WebUiPaths, filePath: string): Record | null { try { const content = readFileSync(filePath, "utf8"); const lines = content.trim().split("\n"); if (!lines.length) return null; const header = JSON.parse(lines[0]); if (header.type !== "session") return null; let nameFromInfo = ""; let messageCount = 0; let firstMessage = ""; const stats = statSync(filePath); for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line); if (entry.type === "session_info" && entry.name) { const n = String(entry.name).trim(); if (n && !isMachineSessionLabel(n, header.id)) { nameFromInfo = n; } } if (entry.type === "message") { messageCount++; if (!firstMessage && entry.message?.role === "user") { firstMessage = extractPreview(entry.message); } } } catch { /* skip */ } } const fromFirstUser = titleFromFirstUserMessage(firstMessage); let name = nameFromInfo; if (!name || isMachineSessionLabel(name, header.id)) { name = fromFirstUser || ""; } return { path: filePath, id: header.id, name, created: header.timestamp, modified: stats.mtime.toISOString(), messageCount, firstMessage: firstMessage || "(空)", }; } catch { return null; } } export function readSessionMessages(filePath: string): unknown[] { try { const content = readFileSync(filePath, "utf8"); return content .trim() .split("\n") .map((line) => { try { const entry = JSON.parse(line); return entry.type === "message" ? entry.message : null; } catch { return null; } }) .filter(Boolean); } catch { return []; } } export function resolveSessionFile(paths: WebUiPaths, filePath: string): string { if (typeof filePath !== "string" || !filePath.trim()) { throw new Error("无效会话路径"); } const sessionsRoot = resolve(paths.sessionsDir); const resolved = resolve(filePath.trim()); if (!resolved.toLowerCase().endsWith(".jsonl")) { throw new Error("无效会话路径"); } if (!isPathInsideRoot(sessionsRoot, resolved)) { throw new Error("无效会话路径"); } return resolved; } export function appendSessionName(paths: WebUiPaths, filePath: string, name: string): string { const sessionPath = resolveSessionFile(paths, filePath); if (!existsSync(sessionPath)) throw new Error("会话不存在"); const trimmed = name.trim(); if (!trimmed) throw new Error("会话名称不能为空"); const lines = readFileSync(sessionPath, "utf8").trim().split("\n"); const ids = new Set(); let leafId: string | null = null; for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line); if (typeof entry.id === "string") { ids.add(entry.id); leafId = entry.id; } } catch { /* skip */ } } if (!leafId) throw new Error("无效会话文件"); let id = randomUUID().slice(0, 8); for (let i = 0; i < 100 && ids.has(id); i++) { id = randomUUID().slice(0, 8); } const entry = { type: "session_info", id, parentId: leafId, timestamp: new Date().toISOString(), name: trimmed, }; appendFileSync(sessionPath, `\n${JSON.stringify(entry)}`, "utf8"); return trimmed; } function sortSessionSummaries( summaries: Array>, pinnedPaths: string[], ): Array> { const pinnedOrder = new Map(pinnedPaths.map((path, index) => [path, index])); return [...summaries].sort((a, b) => { const aPath = String(a.path); const bPath = String(b.path); const aPin = pinnedOrder.get(aPath); const bPin = pinnedOrder.get(bPath); if (aPin !== undefined && bPin !== undefined) return aPin - bPin; if (aPin !== undefined) return -1; if (bPin !== undefined) return 1; return ( new Date(String(b.modified || b.created || 0)).getTime() - new Date(String(a.modified || a.created || 0)).getTime() ); }); } function annotatePinnedSessions( summaries: Array>, pinnedPaths: string[], ): Array> { const pinnedSet = new Set(pinnedPaths); return summaries.map((summary) => ({ ...summary, pinned: pinnedSet.has(String(summary.path)), })); } export function buildSessionListResponse(paths: WebUiPaths) { const summaries = listSessionFiles(paths) .map((filePath) => readSessionSummary(paths, filePath)) .filter(Boolean) as Array>; const pinnedPaths = prunePinnedSessionPaths(summaries.map((summary) => String(summary.path))); const sorted = sortSessionSummaries(summaries, pinnedPaths); return { sessions: annotatePinnedSessions(sorted, pinnedPaths), pinnedPaths, }; } export function deleteSessionFile(paths: WebUiPaths, sessionPathInput: string): void { const sessionPath = resolveSessionFile(paths, sessionPathInput); if (!existsSync(sessionPath)) throw new Error("会话不存在"); unlinkSync(sessionPath); removePinnedSessionPath(sessionPath); } export function pinSession(paths: WebUiPaths, sessionPathInput: string, pinned: boolean): { path: string; pinned: boolean; pinnedPaths: string[]; } { const sessionPath = resolveSessionFile(paths, sessionPathInput); if (!existsSync(sessionPath)) throw new Error("会话不存在"); const pinnedPaths = setSessionPinned(sessionPath, pinned); return { path: sessionPath, pinned, pinnedPaths }; }