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.
- package/README.md +95 -0
- package/adapters/claude-code/SKILL.md +381 -0
- package/adapters/claude-code/install.mjs +9 -0
- package/adapters/codex/marketplace.json +14 -0
- package/adapters/codex/promptfigure/.codex-plugin/plugin.json +6 -0
- package/adapters/codex/promptfigure/skills/promptfigure-local/SKILL.md +381 -0
- package/bin/pf.mjs +1953 -0
- package/package.json +44 -0
- package/scripts/build-adapters.mjs +80 -0
- package/scripts/test-e2e.mjs +117 -0
- package/skill/promptfigure-local/SKILL.md +381 -0
- package/src/anchor.mjs +63 -0
- package/src/config.mjs +75 -0
- package/src/craft-rules.mjs +158 -0
- package/src/craft.mjs +600 -0
- package/src/doc/docx.mjs +69 -0
- package/src/doc/index.mjs +35 -0
- package/src/doc/para.mjs +54 -0
- package/src/doc/tex.mjs +161 -0
- package/src/doc/texbuild.mjs +158 -0
- package/src/docsearch.mjs +96 -0
- package/src/entity-pair.mjs +17 -0
- package/src/events.mjs +52 -0
- package/src/extract.mjs +124 -0
- package/src/figure-catalog.mjs +326 -0
- package/src/journal.mjs +67 -0
- package/src/ledger.mjs +43 -0
- package/src/next.mjs +125 -0
- package/src/plan.mjs +112 -0
- package/src/png-trim.mjs +217 -0
- package/src/quality.mjs +325 -0
- package/src/ratio.mjs +87 -0
- package/src/render.mjs +247 -0
- package/src/review.mjs +48 -0
- package/src/server.mjs +218 -0
- package/src/store.mjs +91 -0
- package/src/vectorize.mjs +54 -0
- package/tray/pf-tray.py +265 -0
- package/web/app.js +613 -0
- package/web/index.html +73 -0
- package/web/probe.html +36 -0
- package/web/style.css +208 -0
package/src/doc/docx.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// docx.mjs — 服务端解析 .docx:段落文本 + 章节路径推断(§3.2 ¶2)
|
|
2
|
+
// 只读 word/document.xml,不依赖样式文件;标题识别 pStyle Heading1-6 / 标题1-6
|
|
3
|
+
import JSZip from "jszip";
|
|
4
|
+
|
|
5
|
+
function decodeXml(s) {
|
|
6
|
+
return s
|
|
7
|
+
.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, "'").replace(/&/g, "&");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function headingLevel(style) {
|
|
12
|
+
if (!style) return 0;
|
|
13
|
+
const m = /^(?:Heading|heading|标题)\s*(\d)$/.exec(style.trim());
|
|
14
|
+
return m ? Math.min(parseInt(m[1], 10), 6) : 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function parseDocx(buf) {
|
|
18
|
+
const zip = await JSZip.loadAsync(buf);
|
|
19
|
+
const entry = zip.file("word/document.xml");
|
|
20
|
+
if (!entry) throw new Error("不是有效的 .docx(缺 word/document.xml)");
|
|
21
|
+
const xml = await entry.async("string");
|
|
22
|
+
|
|
23
|
+
const blocks = []; // { i, type:'heading'|'para', level, secPath, para, text }
|
|
24
|
+
const counters = [0, 0, 0, 0, 0, 0];
|
|
25
|
+
let paraIdx = 0;
|
|
26
|
+
|
|
27
|
+
const pRe = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>|<w:p\b[^>]*\/>/g;
|
|
28
|
+
let m;
|
|
29
|
+
while ((m = pRe.exec(xml))) {
|
|
30
|
+
const inner = m[1] || "";
|
|
31
|
+
const styleM = /<w:pStyle w:val="([^"]+)"/.exec(inner);
|
|
32
|
+
const level = headingLevel(styleM && styleM[1]);
|
|
33
|
+
let text = "";
|
|
34
|
+
const tRe = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g;
|
|
35
|
+
let t;
|
|
36
|
+
while ((t = tRe.exec(inner))) text += t[1];
|
|
37
|
+
text = decodeXml(text).replace(/\s+/g, " ").trim();
|
|
38
|
+
if (!text && level === 0) continue;
|
|
39
|
+
|
|
40
|
+
if (level > 0) {
|
|
41
|
+
counters[level - 1] += 1;
|
|
42
|
+
for (let i = level; i < 6; i++) counters[i] = 0;
|
|
43
|
+
paraIdx = 0;
|
|
44
|
+
} else {
|
|
45
|
+
paraIdx += 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 当前生效章节路径 = 计数器从第一个 0 处截断(全非 0 则取全部 6 级)
|
|
49
|
+
const firstZero = counters.indexOf(0);
|
|
50
|
+
const curSec = counters.slice(0, firstZero === -1 ? 6 : firstZero).join(".");
|
|
51
|
+
|
|
52
|
+
blocks.push({
|
|
53
|
+
i: blocks.length,
|
|
54
|
+
type: level > 0 ? "heading" : "para",
|
|
55
|
+
level,
|
|
56
|
+
secPath: level > 0 ? counters.slice(0, level).join(".") : curSec,
|
|
57
|
+
para: level > 0 ? 0 : paraIdx,
|
|
58
|
+
text,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// outline:树形章节
|
|
63
|
+
const outline = [];
|
|
64
|
+
for (const b of blocks) {
|
|
65
|
+
if (b.type !== "heading") continue;
|
|
66
|
+
outline.push({ path: b.secPath, level: b.level, title: b.text, blockIdx: b.i });
|
|
67
|
+
}
|
|
68
|
+
return { blocks, outline };
|
|
69
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// doc/index.mjs — 按扩展名分发解析
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseDocx } from "./docx.mjs";
|
|
5
|
+
import { parseTex } from "./tex.mjs";
|
|
6
|
+
|
|
7
|
+
export function docKind(filePath) {
|
|
8
|
+
const base = filePath.split(/[\\/]/).pop();
|
|
9
|
+
const dot = base.lastIndexOf(".");
|
|
10
|
+
// 🔴 红队实测(2026-09-23 fuzz):无扩展名路径(如目录)会整串当 ext 报出来(拼接损坏)
|
|
11
|
+
if (dot <= 0) throw new Error(`无法识别文件类型(没有扩展名):${filePath}——pf open 需要指向 .docx / .tex / .pdf 文件`);
|
|
12
|
+
const ext = base.slice(dot + 1).toLowerCase();
|
|
13
|
+
if (ext === "docx") return "docx";
|
|
14
|
+
if (ext === "tex") return "tex";
|
|
15
|
+
if (ext === "pdf") return "pdf";
|
|
16
|
+
if (ext === "wps" || ext === "wpt") {
|
|
17
|
+
throw new Error(".wps/.wpt 是私有格式,本插件不支持。请在 WPS 里另存为 .docx 后再打开");
|
|
18
|
+
}
|
|
19
|
+
throw new Error(`不支持的格式 .${ext}(一期支持 .docx / .tex / .pdf)`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function parseDocument(filePath) {
|
|
23
|
+
const kind = docKind(filePath);
|
|
24
|
+
if (kind === "docx") {
|
|
25
|
+
return { kind, ...(await parseDocx(fs.readFileSync(filePath))) };
|
|
26
|
+
}
|
|
27
|
+
if (kind === "tex") {
|
|
28
|
+
return { kind, ...parseTex(fs.readFileSync(filePath, "utf8"), path.dirname(filePath)) };
|
|
29
|
+
}
|
|
30
|
+
// pdf:服务端不做段落解析(段落级高亮走浏览器 textLayer),outline 留空
|
|
31
|
+
if (kind === "pdf") {
|
|
32
|
+
return { kind, blocks: [], outline: [] };
|
|
33
|
+
}
|
|
34
|
+
throw new Error("unreachable");
|
|
35
|
+
}
|
package/src/doc/para.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// para.mjs — 段落引用 "§3.2 ¶2" 的解析、校验与定位
|
|
2
|
+
// 🔴 用章节路径+段序,不用全局段序(全局段序会因增删段落整体漂移)
|
|
3
|
+
|
|
4
|
+
export function parseRef(ref) {
|
|
5
|
+
if (!ref || typeof ref !== "string") throw new Error("锚点引用必须是字符串,如 \"§3.2 ¶2\"");
|
|
6
|
+
const m = /^\s*§?\s*([\d.]+)\s*(?:¶\s*(\d+))?\s*$/.exec(ref);
|
|
7
|
+
if (!m) {
|
|
8
|
+
throw new Error(`无法解析锚点引用 "${ref}"。格式:§<章节路径> ¶<段序>,例如 "§3.2 ¶2"(¶ 缺省时指该章节标题本身)`);
|
|
9
|
+
}
|
|
10
|
+
return { secPath: m[1].replace(/\.$/, ""), para: m[2] ? parseInt(m[2], 10) : 0 };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveRef(blocks, ref) {
|
|
14
|
+
const { secPath, para } = parseRef(ref);
|
|
15
|
+
const head = blocks.find((b) => b.type === "heading" && b.secPath === secPath);
|
|
16
|
+
if (!head) {
|
|
17
|
+
// 🔴 2026-09-21 死路修复:摘要/前言等无标题章节(tex 抽出的 §0)不在 outline 里,
|
|
18
|
+
// 但 pf doc search / pf doc data 会给出这类引用——不能因为没 heading 就拒绝自家工具给的引用。
|
|
19
|
+
// 只要该章节下有真实段落/图注块就放行;¶ 缺省时落到该章节第一个段落。
|
|
20
|
+
const same0 = blocks.filter(
|
|
21
|
+
(b) => (b.type === "para" || b.type === "caption") && b.secPath === secPath
|
|
22
|
+
);
|
|
23
|
+
if (!same0.length) {
|
|
24
|
+
const avail = blocks.filter((b) => b.type === "heading").map((b) => b.secPath).slice(0, 40);
|
|
25
|
+
throw new Error(`章节 §${secPath} 不存在。可用章节(前 40):${avail.join(", ") || "(无)"}。先 pf doc outline 查看。`);
|
|
26
|
+
}
|
|
27
|
+
if (para === 0) return same0[0];
|
|
28
|
+
const t0 = same0.find((b) => b.para === para);
|
|
29
|
+
if (!t0) {
|
|
30
|
+
throw new Error(`§${secPath} 下只有 ${same0.length} 个段落/图注,¶${para} 不存在(若该章只有子章节没有直属段落,引用要到段落所在的子章节,如 §4.1 ¶2)`);
|
|
31
|
+
}
|
|
32
|
+
return t0;
|
|
33
|
+
}
|
|
34
|
+
if (para === 0) return head;
|
|
35
|
+
// 🔴 caption 块也算段落(tex.mjs 把 \caption 抽成 caption 块并占段号——doc figures 报的
|
|
36
|
+
// 图位置正是它)。只搜 type==="para" 会让 doc figures 给出的每个图位置都过不了校验。
|
|
37
|
+
const same = blocks.filter(
|
|
38
|
+
(b) => (b.type === "para" || b.type === "caption") && b.secPath === secPath
|
|
39
|
+
);
|
|
40
|
+
const target = same.find((b) => b.para === para);
|
|
41
|
+
if (!target) {
|
|
42
|
+
throw new Error(`§${secPath} 下只有 ${same.length} 个段落/图注,¶${para} 不存在(若该章只有子章节没有直属段落,引用要到段落所在的子章节,如 §4.1 ¶2)`);
|
|
43
|
+
}
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 变化检测:quote 是否还在原文里(确定性比对,不猜)
|
|
48
|
+
export function checkQuote(blocks, quote) {
|
|
49
|
+
if (!quote) return "ok";
|
|
50
|
+
const norm = (s) => s.replace(/\s+/g, "");
|
|
51
|
+
const q = norm(quote);
|
|
52
|
+
if (!q) return "ok";
|
|
53
|
+
return blocks.some((b) => norm(b.text).includes(q)) ? "ok" : "changed";
|
|
54
|
+
}
|
package/src/doc/tex.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// tex.mjs — 解析 .tex:\section 层级 + 段落(一期只读预览,不编译)
|
|
2
|
+
// 支持 \input/\include 递归展开(CVPR 等模板 main.tex 只是壳,正文在 sec/*.tex)
|
|
3
|
+
// 🔴 多文件共享同一个章节计数器 —— 否则每个 input 文件里的 § 都从 1 重新数
|
|
4
|
+
// 图/表环境整体跳过,但 \caption{...} 单独抽成 caption 段(锚点定位高频目标)
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const SKIP_ENVS = /(figure|table|algorithm|wraptable|wrapfigure)\*?/;
|
|
9
|
+
const SKIP_CMDS = /^\\(documentclass|usepackage|title|author|affiliations|email|maketitle|bibliographystyle|bibliography|input|includegraphics|graphicspath|label|ref|cite|pagestyle|thispagestyle|setlength|newcommand|renewcommand|def|let|providecommand|DeclareMathOperator|hypersetup|nocite|balance|printacmref|settopmatter|ccsdesc|keywords|terms|acmConference|acmBooktitle|acmPrice|acmISBN|acmDOI|fancyhead|fancyfoot|newenvironment|renewenvironment|vspace|hspace|quad|qquad|bibliographyplot|onecolumn|twocolumn|clearpage|newpage|tableofcontents|appendix|part|centering|raggedright|raggedleft|parbox|color|textcolor|definecolor|usebox|savebox|resizebox|scalebox|rotatebox|adjustbox|toprule|midrule|bottomrule|cmidrule|multicolumn|multirow|hline|cline|rowcolor|columncolor|arraystretch|tabcolsep|specialrule|addlinespace|makeatletter|makeatother|newlength|setcounter|addtocounter|value|numberwithin|allowdisplaybreaks|displaybreak|allowbreak|newline|linebreak|noindent|indent|parskip|par|smallskip|medskip|bigskip|vfill|cleardoublepage|cleardoublepage|frontmatter|mainmatter|backmatter|maketitle|printbibliography|endinput|includeonly|externaldocument|subfile|import|subimport|graphicspath)\b/;
|
|
10
|
+
|
|
11
|
+
function readTexFile(baseDir, rel, visited) {
|
|
12
|
+
let p = path.resolve(baseDir, rel);
|
|
13
|
+
if (!/\.tex$/i.test(p)) p += ".tex";
|
|
14
|
+
if (visited.has(p) || !fs.existsSync(p)) return null;
|
|
15
|
+
visited.add(p);
|
|
16
|
+
return fs.readFileSync(p, "utf8");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseTex(text, baseDir = ".") {
|
|
20
|
+
const state = { blocks: [], counters: [0, 0, 0], paraIdx: 0 };
|
|
21
|
+
parseInto(text, path.resolve(baseDir), state, new Set());
|
|
22
|
+
const outline = state.blocks.filter((b) => b.type === "heading")
|
|
23
|
+
.map((b) => ({ path: b.secPath, level: b.level, title: b.text, blockIdx: b.i }));
|
|
24
|
+
return { blocks: state.blocks, outline };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseInto(text, baseDir, state, visited) {
|
|
28
|
+
const blocks = state.blocks;
|
|
29
|
+
const counters = state.counters;
|
|
30
|
+
let paraIdx = state.paraIdx;
|
|
31
|
+
let buf = [];
|
|
32
|
+
let env = null;
|
|
33
|
+
|
|
34
|
+
const curSecPath = () => {
|
|
35
|
+
const k = counters.findIndex((c) => c === 0);
|
|
36
|
+
return counters.slice(0, k === -1 ? 3 : Math.max(k, 1)).join(".");
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const flush = () => {
|
|
40
|
+
const t = buf.join(" ").replace(/\s+/g, " ").trim();
|
|
41
|
+
buf = [];
|
|
42
|
+
if (!t) return;
|
|
43
|
+
paraIdx += 1;
|
|
44
|
+
state.paraIdx = paraIdx;
|
|
45
|
+
blocks.push({ i: blocks.length, type: "para", level: 0, secPath: curSecPath(), para: paraIdx, text: t });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const pushHeading = (level, title) => {
|
|
49
|
+
flush();
|
|
50
|
+
counters[level - 1] += 1;
|
|
51
|
+
for (let i = level; i < 3; i++) counters[i] = 0;
|
|
52
|
+
paraIdx = 0;
|
|
53
|
+
state.paraIdx = 0;
|
|
54
|
+
blocks.push({ i: blocks.length, type: "heading", level, secPath: counters.slice(0, level).join("."), para: 0, text: title });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const pushCaption = (cap, images) => {
|
|
58
|
+
flush();
|
|
59
|
+
const t = cap.replace(/\s+/g, " ").trim();
|
|
60
|
+
if (!t && !images?.length) return;
|
|
61
|
+
paraIdx += 1;
|
|
62
|
+
state.paraIdx = paraIdx;
|
|
63
|
+
const b = { i: blocks.length, type: "caption", level: 0, secPath: curSecPath(), para: paraIdx, text: t };
|
|
64
|
+
// 原图清单:figure 环境里捕获的 \includegraphics 路径挂到图注块上(pf doc figures 用)
|
|
65
|
+
if (images?.length) b.images = images;
|
|
66
|
+
blocks.push(b);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleLine = (rawLine) => {
|
|
70
|
+
const line = rawLine.replace(/(?<!\\)%.*$/, "").trim();
|
|
71
|
+
if (!line) { if (!env) flush(); return; }
|
|
72
|
+
|
|
73
|
+
// ---- \input / \include 递归展开(共享计数器与 blocks)----
|
|
74
|
+
const inc = /^\\(?:input|include)\{([^}]+)\}/.exec(line);
|
|
75
|
+
if (inc) {
|
|
76
|
+
flush();
|
|
77
|
+
const sub = readTexFile(baseDir, inc[1], visited);
|
|
78
|
+
if (sub) parseInto(sub, baseDir, state, visited);
|
|
79
|
+
const rest = line.slice(inc[0].length).trim();
|
|
80
|
+
if (rest) handleLine(rest);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- 图/表环境:跳过内容,抓 caption ----
|
|
85
|
+
const beginEnv = /\\begin\{([^}]+)\}/.exec(line);
|
|
86
|
+
if (beginEnv && SKIP_ENVS.test(beginEnv[1])) {
|
|
87
|
+
flush();
|
|
88
|
+
env = { name: beginEnv[1] };
|
|
89
|
+
}
|
|
90
|
+
if (env) {
|
|
91
|
+
// 捕获 figure 环境内的原图引用(\includegraphics[opts]{path},subfigure 多图全收)
|
|
92
|
+
const ig = /\\includegraphics(?:\[[^\]]*\])?\{([^}]+)\}/g;
|
|
93
|
+
let igm;
|
|
94
|
+
while ((igm = ig.exec(line))) (env.images ||= []).push(igm[1]);
|
|
95
|
+
const capM = /\\caption\*?(?:\[([^\]]*)\])?\{/.exec(line);
|
|
96
|
+
if (capM) {
|
|
97
|
+
const start = line.indexOf("{", capM.index) + 1;
|
|
98
|
+
let d = 1, cap = "";
|
|
99
|
+
for (let i = start; i < line.length && d > 0; i++) {
|
|
100
|
+
const ch = line[i];
|
|
101
|
+
if (ch === "{") d++;
|
|
102
|
+
else if (ch === "}") d--;
|
|
103
|
+
if (d > 0) cap += ch;
|
|
104
|
+
}
|
|
105
|
+
pushCaption(cap, env.images);
|
|
106
|
+
env.images = null;
|
|
107
|
+
env.captioned = true;
|
|
108
|
+
}
|
|
109
|
+
if (/\\end\{([^}]+)\}/.test(line) && RegExp.$1 === env.name) {
|
|
110
|
+
// 有原图引用但没写 caption 的环境(少见):落一个 caption 块兜底,别让原图清单漏图
|
|
111
|
+
if (env.images?.length && !env.captioned) {
|
|
112
|
+
paraIdx += 1;
|
|
113
|
+
state.paraIdx = paraIdx;
|
|
114
|
+
blocks.push({
|
|
115
|
+
i: blocks.length, type: "caption", level: 0, secPath: curSecPath(), para: paraIdx,
|
|
116
|
+
text: "(figure without caption)", images: env.images,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
env = null;
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- 章节标题(括号配平,支持嵌套花括号)----
|
|
125
|
+
if (/^\\(?:sub){0,2}section\*?\s*\{/.test(line)) {
|
|
126
|
+
const level = line.startsWith("\\subsubsection") ? 3 : line.startsWith("\\subsection") ? 2 : 1;
|
|
127
|
+
const start = line.indexOf("{") + 1;
|
|
128
|
+
let d = 1, title = "";
|
|
129
|
+
for (let i = start; i < line.length && d > 0; i++) {
|
|
130
|
+
const ch = line[i];
|
|
131
|
+
if (ch === "{") d++;
|
|
132
|
+
else if (ch === "}") d--;
|
|
133
|
+
if (d > 0) title += ch;
|
|
134
|
+
}
|
|
135
|
+
title = title.replace(/\\[a-zA-Z]+\{([^}]*)\}/g, "$1").replace(/[{}]/g, "").trim();
|
|
136
|
+
pushHeading(level, title);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- 顺序敏感:\end{document} 之后的行全忽略(常有注释草稿)----
|
|
141
|
+
if (/^\\end\{document\}/.test(line)) { flush(); state.stop = true; return; }
|
|
142
|
+
if (state.stop) return;
|
|
143
|
+
|
|
144
|
+
if (SKIP_CMDS.test(line)) { flush(); return; }
|
|
145
|
+
|
|
146
|
+
if (/^\\begin\{(center|enumerate|itemize|quote|abstract|spacing|list|description)\}/.test(line)) { flush(); return; }
|
|
147
|
+
|
|
148
|
+
if (/^\\item\b/.test(line)) {
|
|
149
|
+
flush();
|
|
150
|
+
buf.push(line.replace(/^\\item\s*/, "").replace(/\[[^\]]*\]/, "").trim());
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
buf.push(line);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
157
|
+
if (state.stop) break;
|
|
158
|
+
handleLine(raw);
|
|
159
|
+
}
|
|
160
|
+
flush();
|
|
161
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// texbuild.mjs — LaTeX 本地编译(零侵入:输出只进 ~/.promptfigure,绝不写用户目录)
|
|
2
|
+
// 策略:找本机 TeX 引擎 → 编译成 PDF 给 GUI 渲染(保真);没有引擎时 CLI 给明确指引
|
|
3
|
+
// 引擎优先级:tectonic(自动拉宏包、自动跑 bib)> latexmk > pdflatex(两遍,引用可能为 ?)
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
|
|
9
|
+
const PF_BIN = path.join(os.homedir(), ".promptfigure", "bin");
|
|
10
|
+
|
|
11
|
+
// ---- 引擎探测 ----
|
|
12
|
+
function existsExec(p) {
|
|
13
|
+
try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function findInPath(name) {
|
|
17
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
18
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
|
19
|
+
if (!dir) continue;
|
|
20
|
+
for (const ext of exts) {
|
|
21
|
+
const p = path.join(dir, name + ext);
|
|
22
|
+
if (existsExec(p)) return p;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function findTexLivePdflatex() {
|
|
29
|
+
const roots = ["C:/texlive"];
|
|
30
|
+
for (const root of roots) {
|
|
31
|
+
try {
|
|
32
|
+
const years = fs.readdirSync(root).filter((d) => /^\d{4}/.test(d)).sort().reverse();
|
|
33
|
+
for (const y of years) {
|
|
34
|
+
for (const bin of ["bin/windows", "bin/win32"]) {
|
|
35
|
+
const p = path.join(root, y, bin, "pdflatex.exe");
|
|
36
|
+
if (fs.existsSync(p)) return p;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findMikTeX() {
|
|
45
|
+
const candidates = [
|
|
46
|
+
path.join(process.env.APPDATA || "", "MiKTeX", "miktex", "bin", "x64", "pdflatex.exe"),
|
|
47
|
+
"C:/Program Files/MiKTeX/miktex/bin/x64/pdflatex.exe",
|
|
48
|
+
"C:/Users/" + os.userInfo().username + "/AppData/Local/Programs/MiKTeX/miktex/bin/x64/pdflatex.exe",
|
|
49
|
+
];
|
|
50
|
+
return candidates.find((c) => fs.existsSync(c)) || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function detectTexEngine() {
|
|
54
|
+
// ① 插件自带的(~/.promptfigure/bin,pf setup-tex 下载到这里)
|
|
55
|
+
const local = path.join(PF_BIN, process.platform === "win32" ? "tectonic.exe" : "tectonic");
|
|
56
|
+
if (fs.existsSync(local)) return { engine: "tectonic", cmd: local, origin: "plugin" };
|
|
57
|
+
// ② PATH 与常见安装位置
|
|
58
|
+
const tectonic = findInPath("tectonic");
|
|
59
|
+
if (tectonic) return { engine: "tectonic", cmd: tectonic, origin: "system" };
|
|
60
|
+
const latexmk = findInPath("latexmk");
|
|
61
|
+
if (latexmk) return { engine: "latexmk", cmd: latexmk, origin: "system" };
|
|
62
|
+
const pdflatex = findInPath("pdflatex") || findTexLivePdflatex() || findMikTeX();
|
|
63
|
+
if (pdflatex) return { engine: "pdflatex", cmd: pdflatex, origin: "system" };
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- 编译 ----
|
|
68
|
+
export function compileTex(docPath, { timeoutMs = 300000, outDir } = {}) {
|
|
69
|
+
const eng = detectTexEngine();
|
|
70
|
+
if (!eng) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: "no-engine",
|
|
74
|
+
message: "本机没有 TeX 编译环境。两条路:① 执行 pf setup-tex(自动下载便携版 tectonic 到插件目录)② 自装 TeX Live 或 MiKTeX 后重开",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (!outDir) throw new Error("compileTex 需要 outDir(编译产物只允许写进插件目录)");
|
|
78
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
79
|
+
|
|
80
|
+
const srcDir = path.dirname(docPath);
|
|
81
|
+
let args, opts;
|
|
82
|
+
if (eng.engine === "tectonic") {
|
|
83
|
+
args = ["--outdir", outDir, docPath];
|
|
84
|
+
opts = {};
|
|
85
|
+
} else if (eng.engine === "latexmk") {
|
|
86
|
+
args = ["-pdf", "-interaction=nonstopmode", "-halt-on-error", `-outdir=${outDir}`, docPath];
|
|
87
|
+
opts = {};
|
|
88
|
+
} else {
|
|
89
|
+
// pdflatex 跑两遍解决交叉引用;output-directory 模式下允许在源目录找输入
|
|
90
|
+
const pass = () => spawnSync(eng.cmd, [
|
|
91
|
+
"-interaction=nonstopmode", "-halt-on-error", `-output-directory=${outDir}`, docPath,
|
|
92
|
+
], { cwd: srcDir, timeout: timeoutMs / 2, encoding: "buffer" });
|
|
93
|
+
const r1 = pass();
|
|
94
|
+
if (r1.status !== 0) return fail(eng, r1, docPath);
|
|
95
|
+
const r2 = pass();
|
|
96
|
+
return collect(eng, r2, docPath, outDir, timeoutMs);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const run = spawnSync(eng.cmd, args, { cwd: srcDir, timeout: timeoutMs, encoding: "buffer", ...opts });
|
|
100
|
+
return collect(eng, run, docPath, outDir, timeoutMs);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fail(eng, run, docPath) {
|
|
104
|
+
const log = run.stdout?.toString() + (run.stderr?.toString() || "");
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
engine: eng.engine,
|
|
108
|
+
reason: "compile-error",
|
|
109
|
+
logTail: log.slice(-1500),
|
|
110
|
+
message: `TeX 编译失败(${eng.engine})。常见原因:宏包缺失(tectonic 需要联网拉包,走代理时先设 HTTPS_PROXY)/ 文档本身有错。可用用户目录下的 .log 复核。`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function collect(eng, run, docPath, outDir, timeoutMs) {
|
|
115
|
+
if (run.error?.killed || run.error?.code === "ETIMEDOUT") {
|
|
116
|
+
return { ok: false, engine: eng.engine, reason: "timeout", message: `编译超时(>${Math.round(timeoutMs / 1000)}s)。首次编译 tectonic 要在线拉宏包,可能较慢,重跑一次通常快很多。` };
|
|
117
|
+
}
|
|
118
|
+
if (run.status !== 0) return fail(eng, run, docPath);
|
|
119
|
+
const pdfPath = path.join(outDir, path.basename(docPath).replace(/\.tex$/i, "") + ".pdf");
|
|
120
|
+
if (!fs.existsSync(pdfPath)) {
|
|
121
|
+
return { ok: false, engine: eng.engine, reason: "no-pdf", logTail: (run.stdout?.toString() || "").slice(-1500), message: "编译命令成功但没产出 PDF(检查文档是否有 \\documentclass)" };
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, engine: eng.engine, pdfPath };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- pf setup-tex:下载便携 tectonic 到插件目录(国内镜像优先,逐个回退)----
|
|
127
|
+
const MIRRORS = [
|
|
128
|
+
"https://ghfast.top/https://github.com",
|
|
129
|
+
"https://gh-proxy.com/https://github.com",
|
|
130
|
+
"https://ghproxy.net/https://github.com",
|
|
131
|
+
"https://github.com",
|
|
132
|
+
];
|
|
133
|
+
const TECTONIC_VER = "0.15.0";
|
|
134
|
+
const TECTONIC_URL = `/tectonic-typesetting/tectonic/releases/download/tectonic%40${TECTONIC_VER}/tectonic-${TECTONIC_VER}-x86_64-pc-windows-msvc.zip`;
|
|
135
|
+
|
|
136
|
+
export async function setupTex({ onLog = console.log } = {}) {
|
|
137
|
+
const target = path.join(PF_BIN, "tectonic.exe");
|
|
138
|
+
if (fs.existsSync(target)) { onLog(`✅ 已有 ${target}`); return { ok: true, already: true }; }
|
|
139
|
+
fs.mkdirSync(PF_BIN, { recursive: true });
|
|
140
|
+
const { execFileSync } = await import("node:child_process");
|
|
141
|
+
const zip = path.join(PF_BIN, "tectonic.zip");
|
|
142
|
+
for (const m of MIRRORS) {
|
|
143
|
+
try {
|
|
144
|
+
onLog(`↓ 尝试 ${m.split("/")[2]} ...`);
|
|
145
|
+
execFileSync("curl", ["-sL", "--max-time", "180", "-o", zip, m + TECTONIC_URL], { stdio: "ignore" });
|
|
146
|
+
if (fs.existsSync(zip) && fs.statSync(zip).size > 1000000) break;
|
|
147
|
+
} catch {}
|
|
148
|
+
}
|
|
149
|
+
if (!fs.existsSync(zip) || fs.statSync(zip).size < 1000000) {
|
|
150
|
+
return { ok: false, message: "下载失败(所有镜像都不通)。手动方案:开代理后重试,或自装 TeX Live / MiKTeX" };
|
|
151
|
+
}
|
|
152
|
+
// 解压(用 powershell Expand-Archive,零依赖)
|
|
153
|
+
execFileSync("powershell", ["-NoProfile", "-Command", `Expand-Archive -Force '${zip}' '${PF_BIN}'`], { stdio: "ignore" });
|
|
154
|
+
fs.rmSync(zip, { force: true });
|
|
155
|
+
if (!fs.existsSync(target)) return { ok: false, message: "解压后没找到 tectonic.exe" };
|
|
156
|
+
onLog(`✅ tectonic ${TECTONIC_VER} → ${target}`);
|
|
157
|
+
return { ok: true, path: target };
|
|
158
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// docsearch.mjs — 🔴 原文检索 + 结果数据抽取(2026-09-21 用户反馈"别人用不了:不读文章、找不到结果数据"):
|
|
2
|
+
// 弱 AI 只有 pf doc outline(章节树),定位内容全靠猜——结果/实验章节里的数字句
|
|
3
|
+
// (准确率、提升幅度、对比基线)更是无处可查。这里给两个确定性工具,全部本地计算、不依赖 daemon:
|
|
4
|
+
// - searchBlocks:关键词全文检索(para/caption 块),返回 §引用 + 命中片段 → "去哪读"
|
|
5
|
+
// - dataSentences:抽定量句(%/小数/×/± 模式)→ "结果数据在哪"
|
|
6
|
+
// 纯函数:CLI 与测试共用。分句走 extract.splitSentences(2026-09-22 中英通用,中文论文不再糊成一坨)。
|
|
7
|
+
import { splitSentences } from "./extract.mjs";
|
|
8
|
+
|
|
9
|
+
// 关键词全文检索。kws 全部小写化匹配;命中块按命中次数排序(多关键词同时命中的排前)。
|
|
10
|
+
// 返回 [{ ref, secPath, para, type, snippet, hits }],snippet 是首个命中点前后 ~90 字符窗口。
|
|
11
|
+
export function searchBlocks(blocks = [], kws = [], { limit = 12 } = {}) {
|
|
12
|
+
const terms = (Array.isArray(kws) ? kws : String(kws).split(/[\s,,]+/))
|
|
13
|
+
.map((k) => String(k).trim().toLowerCase()).filter(Boolean);
|
|
14
|
+
if (!terms.length) return [];
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const b of blocks) {
|
|
17
|
+
if (b.type !== "para" && b.type !== "caption") continue;
|
|
18
|
+
const text = String(b.text || "");
|
|
19
|
+
if (!text) continue;
|
|
20
|
+
const low = text.toLowerCase();
|
|
21
|
+
let hits = 0;
|
|
22
|
+
const hitTerms = [];
|
|
23
|
+
for (const t of terms) {
|
|
24
|
+
const n = low.split(t).length - 1;
|
|
25
|
+
if (n > 0) { hits += n; hitTerms.push(t); }
|
|
26
|
+
}
|
|
27
|
+
if (!hits) continue;
|
|
28
|
+
// 片段窗口:第一个出现的关键词位置
|
|
29
|
+
let pos = -1;
|
|
30
|
+
for (const t of terms) { const p = low.indexOf(t); if (p >= 0 && (pos < 0 || p < pos)) pos = p; }
|
|
31
|
+
const start = Math.max(0, pos - 60);
|
|
32
|
+
const snippet = (start > 0 ? "…" : "") + text.slice(start, pos + 90).replace(/\s+/g, " ") + (start + 90 < text.length ? "…" : "");
|
|
33
|
+
out.push({
|
|
34
|
+
ref: `§${b.secPath}${b.para ? " ¶" + b.para : ""}`,
|
|
35
|
+
secPath: b.secPath, para: b.para || null, type: b.type,
|
|
36
|
+
snippet, hits, hitTerms,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
out.sort((a, b) => b.hits - a.hits);
|
|
40
|
+
return out.slice(0, limit);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 定量句抽取("结果数据"定位):命中任一模式的句子算定量句。
|
|
44
|
+
// 模式全部确定性:百分比 / 带小数的数值 / ×倍数 / ±波动 / dB/mAP 等常见指标后缀。
|
|
45
|
+
// 返回 [{ ref, sentence }],按原文顺序。
|
|
46
|
+
const METRIC_TAIL = /\b(?:dB|PSNR|SSIM|LPIPS|mAP|FID|Acc|accuracy|F1)\b/i;
|
|
47
|
+
const QUANT_RE = new RegExp(
|
|
48
|
+
[
|
|
49
|
+
"\\d+(?:\\.\\d+)?\\s*%", // 34.2%
|
|
50
|
+
"\\d+\\.\\d+", // 小数(0.913、3.14)
|
|
51
|
+
"\\d+\\s*[×x]\\s*\\d+", // 2× 提升 / 1024×1024
|
|
52
|
+
"\\d+(?:\\.\\d+)?\\s*[±]\\s*\\d+",// 1.2±0.1
|
|
53
|
+
"[×x]\\s*\\d+(?:\\.\\d+)?", // ×1.5
|
|
54
|
+
METRIC_TAIL.source, // 指标名
|
|
55
|
+
].join("|"),
|
|
56
|
+
"g"
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export function dataSentences(blocks = [], { limit = 20 } = {}) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const b of blocks) {
|
|
62
|
+
if (b.type !== "para") continue;
|
|
63
|
+
const text = String(b.text || "");
|
|
64
|
+
if (!text) continue;
|
|
65
|
+
const sents = splitSentences(text);
|
|
66
|
+
for (const s of sents) {
|
|
67
|
+
// 🔴 最短句过滤 CJK 感知(2026-09-22):原 s.length<15 按英文字符定标,
|
|
68
|
+
// "方案二准确率达到91.3%。"(14 字符)被误杀——中文信息密度高,汉字按 2 计
|
|
69
|
+
const eff = (s.match(/[A-Za-z0-9]+/g) || []).join("").length
|
|
70
|
+
+ (s.match(/[\u4e00-\u9fff]/g) || []).length * 2
|
|
71
|
+
+ (s.match(/[^\u4e00-\u9fffA-Za-z0-9\s]/g) || []).length;
|
|
72
|
+
if (eff < 15) continue;
|
|
73
|
+
QUANT_RE.lastIndex = 0;
|
|
74
|
+
if (!QUANT_RE.test(s)) continue;
|
|
75
|
+
out.push({
|
|
76
|
+
ref: `§${b.secPath}${b.para ? " ¶" + b.para : ""}`,
|
|
77
|
+
sentence: s.slice(0, 200) + (s.length > 200 ? "…" : ""),
|
|
78
|
+
});
|
|
79
|
+
if (out.length >= limit) return out;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 数据句按章节聚合视图(pf doc data 输出用):[{ secPath, items: [{ref, sentence}] }]
|
|
86
|
+
export function dataBySection(blocks = [], { limit = 40 } = {}) {
|
|
87
|
+
const flat = dataSentences(blocks, { limit });
|
|
88
|
+
const bySec = new Map();
|
|
89
|
+
for (const it of flat) {
|
|
90
|
+
// 归一化章节键:去掉引用里的 § 前缀与段落号(CLI 输出时统一加 §)
|
|
91
|
+
const key = it.ref.replace(/^§/, "").replace(/ ¶\d+$/, "");
|
|
92
|
+
if (!bySec.has(key)) bySec.set(key, []);
|
|
93
|
+
bySec.get(key).push(it);
|
|
94
|
+
}
|
|
95
|
+
return [...bySec.entries()].map(([secPath, items]) => ({ secPath, items }));
|
|
96
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// entity-pair.mjs — 实体双语对照解析(2026-09-22 用户拍板补差距②)。
|
|
2
|
+
// 独立叶子模块:quality.mjs 与 craft.mjs 都要用,放任何一边都会成环。
|
|
3
|
+
//
|
|
4
|
+
// 实测痛点:中文用户把实体译成英文喂 craft(standard 档中文乱码率高),但译完溯源就断——
|
|
5
|
+
// checkEntitySource 对"脚本不一致"的实体只能 skipped(fail open)。对照格式
|
|
6
|
+
// 「英文标签|原文词」(如 "Weighted Box Fusion|加权框融合")两头都占:
|
|
7
|
+
// - 溯源对账查原文侧(确定性)
|
|
8
|
+
// - 卡面/提示词用英文侧(不乱码)
|
|
9
|
+
export function splitEntityPair(e) {
|
|
10
|
+
const raw = String(e || "").trim();
|
|
11
|
+
// 🔴 渗透实测(2026-09-22 子智能体 B):一侧为空的残缺对照("Label|" / "|原文")不能当
|
|
12
|
+
// 普通实体放行——英文侧会连 "|" 一起印进卡面,原文侧静默丢掉。标记 invalid 让质量门拦。
|
|
13
|
+
if (!raw.includes("|")) return { label: raw, src: null };
|
|
14
|
+
const parts = raw.split("|").map((s) => s.trim());
|
|
15
|
+
if (parts.length === 2 && parts[0] && parts[1]) return { label: parts[0], src: parts[1] };
|
|
16
|
+
return { label: raw, src: null, invalid: true };
|
|
17
|
+
}
|
package/src/events.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// events.mjs — events.jsonl 只 append + SSE 广播
|
|
2
|
+
// 🔴 永不改写/删除已有行 —— AI 回放审批历史全靠这个文件
|
|
3
|
+
// 🔴 不用 fs.watch —— 进程间通知走本地 HTTP/SSE(跨平台踩过的坑)
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { projectByDocId } from "./store.mjs";
|
|
7
|
+
|
|
8
|
+
const clients = new Set(); // { docId, res }
|
|
9
|
+
|
|
10
|
+
function eventsFile(docId) {
|
|
11
|
+
const dir = projectByDocId(docId);
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
13
|
+
return path.join(dir, "events.jsonl");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function appendEvent(docId, type, data = {}) {
|
|
17
|
+
const line = JSON.stringify({ t: new Date().toISOString(), type, ...data });
|
|
18
|
+
fs.appendFileSync(eventsFile(docId), line + "\n");
|
|
19
|
+
broadcast(docId, { type, ...data });
|
|
20
|
+
return line;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readEvents(docId, limit = 200) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = fs.readFileSync(eventsFile(docId), "utf8").trim();
|
|
26
|
+
if (!raw) return [];
|
|
27
|
+
const lines = raw.split("\n");
|
|
28
|
+
return lines.slice(-limit).map((l) => { try { return JSON.parse(l); } catch { return { type: "corrupt", raw: l }; } });
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function broadcast(docId, payload) {
|
|
35
|
+
const msg = `data: ${JSON.stringify(payload)}\n\n`;
|
|
36
|
+
for (const c of clients) {
|
|
37
|
+
if (c.docId !== docId) continue;
|
|
38
|
+
try { c.res.write(msg); } catch { clients.delete(c); }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function sseHandler(docId, req, res) {
|
|
43
|
+
res.writeHead(200, {
|
|
44
|
+
"Content-Type": "text/event-stream",
|
|
45
|
+
"Cache-Control": "no-store",
|
|
46
|
+
Connection: "keep-alive",
|
|
47
|
+
});
|
|
48
|
+
res.write(`data: ${JSON.stringify({ type: "hello", docId })}\n\n`);
|
|
49
|
+
const entry = { docId, res };
|
|
50
|
+
clients.add(entry);
|
|
51
|
+
req.on("close", () => clients.delete(entry));
|
|
52
|
+
}
|