Some checks failed
CI / build-check-test (push) Has been cancelled
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>
251 lines
7.1 KiB
TypeScript
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 };
|
|
}
|