promptfigure 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +95 -0
  2. package/adapters/claude-code/SKILL.md +381 -0
  3. package/adapters/claude-code/install.mjs +9 -0
  4. package/adapters/codex/marketplace.json +14 -0
  5. package/adapters/codex/promptfigure/.codex-plugin/plugin.json +6 -0
  6. package/adapters/codex/promptfigure/skills/promptfigure-local/SKILL.md +381 -0
  7. package/bin/pf.mjs +1953 -0
  8. package/package.json +44 -0
  9. package/scripts/build-adapters.mjs +80 -0
  10. package/scripts/test-e2e.mjs +117 -0
  11. package/skill/promptfigure-local/SKILL.md +381 -0
  12. package/src/anchor.mjs +63 -0
  13. package/src/config.mjs +75 -0
  14. package/src/craft-rules.mjs +158 -0
  15. package/src/craft.mjs +600 -0
  16. package/src/doc/docx.mjs +69 -0
  17. package/src/doc/index.mjs +35 -0
  18. package/src/doc/para.mjs +54 -0
  19. package/src/doc/tex.mjs +161 -0
  20. package/src/doc/texbuild.mjs +158 -0
  21. package/src/docsearch.mjs +96 -0
  22. package/src/entity-pair.mjs +17 -0
  23. package/src/events.mjs +52 -0
  24. package/src/extract.mjs +124 -0
  25. package/src/figure-catalog.mjs +326 -0
  26. package/src/journal.mjs +67 -0
  27. package/src/ledger.mjs +43 -0
  28. package/src/next.mjs +125 -0
  29. package/src/plan.mjs +112 -0
  30. package/src/png-trim.mjs +217 -0
  31. package/src/quality.mjs +325 -0
  32. package/src/ratio.mjs +87 -0
  33. package/src/render.mjs +247 -0
  34. package/src/review.mjs +48 -0
  35. package/src/server.mjs +218 -0
  36. package/src/store.mjs +91 -0
  37. package/src/vectorize.mjs +54 -0
  38. package/tray/pf-tray.py +265 -0
  39. package/web/app.js +613 -0
  40. package/web/index.html +73 -0
  41. package/web/probe.html +36 -0
  42. package/web/style.css +208 -0
package/src/render.mjs ADDED
@@ -0,0 +1,247 @@
1
+ // render.mjs — 调我们服务器出图(唯一外呼:promptfigure.top)
2
+ //
3
+ // 链路:POST /api/v1/generate(额度通道)→ 402 quota_exhausted → 自动改走
4
+ // /api/v1/generate/balance(余额通道,官网已写明此规则)→ b64_json 存本地。
5
+ // 🔴 polish:false —— 本地 AI 写的提示词直接出图,服务端不润色
6
+ // 🔴 premium 代码级门禁 —— 未 approved 的图禁止出 premium(不靠提示词自觉)
7
+ // 🔴 计费通道切换必须留痕(事件流 billing.fallback)
8
+ import { loadConfig, maskKey } from "./config.mjs";
9
+ import { readMeta, writeMeta, saveFigureFile, resolveDoc, readAnchors, readReview } from "./store.mjs";
10
+ import { appendEvent } from "./events.mjs";
11
+ import { resolveRef } from "./doc/para.mjs";
12
+ import { isApproved } from "./review.mjs";
13
+ import { setAnchor } from "./anchor.mjs";
14
+ import { pngDims, ratioMismatch } from "./ratio.mjs";
15
+ import { trimWhitespace, pngStats } from "./png-trim.mjs";
16
+
17
+ const BASE = "https://promptfigure.top";
18
+ const GEN_URL = `${BASE}/api/v1/generate`;
19
+ const GEN_BALANCE_URL = `${BASE}/api/v1/generate/balance`;
20
+
21
+ async function callGenerate(url, key, body) {
22
+ const resp = await fetch(url, {
23
+ method: "POST",
24
+ headers: {
25
+ Authorization: `Bearer ${key}`,
26
+ "Content-Type": "application/json",
27
+ // 🔴 不带 UA 会被边缘 WAF 当脚本质询(返回 HTML 200,JSON 解析就炸了)
28
+ "User-Agent": "promptfigure-local-plugin/0.1",
29
+ Accept: "application/json",
30
+ },
31
+ body: JSON.stringify(body),
32
+ });
33
+ let data = null;
34
+ let rawText = "";
35
+ try {
36
+ rawText = await resp.text();
37
+ data = JSON.parse(rawText);
38
+ } catch {
39
+ // 非 JSON(WAF 质询页 / 网关错误页)→ 给出可诊断的短摘要
40
+ return { status: resp.status, data: { error: "non_json_response", detail: rawText.slice(0, 160).replace(/<[^>]+>/g, " ").trim() } };
41
+ }
42
+ return { status: resp.status, ok: resp.ok, data };
43
+ }
44
+
45
+ export async function renderFigure({ docId, prompt, model = "standard", at, side = "after", ratio, force = false, forceReason = null, sidecar = null }) {
46
+ const cfg = loadConfig();
47
+ if (!cfg.key) {
48
+ throw new Error("还没有配置 pf_ API key。先运行 pf login(控制台创建 key 后:pf login pf_xxxxx)");
49
+ }
50
+ if (!prompt || !prompt.trim()) throw new Error("提示词不能为空(--prompt 或 --prompt-file)");
51
+ if (!at) throw new Error("必须显式声明锚点位置 --at \"§3.2 ¶2\" —— 插件不猜位置");
52
+ if (!["standard", "premium"].includes(model)) throw new Error(`未知档位 ${model}(standard | premium)`);
53
+
54
+ const dir = resolveDoc(docId);
55
+ docId = dir.split(/[\\/]/).pop();
56
+ const meta = readMeta(docId);
57
+ resolveRef(meta.blocks, at); // 校验位置存在
58
+
59
+ const anchors = readAnchors(docId);
60
+ let anchor = anchors.find((a) => a.placement.at === at && a.placement.side === side);
61
+ if (!anchor) {
62
+ // 🔴 自愈建锚(2026-09-21 实测):render 只找不建时,新位置出图 = 无锚孤儿图,
63
+ // GUI 永远不显示、无高亮、无内联卡片 —— AI 照 help 走 craft→render 必踩。
64
+ const { anchor: created } = setAnchor(docId, { at, side });
65
+ anchor = created;
66
+ anchors.push(anchor);
67
+ }
68
+
69
+ // ---- 红线 1:standard 草稿数量上限 ----
70
+ const limits = cfg.limits || {};
71
+ const perDoc = limits.perDocStandard ?? 60;
72
+ const figCount = Object.keys(meta.figures || {}).length;
73
+ if (model === "standard" && figCount >= perDoc) {
74
+ throw new Error(`已达本文 standard 出图上限(${perDoc} 张,可用 pf config limits.perDocStandard 调整)。先整理图库再继续`);
75
+ }
76
+
77
+ // ---- 红线 2:premium 门禁(代码级,不靠提示词) ----
78
+ // --force 豁免(2026-09-21 定规):多轮精修循环会死锁——修正轮要真实渲染视觉缺陷,
79
+ // 但缺陷只有 premium 渲得出(standard 渲文字必乱码),而门禁要求先审批通过、审批要求零缺陷。
80
+ // force = 显式声明"这轮是已授权的修正迭代",事件留痕 figure.gate_bypass 可审计,默认仍锁死。
81
+ const figures = meta.figures || {};
82
+ const prevFig = anchor?.figure ? figures[anchor.figure] : null;
83
+ if (model === "premium" && !force) {
84
+ if (!prevFig) throw new Error(`premium 门禁拒绝:位置 ${at} 还没有 standard 草稿。下一步: pf craft … --out p.txt 然后 pf render --at "${at}" --model standard --prompt-file p.txt(修正迭代轮可用 --force "理由" 豁免,事件留痕)`);
85
+ if (!isApproved(docId, prevFig.figureId)) {
86
+ throw new Error(`premium 门禁拒绝:图 ${prevFig.figureId} 尚未审批通过。下一步: pf review ai ${prevFig.figureId}(AI 核验)→ pf review resolve ${prevFig.figureId} --approve;或在 GUI 审批队列点「通过」(修正迭代轮可用 --force "理由" 豁免,事件留痕)`);
87
+ }
88
+ }
89
+ if (model === "premium" && force && prevFig) {
90
+ appendEvent(docId, "figure.gate_bypass", {
91
+ figure: prevFig.figureId, version: (prevFig.versions?.length || 0) + 1,
92
+ reason: forceReason || "(无理由)",
93
+ reviewStatus: (readReview(docId)[prevFig.figureId] || {}).status || "unknown", at: new Date().toISOString(),
94
+ });
95
+ } else if (force && !(model === "premium" && prevFig)) {
96
+ // 非 premium 门的 force(如字节预算绕门)同样留痕——凡是绕门都要能在 pf audit 里查到
97
+ appendEvent(docId, "render.forced", { at, model, reason: forceReason || "(无理由)", ts: new Date().toISOString() });
98
+ }
99
+
100
+ // ---- 出图(402 自动换余额通道) ----
101
+ const body = { prompt: prompt.trim(), model, polish: false };
102
+ if (ratio) body.ratio = ratio;
103
+ let channel = "quota";
104
+ let resp = await callGenerate(GEN_URL, cfg.key, body);
105
+ if (resp.status === 402 && resp.data?.error === "quota_exhausted") {
106
+ channel = "balance";
107
+ appendEvent(docId, "billing.fallback", { from: "quota", to: "balance", model });
108
+ resp = await callGenerate(GEN_BALANCE_URL, cfg.key, body);
109
+ }
110
+ if (resp.status === 429) throw new Error(`触发 RPM 限流(${resp.data?.limit ?? "?"}/分钟),稍等一分钟再试`);
111
+ if (!resp.ok || !resp.data?.b64_json) {
112
+ let detail = resp.data?.detail || resp.data?.error || `HTTP ${resp.status}`;
113
+ const refunded = resp.data?.refunded ? "(已自动退款/还原额度)" : "";
114
+ // 弱模型实测(weak-agent-sim 2026-09-20):invalid_api_key 时弱 AI 卡死在这,不知道下一步
115
+ if (/invalid[_ ]api[_ ]key/i.test(String(detail))) {
116
+ detail += " —— pf_ key 无效或已被覆盖。让用户执行 pf login <pf_key> 重新配置(控制台 promptfigure.top 可建 key)";
117
+ }
118
+ throw new Error(`出图失败:${detail}${refunded}`);
119
+ }
120
+
121
+ // ---- 落盘:同锚点追加版本,否则新图 ----
122
+ const now = new Date();
123
+ const stamp = now.toISOString().replace(/[-:T]/g, "").slice(0, 14);
124
+ let figureId;
125
+ if (prevFig) {
126
+ figureId = prevFig.figureId;
127
+ } else {
128
+ figureId = `fig_${stamp}_${Math.random().toString(36).slice(2, 6)}`;
129
+ }
130
+ const verNo = prevFig ? prevFig.versions.length + 1 : 1;
131
+ const file = `v${verNo}.png`;
132
+ let imgBuf = Buffer.from(resp.data.b64_json, "base64");
133
+ // ---- 画布比核验(2026-09-21 V10 事故补的闭环):对**裁剪前**像素算 —— 白边裁剪是预期行为,
134
+ // 裁后比例对不上请求比不是事故(V14 后:2560x685 vs 16:9 是正常产出)----
135
+ const preTrimDims = pngDims(imgBuf);
136
+ const ratioIssue = body.ratio ? ratioMismatch(body.ratio, preTrimDims) : null;
137
+ // ---- 自动体检(2026-09-21 定规:核验数字由插件算,AI 只做语义判断)----
138
+ // 对**裁前**图统计:留白占比(模型原始行为,对账用)/ 边缘白度 / 彩色像素占比
139
+ const autoChecks = pngStats(imgBuf);
140
+ // ---- 白边自动裁剪(2026-09-21 V14 复盘:画布句"撑满高度"模型不听——上下空白 >50%)----
141
+ // 措辞路线到头了,留白改由确定性后处理保证:留白面积 >30% 就裁到内容包围盒+窄边距。
142
+ // 任何异常 png-trim 内部吞掉返回 null(保留原图,宁可多留白不可损坏图)。
143
+ const trimmed = trimWhitespace(imgBuf);
144
+ let trimInfo = null;
145
+ if (trimmed) {
146
+ imgBuf = trimmed.buf;
147
+ trimInfo = trimmed;
148
+ }
149
+ const rel = saveFigureFile(docId, figureId, file, imgBuf);
150
+ const dims = pngDims(imgBuf);
151
+
152
+ if (trimInfo) {
153
+ appendEvent(docId, "figure.trimmed", {
154
+ figure: figureId, version: verNo,
155
+ from: `${trimInfo.from.w}x${trimInfo.from.h}`,
156
+ to: `${trimInfo.to.w}x${trimInfo.to.h}`,
157
+ savedPct: trimInfo.savedPct, at: now.toISOString(),
158
+ });
159
+ }
160
+
161
+ const version = {
162
+ file: rel,
163
+ model,
164
+ status: model === "premium" ? "final" : "draft",
165
+ channel,
166
+ promptSha: Buffer.from(prompt).toString("base64").slice(0, 24),
167
+ prompt,
168
+ // 🔴 画布比留痕(V10 事故:之前 ratio 参数完全不落盘,出了压缩图无从对账)
169
+ ratio: body.ratio || null,
170
+ pixels: dims ? `${dims.w}x${dims.h}` : null,
171
+ // 白边裁剪留痕(V14 事故):裁剪前的原始像素,便于对账
172
+ trimmedFrom: trimInfo ? `${trimInfo.from.w}x${trimInfo.from.h}` : null,
173
+ // 🔴 自动体检结果(2026-09-21):裁前量化数字,AI 核验以此为底数,不许凭感觉
174
+ autoChecks,
175
+ // craft 伴随 JSON 路径(pf refine 重拼下一轮命令的依据)
176
+ sidecar: sidecar || null,
177
+ at: now.toISOString(),
178
+ };
179
+ meta.figures = meta.figures || {};
180
+ if (prevFig) {
181
+ prevFig.versions.push(version);
182
+ // 🔴 新版本 = 送审对象变了(2026-09-21 "别放水"定规):
183
+ // ① 旧 QA 核验记录作废(审批门禁按最新版对账,不清会把旧版 PASS 错套在新版上);
184
+ // ② 审批状态重置回 pending —— premium 定稿也要重新核验,不许"标准版过了定稿自动过";
185
+ // ③ activeFixes 清空(2026-09-21 子智能体实测:全插件没人清它 → pf next 永久死锁在
186
+ // refine,连真实渲染后也出不去)。新版本就是修复尝试的产物,缺陷是否真修好由
187
+ // QA 重新核验裁决:pass 收工 / fail 会把缺陷重新写回状态机,闭环不丢。
188
+ delete prevFig.qa;
189
+ prevFig.activeFixes = [];
190
+ } else {
191
+ meta.figures[figureId] = { figureId, at, side, anchorId: anchor?.id || null, versions: [version] };
192
+ if (anchor) anchor.figure = figureId;
193
+ }
194
+ writeMeta(docId, meta);
195
+ // 新版本审批状态重置(pending):写回要在 writeMeta 之后,setStatus 自己读写 review.json 不冲突
196
+ if (prevFig) {
197
+ const { setStatus } = await import("./review.mjs");
198
+ try {
199
+ const prevStatus = readReview(docId)[figureId]?.status;
200
+ if (prevStatus && prevStatus !== "pending") {
201
+ setStatus(docId, figureId, "pending", `v${verNo} 新版本落盘,旧核验作废,重新送审`, "system");
202
+ }
203
+ } catch { /* 审批状态重置失败不阻塞出图 */ }
204
+ }
205
+ if (anchor) {
206
+ const idx = anchors.findIndex((a) => a.id === anchor.id);
207
+ anchors[idx] = anchor;
208
+ // 写回 anchors.json
209
+ const { writeAnchors } = await import("./store.mjs");
210
+ writeAnchors(docId, anchors);
211
+ }
212
+
213
+ appendEvent(docId, "figure.created", {
214
+ figure: figureId, version: verNo, model, at, side, channel,
215
+ ratio: body.ratio || null, pixels: version.pixels,
216
+ quota: resp.data.quota, balance: resp.data.balance,
217
+ });
218
+ if (ratioIssue) {
219
+ appendEvent(docId, "figure.ratio_mismatch", {
220
+ figure: figureId, version: verNo, requested: body.ratio, ...ratioIssue,
221
+ });
222
+ }
223
+
224
+ const notes = [];
225
+ if (model === "standard") notes.push("草稿已入库,等用户在 GUI 审批;通过后才可出 premium 定稿");
226
+ else notes.push("定稿已生成,高亮会转为绿色");
227
+ if (ratioIssue) {
228
+ notes.push(`⚠️ 画布比不符:请求 ${body.ratio},实测 ${version.pixels}(偏差 ${ratioIssue.dev}%)—— 宽幅内容可能被压缩/裁切,读图核验后决定是否重出`);
229
+ }
230
+
231
+ return {
232
+ figureId, version: verNo, file: rel, model, channel,
233
+ ratio: body.ratio || null, pixels: version.pixels, ratioIssue,
234
+ quota: resp.data.quota, balance: resp.data.balance,
235
+ keyMasked: maskKey(cfg.key),
236
+ note: notes.join(";"),
237
+ };
238
+ }
239
+
240
+ export function listFigures({ docId, at }) {
241
+ const dir = resolveDoc(docId);
242
+ docId = dir.split(/[\\/]/).pop();
243
+ const meta = readMeta(docId);
244
+ let figs = Object.values(meta.figures || {});
245
+ if (at) figs = figs.filter((f) => f.at === at);
246
+ return figs;
247
+ }
package/src/review.mjs ADDED
@@ -0,0 +1,48 @@
1
+ // review.mjs — 审批状态机:pending → approved | rejected
2
+ // 两个入口写同一份状态:GUI(会话令牌)/ CLI(pf review resolve)
3
+ // AI 每轮开工先 pf review status —— 写进 skill 的硬规矩
4
+ import { readReview, writeReview, readMeta } from "./store.mjs";
5
+ import { appendEvent, readEvents } from "./events.mjs";
6
+
7
+ export function latestVersion(fig) {
8
+ return fig.versions[fig.versions.length - 1];
9
+ }
10
+
11
+ export function setStatus(docId, figureId, status, note, by) {
12
+ const meta = readMeta(docId);
13
+ if (!meta || !meta.figures?.[figureId]) throw new Error(`图 ${figureId} 不存在`);
14
+ if (!["approved", "rejected", "pending"].includes(status)) throw new Error(`非法状态 ${status}`);
15
+ const review = readReview(docId);
16
+ const prev = review[figureId];
17
+ review[figureId] = { status, note: note || "", by, at: new Date().toISOString(), prevStatus: prev?.status || null };
18
+ writeReview(docId, review);
19
+ appendEvent(docId, "review." + (status === "approved" ? "approved" : status === "rejected" ? "rejected" : "reset"), {
20
+ figure: figureId, status, note, by,
21
+ });
22
+ return review[figureId];
23
+ }
24
+
25
+ export function reviewStatus(docId) {
26
+ const meta = readMeta(docId);
27
+ if (!meta) throw new Error(`项目 ${docId} 不存在`);
28
+ const review = readReview(docId);
29
+ const figures = meta.figures || {};
30
+ const items = Object.entries(figures).map(([figureId, fig]) => ({
31
+ figureId,
32
+ at: fig.at,
33
+ side: fig.side,
34
+ versions: fig.versions,
35
+ review: review[figureId] || { status: "pending", note: "", by: null },
36
+ }));
37
+ return {
38
+ pending: items.filter((x) => x.review.status === "pending"),
39
+ approved: items.filter((x) => x.review.status === "approved"),
40
+ rejected: items.filter((x) => x.review.status === "rejected"),
41
+ history: readEvents(docId, 500).filter((e) => e.type.startsWith("review.")),
42
+ };
43
+ }
44
+
45
+ // premium 门禁:该图最近一版必须已 approved
46
+ export function isApproved(docId, figureId) {
47
+ return readReview(docId)?.[figureId]?.status === "approved";
48
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,218 @@
1
+ // server.mjs — 本地服务:唯一状态源
2
+ // 🔴 只绑 127.0.0.1,绝不 0.0.0.0
3
+ // 🔴 写操作双令牌:GUI 走会话令牌(开窗口时注入 URL),CLI 走 daemon.json 里的 cliToken
4
+ // 🔴 唯一外呼是出图(render.mjs 里);本服务自己不向任何外部地址发数据
5
+ import http from "node:http";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import crypto from "node:crypto";
9
+ import url from "node:url";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import { readDaemon, writeDaemon, clearDaemon, ensureDirs } from "./config.mjs";
13
+ import { projectByDocId, readMeta, resolveDoc, readReview } from "./store.mjs";
14
+ import { sseHandler, appendEvent, readEvents } from "./events.mjs";
15
+ import { setAnchor, listAnchors } from "./anchor.mjs";
16
+ import { renderFigure, listFigures } from "./render.mjs";
17
+ import { reviewStatus, setStatus } from "./review.mjs";
18
+ import { resolveRef } from "./doc/para.mjs";
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const PKG_ROOT = path.resolve(__dirname, "..");
22
+ const WEB_DIR = path.join(PKG_ROOT, "web");
23
+ const VENDOR = {
24
+ "/vendor/jszip.min.js": "jszip/dist/jszip.min.js",
25
+ "/vendor/docx-preview.min.js": "docx-preview/dist/docx-preview.min.js",
26
+ "/vendor/pdf.min.js": "pdfjs-dist/legacy/build/pdf.min.js",
27
+ "/vendor/pdf.worker.min.js": "pdfjs-dist/legacy/build/pdf.worker.min.js",
28
+ };
29
+
30
+ const MIME = {
31
+ ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css",
32
+ ".png": "image/png", ".json": "application/json", ".map": "application/json",
33
+ ".pdf": "application/pdf",
34
+ };
35
+
36
+ function json(res, code, data) {
37
+ res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
38
+ res.end(JSON.stringify(data));
39
+ }
40
+
41
+ function sendFile(res, file) {
42
+ try {
43
+ const buf = fs.readFileSync(file);
44
+ res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream", "Cache-Control": "no-store" });
45
+ res.end(buf);
46
+ } catch {
47
+ res.writeHead(404); res.end("not found");
48
+ }
49
+ }
50
+
51
+ export function startServer({ port, registerDaemon = true }) {
52
+ ensureDirs();
53
+ const guiToken = crypto.randomBytes(16).toString("hex");
54
+ const cliToken = crypto.randomBytes(16).toString("hex");
55
+
56
+ const server = http.createServer(async (req, res) => {
57
+ const u = new URL(req.url, "http://127.0.0.1");
58
+ const p = u.pathname;
59
+
60
+ try {
61
+ // ---- 静态 ----
62
+ if (p === "/" || p === "/index.html") return sendFile(res, path.join(WEB_DIR, "index.html"));
63
+ if (p === "/app.js") return sendFile(res, path.join(WEB_DIR, "app.js"));
64
+ if (p === "/style.css") return sendFile(res, path.join(WEB_DIR, "style.css"));
65
+ if (p === "/probe.html" && fs.existsSync(path.join(WEB_DIR, "probe.html"))) {
66
+ return sendFile(res, path.join(WEB_DIR, "probe.html"));
67
+ }
68
+ if (VENDOR[p]) {
69
+ const file = path.join(PKG_ROOT, "node_modules", VENDOR[p]);
70
+ return sendFile(res, file);
71
+ }
72
+
73
+ // ---- 图文件(figures/<figureId>/<v>.png)----
74
+ const figM = /^\/fig\/([\w-]+)\/([\w.-]+)$/.exec(p);
75
+ if (figM) {
76
+ const doc = u.searchParams.get("doc");
77
+ const dir = projectByDocId(doc);
78
+ return sendFile(res, path.join(dir, "figures", figM[1], figM[2]));
79
+ }
80
+
81
+ // ---- 原始文档(给浏览器渲染 docx/pdf 用;只读)----
82
+ // tex 文档:若编译成功,返回编译产物 PDF(GUI 渲染排版结果而非源码文本)
83
+ if (p === "/api/file") {
84
+ const doc = u.searchParams.get("doc");
85
+ const meta = readMeta(doc);
86
+ if (!meta) return json(res, 404, { error: "doc not found" });
87
+ if (meta.kind === "tex") {
88
+ const pdf = path.join(projectByDocId(doc), path.basename(meta.source).replace(/\.tex$/i, "") + ".pdf");
89
+ if (fs.existsSync(pdf)) return sendFile(res, pdf, "application/pdf");
90
+ return json(res, 404, { error: "pdf not compiled(pf doc compile 可重编译)" });
91
+ }
92
+ return sendFile(res, meta.source);
93
+ }
94
+
95
+ // ---- SSE ----
96
+ if (p === "/api/events") {
97
+ const doc = u.searchParams.get("doc");
98
+ return sseHandler(doc, req, res);
99
+ }
100
+
101
+ // ---- 全量状态 ----
102
+ if (p === "/api/state") {
103
+ const doc = u.searchParams.get("doc");
104
+ const meta = readMeta(doc);
105
+ if (!meta) return json(res, 404, { error: "doc not found" });
106
+ return json(res, 200, {
107
+ meta: {
108
+ docId: doc, fileName: meta.fileName, kind: meta.kind,
109
+ outline: meta.outline || [], blocks: meta.blocks || [],
110
+ pdfReady: !!meta.pdfReady, pdfEngine: meta.pdfEngine, pdfFailReason: meta.pdfFailReason,
111
+ },
112
+ anchors: listAnchors(doc),
113
+ figures: meta.figures || {},
114
+ review: readReview(doc),
115
+ events: readEvents(doc, 100),
116
+ });
117
+ }
118
+
119
+ // ---- GUI 审批(唯一写操作,需会话令牌)----
120
+ if (p === "/api/review" && req.method === "POST") {
121
+ const body = await readBody(req);
122
+ if (body.token !== guiToken) return json(res, 403, { error: "bad session token" });
123
+ const doc = u.searchParams.get("doc");
124
+ const r = setStatus(doc, body.figureId, body.status, body.note, "user");
125
+ return json(res, 200, { ok: true, review: r });
126
+ }
127
+
128
+ // ---- CLI 通道 ----
129
+ if (p === "/api/cli" && req.method === "POST") {
130
+ if (req.headers["x-pf-cli-token"] !== cliToken) return json(res, 403, { error: "bad cli token" });
131
+ const body = await readBody(req);
132
+ const doc = body.doc || u.searchParams.get("doc");
133
+ const out = await cliAction(doc, body.action, body.params || {});
134
+ return json(res, 200, { ok: true, result: out });
135
+ }
136
+
137
+ res.writeHead(404); res.end("not found");
138
+ } catch (err) {
139
+ json(res, 400, { error: String(err.message || err) });
140
+ }
141
+ });
142
+
143
+ return new Promise((resolve, reject) => {
144
+ server.on("error", (e) => {
145
+ if (e.code === "EADDRINUSE") reject(new Error(`端口 ${port} 已被占用`));
146
+ else reject(e);
147
+ });
148
+ server.listen(port, "127.0.0.1", () => {
149
+ // registerDaemon:false 供测试用 —— 测试 daemon 不写 daemon.json,
150
+ // 否则会把真实托盘守护的服务记录顶掉(2026-09-21 实测反复污染)
151
+ if (registerDaemon) {
152
+ writeDaemon({ port, guiToken, cliToken, pid: process.pid, startedAt: new Date().toISOString() });
153
+ }
154
+ resolve({ server, port, guiToken, cliToken });
155
+ });
156
+ });
157
+ }
158
+
159
+ function readBody(req) {
160
+ return new Promise((resolve, reject) => {
161
+ let raw = "";
162
+ req.on("data", (c) => (raw += c));
163
+ req.on("end", () => {
164
+ try { resolve(raw ? JSON.parse(raw) : {}); } catch (e) { reject(new Error("bad json body")); }
165
+ });
166
+ });
167
+ }
168
+
169
+ async function cliAction(doc, action, params) {
170
+ switch (action) {
171
+ case "anchor.set": {
172
+ const r = setAnchor(doc, params);
173
+ appendEvent(doc, "anchor.set", { at: params.at, side: params.side, reused: r.reused, quoteOk: r.quoteOk });
174
+ // 🔴 2026-09-21 红队实测:只返回 r.anchor 会丢 reused/quoteOk——CLI 的复用标记与
175
+ // 编造引文警告全部哑火(锚点 state 落了 changed 但用户/AI 看不到任何警告)。
176
+ return { ...r.anchor, reused: r.reused, quoteOk: r.quoteOk };
177
+ }
178
+ case "anchor.list":
179
+ return listAnchors(doc, { changedOnly: !!params.changed });
180
+ case "render":
181
+ return renderFigure({ docId: doc, ...params });
182
+ case "render.list":
183
+ return listFigures({ docId: doc, at: params.at });
184
+ case "review.status":
185
+ return reviewStatus(doc);
186
+ case "review.resolve": {
187
+ const status = params.approve ? "approved" : "rejected";
188
+ return setStatus(doc, params.figureId, status, params.note, "ai");
189
+ }
190
+ case "doc.read": {
191
+ const meta = readMeta(doc);
192
+ const target = resolveRef(meta.blocks, params.at);
193
+ return target;
194
+ }
195
+ case "doc.context": {
196
+ // 上下文蒸馏原料:整节段落文本(实体/结构/图种三清单由 AI 自己产出 —— skill 教)
197
+ const meta = readMeta(doc);
198
+ const { parseRef } = await import("./doc/para.mjs");
199
+ const { secPath } = parseRef(params.at);
200
+ const paras = meta.blocks.filter((b) => b.type === "para" && b.secPath === secPath).map((b) => b.text);
201
+ // 🔴 红队实测(2026-09-23 fuzz):§99 等不存在章节静默返回 0 字 = AI 拿空上下文去编素材
202
+ if (!paras.length) throw new Error(`章节 §${secPath} 不存在或没有正文段落(--at: ${params.at})——用 pf doc outline 看有效章节,或 pf doc search 定位关键词`);
203
+ return { section: secPath, paragraphs: paras, chars: paras.join("").length };
204
+ }
205
+ default:
206
+ throw new Error(`未知 action: ${action}`);
207
+ }
208
+ }
209
+
210
+ // 独立进程入口:pf serve-daemon
211
+ if (process.argv[1] && process.argv[1].endsWith("server.mjs")) {
212
+ const { loadConfig } = await import("./config.mjs");
213
+ const cfg = loadConfig();
214
+ const port = Number(process.argv[2]) || cfg.port || (await import("./config.mjs")).DEFAULT_PORT;
215
+ startServer({ port })
216
+ .then(({ port }) => console.log(`[pf] daemon on 127.0.0.1:${port}`))
217
+ .catch((e) => { console.error("[pf] daemon 启动失败:", e.message); process.exit(1); });
218
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,91 @@
1
+ // store.mjs — 项目磁盘布局:~/.promptfigure/projects/<docId>/
2
+ // meta.json(文档+图) / anchors.json / review.json / events.jsonl / figures/
3
+ // 🔴 插件唯一允许写文件的地方就是 ~/.promptfigure/ —— 永不触碰用户文件
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { PROJECTS_DIR, docIdOf, ensureDirs } from "./config.mjs";
7
+
8
+ export function projectDir(docId) {
9
+ // 弱模型实测修复(weak-agent-sim 2026-09-20):projectDir 与 projectByDocId 必须同一套
10
+ // null 回退逻辑 —— writeAnchors/appendEvent 走 projectDir,之前 null 时照样炸 "path null"
11
+ const dir = projectByDocId(docId);
12
+ fs.mkdirSync(path.join(dir, "figures"), { recursive: true });
13
+ return dir;
14
+ }
15
+
16
+ export function projectByDocId(docId) {
17
+ // 弱模型实测(scripts/weak-agent-sim.mjs 2026-09-20)修复:
18
+ // 之前 path.join(PROJECTS_DIR, null) 直接 TypeError "path null"——
19
+ // 所有不带 --doc 的调用(review status / review.resolve / doc.read / events)全部炸出不可读错误。
20
+ // 现在 null/undefined 回退最近打开的项目,没有项目时报人话。
21
+ if (!docId) {
22
+ const latest = latestProject();
23
+ if (!latest) throw new Error("还没有任何项目,先 pf open <论文路径> 打开文档");
24
+ return latest;
25
+ }
26
+ return path.join(PROJECTS_DIR, docId);
27
+ }
28
+
29
+ export function latestProject() {
30
+ ensureDirs();
31
+ // 🔴 显式 lastDocId 优先(pf open 时写入 config):mtime 排序会被 e2e 测试等
32
+ // 新建项目污染——测试一跑,宿主 AI 不带 --doc 的命令就全落到测试文档上(2026-09-21 实测)
33
+ try {
34
+ const cfg = JSON.parse(fs.readFileSync(path.join(path.dirname(PROJECTS_DIR), "config.json"), "utf8"));
35
+ if (cfg.lastDocId) {
36
+ const dir = path.join(PROJECTS_DIR, cfg.lastDocId);
37
+ if (fs.existsSync(path.join(dir, "meta.json"))) return dir;
38
+ }
39
+ } catch {}
40
+ const dirs = fs.readdirSync(PROJECTS_DIR)
41
+ .map((d) => path.join(PROJECTS_DIR, d))
42
+ .filter((d) => fs.existsSync(path.join(d, "meta.json")))
43
+ .sort((a, b) => fs.statSync(path.join(b, "meta.json")).mtimeMs - fs.statSync(path.join(a, "meta.json")).mtimeMs);
44
+ return dirs[0] || null;
45
+ }
46
+
47
+ // 解析 --doc 参数:缺省时取最近打开的项目
48
+ export function resolveDoc(docId) {
49
+ if (docId) {
50
+ // 弱模型实测(weak-agent-sim 2026-09-20):AI 常把文件路径当 docId 传 --doc,给可行动的报错
51
+ if (/[\\/]/.test(docId) || /\.(tex|docx|pdf|doc|wps|md)$/i.test(docId)) {
52
+ throw new Error(`--doc 要传 docId(如 1b0d6cddb9a8),不是文件路径。先执行 pf open "${docId}" 打开文档,用返回的 docId`);
53
+ }
54
+ const dir = projectByDocId(docId);
55
+ if (!fs.existsSync(path.join(dir, "meta.json"))) {
56
+ throw new Error(`找不到项目 ${docId}(先 pf open 打开文档)`);
57
+ }
58
+ return dir;
59
+ }
60
+ const dir = latestProject();
61
+ if (!dir) throw new Error("还没有任何项目,先 pf open <path> 打开文档");
62
+ return dir;
63
+ }
64
+
65
+ function readJson(file, fallback) {
66
+ try {
67
+ return JSON.parse(fs.readFileSync(file, "utf8"));
68
+ } catch {
69
+ return fallback;
70
+ }
71
+ }
72
+
73
+ function writeJson(file, data) {
74
+ const tmp = file + ".tmp";
75
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
76
+ fs.renameSync(tmp, file); // 原子替换,防写坏
77
+ }
78
+
79
+ export const readMeta = (docId) => readJson(path.join(projectByDocId(docId), "meta.json"), null);
80
+ export const writeMeta = (docId, meta) => writeJson(path.join(projectDir(docId), "meta.json"), meta);
81
+ export const readAnchors = (docId) => readJson(path.join(projectByDocId(docId), "anchors.json"), []);
82
+ export const writeAnchors = (docId, arr) => writeJson(path.join(projectDir(docId), "anchors.json"), arr);
83
+ export const readReview = (docId) => readJson(path.join(projectByDocId(docId), "review.json"), {});
84
+ export const writeReview = (docId, obj) => writeJson(path.join(projectDir(docId), "review.json"), obj);
85
+
86
+ export function saveFigureFile(docId, figureId, fileName, buf) {
87
+ const dir = path.join(projectDir(docId), "figures", figureId);
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ fs.writeFileSync(path.join(dir, fileName), buf);
90
+ return `figures/${figureId}/${fileName}`;
91
+ }
@@ -0,0 +1,54 @@
1
+ // vectorize.mjs — 🔴 SVG 矢量导出(2026-09-22 用户拍板:导出矢量必须做,方案用用户早期点名的
2
+ // GitHub 高星项目 visioncortex/vtracer——本机 vectorize-probe 已跑过全量对比,结论见
3
+ // skill pf-vectorize-eval:16 色量化 + polygon/cutout + optimize 是体积×保真甜点)。
4
+ //
5
+ // 设计约束:
6
+ // - 可选增强:vtracer 是 optional 依赖(没装不崩,给出跨平台安装指路)——用户环境千奇百怪,
7
+ // 核心链路不能被一个增强依赖卡死
8
+ // - 转出来的是**描摹路径**:文字全部变路径,不可编辑不可搜索——输出必须带这条红线说明
9
+ // (skill 红线:"否则是投诉源")
10
+ // - 不依赖 sharp:出图管线产物本来就是 PNG,vtracer 1.0 自带 PNG 解码
11
+ // (probe 里转 webp 才需要 sharp 预解码)
12
+ //
13
+ // 两档质量(probe 实测,1280×720 AI 生成图基线):
14
+ // draft: polygon/cutout,29-31KB,PSNR 22.1-22.7dB,明显偏色 5.5-6.1%(交付甜点)
15
+ // high : spline,~185KB,PSNR 22.8dB(渐变/细线更多时用;大 6 倍换 0.6dB,默认不给)
16
+
17
+ const BASE_CFG = {
18
+ clustering: "color-cluster",
19
+ hierarchical: "cutout",
20
+ filterSpeckle: 8,
21
+ maxColors: 16, // vtracer 内置自动量化,不用外接 image-q
22
+ optimize: 2, // 量化+简化+shorthand+分组,平均再省 ~47.5%
23
+ pathPrecision: 3,
24
+ };
25
+
26
+ const QUALITY_CFG = {
27
+ draft: { ...BASE_CFG, mode: "polygon", layerDifference: 24 },
28
+ high: { ...BASE_CFG, mode: "spline", layerDifference: 16 },
29
+ };
30
+
31
+ // 返回 { svg, paths } 或 { error: "not-installed" }。不 throw:调用方决定怎么报。
32
+ export async function pngToSvg(pngBuf, { quality = "draft" } = {}) {
33
+ let vt;
34
+ try {
35
+ vt = await import("@visioncortex/vtracer");
36
+ } catch {
37
+ return { error: "not-installed" };
38
+ }
39
+ const cfg = QUALITY_CFG[quality] || QUALITY_CFG.draft;
40
+ // convertBuffer 是同步的(probe 实测);退化输入(1x1 之类)会让 WASM 崩出 undefined ——
41
+ // 渗透实测后包一层,把崩溃转成可读错误而不是半截异常
42
+ try {
43
+ const svg = vt.convertBuffer(Buffer.from(pngBuf), cfg);
44
+ const s = String(svg);
45
+ return { svg: s, paths: (s.match(/<path/g) || []).length, bytes: Buffer.byteLength(s, "utf8") };
46
+ } catch (e) {
47
+ return { error: "convert-failed", detail: String(e?.message || e || "unknown") };
48
+ }
49
+ }
50
+
51
+ // 安装指路:在插件所在目录跑(全局装了也行,import 走 node 解析链)
52
+ export const INSTALL_HINT =
53
+ 'npm i @visioncortex/vtracer@1.0.0-alpha.4(在本插件目录或你的项目目录执行;' +
54
+ '纯 WASM 无原生编译,MIT/Apache-2.0 双许可可商用)';