feat: 识图提速(模型常驻/精简模板/磁盘缓存)+ WebUI 上传压缩 + 缓存清理脚本

- vision.ts: keep_alive:-1 常驻显存(治冷加载 7.7GB);转录缓存持久化
  data/agent/vision-cache.json(重启后历史图秒回);hook 转录用精简模板
- ChatInput/image-attachments: 上传 JPEG 长边>1600px 自动压缩 q0.85
- scripts/cleanup-cache.mjs: npm/opengrep 缓存、过期会话、旧图瘦身清理
- README 双语同步
This commit is contained in:
luckyyzh
2026-08-03 06:33:43 +08:00
parent e79485bf4c
commit ccb038988e
5 changed files with 425 additions and 23 deletions
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env node
/**
* pi-agent-integrated 缓存/会话清理脚本
*
* 背景:会话 jsonl 内嵌上传图片的 base64(单张相机照片可达 5.7MB),
* 每次 WebUI 加载会话要整体读出+序列化+前端解码渲染 → 加载变慢;
* opengrep/npm 缓存占磁盘数 GB。
*
* 用法:node scripts/cleanup-cache.mjs [--apply] [--target all|sessions|strip-images|npm|opengrep|artifacts] [--days N] [--keep N] [--purge] [--yes]
*
* --target all (默认) | sessions(移走旧会话) | strip-images(旧会话图片替换为占位,保留对话文本) |
* npm(清 cache/npm) | opengrep(清 AppData/Local/opengrep) | artifacts(清旧子代理产物)
* --days N 会话保留 N 天内的 (默认 7);配合 --target sessions/strip-images
* --keep N 额外保留最近 N 个会话文件 (默认 0)
* --apply 真正执行;缺省为 dry-run 只预览
* --purge 同时删除回收目录中 30 天前的备份(默认只清理,不删回收站)
* --yes 跳过确认(--apply 时)
*
* 安全边界:永不触碰 agent/npm(扩展安装)、.pi-lens/tools、.pi-lens/bin、memory(记忆仓库)。
* 所有删除 = 先移入回收目录 data/agent/tmp/session-trash/<时间戳>/,--purge 才清旧备份。
*/
import fs from "node:fs";
import path from "node:path";
const ROOT = path.resolve(import.meta.dirname, "..");
const DATA = path.join(ROOT, "data");
const SESSIONS_DIR = path.join(DATA, "agent", "sessions");
const TRASH_ROOT = path.join(DATA, "agent", "tmp", "session-trash");
const NPM_CACHE = path.join(DATA, "cache", "npm");
const OPENGREP = path.join(DATA, "home", "AppData", "Local", "opengrep");
const CUTOFF_DAYS = () => Date.now() - opt.days * 86400_000;
const args = process.argv.slice(2);
const opt = {
apply: args.includes("--apply"),
purge: args.includes("--purge"),
yes: args.includes("--yes"),
days: 7,
keep: 0,
target: "all",
};
for (let i = 0; i < args.length; i++) {
if (args[i] === "--days") opt.days = parseInt(args[++i], 10);
if (args[i] === "--keep") opt.keep = parseInt(args[++i], 10);
if (args[i] === "--target") opt.target = args[++i];
}
if (isNaN(opt.days) || opt.days < 0) opt.days = 7;
const TARGETS = ["all", "sessions", "strip-images", "npm", "opengrep", "artifacts"];
if (!TARGETS.includes(opt.target)) {
console.error(`未知 --target: ${opt.target}(可选 ${TARGETS.join(" / ")})`);
process.exit(1);
}
const want = (t) => opt.target === "all" || opt.target === t;
const mode = opt.apply ? "执行" : "预览(dry-run)";
console.log(`[cleanup-cache] ${mode} | target=${opt.target} | 会话保留 ${opt.days} 天${opt.keep ? ` + 最近 ${opt.keep} 个` : ""}\n`);
let freed = 0;
const actions = [];
const note = (s) => console.log(" " + s);
const planned = (file, size, why) => {
freed += size;
actions.push({ file, why });
note(`- ${why}: ${file} (${fmtSize(size)})`);
};
function fmtSize(n) {
if (n >= 1 << 30) return (n / (1 << 30)).toFixed(2) + " GB";
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + " MB";
if (n >= 1 << 10) return (n / (1 << 10)).toFixed(0) + " KB";
return n + " B";
}
function dirSize(p) {
if (!fs.existsSync(p)) return 0;
let total = 0;
const st = fs.statSync(p);
if (st.isFile()) return st.size;
for (const e of fs.readdirSync(p, { withFileTypes: true })) {
total += dirSize(path.join(p, e.name));
}
return total;
}
function moveToTrash(p) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const dest = path.join(TRASH_ROOT, stamp, path.basename(p));
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.renameSync(p, dest);
return dest;
}
function countImages(file) {
let n = 0;
let bytes = 0;
try {
const lines = fs.readFileSync(file, "utf8").split("\n");
for (const line of lines) {
if (!line || !line.includes('"image"')) continue;
let e;
try { e = JSON.parse(line); } catch { continue; }
if (e?.type !== "message" || !Array.isArray(e.message?.content)) continue;
for (const b of e.message.content) {
if (b?.type !== "image") continue;
const data = typeof b.data === "string" ? b.data
: b.source?.type === "base64" ? b.source.data : null;
n++;
if (data) {
const pad = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
bytes += Math.floor((data.length * 3) / 4) - pad;
}
}
}
} catch { /* skip */ }
return { n, bytes };
}
function stripImagesInPlace(file) {
const lines = fs.readFileSync(file, "utf8").split("\n");
let stripped = 0;
let bytes = 0;
const out = [];
for (const line of lines) {
if (!line || !line.includes('"image"')) { out.push(line); continue; }
try {
const e = JSON.parse(line);
if (e?.type === "message" && Array.isArray(e.message?.content)
&& (e.message.role === "user" || e.message.role === "toolResult")) {
const content = [];
for (const b of e.message.content) {
if (b?.type === "image") {
const data = typeof b.data === "string" ? b.data
: b.source?.type === "base64" ? b.source.data : null;
const pad = data?.endsWith("==") ? 2 : data?.endsWith("=") ? 1 : 0;
bytes += data ? Math.floor((data.length * 3) / 4) - pad : 0;
stripped++;
content.push({ type: "text", text: "[图片已由 cleanup-cache 替换为占位,原始数据已移入回收目录]" });
} else content.push(b);
}
e.message.content = content;
out.push(JSON.stringify(e));
continue;
}
} catch { /* keep as-is */ }
out.push(line);
}
if (stripped > 0) {
fs.writeFileSync(file, out.join("\n"));
}
return { stripped, bytes };
}
function listSessionFiles() {
const files = [];
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.name === "subagent-artifacts") continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith(".jsonl")) files.push(p);
}
};
walk(SESSIONS_DIR);
return files;
}
function sessionInfo(file) {
const st = fs.statSync(file);
const { n, bytes } = countImages(file);
return { file, size: st.size, mtime: st.mtimeMs, images: n, imgBytes: bytes };
}
// ---------- 1. sessions / strip-images ----------
if (want("sessions") || want("strip-images")) {
console.log(`[会话] 目录 ${SESSIONS_DIR}`);
const infos = listSessionFiles().map(sessionInfo).sort((a, b) => b.mtime - a.mtime);
if (infos.length === 0) { console.log(" (无会话文件)"); }
const cutoff = CUTOFF_DAYS();
const keepIdx = Math.min(opt.keep, infos.length);
const doomed = infos.filter((s, i) => s.mtime < cutoff && i >= keepIdx);
if (want("sessions")) {
for (const s of doomed) {
planned(s.file, s.size, s.images > 0 ? `旧会话(含 ${s.images} 图)` : "旧会话");
}
if (doomed.length === 0) console.log(` (最近 ${opt.days} 天内无会话需要移走)`);
}
if (want("strip-images")) {
for (const s of doomed) {
if (s.images === 0) continue;
note(`- 图片瘦身: ${path.basename(s.file)} (${s.images} 图, ~${fmtSize(s.imgBytes)} 将替换为占位)`);
freed += s.imgBytes;
}
const recent = infos.filter((s, i) => !(s.mtime < cutoff && i >= keepIdx));
for (const s of recent) {
if (s.images === 0) continue;
note(`- 图片瘦身(最近窗口, 可选): ${path.basename(s.file)} (${s.images} 图, ~${fmtSize(s.imgBytes)})`);
}
}
console.log();
}
// ---------- 2. npm cache ----------
if (want("npm")) {
const sz = dirSize(NPM_CACHE);
if (sz > 0) planned(NPM_CACHE, sz, "npm 缓存(_cacache/_npx/_logs)");
else console.log("[npm] 缓存已空");
}
// ---------- 3. opengrep ----------
if (want("opengrep")) {
const sz = dirSize(OPENGREP);
if (sz > 0) planned(OPENGREP, sz, "opengrep 语义扫描缓存");
else console.log("[opengrep] 缓存已空");
}
// ---------- 4. artifacts ----------
if (want("artifacts")) {
const dirs = [];
const findArtifactDirs = (dir) => {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === "subagent-artifacts") dirs.push(p);
else findArtifactDirs(p);
}
}
};
findArtifactDirs(SESSIONS_DIR);
let cnt = 0;
let sz = 0;
const files = [];
for (const d of dirs) {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
const st = fs.statSync(p);
if (st.mtimeMs < CUTOFF_DAYS()) { cnt++; sz += st.size; files.push(p); }
}
}
if (cnt > 0) { note(`- ${cnt} 个旧子代理产物文件 (~${fmtSize(sz)})`); for (const f of files) planned(f, fs.statSync(f).size, "旧子代理产物"); }
else console.log("[artifacts] 无旧产物");
}
// ---------- 汇总 ----------
console.log(`\n合计可释放: ${fmtSize(freed)}`);
if (!opt.apply) {
console.log("\n以上为预览。加 --apply 真正执行(删除前先移入回收目录)。");
process.exit(0);
}
if (!opt.yes) {
console.log(`\n确认执行 ${actions.length} 项清理?输入 yes 继续:`);
const readline = (await import("node:readline")).default;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ans = await new Promise((r) => rl.question("> ", r));
rl.close();
if (ans.trim().toLowerCase() !== "yes") { console.log("已取消。"); process.exit(1); }
}
for (const a of actions) {
try {
if (a.file.includes("opengrep") || a.file === NPM_CACHE) {
fs.rmSync(a.file, { recursive: true, force: true });
console.log(`已删除 ${a.file}`);
} else {
const dest = moveToTrash(a.file);
console.log(`已移入回收目录 ${dest}`);
}
} catch (err) {
console.error(`失败: ${a.file}: ${err.message}`);
}
}
if (want("strip-images")) {
const infos = listSessionFiles().map(sessionInfo).sort((a, b) => b.mtime - a.mtime);
const keepIdx = Math.min(opt.keep, infos.length);
const cutoff = CUTOFF_DAYS();
for (const s of infos) {
if (s.images === 0) continue;
const i = infos.indexOf(s);
if (!(s.mtime < cutoff && i >= keepIdx)) continue;
const backup = moveToTrash(s.file);
const { stripped, bytes } = stripImagesInPlace(s.file);
console.log(`图片瘦身完成: ${path.basename(s.file)} (${stripped} 图, ~${fmtSize(bytes)}, 原备份 ${backup})`);
}
}
if (opt.purge) {
const purged = [];
const old = Date.now() - 30 * 86400_000;
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
const st = fs.statSync(p);
if (st.mtimeMs < old) { fs.rmSync(p, { recursive: true, force: true }); purged.push(p); }
else walk(p);
}
}
};
walk(TRASH_ROOT);
console.log(purged.length ? `回收目录已清旧备份: ${purged.join(", ")}` : "回收目录无 30 天前备份");
}
console.log(`\n完成。释放 ${fmtSize(freed)}。注意:当前正在使用的会话若被移走会丢失历史——建议先退出 pi 再执行。`);