Files
sproutclaw/.pi/agent/extensions/webui/backend/services/sessions.ts
root cf5edd6394
Some checks failed
CI / build-check-test (push) Has been cancelled
feat(sproutclaw): modularize webui, extensions, and agent config layout
Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 16:57:08 +08:00

251 lines
7.1 KiB
TypeScript

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 { 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<string, unknown> | 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 {
const resolved = resolve(filePath);
const sessionsRoot = resolve(paths.sessionsDir);
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
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<string>();
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<Record<string, unknown>>,
pinnedPaths: string[],
): Array<Record<string, unknown>> {
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<Record<string, unknown>>,
pinnedPaths: string[],
): Array<Record<string, unknown>> {
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<Record<string, unknown>>;
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 };
}