annotatable-html 0.0.0-stage → 1.38.3
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/LICENSE +21 -0
- package/README.md +289 -2
- package/agent/annot_answer.js +7 -0
- package/agent/annot_edit.js +6 -0
- package/agent/annot_poll.js +12 -0
- package/agent/annot_read.js +5 -0
- package/agent/annot_reply.js +50 -0
- package/bin/cli.js +239 -0
- package/lib/core.js +2198 -0
- package/lib/lang-en.js +462 -0
- package/lib/serve.js +293 -0
- package/lib/vendor/marked.cjs +2811 -0
- package/lib/vendor/temml.cjs +14710 -0
- package/package.json +48 -4
- package/templates/AGENTS.md +107 -0
- package/templates/SKILL.md +99 -0
- package/templates/annotatable-doc.html +5708 -0
- package/templates/annotatable.theme.css +28 -0
- package/templates/commands/html4agent.md +37 -0
- package/templates/theme-studio.html +330 -0
package/lib/core.js
ADDED
|
@@ -0,0 +1,2198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* 可批注 HTML · 核心逻辑
|
|
4
|
+
* 供 bin/cli.js 与 agent/ 薄封装共用;与 AGENTS.md 描述的工作流保持一致。 */
|
|
5
|
+
|
|
6
|
+
var fs = require('fs');
|
|
7
|
+
var path = require('path');
|
|
8
|
+
var LANG = require('./lang-en.js');
|
|
9
|
+
|
|
10
|
+
/* ---------- 英文烤制(v1.25.0)----------
|
|
11
|
+
* 词典最长优先替换全文(HTML 文本/属性/JS 字面量)+ 两个 agent 协议块整体英译。
|
|
12
|
+
* 词典本体(zh 键)先掩码再替换——en 文档运行时还要用它把动态生成的中文翻成英文
|
|
13
|
+
* (boot 后 MutationObserver 消费 LANG_PAIRS,模板里 langTr 只认 DOC_LANG==='en')。 */
|
|
14
|
+
var LANG_BLOCK_ANNOT_EN = [
|
|
15
|
+
'<!-- ═══ ANNOTATION DATA BEGIN — response protocol for AI agents (this block is the only part you need to read):',
|
|
16
|
+
'① Handle only annotations with status:"new"; the thread array is the full conversation (role: user/ai). Read the whole thread before answering; answer only what is asked, quote minimal context, do not pad;',
|
|
17
|
+
'② Reply format: short answers (≤5 lines) go inline as the reply; long answers are written to answers/{project}_ANSWER__{topic}__{agent}.md next to this file (staging + per-round sections; reuse the same file for the same topic), with a 1-3 line summary plus the relative md path as the reply;',
|
|
18
|
+
'③ Write back with the standard tools annot_reply.js / annotatable reply (appends the ai turn, maintains history snapshots, rev+1); the reply field mirrors the latest AI turn;',
|
|
19
|
+
'④ If a comment contains "stash" or "keep" → file the quoted text into that ANSWER.md staging area; "distill" or "distill to: file" → distill reusable knowledge into docs/Learn/ (or the named file);',
|
|
20
|
+
'⑤ history is the version ledger: since v1.10.0 the file keeps a skeleton (rev/time/op/n, last 60; adjustable in the history panel ⚙, persisted via store histCaps — the CLI tools trim by the same counts; edit entries carry bodyOps strips); the full snapshot ledger lives in .annotatable/<file>.hist.json (tool-maintained, with DOC_ID ownership checks). Users can roll back any version (including body) in the page Version history. Agents append via annot_reply.js / annot_edit.js as usual — never hand-edit the history structure;',
|
|
21
|
+
'⑥ Duty & streaming (v1.21.0): while on duty, append with `annotatable reply doc.html --stream \'chunk\' --id annId` (the page renders typing live); finish with `--stream-end \'full text\'`; exit code 4 = the human pressed ⏹ (stop immediately), 5 = the annotation was deleted (stop too), 7 = no open streaming round (start one with --id). Without --id, a chunk auto-attaches to the most recently STARTED still-open streaming round and never to a finished one. An ai turn with streaming:true means a reply is in progress; top-level control.stop is an interrupt request written by the page button — tools consume it automatically, never hand-edit. After each round, re-mount `annotatable poll` to keep watching ═══ -->'
|
|
22
|
+
].join('\n');
|
|
23
|
+
var LANG_BLOCK_LESSON_EN = [
|
|
24
|
+
'<!-- ═══ LESSON DATA BEGIN — long answers / lesson cards: the agent writes via `annotatable answer` (the page renders read-only; browser saves preserve this block):',
|
|
25
|
+
'Shape { rev, answers: [{ id, annId (source annotation id), title, rungs: [{ title rung heading, body rung body, checkpoint checkpoint (optional), answer answer key (optional) }] }] }',
|
|
26
|
+
'Multi-rung or checkpointed entries render on the answer page as staged lesson cards (rung-by-rung unlock); single-rung entries render as one long-answer card.',
|
|
27
|
+
'The annotation store stays slim: to read pending annotations, read the annotations-store above — no need to swallow lesson full texts ═══ -->'
|
|
28
|
+
].join('\n');
|
|
29
|
+
function applyLangEn(tpl) {
|
|
30
|
+
var pairs = LANG.pairs.slice().sort(function (a, b) { return b[0].length - a[0].length; });
|
|
31
|
+
var masked = tpl.replace(/var LANG_PAIRS = null;[^\n]*/, '/*__LANG_PAIRS__*/');
|
|
32
|
+
/* v1.36.0:层三件的四个哨兵标记是结构坐标(inject/upgrade 按它们定位换装),先掩码——
|
|
33
|
+
否则词典短键「批注」把「批注层样式 BEGIN」烤成「annotation层…」,en 注入页从此造不出来(v1.17.0 潜伏 bug) */
|
|
34
|
+
masked = masked.split('/* ═══ 批注层样式 BEGIN').join('/*__SENT_CSS_B__')
|
|
35
|
+
.split('批注层样式 END ═══ */').join('__SENT_CSS_E__*/')
|
|
36
|
+
.split('<!-- ═══ 批注层骨架 BEGIN').join('<!--__SENT_SK_B__')
|
|
37
|
+
.split('批注层骨架 END ═══ -->').join('__SENT_SK_E__-->');
|
|
38
|
+
/* OP_LABELS 以中文为对象键(键是账本里的 op 代码,必须保持 zh;值另有词典条目负责显示),
|
|
39
|
+
键被替换会产出 "follow-up:'follow-up'" 这类未加引号含连字符的键=语法错,同样掩码 */
|
|
40
|
+
masked = masked.replace(/var OP_LABELS = \{[^}]*\};/, '/*__OP_LABELS__*/');
|
|
41
|
+
masked = masked.replace('<html lang="zh-CN">', '<html lang="en">');
|
|
42
|
+
masked = masked.replace(/<!-- ═══ AI批注数据 BEGIN[\s\S]*?═══ -->/, LANG_BLOCK_ANNOT_EN);
|
|
43
|
+
masked = masked.replace(/<!-- ═══ AI教学数据 BEGIN[\s\S]*?═══ -->/, LANG_BLOCK_LESSON_EN);
|
|
44
|
+
var missed = [];
|
|
45
|
+
pairs.forEach(function (p) {
|
|
46
|
+
if (masked.indexOf(p[0]) === -1) { missed.push(p[0]); return; }
|
|
47
|
+
masked = masked.split(p[0]).join(p[1]);
|
|
48
|
+
});
|
|
49
|
+
masked = masked.replace('var DOC_LANG = \'zh\';', 'var DOC_LANG = \'en\';');
|
|
50
|
+
masked = masked.replace('/*__LANG_PAIRS__*/',
|
|
51
|
+
'var LANG_PAIRS=' + JSON.stringify(pairs).replace(/<\//g, '<\\/') + ';');
|
|
52
|
+
masked = masked.split('/*__SENT_CSS_B__').join('/* ═══ 批注层样式 BEGIN')
|
|
53
|
+
.split('__SENT_CSS_E__*/').join('批注层样式 END ═══ */')
|
|
54
|
+
.split('<!--__SENT_SK_B__').join('<!-- ═══ 批注层骨架 BEGIN')
|
|
55
|
+
.split('__SENT_SK_E__-->').join('批注层骨架 END ═══ -->');
|
|
56
|
+
masked = masked.replace('/*__OP_LABELS__*/', function () {
|
|
57
|
+
var m = tpl.match(/var OP_LABELS = \{[^}]*\};/);
|
|
58
|
+
return m ? m[0] : '';
|
|
59
|
+
});
|
|
60
|
+
return { html: masked, missed: missed };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ---------- 版本历史外置存储(v1.10.0)----------
|
|
64
|
+
* 主文件 store 只留骨架 history(无 snap,体积有界);
|
|
65
|
+
* 全量账本(含批注层快照)放同目录 .annotatable/<文件名>.hist.json。
|
|
66
|
+
* 伴生档案打开时做 DOC_ID 归属校验:不匹配视为不存在(防复制文件互染)。
|
|
67
|
+
* 写伴生失败(如目录只读)时自动降级:snap 留在主文件(v1.9 兼容模式)。 */
|
|
68
|
+
var HIST_DIR = '.annotatable';
|
|
69
|
+
var HIST_MAIN_MAX = 60; // 主文件内骨架条数上限(默认值,可被 store.histCaps 覆盖)
|
|
70
|
+
var HIST_SIDECAR_MAX = 1000; // 外置档案条数上限(默认值,可被 store.histCaps 覆盖)
|
|
71
|
+
var HIST_FALLBACK_MAX = 120; // 无外置档案时主文件内全量历史上限(v1.9 行为,不随设置走)
|
|
72
|
+
|
|
73
|
+
/* 用户可调的快照保留数量(v1.13.0):浏览器在历史面板设置、随 store.histCaps 落盘;
|
|
74
|
+
* CLI 必须按同一数量裁剪——否则 agent 一次提交就把用户调大的档案砍回默认(数据丢失)。
|
|
75
|
+
* 与模板 clampCaps 同规则双实现,改动必须两侧同步。范围:main 10~500 / side 50~5000。 */
|
|
76
|
+
function histCapsOf(o) {
|
|
77
|
+
var c = o && o.histCaps ? o.histCaps : {};
|
|
78
|
+
var main = Math.round(+c.main), side = Math.round(+c.side);
|
|
79
|
+
if (!isFinite(main) || main <= 0) main = HIST_MAIN_MAX;
|
|
80
|
+
if (!isFinite(side) || side <= 0) side = HIST_SIDECAR_MAX;
|
|
81
|
+
return { main: Math.max(10, Math.min(500, main)), side: Math.max(50, Math.min(5000, side)) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function docIdOf(htmlText) {
|
|
85
|
+
var m = htmlText.match(/var DOC_ID\s*=\s*'([^']*)'/);
|
|
86
|
+
return m ? m[1] : '';
|
|
87
|
+
}
|
|
88
|
+
function histSidePath(htmlPath) {
|
|
89
|
+
return path.join(path.dirname(htmlPath), HIST_DIR, path.basename(htmlPath) + '.hist.json');
|
|
90
|
+
}
|
|
91
|
+
function readSideHist(htmlPath, expectDoc) {
|
|
92
|
+
try {
|
|
93
|
+
var j = JSON.parse(fs.readFileSync(histSidePath(htmlPath), 'utf8'));
|
|
94
|
+
if (j && Array.isArray(j.entries) && j.DOC_ID === expectDoc) return j; // 归属校验
|
|
95
|
+
} catch (e) { /* 不存在/损坏 → 视为无档案 */ }
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function writeSideHist(htmlPath, docId, entries, sideMax) {
|
|
99
|
+
try {
|
|
100
|
+
var p = histSidePath(htmlPath);
|
|
101
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
102
|
+
var capped = entries.slice(-sideMax);
|
|
103
|
+
fs.writeFileSync(p, JSON.stringify({
|
|
104
|
+
annotatableHist: 1, DOC_ID: docId, FILE_NAME: path.basename(htmlPath),
|
|
105
|
+
rev: capped.length ? capped[capped.length - 1].rev : 0, entries: capped
|
|
106
|
+
}).replace(/</g, '\\u003c'));
|
|
107
|
+
return true;
|
|
108
|
+
} catch (e) { return false; }
|
|
109
|
+
}
|
|
110
|
+
/* v1.31.0:serve 页面写侧车(POST /__annot/hist 的服务端半边)。与读端点对称:
|
|
111
|
+
* 锁内做 DOC_ID/文件名归属校验(不符=复制文件互染,拒收),条数按盘上 store.histCaps
|
|
112
|
+
* 收敛(与 commitHistory 同纪律——服务端不认客户端多带的多留要求),复用 writeSideHist 落盘。 */
|
|
113
|
+
function writeHistForFile(target, hist) {
|
|
114
|
+
if (!hist || !Array.isArray(hist.entries)) return { ok: false, error: 'bad-hist' };
|
|
115
|
+
var html = '';
|
|
116
|
+
try { html = fs.readFileSync(target, 'utf8'); } catch (e) { return { ok: false, error: 'no-file' }; }
|
|
117
|
+
var docId = docIdOf(html);
|
|
118
|
+
if (!docId) return { ok: false, error: 'no-doc-id' };
|
|
119
|
+
if (hist.DOC_ID !== docId || hist.FILE_NAME !== path.basename(target)) return { ok: false, error: 'doc-mismatch' };
|
|
120
|
+
var sideMax = histCapsOf(readStore(html)).side;
|
|
121
|
+
return withDocLock(target, function () {
|
|
122
|
+
return writeSideHist(target, docId, hist.entries, sideMax) ? { ok: true } : { ok: false, error: 'write-failed' };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* 合并文件内 history 与外置档案(按 rev 去重,文件内优先、档案补 snap/bodyOps),返回全量账本 */
|
|
127
|
+
function mergeHistory(inFileHist, sideEntries) {
|
|
128
|
+
var byRev = {}, out = [];
|
|
129
|
+
(inFileHist || []).forEach(function (en) { if (en && !byRev[en.rev]) { byRev[en.rev] = en; out.push(en); } });
|
|
130
|
+
(sideEntries || []).forEach(function (en) {
|
|
131
|
+
if (!en) return;
|
|
132
|
+
if (!byRev[en.rev]) { byRev[en.rev] = en; out.push(en); }
|
|
133
|
+
else {
|
|
134
|
+
if (!byRev[en.rev].snap && en.snap) byRev[en.rev].snap = en.snap;
|
|
135
|
+
/* 大纸条只在外置档案(浏览器端 skelEntry 同规则),并入内存时回填 */
|
|
136
|
+
if (!byRev[en.rev].bodyOps && en.bodyOps) byRev[en.rev].bodyOps = en.bodyOps;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
out.sort(function (a, b) { return a.rev - b.rev; });
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
/* 提交一版:push 新条目 → 写外置档案 → 决定主文件内 history 形态。返回 {full, sideOk} */
|
|
143
|
+
function commitHistory(htmlPath, htmlText, o, newEntry) {
|
|
144
|
+
o.history.push(newEntry);
|
|
145
|
+
var caps = histCapsOf(o);
|
|
146
|
+
var docId = docIdOf(htmlText) || ('doc-unknown-' + path.basename(htmlPath));
|
|
147
|
+
var side = readSideHist(htmlPath, docId);
|
|
148
|
+
var full = mergeHistory(o.history, side ? side.entries : null);
|
|
149
|
+
var sideOk = writeSideHist(htmlPath, docId, full, caps.side);
|
|
150
|
+
var mainHist;
|
|
151
|
+
if (sideOk) {
|
|
152
|
+
mainHist = full.slice(-caps.main).map(function (en) {
|
|
153
|
+
var c = { rev: en.rev, time: en.time, op: en.op, n: en.n };
|
|
154
|
+
/* 纸条小随骨架走(正文回滚要用);>20KB 大纸条(浏览器 Word 模式整节改写)只留外置档案 */
|
|
155
|
+
if (en.bodyOps && JSON.stringify(en.bodyOps).length <= 20000) c.bodyOps = en.bodyOps;
|
|
156
|
+
return c;
|
|
157
|
+
});
|
|
158
|
+
} else {
|
|
159
|
+
mainHist = full.slice(-HIST_FALLBACK_MAX); // 降级:snap 留在主文件(v1.9 行为)
|
|
160
|
+
}
|
|
161
|
+
o.history = mainHist;
|
|
162
|
+
return { full: full, sideOk: sideOk };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function fmtTime(ts) {
|
|
166
|
+
if (!ts) return '';
|
|
167
|
+
var d = new Date(ts);
|
|
168
|
+
function p(x) { return (x < 10 ? '0' : '') + x; }
|
|
169
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* ---------- 并发写回防线(v1.26.0,待改进⑮)----------
|
|
173
|
+
* 浏览器保存与 CLI 写回曾都是无锁「读→改→整文件覆盖」,毫秒级同时落盘后写者胜,
|
|
174
|
+
* 人类批注被静默冲掉。防线三层:
|
|
175
|
+
* ① 文档锁 withDocLock:.annotatable/<文件名>.lock 独占创建(wx 标志,已存在即失败),
|
|
176
|
+
* 持有者写完即删;锁内容 {pid,ts},持锁>15s 或 pid 已死视为陈锁(持有者暴毙)破门重取;
|
|
177
|
+
* serve 模式下 CLI 与服务器两个写入方都走锁 → 完全串行。
|
|
178
|
+
* ② 原子写 writeDocAtomic:临时文件+rename 覆盖,读方永远读到完整旧版或完整新版;
|
|
179
|
+
* Windows rename 撞上正在读的进程(EPERM/EBUSY)小睡重试。
|
|
180
|
+
* ③ 写票日志 writes.jsonl:锁内每次落盘追加 {writer,rev,ts},供无锁一方(浏览器 FS 直连
|
|
181
|
+
* 通道,无法参与独占锁)写后自查「我盖没盖掉别人」,盖了就自动合并重写。
|
|
182
|
+
* 配套规则:CLI 写回统一「锁内:重读最新→应用变更→写前再验磁盘未变→不符重来→原子写→记票」。
|
|
183
|
+
* 模板 autosaveNow/serverSaveNow 是本协议的另一端(锁礼让/rev 复验/回读/查票),双实现改动必须两侧同步。 */
|
|
184
|
+
var LOCK_STALE_MS = 15000;
|
|
185
|
+
var LOCK_WAIT_MS = 5000;
|
|
186
|
+
var ANCHOR_SUSPECT_LEN = 2000; // 锚定怀疑阈值:pos/文档长差异超过它即不可自动裁决(规范化字符)
|
|
187
|
+
function docLockPath(htmlPath) {
|
|
188
|
+
return path.join(path.dirname(htmlPath), HIST_DIR, path.basename(htmlPath) + '.lock');
|
|
189
|
+
}
|
|
190
|
+
function docWritesLogPath(htmlPath) {
|
|
191
|
+
return path.join(path.dirname(htmlPath), HIST_DIR, path.basename(htmlPath) + '.writes.jsonl');
|
|
192
|
+
}
|
|
193
|
+
function pidAlive(pid) {
|
|
194
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
195
|
+
}
|
|
196
|
+
/* 独占拿锁;成功返回 release 函数,超时抛错。EEXIST 时做陈锁判定(超龄/pid 已死/内容损坏)破门。 */
|
|
197
|
+
function acquireDocLock(htmlPath, opts) {
|
|
198
|
+
var lockP = docLockPath(htmlPath);
|
|
199
|
+
var waitMs = (opts && opts.waitMs) || LOCK_WAIT_MS;
|
|
200
|
+
try { fs.mkdirSync(path.dirname(lockP), { recursive: true }); } catch (e0) { /* 目录已存在 */ }
|
|
201
|
+
var deadline = Date.now() + waitMs;
|
|
202
|
+
for (;;) {
|
|
203
|
+
try {
|
|
204
|
+
var fd = fs.openSync(lockP, 'wx');
|
|
205
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
206
|
+
fs.closeSync(fd);
|
|
207
|
+
var mine = '';
|
|
208
|
+
try { mine = fs.readFileSync(lockP, 'utf8'); } catch (e1) {}
|
|
209
|
+
return function release() {
|
|
210
|
+
try {
|
|
211
|
+
var cur = fs.readFileSync(lockP, 'utf8');
|
|
212
|
+
if (!mine || cur === mine) fs.unlinkSync(lockP); // 只删自己创建的那把(防误删破门后他人的新锁)
|
|
213
|
+
} catch (e) { /* 锁已被他人接管/删除 → 不管 */ }
|
|
214
|
+
};
|
|
215
|
+
} catch (e) {
|
|
216
|
+
if (e.code !== 'EEXIST') throw e;
|
|
217
|
+
var stale = false;
|
|
218
|
+
try {
|
|
219
|
+
var j = JSON.parse(fs.readFileSync(lockP, 'utf8'));
|
|
220
|
+
if (!j || Date.now() - (j.ts || 0) > LOCK_STALE_MS) stale = true;
|
|
221
|
+
else if (typeof j.pid === 'number' && j.pid !== process.pid && !pidAlive(j.pid)) stale = true;
|
|
222
|
+
} catch (e2) { stale = true; } // 锁内容损坏 → 视为陈锁
|
|
223
|
+
if (stale) { try { fs.unlinkSync(lockP); } catch (e3) {} continue; }
|
|
224
|
+
if (Date.now() >= deadline) throw new Error('文档正被另一写入方持有(等待锁超时 ' + (waitMs / 1000) + 's):' + lockP);
|
|
225
|
+
var spin = Date.now() + 120;
|
|
226
|
+
while (Date.now() < spin) { /* CLI 是同步代码,忙等片刻再试 */ }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function withDocLock(htmlPath, fn, opts) {
|
|
231
|
+
var release = acquireDocLock(htmlPath, opts);
|
|
232
|
+
try { return fn(release); } finally { release(); }
|
|
233
|
+
}
|
|
234
|
+
/* 原子写:临时文件 + rename 覆盖。Windows 下目标正被别的进程读取时 rename 报
|
|
235
|
+
* EPERM/EBUSY——读者寿命都很短(readFileSync 即读即关),小睡重试即可。 */
|
|
236
|
+
function writeDocAtomic(htmlPath, text) {
|
|
237
|
+
var tmp = htmlPath + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2, 6);
|
|
238
|
+
fs.writeFileSync(tmp, text);
|
|
239
|
+
for (var i = 0; i < 4; i++) {
|
|
240
|
+
try { fs.renameSync(tmp, htmlPath); return; }
|
|
241
|
+
catch (e) {
|
|
242
|
+
if (i >= 3 || (e.code !== 'EPERM' && e.code !== 'EACCES' && e.code !== 'EBUSY')) {
|
|
243
|
+
try { fs.unlinkSync(tmp); } catch (e2) { /* temp 残留可容忍 */ }
|
|
244
|
+
throw e;
|
|
245
|
+
}
|
|
246
|
+
var spin = Date.now() + 60;
|
|
247
|
+
while (Date.now() < spin) { /* 等读者退出 */ }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/* 记写票:锁内每次成功落盘追加一行 {writer,rev,ts};超长只留尾部,失败不影响写回 */
|
|
252
|
+
function appendWriteTicket(htmlPath, writer, rev) {
|
|
253
|
+
try {
|
|
254
|
+
var p = docWritesLogPath(htmlPath);
|
|
255
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
256
|
+
fs.appendFileSync(p, JSON.stringify({ writer: writer || 'cli', rev: rev, ts: Date.now() }) + '\n');
|
|
257
|
+
try {
|
|
258
|
+
var lines = fs.readFileSync(p, 'utf8').split('\n');
|
|
259
|
+
if (lines.length > 220) fs.writeFileSync(p, lines.slice(-200).join('\n'));
|
|
260
|
+
} catch (e2) { /* 裁剪失败可容忍 */ }
|
|
261
|
+
} catch (e) { /* 日志失败不影响写回 */ }
|
|
262
|
+
}
|
|
263
|
+
/* serve 写回的锁内复验 + 原子落盘(serve.js 与测试共用)。
|
|
264
|
+
* baseRev = 浏览器提交所基于的 rev;null/undefined = 老页面,跳过校验按现状放行。
|
|
265
|
+
* 返回 { ok:true, rev } 或 { ok:false, conflict:true, diskRev }。 */
|
|
266
|
+
/* v1.37.3 Bug C:serve 整页回传里的 answers-store 可能来自旧加载的内存快照(页面加载后
|
|
267
|
+
* DOM 里的区块不随 liveAdopt 更新,旧版 buildAnnotatedHTML 又序列化 DOM 旧块),直接落盘
|
|
268
|
+
* 会把 CLI 新写的教学卡冲掉(实测 P001 卡被清空)。教学卡唯一写方是 CLI(annotatable
|
|
269
|
+
* answer,区块 rev 单调递增):锁内比对盘上与回传的区块,回传 rev 落后 → 以盘上为准保留。 */
|
|
270
|
+
function answersBlockOf(htmlText) {
|
|
271
|
+
var m = htmlText.match(/<script id="answers-store" type="application\/json">\s*([\s\S]*?)\s*<\/script>/);
|
|
272
|
+
if (!m) return null;
|
|
273
|
+
var v = null;
|
|
274
|
+
try { v = JSON.parse(m[1]); } catch (e) { v = null; }
|
|
275
|
+
if (!v || typeof v !== 'object' || !Array.isArray(v.answers)) v = null;
|
|
276
|
+
return { m: m, v: v };
|
|
277
|
+
}
|
|
278
|
+
function saveDocHtml(target, html, baseRev, writer) {
|
|
279
|
+
return withDocLock(target, function () {
|
|
280
|
+
var disk = '';
|
|
281
|
+
try { disk = fs.readFileSync(target, 'utf8'); } catch (e) { /* 调用方已保证存在,兜底按空处理 */ }
|
|
282
|
+
var st = disk ? readStore(disk) : null;
|
|
283
|
+
var diskRev = st ? (st.rev || 0) : 0;
|
|
284
|
+
if (baseRev !== null && baseRev !== undefined && diskRev !== baseRev) {
|
|
285
|
+
return { ok: false, conflict: true, diskRev: diskRev };
|
|
286
|
+
}
|
|
287
|
+
/* v1.37.3 Bug C:教学卡字段级保留——盘上有卡(区块可解析)而回传块更旧/损坏 → 回填盘上区块 */
|
|
288
|
+
var diskAns = disk ? answersBlockOf(disk) : null;
|
|
289
|
+
if (diskAns && diskAns.v) {
|
|
290
|
+
var inAns = answersBlockOf(html);
|
|
291
|
+
if (inAns && inAns.m && (!inAns.v || (inAns.v.rev || 0) < (diskAns.v.rev || 0))) {
|
|
292
|
+
var keptScript = '<script id="answers-store" type="application/json">\n' + JSON.stringify(diskAns.v).replace(/</g, '\\u003c') + '\n</' + 'script>';
|
|
293
|
+
html = html.slice(0, inAns.m.index) + keptScript + html.slice(inAns.m.index + inAns.m[0].length);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
writeDocAtomic(target, html);
|
|
297
|
+
var st2 = readStore(html);
|
|
298
|
+
var newRev = st2 ? (st2.rev || 0) : 0;
|
|
299
|
+
appendWriteTicket(target, writer || 'serve', newRev);
|
|
300
|
+
return { ok: true, rev: newRev };
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/* 片段规范化:剥标签+解常见实体+压空白(供 bodyOps 纸条与批注 quote 的软比对) */
|
|
304
|
+
function normSnippet(s) {
|
|
305
|
+
return String(s || '')
|
|
306
|
+
.replace(/<[^>]*>/g, ' ')
|
|
307
|
+
.replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')
|
|
308
|
+
.replace(/"/gi, '"').replace(/'/gi, "'")
|
|
309
|
+
.replace(/\s+/g, ' ').trim();
|
|
310
|
+
}
|
|
311
|
+
/* 锚定失效追溯:在侧车账本里找「哪个 rev 的 bodyOps 纸条替换掉了含引用原文的片段」,
|
|
312
|
+
* 返回 { goneAt, lastWith }(lastWith=回退目标 rev);账本缺失/无纸条记录返回 null。 */
|
|
313
|
+
function findLastRevWithQuote(htmlPath, docId, quote) {
|
|
314
|
+
var side = readSideHist(htmlPath, docId);
|
|
315
|
+
if (!side || !Array.isArray(side.entries)) return null;
|
|
316
|
+
var q = normSnippet(quote);
|
|
317
|
+
if (!q) return null;
|
|
318
|
+
var entries = side.entries.slice().sort(function (a, b) { return (b.rev || 0) - (a.rev || 0); });
|
|
319
|
+
for (var i = 0; i < entries.length; i++) {
|
|
320
|
+
var ops = entries[i].bodyOps;
|
|
321
|
+
if (!Array.isArray(ops)) continue;
|
|
322
|
+
for (var j = 0; j < ops.length; j++) {
|
|
323
|
+
if (ops[j] && ops[j].o && normSnippet(ops[j].o).indexOf(q) !== -1) {
|
|
324
|
+
return { goneAt: entries[i].rev, lastWith: (entries[i].rev || 1) - 1 };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function readStore(t) {
|
|
332
|
+
var m = t.match(/<script id="annotations-store" type="application\/json">\s*([\s\S]*?)\s*<\/script>/);
|
|
333
|
+
if (!m) return null;
|
|
334
|
+
var v;
|
|
335
|
+
try { v = JSON.parse(m[1]); } catch (e) { return null; }
|
|
336
|
+
if (Array.isArray(v)) return { rev: 0, annotations: v, history: [] };
|
|
337
|
+
if (v && Array.isArray(v.annotations)) {
|
|
338
|
+
return { rev: v.rev || 0, annotations: v.annotations, history: Array.isArray(v.history) ? v.history : [], histCaps: v.histCaps, control: (v.control && typeof v.control === 'object') ? v.control : {} };
|
|
339
|
+
}
|
|
340
|
+
return { rev: 0, annotations: [], history: [], control: {} };
|
|
341
|
+
}
|
|
342
|
+
/* v1.29.0 纯高亮静默:没打字的批注(仅划线)不算「待回应」——人类划重点/标记看过,
|
|
343
|
+
* AI 保持静默;想让 AI 回话就打字(哪怕一个「?」)。有字才算数。 */
|
|
344
|
+
function hasUserText(a) {
|
|
345
|
+
return (a.thread || []).some(function (t) { return t.role === 'user' && String(t.text || '').trim(); });
|
|
346
|
+
}
|
|
347
|
+
/* store 序列化唯一出口:control 区(stop 打断 / wordLock 编辑旗 / pendingBodyOps 排队 /
|
|
348
|
+
* replay 重做请求)必须随每次写回透传,丢了旗子排队制就断(v1.29.0) */
|
|
349
|
+
function storePayload(o) {
|
|
350
|
+
var out = { rev: o.rev, annotations: o.annotations, history: o.history, histCaps: o.histCaps };
|
|
351
|
+
if (o.control && Object.keys(o.control).length) out.control = o.control;
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* ---------- 页面结构体检(v1.26.3)----------
|
|
356
|
+
* 背景:v1.26.0 发版时的壳升级脚本曾把调研页烤成「双尾巴 + 丢 </article> + 主脚本裸 </script>
|
|
357
|
+
* 截断」,腐坏页静默存活两天,直到人类刷新才暴露为全页控件失灵。这类「整段复制/标签被吃」
|
|
358
|
+
* 腐蚀可能来自任何写手(升级脚本、浏览器写回 bug、手工编辑)。read/reply 是 agent 动笔前后
|
|
359
|
+
* 必经之门——在这里亮警告,让 AI 在腐坏页上落笔前先看见。只警告不拦截(抢救数据优先于洁癖)。 */
|
|
360
|
+
function lintStructure(htmlText) {
|
|
361
|
+
var w = [];
|
|
362
|
+
if (!/<script id="annotations-store"/.test(htmlText)) return w; // 非批注页不体检
|
|
363
|
+
['<aside id="annot-nav"', '<button id="annot-fab"', '<script id="annotations-store"', '<aside id="annot-panel"'].forEach(function (sig) {
|
|
364
|
+
var n = htmlText.split(sig).length - 1;
|
|
365
|
+
if (n > 1) w.push('批注层骨架重复 ' + n + ' 份(' + sig + ' …)——疑似整段复制腐蚀,以 rev 最大的一份为数据基准重建壳');
|
|
366
|
+
});
|
|
367
|
+
/* article 存在性必须在剥 script 后测:inject 页的 article 字样全在运行时 JS 注释/字符串里
|
|
368
|
+
(模板源码自含 </article> 字面量),直接全文测=每次 read/reply 假报「闭合丢失」(v1.32.2 实证) */
|
|
369
|
+
var lintNoScript = htmlText.replace(new RegExp('<' + 'script[\\s\\S]*?<' + '/script>', 'g'), '');
|
|
370
|
+
if (/<article[\s>]/.test(lintNoScript) &&
|
|
371
|
+
!/<\/article>\s*(?:<\/div>\s*)?(?:<!--[\s\S]{0,200}?AI批注数据 BEGIN|<script id="annotations-store")/.test(htmlText)) {
|
|
372
|
+
w.push('article 闭合标签疑似丢失(批注尾巴被吞进正文,正文区可能被污染)');
|
|
373
|
+
}
|
|
374
|
+
var ms = htmlText.match(/<script id="annotations-store" type="application\/json">([\s\S]*?)<\/script>/);
|
|
375
|
+
if (ms) { try { JSON.parse(ms[1]); } catch (e) { w.push('批注数据 store JSON 解析失败:' + e.message); } }
|
|
376
|
+
if (htmlText.indexOf('</scr' + 'ipt></article') !== -1) w.push('存在裸 </script></article> 序列——主脚本可能被截断(下次解析整层哑火)');
|
|
377
|
+
if (!/<meta name="annotatable-html"/.test(htmlText)) w.push('无版本 meta(v1.26.1 前旧层页)——建议重建壳升级');
|
|
378
|
+
/* v1.31.0:通用重复 id 体检——第三方运行时壳被烤入磁盘的典型症状(重载后静态壳+运行时壳
|
|
379
|
+
* 并存,getElementById 命中静态死元素=控件失灵)。先剥 script 区再扫,防模板/脚本源码字面量假阳性 */
|
|
380
|
+
var markupOnly = htmlText.replace(/<script[\s\S]*?<\/script>/gi, ' ');
|
|
381
|
+
var idCount = {};
|
|
382
|
+
(markupOnly.match(/\sid="[^"]+"/g) || []).forEach(function (m) {
|
|
383
|
+
var idv = m.slice(5, -1);
|
|
384
|
+
idCount[idv] = (idCount[idv] || 0) + 1;
|
|
385
|
+
});
|
|
386
|
+
var idDups = Object.keys(idCount).filter(function (k) { return idCount[k] > 1; });
|
|
387
|
+
if (idDups.length) w.push('重复 id ' + idDups.length + ' 个(' + idDups.slice(0, 3).join(' / ') + ')——疑似运行时 DOM 被烤入磁盘(第三方脚本建壳需注册 __annotSaveStrip),重载会双实例撞车');
|
|
388
|
+
return w;
|
|
389
|
+
}
|
|
390
|
+
function printLintWarnings(htmlText) {
|
|
391
|
+
var w = lintStructure(htmlText);
|
|
392
|
+
if (w.length) {
|
|
393
|
+
console.log('⚠ 结构体检 ' + w.length + ' 项(页面可能被腐蚀,先抢救数据再写回):');
|
|
394
|
+
w.forEach(function (x) { console.log(' ! ' + x); });
|
|
395
|
+
}
|
|
396
|
+
return w;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* 列出待回应批注(含完整对话线)。v1.29.0:仅划线(无文字)不进待回应清单,单列计数保持静默 */
|
|
400
|
+
function readPending(htmlPath) {
|
|
401
|
+
silentUpgrade(htmlPath); // v1.36.0 触碰即换装:旧层页先升最新层再读(用户无感)
|
|
402
|
+
var t = fs.readFileSync(htmlPath, 'utf8');
|
|
403
|
+
var o = readStore(t);
|
|
404
|
+
if (!o) { console.log('NO_STORE(该文件没有批注区块)'); return; }
|
|
405
|
+
var neu = o.annotations.filter(function (a) { return a.status !== 'answered' && hasUserText(a); });
|
|
406
|
+
var marks = o.annotations.filter(function (a) { return a.status !== 'answered' && !hasUserText(a); });
|
|
407
|
+
console.log('文件: ' + htmlPath);
|
|
408
|
+
console.log('rev=' + o.rev + ' · 总批注=' + o.annotations.length + ' · 待回应=' + neu.length + ' · 版本记录=' + o.history.length);
|
|
409
|
+
var gapNote = watchGapNote(htmlPath, neu.map(function (a) { return a.created || 0; }));
|
|
410
|
+
if (gapNote) console.log(gapNote); // v1.33.0 断档补捞:值守离线期收到的批注一眼定优先级
|
|
411
|
+
printLintWarnings(t);
|
|
412
|
+
var replay = o.control && o.control.replay;
|
|
413
|
+
if (replay) {
|
|
414
|
+
console.log('⚠ 排队正文改动待重做 ' + ((replay.ops || []).length) + ' 处(人类随后大改正文,原文锚不上)——请重读正文,基于新文重新生成这些改动再 annot_edit 提交(提交即视为已处理):');
|
|
415
|
+
(replay.ops || []).forEach(function (s, i) {
|
|
416
|
+
var o0 = String(s.o || ''), n0 = String(s.n == null ? '' : s.n);
|
|
417
|
+
console.log(' [' + (i + 1) + '] 原:「' + (o0.length > 40 ? o0.slice(0, 40) + '…' : o0) + '」 新:「' + (n0.length > 40 ? n0.slice(0, 40) + '…' : n0) + '」');
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
neu.forEach(function (a, i) {
|
|
421
|
+
var streaming = (a.thread || []).some(function (x) { return x.role === 'ai' && x.streaming; });
|
|
422
|
+
console.log('---【' + (i + 1) + '】id=' + a.id + (a.section ? ' §' + a.section : '') + (a.scope ? ' (AI回答子批注,父=' + a.scope + ')' : '') + (streaming ? ' ⏳流式作答中' : '') + '(' + relTime(a.created) + '创建)');
|
|
423
|
+
console.log('原句:', a.quote);
|
|
424
|
+
console.log('上下文: …' + (a.prefix || '') + '⟨原句⟩' + (a.suffix || '') + '…');
|
|
425
|
+
console.log('对话线(共 ' + ((a.thread || []).length) + ' 轮):');
|
|
426
|
+
(a.thread || []).forEach(function (x) {
|
|
427
|
+
console.log(' [' + (x.role === 'ai' ? 'AI' : '你') + '] ' + x.text);
|
|
428
|
+
});
|
|
429
|
+
if (!a.thread || !a.thread.length) console.log(' [你] ' + (a.comment || '(仅划线)'));
|
|
430
|
+
});
|
|
431
|
+
if (!neu.length) console.log('(无待回应批注)');
|
|
432
|
+
if (marks.length) console.log('(另有 ' + marks.length + ' 条仅划线批注:人类划重点,保持静默,无需回应)');
|
|
433
|
+
}
|
|
434
|
+
function relTime(ts) {
|
|
435
|
+
if (!ts) return '未知时间';
|
|
436
|
+
var d = Date.now() - ts;
|
|
437
|
+
if (d < 60e3) return Math.max(1, Math.round(d / 1e3)) + '秒前';
|
|
438
|
+
if (d < 3600e3) return Math.round(d / 60e3) + '分钟前';
|
|
439
|
+
if (d < 86400e3) return Math.round(d / 3600e3) + '小时前';
|
|
440
|
+
return Math.round(d / 86400e3) + '天前';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* ---------- 值守心跳(v1.33.0,issue#6)----------
|
|
444
|
+
* poll 生存期写 .annotatable/<文件名>.watch.json:{pid,file,startedAt,lastBeat,state}。
|
|
445
|
+
* state 'watching'=在岗轮询(每 tick 续跳);退出(命中/超时)改写 'handoff'=已交班。
|
|
446
|
+
* serve /__annot/status 与页面值守灯据此区分 在岗/心跳超龄/换班中/无人值守;
|
|
447
|
+
* watchGapNote 对比 pending 批注出生时间与最后心跳,read/poll 报「值守离线期收到 N 条」
|
|
448
|
+
* =断档补捞(两班之间收到的批注不再只靠进程考古才发现)。 */
|
|
449
|
+
function watchBeatPath(htmlPath) {
|
|
450
|
+
return path.join(path.dirname(htmlPath), HIST_DIR, path.basename(htmlPath) + '.watch.json');
|
|
451
|
+
}
|
|
452
|
+
function readWatchBeat(htmlPath) {
|
|
453
|
+
try { return JSON.parse(fs.readFileSync(watchBeatPath(htmlPath), 'utf8')); } catch (e) { return null; }
|
|
454
|
+
}
|
|
455
|
+
function writeWatchBeat(htmlPath, state, startedAt) {
|
|
456
|
+
try {
|
|
457
|
+
fs.mkdirSync(path.join(path.dirname(htmlPath), HIST_DIR), { recursive: true });
|
|
458
|
+
fs.writeFileSync(watchBeatPath(htmlPath), JSON.stringify({
|
|
459
|
+
pid: process.pid, file: path.basename(htmlPath),
|
|
460
|
+
startedAt: startedAt || Date.now(), lastBeat: Date.now(), state: state
|
|
461
|
+
}));
|
|
462
|
+
} catch (e) { /* 心跳尽力而为:档案目录不可写不阻塞值守 */ }
|
|
463
|
+
}
|
|
464
|
+
function watchGapNoteFrom(beat, createdArr) {
|
|
465
|
+
if (!beat || !beat.lastBeat || !Array.isArray(createdArr) || !createdArr.length) return '';
|
|
466
|
+
var missed = createdArr.filter(function (ts) { return ts > beat.lastBeat; });
|
|
467
|
+
if (!missed.length) return '';
|
|
468
|
+
var oldest = Math.min.apply(null, missed);
|
|
469
|
+
return '⚠ 值守离线期收到 ' + missed.length + ' 条批注(最后心跳 ' + relTime(beat.lastBeat) +
|
|
470
|
+
',最早一条 ' + relTime(oldest) + '创建)——断档期间无人监听,请优先处理';
|
|
471
|
+
}
|
|
472
|
+
function watchGapNote(htmlPath, createdArr) {
|
|
473
|
+
return watchGapNoteFrom(readWatchBeat(htmlPath), createdArr);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* ---------- poll:挂起等待新批注(v1.11.0,借鉴 human-review 的 poll 等待体验)----------
|
|
477
|
+
* 供 agent 配 harness 后台任务使用:无待回应批注时阻塞轮询,人类划线批注保存后
|
|
478
|
+
* 打印 POLL_HIT <json> 并以 0 退出唤醒 agent;--timeout 秒数到点以 3 退出。
|
|
479
|
+
* 轮询只比对 {rev,总数,待回应} 签名,文件被写盘中(EBUSY 等)时静默跳过本轮。
|
|
480
|
+
* v1.29.0:①仅划线(无文字)不响铃不计数(纯高亮静默);②铃带每条批注的出生时间
|
|
481
|
+
* (agent 可自判「这条刚出生」);③排队正文改动重做请求(control.replay)同样响铃。 */
|
|
482
|
+
function pendingState(htmlPath) {
|
|
483
|
+
var o = readStore(fs.readFileSync(htmlPath, 'utf8'));
|
|
484
|
+
if (!o) return null;
|
|
485
|
+
var neu = o.annotations.filter(function (a) { return a.status !== 'answered' && hasUserText(a); });
|
|
486
|
+
return {
|
|
487
|
+
rev: o.rev || 0, total: o.annotations.length, pending: neu.length, ids: neu.map(function (a) { return a.id; }),
|
|
488
|
+
created: neu.map(function (a) { return a.created || 0; }),
|
|
489
|
+
replay: (o.control && o.control.replay) || null
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function pollAnnotations(htmlPath, opts) {
|
|
493
|
+
silentUpgrade(htmlPath); // v1.36.0 值守挂上即换装:旧层页先升最新层再开等
|
|
494
|
+
opts = opts || {};
|
|
495
|
+
var intervalSec = Math.max(1, parseInt(opts.interval, 10) || 3);
|
|
496
|
+
var timeoutSec = parseInt(opts.timeout, 10) || 0; // 0 = 一直等
|
|
497
|
+
var startedAt = Date.now();
|
|
498
|
+
var st = pendingState(htmlPath);
|
|
499
|
+
if (!st) throw new Error('NO_STORE(该文件没有批注区块)');
|
|
500
|
+
var prevBeat = readWatchBeat(htmlPath); // 上一班心跳(补捞基线)——必须在本班开跳前取走,否则被自己的心跳冲掉(探针揪出)
|
|
501
|
+
writeWatchBeat(htmlPath, 'watching', startedAt);
|
|
502
|
+
console.log('文件: ' + htmlPath);
|
|
503
|
+
console.log('rev=' + st.rev + ' · 总批注=' + st.total + ' · 待回应=' + st.pending + (st.replay ? ' · ⚠有排队正文改动待重做' : ''));
|
|
504
|
+
var gap0 = watchGapNoteFrom(prevBeat, st.created);
|
|
505
|
+
if (gap0) console.log(gap0);
|
|
506
|
+
function hit(s) {
|
|
507
|
+
var gap = watchGapNoteFrom(prevBeat, s.created);
|
|
508
|
+
if (gap) console.log(gap);
|
|
509
|
+
writeWatchBeat(htmlPath, 'handoff', startedAt); // 退出前留交班心跳:页面灯据此显示「换班中/无人值守 X 分钟」
|
|
510
|
+
var payload = { file: htmlPath, rev: s.rev, pending: s.pending, ids: s.ids, created: s.created };
|
|
511
|
+
if (s.replay) payload.replay = s.replay;
|
|
512
|
+
console.log('POLL_HIT ' + JSON.stringify(payload));
|
|
513
|
+
process.exit(0);
|
|
514
|
+
}
|
|
515
|
+
if (st.pending || st.replay) hit(st);
|
|
516
|
+
console.log('已有批注都已回应,等待新批注…(每 ' + intervalSec + 's 检查' + (timeoutSec ? ',最长 ' + timeoutSec + 's' : '') + ',Ctrl+C 退出)');
|
|
517
|
+
var lastSig = st.rev + ':' + st.total + ':' + st.pending + ':' + (st.replay ? st.replay.at : 0);
|
|
518
|
+
var deadline = timeoutSec > 0 ? Date.now() + timeoutSec * 1000 : 0;
|
|
519
|
+
return setInterval(function () {
|
|
520
|
+
var s2;
|
|
521
|
+
try { s2 = pendingState(htmlPath); } catch (e) { return; } // 写盘中 → 下轮再看
|
|
522
|
+
if (!s2) return;
|
|
523
|
+
writeWatchBeat(htmlPath, 'watching', startedAt); // v1.33.0 心跳:进程活着的可观测证据(死了一跳就停)
|
|
524
|
+
var sig = s2.rev + ':' + s2.total + ':' + s2.pending + ':' + (s2.replay ? s2.replay.at : 0);
|
|
525
|
+
if (sig !== lastSig) {
|
|
526
|
+
lastSig = sig;
|
|
527
|
+
if (s2.pending > 0 || s2.replay) hit(s2); // rev 变了但无新批注且无重做请求(如 AI 自己写回)→ 静默继续
|
|
528
|
+
}
|
|
529
|
+
if (deadline && Date.now() > deadline) {
|
|
530
|
+
writeWatchBeat(htmlPath, 'handoff', startedAt);
|
|
531
|
+
console.log('TIMEOUT · 待回应=' + s2.pending); process.exit(3);
|
|
532
|
+
}
|
|
533
|
+
}, intervalSec * 1000);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/* 把 AI 回复追加为 ai 轮次,维护镜像/状态/快照/rev。
|
|
537
|
+
* v1.26.0:全程持文档锁;写前复验磁盘未变(被浏览器抢先落盘则在最新状态上重放同一批回复,
|
|
538
|
+
* 幂等:按 id 覆盖式追加);原子写+记写票。目标批注已被删除 → 跳过并在输出明报,不整单失败。 */
|
|
539
|
+
function applyReplies(htmlPath, repliesPath) {
|
|
540
|
+
silentUpgrade(htmlPath); // v1.36.0 触碰即换装
|
|
541
|
+
if (wordLockActive(fs.readFileSync(htmlPath, 'utf8'))) { // v1.37.0 编辑礼让:人类举旗期间回复不落盘
|
|
542
|
+
console.log('WORD_MODE 人类编辑中(wordLock 旗鲜活):回复挂起不落盘,等收笔再发');
|
|
543
|
+
process.exitCode = 6;
|
|
544
|
+
return { status: 'wordmode' };
|
|
545
|
+
}
|
|
546
|
+
var replies = JSON.parse(fs.readFileSync(repliesPath, 'utf8'));
|
|
547
|
+
printLintWarnings(fs.readFileSync(htmlPath, 'utf8')); // v1.26.3:落笔前体检,腐坏页先暴露
|
|
548
|
+
var out = withDocLock(htmlPath, function () {
|
|
549
|
+
var attempt = 0;
|
|
550
|
+
for (;;) {
|
|
551
|
+
attempt++;
|
|
552
|
+
var t = fs.readFileSync(htmlPath, 'utf8');
|
|
553
|
+
var m = t.match(/(<script id="annotations-store" type="application\/json">\s*)([\s\S]*?)(\s*<\/script>)/);
|
|
554
|
+
if (!m) throw new Error('NO_STORE(未找到批注区块)');
|
|
555
|
+
var o = JSON.parse(m[2]);
|
|
556
|
+
if (!Array.isArray(o.history)) o.history = [];
|
|
557
|
+
var by = {}, emptySkip = [];
|
|
558
|
+
replies.forEach(function (r) {
|
|
559
|
+
/* v1.29.0 空回复防呆:空串回复落盘就是空气泡,拒绝而非静默入账 */
|
|
560
|
+
if (typeof r.reply === 'string' && !r.reply.trim()) { emptySkip.push(r.id); return; }
|
|
561
|
+
by[r.id] = r.reply;
|
|
562
|
+
});
|
|
563
|
+
var n = 0, missing = [];
|
|
564
|
+
o.annotations.forEach(function (a) {
|
|
565
|
+
if (by[a.id] === undefined) return;
|
|
566
|
+
if (!Array.isArray(a.thread)) {
|
|
567
|
+
a.thread = [];
|
|
568
|
+
if (a.comment) a.thread.push({ role: 'user', text: a.comment, time: a.created || Date.now() });
|
|
569
|
+
}
|
|
570
|
+
if (!a.thread.length) a.thread.push({ role: 'user', text: a.comment || '', time: a.created || Date.now() });
|
|
571
|
+
a.thread.forEach(function (x) { if (x.role === 'ai' && x.streaming) delete x.streaming; }); // v1.37.3:该批注残留的流式轮旗全部落定(旧版只摘最后一轮)
|
|
572
|
+
a.thread.push({ role: 'ai', text: by[a.id], time: Date.now() });
|
|
573
|
+
a.reply = by[a.id];
|
|
574
|
+
if (!a.comment && a.thread[0]) a.comment = a.thread[0].text;
|
|
575
|
+
a.status = 'answered';
|
|
576
|
+
n++;
|
|
577
|
+
});
|
|
578
|
+
replies.forEach(function (r) { if (!o.annotations.some(function (a) { return a.id === r.id; })) missing.push(r.id); });
|
|
579
|
+
if (o.control) delete o.control.stop; // AI 正式写回 = 值守在岗,打断标志随之失效
|
|
580
|
+
/* 写前复验:读取期间磁盘又被写(浏览器抢先保存)→ 丢掉本次结果,在最新状态上重来 */
|
|
581
|
+
var diskNow = fs.readFileSync(htmlPath, 'utf8');
|
|
582
|
+
if (diskNow !== t && attempt < 8) continue;
|
|
583
|
+
if (diskNow !== t) throw new Error('并发写回冲突:重试 8 轮磁盘仍在变化,本轮未写盘(稍后重试即可)');
|
|
584
|
+
var res = null, sideNote = '';
|
|
585
|
+
if (n > 0) {
|
|
586
|
+
o.rev = (o.rev || 0) + 1;
|
|
587
|
+
res = commitHistory(htmlPath, t, o, {
|
|
588
|
+
rev: o.rev, time: Date.now(), op: 'AI回应 ' + n + ' 条', n: o.annotations.length,
|
|
589
|
+
snap: JSON.parse(JSON.stringify(o.annotations))
|
|
590
|
+
});
|
|
591
|
+
sideNote = res.sideOk
|
|
592
|
+
? '(全量账本 → ' + HIST_DIR + '/' + path.basename(htmlPath) + '.hist.json)'
|
|
593
|
+
: '(外置档案不可写,快照保留在主文件)';
|
|
594
|
+
}
|
|
595
|
+
var t2 = t.replace(m[0], m[1] + '\n' + JSON.stringify(storePayload(o)).replace(/</g, '\\u003c') + '\n' + m[3]);
|
|
596
|
+
writeDocAtomic(htmlPath, t2);
|
|
597
|
+
if (n > 0) appendWriteTicket(htmlPath, 'cli', o.rev);
|
|
598
|
+
return { n: n, rev: o.rev, missing: missing, emptySkip: emptySkip, sideNote: sideNote, histLen: res ? res.full.length : o.history.length };
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
console.log('已追加 ' + out.n + ' 条 AI 回复 · rev=' + out.rev + ' · 版本记录=' + out.histLen + ' ' + out.sideNote);
|
|
602
|
+
if (out.missing.length) console.log('⚠️ 未找到的批注id(可能已被删除,已跳过): ' + out.missing.join(', '));
|
|
603
|
+
if (out.emptySkip.length) {
|
|
604
|
+
console.log('⛔ ' + out.emptySkip.length + ' 条空回复被拒绝(回复前请重读批注;每条批注最后 AI 轮必须非空): ' + out.emptySkip.join(', '));
|
|
605
|
+
process.exitCode = 2;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/* ---------- 流式回答(v1.21.0)----------
|
|
610
|
+
* 值守协议的作答通道:agent 每生成一段就调一次,页面 live-sync 实时渲染出
|
|
611
|
+
* 打字中的回答;人类点「⏹ 停止」会把 control.stop 写进文件,下一次追加就地
|
|
612
|
+
* 终止;批注被整条删除 → 目标消失,同样终止。返回 {status, annId, rev}:
|
|
613
|
+
* ok 追加/开始/收尾成功
|
|
614
|
+
* interrupted 人类已打断(本轮作答到此为止,agent 不应再追加)
|
|
615
|
+
* target-gone 目标批注已被删除/不存在
|
|
616
|
+
* no-stream 没有进行中的流式轮(未带 id 的追加找不到落点)
|
|
617
|
+
* rev 每次写入 +1(浏览器靠 rev 防互相覆盖);history 只在 流开始/收尾/打断
|
|
618
|
+
* 三种节点记账——中间段不进账本,防骨架账本被长回答刷爆;收尾/打断条目持有
|
|
619
|
+
* 全量快照,回滚语义不变。 */
|
|
620
|
+
function findStreamTurn(o, annId) {
|
|
621
|
+
var hit = null;
|
|
622
|
+
o.annotations.forEach(function (a) {
|
|
623
|
+
if (annId && a.id !== annId) return;
|
|
624
|
+
(a.thread || []).forEach(function (x) {
|
|
625
|
+
if (x.role === 'ai' && x.streaming) {
|
|
626
|
+
/* v1.37.3:按开始时间取最近——旧版按数组序取最后一条,多开流式轮/残留轮时
|
|
627
|
+
不带 --id 的续段会串线到早已收尾的批注(实测三次)。时间缺失退数组序(保底)。 */
|
|
628
|
+
if (!hit || ((x.time || 0) >= (hit.turn.time || 0))) hit = { ann: a, turn: x };
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
return hit;
|
|
633
|
+
}
|
|
634
|
+
/* v1.37.3:流式轮全系统至多一条打开。开始新流/收尾/打断时,把其他批注上遗留的打开轮
|
|
635
|
+
就地封轮(摘 streaming 旗,已写文本保留)——残留打开轮是不带 --id 续段串线的根源。 */
|
|
636
|
+
function closeOrphanStreams(o, keepAnnId) {
|
|
637
|
+
var closed = [];
|
|
638
|
+
o.annotations.forEach(function (a) {
|
|
639
|
+
if (keepAnnId && a.id === keepAnnId) return;
|
|
640
|
+
(a.thread || []).forEach(function (x) {
|
|
641
|
+
if (x.role === 'ai' && x.streaming) { delete x.streaming; if (closed.indexOf(a.id) === -1) closed.push(a.id); }
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
return closed;
|
|
645
|
+
}
|
|
646
|
+
/* 摘掉一条批注线程里全部 streaming 旗(end/打断后兜底,防旧版多旗残留漏摘) */
|
|
647
|
+
function clearAnnStreamFlags(a) {
|
|
648
|
+
(a.thread || []).forEach(function (x) { if (x.role === 'ai' && x.streaming) delete x.streaming; });
|
|
649
|
+
}
|
|
650
|
+
function streamBuild(t, m, o) {
|
|
651
|
+
var out = { rev: o.rev, annotations: o.annotations, history: o.history, histCaps: o.histCaps };
|
|
652
|
+
if (o.control && Object.keys(o.control).length) out.control = o.control;
|
|
653
|
+
return t.replace(m[0], m[1] + '\n' + JSON.stringify(out).replace(/</g, '\\u003c') + '\n' + m[3]);
|
|
654
|
+
}
|
|
655
|
+
function streamReply(htmlPath, opts) {
|
|
656
|
+
opts = opts || {};
|
|
657
|
+
var text = String(opts.text == null ? '' : opts.text);
|
|
658
|
+
if (!text && !opts.end) return { status: 'empty' }; // 空段落是误调用,别烧掉打断标志也别建空轮
|
|
659
|
+
silentUpgrade(htmlPath); // v1.36.0 触碰即换装(误调用不升,真作答先升)
|
|
660
|
+
if (wordLockActive(fs.readFileSync(htmlPath, 'utf8'))) return { status: 'wordmode' }; // v1.37.0 编辑礼让:举旗期间不落盘
|
|
661
|
+
/* v1.26.0:每段写入都持锁+写前复验(人在流式期间插追问/保存 → 段与段都落在最新状态上,线程交错但零丢失) */
|
|
662
|
+
return withDocLock(htmlPath, function () {
|
|
663
|
+
var attempt = 0;
|
|
664
|
+
for (;;) {
|
|
665
|
+
attempt++;
|
|
666
|
+
var t = fs.readFileSync(htmlPath, 'utf8');
|
|
667
|
+
var m = t.match(/(<script id="annotations-store" type="application\/json">\s*)([\s\S]*?)(\s*<\/script>)/);
|
|
668
|
+
if (!m) throw new Error('NO_STORE(未找到批注区块)');
|
|
669
|
+
var o = JSON.parse(m[2]);
|
|
670
|
+
if (!Array.isArray(o.history)) o.history = [];
|
|
671
|
+
if (!o.control || typeof o.control !== 'object') o.control = {};
|
|
672
|
+
var stopped = !!o.control.stop;
|
|
673
|
+
var hit = findStreamTurn(o, opts.id || null);
|
|
674
|
+
var book = null; // 需要记账(history 条目)的分支在这里备料
|
|
675
|
+
/* 打断优先:有人按过 ⏹ 且存在进行中的流式轮 → 本次写入就地终止它 */
|
|
676
|
+
if (stopped && hit) {
|
|
677
|
+
delete hit.turn.streaming;
|
|
678
|
+
hit.turn.interrupted = true;
|
|
679
|
+
clearAnnStreamFlags(hit.ann); // v1.37.3:同线程残余旗一并摘(旧版多旗残留兜底)
|
|
680
|
+
var closedI = closeOrphanStreams(o, hit.ann.id);
|
|
681
|
+
delete o.control.stop;
|
|
682
|
+
o.rev = (o.rev || 0) + 1;
|
|
683
|
+
book = { op: 'AI回应被打断', done: false, status: 'interrupted', annId: hit.ann.id, closed: closedI };
|
|
684
|
+
} else {
|
|
685
|
+
if (stopped) delete o.control.stop; // 陈旧标志(已无流式轮):清掉不拦新回答
|
|
686
|
+
if (opts.end) {
|
|
687
|
+
var ann = null;
|
|
688
|
+
var keptStream = false;
|
|
689
|
+
if (hit) {
|
|
690
|
+
/* v1.29.0 空收尾防呆:收尾没带全文=误调用,保留已流式内容收尾,绝不静默清空 */
|
|
691
|
+
if (!text && (hit.turn.text || '').length) { text = hit.turn.text; keptStream = true; }
|
|
692
|
+
ann = hit.ann;
|
|
693
|
+
delete hit.turn.streaming; hit.turn.text = text;
|
|
694
|
+
}
|
|
695
|
+
else if (opts.id) {
|
|
696
|
+
if (!text) return { status: 'empty-end' }; // 没有流式轮还收空尾=要落一条空回答,拒绝
|
|
697
|
+
ann = o.annotations.find(function (a) { return a.id === opts.id; });
|
|
698
|
+
if (!ann) return { status: 'target-gone', annId: opts.id };
|
|
699
|
+
ann.thread.push({ role: 'ai', text: text, time: Date.now() }); // 没 start 就 end:按一次性回答落
|
|
700
|
+
} else return { status: 'no-stream' };
|
|
701
|
+
clearAnnStreamFlags(ann); // v1.37.3:目标线程残余旗兜底摘净
|
|
702
|
+
var closedE = closeOrphanStreams(o, ann.id); // v1.37.3:别的批注上遗留的打开轮就地封轮
|
|
703
|
+
ann.status = 'answered';
|
|
704
|
+
ann.reply = text;
|
|
705
|
+
o.rev = (o.rev || 0) + 1;
|
|
706
|
+
book = { op: 'AI回应', done: true, status: 'ok', annId: ann.id, kept: keptStream, closed: closedE };
|
|
707
|
+
} else if (hit) {
|
|
708
|
+
hit.turn.text += text;
|
|
709
|
+
o.rev = (o.rev || 0) + 1;
|
|
710
|
+
} else {
|
|
711
|
+
if (!opts.id) return { status: 'no-stream' };
|
|
712
|
+
var ann2 = o.annotations.find(function (a) { return a.id === opts.id; });
|
|
713
|
+
if (!ann2) return { status: 'target-gone', annId: opts.id };
|
|
714
|
+
var closedS = closeOrphanStreams(o, ann2.id); // v1.37.3:新流开始前封掉全部遗留打开轮(不变量:至多一条)
|
|
715
|
+
if (!Array.isArray(ann2.thread)) ann2.thread = [];
|
|
716
|
+
ann2.thread.push({ role: 'ai', text: text, time: Date.now(), streaming: true });
|
|
717
|
+
ann2.status = 'answered';
|
|
718
|
+
o.rev = (o.rev || 0) + 1;
|
|
719
|
+
book = { op: 'AI回应(流式开始)', done: false, status: 'ok', annId: ann2.id, started: true, closed: closedS };
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
/* 写前复验:读取期间磁盘被写(人类追问抢先保存)→ 在最新状态上重放本段 */
|
|
723
|
+
var diskNow = fs.readFileSync(htmlPath, 'utf8');
|
|
724
|
+
if (diskNow !== t && attempt < 8) continue;
|
|
725
|
+
if (diskNow !== t) throw new Error('并发写回冲突:重试 8 轮磁盘仍在变化,本段未写盘(下一段会带上重试)');
|
|
726
|
+
if (book) {
|
|
727
|
+
/* 记账(开始/收尾/打断三种节点)在序列化前:骨架随本次落盘,单次原子写 */
|
|
728
|
+
commitHistory(htmlPath, t, o, {
|
|
729
|
+
rev: o.rev, time: Date.now(), op: book.op, n: o.annotations.length,
|
|
730
|
+
snap: JSON.parse(JSON.stringify(o.annotations))
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
var t2 = streamBuild(t, m, o);
|
|
734
|
+
writeDocAtomic(htmlPath, t2);
|
|
735
|
+
appendWriteTicket(htmlPath, 'cli', o.rev);
|
|
736
|
+
if (book) return { status: book.status, annId: book.annId, rev: o.rev, done: !!book.done, started: !!book.started, kept: !!book.kept, closed: book.closed || [], chars: book.kept ? text.length : undefined };
|
|
737
|
+
return { status: 'ok', annId: (hit ? hit.ann.id : null), rev: o.rev };
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/* ---------- answer:长答/教学卡写入(v1.18.0)----------
|
|
743
|
+
* answers-store 与批注 store 分离成两个区块:agent 读待回应批注只读 annotations-store,
|
|
744
|
+
* 不必吞教学全文;页面只读渲染(回答大页教学卡),浏览器保存原样保留区块,写方只有本工具。
|
|
745
|
+
* 合并语义:entry.id 命中 → 整卡替换;annId+title 命中 → 整卡替换(「继续」时全量带上旧阶+新阶);否则新增。
|
|
746
|
+
* 账本:annotations-store rev+1 + history 快照——教学写入也是一次文件写回,浏览器靠 rev 防互相覆盖。 */
|
|
747
|
+
function readAnswers(t) {
|
|
748
|
+
var m = t.match(/<script id="answers-store" type="application\/json">\s*([\s\S]*?)\s*<\/script>/);
|
|
749
|
+
if (!m) return null;
|
|
750
|
+
var v;
|
|
751
|
+
try { v = JSON.parse(m[1]); } catch (e) { return null; }
|
|
752
|
+
if (v && Array.isArray(v.answers)) return { rev: v.rev || 0, answers: v.answers };
|
|
753
|
+
return { rev: 0, answers: [] };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function validateAnswerEntry(e, i) {
|
|
757
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) return '第 ' + (i + 1) + ' 条不是对象';
|
|
758
|
+
if (!Array.isArray(e.rungs) || !e.rungs.length) return '第 ' + (i + 1) + ' 条(' + (e.title || '无标题') + ')rungs 为空数组';
|
|
759
|
+
for (var j = 0; j < e.rungs.length; j++) {
|
|
760
|
+
var r = e.rungs[j];
|
|
761
|
+
if (!r || typeof r !== 'object' || typeof r.body !== 'string' || !r.body.trim())
|
|
762
|
+
return '第 ' + (i + 1) + ' 条第 ' + (j + 1) + ' 阶缺 body(阶正文必填)';
|
|
763
|
+
}
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/* 列出教学卡(annotatable answer 不带 json 参数时):agent「继续」前先看既有阶次 */
|
|
768
|
+
function listAnswers(htmlPath) {
|
|
769
|
+
var t = fs.readFileSync(htmlPath, 'utf8');
|
|
770
|
+
var a = readAnswers(t);
|
|
771
|
+
if (!a) { console.log('NO_ANSWERS(该文件没有教学区块,agent 直接写入即可)'); return; }
|
|
772
|
+
console.log('文件: ' + htmlPath);
|
|
773
|
+
console.log('教学卡 rev=' + a.rev + ' · 共 ' + a.answers.length + ' 张');
|
|
774
|
+
a.answers.forEach(function (x, i) {
|
|
775
|
+
console.log('---【' + (i + 1) + '】id=' + x.id + (x.annId ? ' · 批注=' + x.annId : '') + ' · ' + (x.rungs || []).length + ' 阶 · ' + fmtTime(x.created));
|
|
776
|
+
console.log(' ' + (x.title || '(无标题)'));
|
|
777
|
+
(x.rungs || []).forEach(function (r, j) {
|
|
778
|
+
console.log(' 阶' + (j + 1) + ': ' + (r.title || '(无阶标题)') + (r.checkpoint ? ' +检查点' : ''));
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
if (!a.answers.length) console.log('(暂无教学卡)');
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function applyAnswers(htmlPath, answersPath) {
|
|
785
|
+
silentUpgrade(htmlPath); // v1.36.0 触碰即换装
|
|
786
|
+
if (wordLockActive(fs.readFileSync(htmlPath, 'utf8'))) { // v1.37.0 编辑礼让:人类举旗期间教学卡不落盘
|
|
787
|
+
console.log('WORD_MODE 人类编辑中(wordLock 旗鲜活):教学卡挂起不落盘,等收笔再发');
|
|
788
|
+
process.exitCode = 6;
|
|
789
|
+
return { status: 'wordmode' };
|
|
790
|
+
}
|
|
791
|
+
var entries = JSON.parse(fs.readFileSync(answersPath, 'utf8'));
|
|
792
|
+
if (!Array.isArray(entries)) throw new Error('answers.json 顶层须为数组:[{ annId, title, rungs: [{ title, body, checkpoint?, answer? }] }]');
|
|
793
|
+
for (var i = 0; i < entries.length; i++) {
|
|
794
|
+
var verr = validateAnswerEntry(entries[i], i);
|
|
795
|
+
if (verr) throw new Error(verr);
|
|
796
|
+
}
|
|
797
|
+
/* v1.26.0:持锁+写前复验+原子写(教学写入也是一次文件写回,同样不许冲掉并发的人类批注) */
|
|
798
|
+
var out = withDocLock(htmlPath, function () {
|
|
799
|
+
var attempt = 0;
|
|
800
|
+
for (;;) {
|
|
801
|
+
attempt++;
|
|
802
|
+
var t = fs.readFileSync(htmlPath, 'utf8');
|
|
803
|
+
var m = t.match(/(<script id="annotations-store" type="application\/json">\s*)([\s\S]*?)(\s*<\/script>)/);
|
|
804
|
+
if (!m) throw new Error('NO_STORE(未找到批注区块)');
|
|
805
|
+
var o = JSON.parse(m[2]);
|
|
806
|
+
if (!Array.isArray(o.history)) o.history = [];
|
|
807
|
+
var a0 = readAnswers(t) || { rev: 0, answers: [] };
|
|
808
|
+
|
|
809
|
+
var nIns = 0, nUpd = 0;
|
|
810
|
+
entries.forEach(function (e, idx) {
|
|
811
|
+
var hit = null;
|
|
812
|
+
if (e.id) hit = a0.answers.filter(function (x) { return x.id === e.id; })[0];
|
|
813
|
+
if (!hit && e.annId) hit = a0.answers.filter(function (x) { return x.annId === e.annId && (x.title || '') === (e.title || ''); })[0];
|
|
814
|
+
var clean = {
|
|
815
|
+
id: hit ? hit.id : ('ans-' + Date.now().toString(36) + '-' + idx + Math.random().toString(36).slice(2, 5)),
|
|
816
|
+
annId: e.annId || (hit ? hit.annId : '') || '',
|
|
817
|
+
title: e.title || (hit ? hit.title : '') || '',
|
|
818
|
+
rungs: e.rungs.map(function (r) {
|
|
819
|
+
var rr = { title: r.title || '', body: r.body };
|
|
820
|
+
if (r.checkpoint) rr.checkpoint = r.checkpoint;
|
|
821
|
+
if (r.answer) rr.answer = r.answer;
|
|
822
|
+
return rr;
|
|
823
|
+
}),
|
|
824
|
+
created: hit ? hit.created : Date.now()
|
|
825
|
+
};
|
|
826
|
+
if (hit) { a0.answers[a0.answers.indexOf(hit)] = clean; nUpd++; }
|
|
827
|
+
else { a0.answers.push(clean); nIns++; }
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
var missing = [];
|
|
831
|
+
entries.forEach(function (e) {
|
|
832
|
+
if (e.annId && !o.annotations.some(function (a) { return a.id === e.annId; })) missing.push(e.annId);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
/* 写前复验:读取期间磁盘又被写 → 在最新状态上重放 */
|
|
836
|
+
var diskNow = fs.readFileSync(htmlPath, 'utf8');
|
|
837
|
+
if (diskNow !== t && attempt < 8) continue;
|
|
838
|
+
if (diskNow !== t) throw new Error('并发写回冲突:重试 8 轮磁盘仍在变化,本轮未写盘(稍后重试即可)');
|
|
839
|
+
|
|
840
|
+
var sideNote = '';
|
|
841
|
+
if (nIns + nUpd > 0) {
|
|
842
|
+
o.rev = (o.rev || 0) + 1;
|
|
843
|
+
var res = commitHistory(htmlPath, t, o, {
|
|
844
|
+
rev: o.rev, time: Date.now(), op: '🎓 教学卡 ' + (nIns + nUpd) + ' 条', n: o.annotations.length,
|
|
845
|
+
snap: JSON.parse(JSON.stringify(o.annotations))
|
|
846
|
+
});
|
|
847
|
+
sideNote = res.sideOk
|
|
848
|
+
? '(全量账本 → ' + HIST_DIR + '/' + path.basename(htmlPath) + '.hist.json)'
|
|
849
|
+
: '(外置档案不可写,快照保留在主文件)';
|
|
850
|
+
a0.rev = (a0.rev || 0) + 1;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
var scriptOpen = '<script id="answers-store" type="application/json">';
|
|
854
|
+
var scriptClose = '</' + 'script>';
|
|
855
|
+
var newScript = scriptOpen + '\n' + JSON.stringify({ rev: a0.rev, answers: a0.answers }).replace(/</g, '\\u003c') + '\n' + scriptClose;
|
|
856
|
+
/* 批注区块与教学区块基于同一份原文一次性重写:rev+1+history 骨架落主文件,
|
|
857
|
+
否则浏览器拿旧 rev 会误判无外部修改;answers 区块不存在时随后插入(老文件补建) */
|
|
858
|
+
var annotNew = m[1] + '\n' + JSON.stringify({ rev: o.rev, annotations: o.annotations, history: o.history, histCaps: o.histCaps }).replace(/</g, '\\u003c') + '\n' + m[3];
|
|
859
|
+
var am0 = t.match(/(<script id="answers-store" type="application\/json">\s*)([\s\S]*?)(\s*<\/script>)/);
|
|
860
|
+
var t2;
|
|
861
|
+
if (am0) {
|
|
862
|
+
t2 = t.replace(m[0], function () { return annotNew; });
|
|
863
|
+
t2 = t2.replace(am0[0], function () { return newScript; });
|
|
864
|
+
} else {
|
|
865
|
+
var block = '<!-- ═══ AI教学数据 BEGIN — agent 用 annotatable answer 写入;页面只读;浏览器保存原样保留 ═══ -->\n' +
|
|
866
|
+
newScript + '\n<!-- ═══ AI教学数据 END ═══ -->';
|
|
867
|
+
t2 = t.replace(m[0], function () { return annotNew + '\n\n' + block; });
|
|
868
|
+
}
|
|
869
|
+
writeDocAtomic(htmlPath, t2);
|
|
870
|
+
if (nIns + nUpd > 0) appendWriteTicket(htmlPath, 'cli', o.rev);
|
|
871
|
+
return { nIns: nIns, nUpd: nUpd, rev: o.rev, ansRev: a0.rev, total: a0.answers.length, sideNote: sideNote, missing: missing };
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
console.log('已写入教学卡:新增 ' + out.nIns + ' · 更新 ' + out.nUpd + ' · 批注 rev=' + out.rev + ' · 教学 rev=' + out.ansRev + ' · 卡总数=' + out.total + ' ' + out.sideNote);
|
|
875
|
+
if (out.missing.length) console.log('⚠️ 未找到关联的批注id(annId 可能已失效): ' + out.missing.join(', '));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/* ---------- teach:从源文档批注生成教学文档脚手架(v1.18.0)----------
|
|
879
|
+
* CLI 没有 AI:本命令把源文档的批注(原句+批语)按主题铺成分节骨架(每节含 原句引用块 +
|
|
880
|
+
* 教学正文占位 + 检查点占位),agent 随后用 annotatable edit 逐节填充(走锚定管线、入版本账本);
|
|
881
|
+
* 产物是完整可批注 HTML(同模板同主题机制),批注循环与源文档相互独立。 */
|
|
882
|
+
function teachDoc(srcPath, opts, pkgRoot) {
|
|
883
|
+
opts = opts || {};
|
|
884
|
+
var src = path.resolve(srcPath || '');
|
|
885
|
+
if (!fs.existsSync(src)) throw new Error('找不到源文档: ' + src);
|
|
886
|
+
var t = fs.readFileSync(src, 'utf8');
|
|
887
|
+
var o = readStore(t);
|
|
888
|
+
if (!o || !o.annotations.length) throw new Error('源文档没有任何批注——先在浏览器里划线批注,再生成教学文档');
|
|
889
|
+
var name = opts.out ||
|
|
890
|
+
path.join(path.dirname(src), path.basename(src).replace(/\.html?$/i, '') + '-教学.html');
|
|
891
|
+
if (!/\.html?$/i.test(name)) name += '.html';
|
|
892
|
+
var dst = path.resolve(name);
|
|
893
|
+
if (fs.existsSync(dst)) throw new Error('文件已存在: ' + dst);
|
|
894
|
+
|
|
895
|
+
var rsl = resolveTemplate(path.dirname(dst), pkgRoot, opts);
|
|
896
|
+
var tpl = rsl.tpl, tplSrc = rsl.src;
|
|
897
|
+
var theme = loadThemeChain(path.dirname(dst), opts);
|
|
898
|
+
tpl = applyTheme(tpl, theme);
|
|
899
|
+
|
|
900
|
+
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
901
|
+
function cutTitle(s, n) { s = String(s || '').replace(/\s+/g, ' ').trim(); n = n || 26; return s.length > n ? s.slice(0, n) + '…' : s; }
|
|
902
|
+
|
|
903
|
+
var srcTitle = '';
|
|
904
|
+
var tm = t.match(/<title>([\s\S]*?)<\/title>/);
|
|
905
|
+
if (tm) srcTitle = tm[1].replace(/<[^>]+>/g, '').trim();
|
|
906
|
+
var title = (opts.title || srcTitle || path.basename(dst).replace(/\.html?$/i, '')) + ' · 教学版';
|
|
907
|
+
var docSeed = Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
|
908
|
+
|
|
909
|
+
var body = '<h1>' + esc(title) + '</h1>\n';
|
|
910
|
+
body += '<section data-title="来源与用法" id="sec-src">\n';
|
|
911
|
+
body += '<h2>📋 来源与用法</h2>\n';
|
|
912
|
+
body += '<p>本文档由源文档 <strong>' + esc(path.basename(src)) + '</strong> 的批注汇总生成(' + o.annotations.length + ' 个主题)。两个文档的批注循环相互独立:在这里划线批注 = 对教学本身提问或纠偏;回到原文语境请打开源文件。</p>\n';
|
|
913
|
+
body += '<p>agent 将用 <code>annotatable edit</code> 逐节替换下面的占位段落(锚定自动重算、每节入版本账本可回滚)。批语含「继续」= 推进下一节;「重讲」= 本节换一种讲法。</p>\n';
|
|
914
|
+
body += '</section>\n';
|
|
915
|
+
|
|
916
|
+
/* 每节预置一条任务批注(锚定在占位段上):agent 无需等待人类批注即可 edit 填充,
|
|
917
|
+
填充后该批注自动变成这一节的讨论线(锚定随 newText 重算) */
|
|
918
|
+
var teachAnns = [];
|
|
919
|
+
o.annotations.forEach(function (a, i) {
|
|
920
|
+
var q = '';
|
|
921
|
+
(a.thread || []).forEach(function (x) { if (x.role === 'user' && x.text) q = x.text; });
|
|
922
|
+
if (!q) q = a.comment || '';
|
|
923
|
+
var st = a.status === 'answered' ? '(已回应过——本次教学可更系统)' : '(待回应——回答时引用本文档)';
|
|
924
|
+
var ph = '(agent 待撰写:围绕「' + cutTitle(q || a.quote, 30) + '」分阶展开——从动机讲到机制再落到边界与失效场景,每阶结尾给一个检查点。)' + st;
|
|
925
|
+
body += '<section data-title="' + esc((i + 1) + '. ' + cutTitle(q || a.quote)) + '" id="sec-t' + (i + 1) + '">\n';
|
|
926
|
+
body += '<h2>❓ ' + (i + 1) + '. ' + esc(cutTitle(q || a.quote, 44)) + '</h2>\n';
|
|
927
|
+
body += '<blockquote><p><strong>原句:</strong>' + esc(a.quote) + '</p>' + (q ? '<p><strong>批语:</strong>' + esc(q) + '</p>' : '') + '</blockquote>\n';
|
|
928
|
+
body += '<p>' + esc(ph) + '</p>\n';
|
|
929
|
+
body += '<p><strong>✅ 检查点(占位):</strong>学完本节,不看原文回答:这一节的核心机制是什么?它在哪里失效?</p>\n';
|
|
930
|
+
body += '</section>\n';
|
|
931
|
+
teachAnns.push({
|
|
932
|
+
id: 'teach-' + i + '-' + docSeed, quote: ph, prefix: '', suffix: '', ordinal: 0,
|
|
933
|
+
section: (i + 1) + '. ' + cutTitle(q || a.quote),
|
|
934
|
+
comment: '🎓 教学占位:请把本节教学正文直接写进这个段落(对 agent:edit 本条批注,newText=教学正文)',
|
|
935
|
+
created: Date.now(), status: 'new',
|
|
936
|
+
thread: [{ role: 'user', text: '🎓 教学占位:请把本节教学正文直接写进这个段落', time: Date.now() }]
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
tpl = tpl.replace(/<title>[\s\S]*?<\/title>/, '<title>' + esc(title) + '</title>');
|
|
941
|
+
tpl = tpl.replace(/<article>[\s\S]*?<\/article>/, '<article>\n' + body + '\n </article>');
|
|
942
|
+
var docId = 'doc-' + docSeed + Math.random().toString(36).slice(2, 8);
|
|
943
|
+
tpl = tpl.replace(/var DOC_ID\s*=\s*'[^']*';/, "var DOC_ID = '" + docId + "';");
|
|
944
|
+
tpl = tpl.replace(/var FILE_NAME = '[^']*';/, "var FILE_NAME = '" + path.basename(dst) + "';");
|
|
945
|
+
tpl = stampPageVersion(tpl, pkgRoot);
|
|
946
|
+
/* 预置任务批注写进 store:agent 打开即可 read → edit,无需等人类先批注 */
|
|
947
|
+
tpl = tpl.replace(/(<script id="annotations-store" type="application\/json">\s*)([\s\S]*?)(\s*<\/script>)/, function (all, p1, p2, p3) {
|
|
948
|
+
return p1 + '\n' + JSON.stringify({ rev: 0, annotations: teachAnns, history: [] }).replace(/</g, '\\u003c') + '\n' + p3;
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
fs.writeFileSync(dst, tpl);
|
|
952
|
+
console.log('已从源文档批注生成教学文档: ' + dst);
|
|
953
|
+
console.log(' 来源: ' + src + ' · 主题数: ' + o.annotations.length + ' · 模板: ' + tplSrc + (theme ? ' + 主题(' + THEME_FILE + ')' : ''));
|
|
954
|
+
console.log('下一步: agent 用 annotatable edit 逐节填充占位段落;填充完让人类用浏览器打开批注/推进。');
|
|
955
|
+
return dst;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/* ---------- edit:按批注指令修改正文,同步重算锚定 ----------
|
|
959
|
+
* 与模板锚定算法(buildIndex/normalizeWS)对齐的充分近似:
|
|
960
|
+
* 跳过 script/style/注释/批注层UI(#annot-*、#toc、#toc-toggle)与 store;
|
|
961
|
+
* 空白折叠规则一致;块级标签边界插一个空格;常见实体解码为单字符。
|
|
962
|
+
* 定位失败或全部失败时不写盘(宁可不改也不改错地方)。 */
|
|
963
|
+
var EDIT_SKIP_IDS = ['annot-panel', 'annot-popover', 'annot-selbar', 'annot-toast', 'annot-fab', 'annot-savestate', 'hist-view', 'toc', 'toc-toggle', 'annot-list', 'annot-nav']; /* v1.37.3 补 annot-nav:导航窗格烤入的标题副本(开过一次导航就整树在文件里)同属预渲染区 */
|
|
964
|
+
var EDIT_BLOCK_TAGS = /^\/?(p|h1|h2|h3|h4|h5|h6|li|blockquote|td|th|pre|dd|dt|figcaption|section)$/i;
|
|
965
|
+
var EDIT_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', nbsp: ' ', hellip: '…', mdash: '—', ndash: '–', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', middot: '·', copy: '©', reg: '®', times: '×', divide: '÷' };
|
|
966
|
+
|
|
967
|
+
function buildHtmlIndex(html) {
|
|
968
|
+
var norm = '', map = [];
|
|
969
|
+
var i = 0, n = html.length, skipName = null, skipDepth = 0;
|
|
970
|
+
function boundary() {
|
|
971
|
+
if (norm.length && norm.charAt(norm.length - 1) !== ' ') { norm += ' '; map.push(null); }
|
|
972
|
+
}
|
|
973
|
+
function pushCh(decoded, off, len) {
|
|
974
|
+
if (/\s/.test(decoded)) {
|
|
975
|
+
if (norm.length && norm.charAt(norm.length - 1) !== ' ') { norm += ' '; map.push({ off: off, len: len }); }
|
|
976
|
+
} else { norm += decoded; map.push({ off: off, len: len }); }
|
|
977
|
+
}
|
|
978
|
+
while (i < n) {
|
|
979
|
+
var ch = html.charAt(i);
|
|
980
|
+
if (ch === '<') {
|
|
981
|
+
if (html.substr(i, 4) === '<!--') { var eC = html.indexOf('-->', i); i = eC === -1 ? n : eC + 3; continue; }
|
|
982
|
+
var mt = html.slice(i, i + 512).match(/^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/);
|
|
983
|
+
if (!mt) { i++; continue; }
|
|
984
|
+
var tag = mt[2].toLowerCase(), closing = mt[1] === '/', selfClosed = /\/\s*>$/.test(mt[0]);
|
|
985
|
+
if (skipName) {
|
|
986
|
+
if (!closing && !selfClosed && tag === skipName) skipDepth++;
|
|
987
|
+
else if (closing && tag === skipName) { skipDepth--; if (skipDepth <= 0) { skipName = null; skipDepth = 0; } }
|
|
988
|
+
} else if (!closing && (tag === 'script' || tag === 'style')) {
|
|
989
|
+
var eS = html.toLowerCase().indexOf('</' + tag, i);
|
|
990
|
+
i = eS === -1 ? n : eS; /* 停在闭合标签处,下一轮正常处理 */
|
|
991
|
+
continue;
|
|
992
|
+
} else {
|
|
993
|
+
var idm = mt[3].match(/id\s*=\s*["']?([^"'\s>]+)/i);
|
|
994
|
+
if (idm && EDIT_SKIP_IDS.indexOf(idm[1]) !== -1 && !selfClosed) { skipName = tag; skipDepth = 1; i += mt[0].length; continue; }
|
|
995
|
+
if (EDIT_BLOCK_TAGS.test(tag)) boundary();
|
|
996
|
+
}
|
|
997
|
+
i += mt[0].length;
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (skipName) { i++; continue; }
|
|
1001
|
+
if (ch === '&') {
|
|
1002
|
+
var em = html.slice(i, i + 12).match(/^&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/);
|
|
1003
|
+
if (em) {
|
|
1004
|
+
var dec = em[1].charAt(0) === '#'
|
|
1005
|
+
? String.fromCodePoint(parseInt(em[1].charAt(1) === 'x' || em[1].charAt(1) === 'X' ? em[1].slice(2) : em[1].slice(1), em[1].charAt(1) === 'x' || em[1].charAt(1) === 'X' ? 16 : 10))
|
|
1006
|
+
: (EDIT_ENTITIES.hasOwnProperty(em[1]) ? EDIT_ENTITIES[em[1]] : null);
|
|
1007
|
+
if (dec !== null) { pushCh(dec, i, em[0].length); i += em[0].length; continue; }
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
pushCh(ch, i, 1);
|
|
1011
|
+
i++;
|
|
1012
|
+
}
|
|
1013
|
+
return { norm: norm, map: map };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function allIndices(hay, needle) {
|
|
1017
|
+
var out = [], i = 0;
|
|
1018
|
+
while ((i = hay.indexOf(needle, i)) !== -1) { out.push(i); i += 1; }
|
|
1019
|
+
return out;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/* ---------- 锚定消歧打分(v1.11.0,借鉴 human-review 的 bestHit)----------
|
|
1023
|
+
* 多处命中同一 quote 时,按 prefix/suffix 上下文与每个候选的贴近度打分:
|
|
1024
|
+
* 前缀从右端对齐比(紧邻字符权重自然最高)+ 后缀从左端对齐比。
|
|
1025
|
+
* 全 0 分(无上下文/上下文也对不上)回退 ordinal,保持 v1.10 前的协议语义。 */
|
|
1026
|
+
function commonSuffixLength(a, b) {
|
|
1027
|
+
var n = 0;
|
|
1028
|
+
while (n < a.length && n < b.length && a.charAt(a.length - 1 - n) === b.charAt(b.length - 1 - n)) n++;
|
|
1029
|
+
return n;
|
|
1030
|
+
}
|
|
1031
|
+
function commonPrefixLength(a, b) {
|
|
1032
|
+
var n = 0;
|
|
1033
|
+
while (n < a.length && n < b.length && a.charAt(n) === b.charAt(n)) n++;
|
|
1034
|
+
return n;
|
|
1035
|
+
}
|
|
1036
|
+
function bestOccurrence(norm, occ, qLen, prefix, suffix, wantOrdinal) {
|
|
1037
|
+
var bestI = 0, bestScore = -1, i, score;
|
|
1038
|
+
for (i = 0; i < occ.length; i++) {
|
|
1039
|
+
score = commonSuffixLength(prefix, norm.slice(Math.max(0, occ[i] - prefix.length), occ[i])) +
|
|
1040
|
+
commonPrefixLength(suffix, norm.slice(occ[i] + qLen, occ[i] + qLen + suffix.length));
|
|
1041
|
+
if (score > bestScore) { bestScore = score; bestI = i; }
|
|
1042
|
+
}
|
|
1043
|
+
if (bestScore <= 0) return occ[wantOrdinal] !== undefined ? occ[wantOrdinal] : occ[0];
|
|
1044
|
+
if (occ[wantOrdinal] !== undefined) { /* ordinal 命中同等分时优先(老协议语义) */
|
|
1045
|
+
var w = occ[wantOrdinal];
|
|
1046
|
+
var ws = commonSuffixLength(prefix, norm.slice(Math.max(0, w - prefix.length), w)) +
|
|
1047
|
+
commonPrefixLength(suffix, norm.slice(w + qLen, w + qLen + suffix.length));
|
|
1048
|
+
if (ws === bestScore) return w;
|
|
1049
|
+
}
|
|
1050
|
+
return occ[bestI];
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/* 定位批注原句:单一命中直接用;多处命中按 prefix/suffix 打分消歧,无上下文回退 ordinal。
|
|
1054
|
+
* v1.26.0 返回值扩展:occ(全部命中的规范化偏移)、scores(各候选上下文分)——
|
|
1055
|
+
* applyEdits 的怀疑标记(分差/距离)靠它们判断,不再二次扫描。 */
|
|
1056
|
+
function findQuoteHit(idx, a) {
|
|
1057
|
+
var q = String(a.quote || '').replace(/\s+/g, ' ').trim();
|
|
1058
|
+
if (!q) return null;
|
|
1059
|
+
var occ = allIndices(idx.norm, q);
|
|
1060
|
+
if (!occ.length) return null;
|
|
1061
|
+
var prefix = String(a.prefix || ''), suffix = String(a.suffix || '');
|
|
1062
|
+
var scores = occ.map(function (o) {
|
|
1063
|
+
return commonSuffixLength(prefix, idx.norm.slice(Math.max(0, o - prefix.length), o)) +
|
|
1064
|
+
commonPrefixLength(suffix, idx.norm.slice(o + q.length, o + q.length + suffix.length));
|
|
1065
|
+
});
|
|
1066
|
+
var s = occ.length === 1 ? occ[0]
|
|
1067
|
+
: bestOccurrence(idx.norm, occ, q.length, prefix, suffix, a.ordinal || 0);
|
|
1068
|
+
return { s: s, e: s + q.length, q: q, ambiguous: occ.length > 1, occ: occ, scores: scores };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/* 前后缀模糊重找(v1.26.0 锚定三级):引用原文不在,但前后缀仍能框出「原引用位置」的
|
|
1072
|
+
* 唯一候选——前缀在正文中定位左界,后缀唯一定位右界,两界之间的文字即疑似改写后的内容。
|
|
1073
|
+
* 宁缺勿滥:后缀不唯一 / 前缀找不到 / 空洞(整段删了)/ 离谱长,一律不给候选。
|
|
1074
|
+
* 返回 {s,e,text}(规范化坐标+预览文本)或 null。 */
|
|
1075
|
+
function fuzzyFindByContext(idx, a) {
|
|
1076
|
+
var suffix = String(a.suffix || ''), prefix = String(a.prefix || '');
|
|
1077
|
+
if (suffix.replace(/\s+/g, '').length < 6) return null; // 后缀太短框不住
|
|
1078
|
+
var occSuf = allIndices(idx.norm, suffix);
|
|
1079
|
+
if (occSuf.length !== 1) return null; // 后缀必须全文唯一
|
|
1080
|
+
var at = occSuf[0];
|
|
1081
|
+
if (!prefix) return null; // 无前缀无法框左界
|
|
1082
|
+
var occPre = allIndices(idx.norm, prefix);
|
|
1083
|
+
var p = -1;
|
|
1084
|
+
for (var i = occPre.length - 1; i >= 0; i--) { // 取 suffix 之前最近的 prefix 出现
|
|
1085
|
+
if (occPre[i] <= at) { p = occPre[i] + prefix.length; break; }
|
|
1086
|
+
}
|
|
1087
|
+
if (p === -1) return null;
|
|
1088
|
+
if (at - p <= 0 || at - p > 200) return null; // 空洞(整段删了)或离谱长 → 不给候选
|
|
1089
|
+
var text = idx.norm.slice(p, at);
|
|
1090
|
+
return { s: p, e: at, text: text };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
var WORD_LOCK_TTL = 30 * 60 * 1000; // v1.29.0 wordLock 旗有效期:页面崩了忘撤旗,半小时自动作废,不永久堵 AI
|
|
1094
|
+
|
|
1095
|
+
function applyEdits(htmlPath, editsPath) {
|
|
1096
|
+
silentUpgrade(htmlPath); // v1.36.0 触碰即换装
|
|
1097
|
+
var edits = JSON.parse(fs.readFileSync(editsPath, 'utf8'));
|
|
1098
|
+
if (!Array.isArray(edits) || !edits.length) throw new Error('edits.json 应为非空数组 [{"id","newText","reply"?}]');
|
|
1099
|
+
/* v1.26.0:持锁+写前复验+原子写;锚定三级(唯一/多现怀疑/模糊重找/失效追溯)全部在
|
|
1100
|
+
本函数内部机器代码里完成,agent(LLM) 只管发命令读输出。 */
|
|
1101
|
+
var out = withDocLock(htmlPath, function () {
|
|
1102
|
+
var attempt = 0;
|
|
1103
|
+
for (;;) {
|
|
1104
|
+
attempt++;
|
|
1105
|
+
var t = fs.readFileSync(htmlPath, 'utf8');
|
|
1106
|
+
var storeRe = /(<script id="annotations-store" type="application\/json">\s*)([\s\S]*?)(\s*<\/script>)/;
|
|
1107
|
+
var sm = t.match(storeRe);
|
|
1108
|
+
if (!sm) throw new Error('NO_STORE(未找到批注区块)');
|
|
1109
|
+
var o = JSON.parse(sm[2]);
|
|
1110
|
+
if (!Array.isArray(o.history)) o.history = [];
|
|
1111
|
+
if (!o.control || typeof o.control !== 'object') o.control = {};
|
|
1112
|
+
/* v1.29.0 排队制:人类正在编辑正文(wordLock 旗在且新鲜)→ AI 对正文不落笔,改动进待办槽,
|
|
1113
|
+
人类收笔瞬间由页面铺上;铺不上(正文大改锚不准)→ control.replay 立即唤醒 AI 重做 */
|
|
1114
|
+
var queued = !!(o.control.wordLock && (Date.now() - (o.control.wordLock.at || 0)) < WORD_LOCK_TTL);
|
|
1115
|
+
if (o.control.wordLock && !queued) delete o.control.wordLock; // 过期旗顺手作废
|
|
1116
|
+
if (o.control.replay) delete o.control.replay; // 本次提交即视为已处理(排队或直写都是消费重做请求)
|
|
1117
|
+
var byId = {};
|
|
1118
|
+
o.annotations.forEach(function (a) { byId[a.id] = a; });
|
|
1119
|
+
var docId = docIdOf(t) || ('doc-unknown-' + path.basename(htmlPath));
|
|
1120
|
+
var brief = function (x) { x = String(x); return x.length > 40 ? x.slice(0, 40) + '…' : x; };
|
|
1121
|
+
|
|
1122
|
+
var html = t, changed = [], pendingC = [], lost = [], failed = [], queuedIds = [];
|
|
1123
|
+
var strips = []; // bodyOps:每次替换的 {o:原文,n:新文,b:前文30字符,a:后文30字符}(均为原始 HTML 串,供回滚逆替换精确定位)
|
|
1124
|
+
edits.forEach(function (ed) {
|
|
1125
|
+
var a = byId[ed.id];
|
|
1126
|
+
if (!a) { failed.push(ed.id + '(批注id不存在)'); return; }
|
|
1127
|
+
var newText = ed.newText === undefined || ed.newText === null ? '' : String(ed.newText);
|
|
1128
|
+
var idx = buildHtmlIndex(html);
|
|
1129
|
+
var hit = findQuoteHit(idx, a);
|
|
1130
|
+
if (!hit) {
|
|
1131
|
+
/* 锚定三级之二:模糊重找——引用不在但前后缀框出唯一候选 → 给候选不自动执行 */
|
|
1132
|
+
var fz = fuzzyFindByContext(idx, a);
|
|
1133
|
+
if (fz) {
|
|
1134
|
+
a.pendingAnchor = {
|
|
1135
|
+
reason: 'notfound', quote: a.quote, newText: newText,
|
|
1136
|
+
candidates: [{ s: fz.s, e: fz.e, text: fz.text,
|
|
1137
|
+
prefix: idx.norm.slice(Math.max(0, fz.s - 30), fz.s),
|
|
1138
|
+
suffix: idx.norm.slice(fz.e, fz.e + 30) }],
|
|
1139
|
+
created: Date.now()
|
|
1140
|
+
};
|
|
1141
|
+
if (!Array.isArray(a.thread)) a.thread = [];
|
|
1142
|
+
a.thread.push({ role: 'ai', text: '⚠️ AI 未自动执行:引用原文「' + brief(a.quote) + '」已不在正文,疑似改写为「' + brief(fz.text) + '」(前后缀上下文吻合)。确认后本次修改将应用到新文字;放弃则本条批语保持原样(不催不过期)。', time: Date.now() });
|
|
1143
|
+
pendingC.push(ed.id + '(疑似改写为「' + brief(fz.text) + '」)');
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
/* 锚定三级之三:失效——追溯侧车账本给「回退恢复原文」的出路,不再让用户手改 */
|
|
1147
|
+
var trace = findLastRevWithQuote(htmlPath, docId, a.quote);
|
|
1148
|
+
a.lostAnchor = { quote: a.quote, lastWith: trace ? trace.lastWith : null, goneAt: trace ? trace.goneAt : null, created: Date.now() };
|
|
1149
|
+
if (!Array.isArray(a.thread)) a.thread = [];
|
|
1150
|
+
a.thread.push({ role: 'ai', text: '⛔ AI 未动手:引用原文「' + brief(a.quote) + '」已不在正文' + (trace ? '(最后见于 r' + trace.lastWith + ',疑在 r' + trace.goneAt + ' 被替换)' : '') + '。可在版本历史回退到原文还在的版本(锚会自动复活)、放弃本条批语,或重新划选——正文保持可读,不催不过期。', time: Date.now() });
|
|
1151
|
+
lost.push(ed.id + (trace ? '(原文最后见于 r' + trace.lastWith + ',页面可一键回退恢复)' : ''));
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
/* 锚定三级之一:多现时的怀疑标记(三个纯计算,纳秒级)——任一命中即不自动落笔 */
|
|
1155
|
+
if (hit.ambiguous) {
|
|
1156
|
+
var suspects = [];
|
|
1157
|
+
var sorted = (hit.scores || []).slice().sort(function (x, y) { return y - x; });
|
|
1158
|
+
if (sorted.length > 1 && sorted[0] - sorted[1] < 6) suspects.push('上下文分差不大');
|
|
1159
|
+
if (a.docLen && Math.abs(idx.norm.length - a.docLen) > ANCHOR_SUSPECT_LEN) suspects.push('文档发生过大编辑');
|
|
1160
|
+
if (a.pos !== undefined && a.pos !== null) {
|
|
1161
|
+
var ds = (hit.occ || []).map(function (o2) { return Math.abs(o2 - a.pos); }).sort(function (x, y) { return x - y; });
|
|
1162
|
+
if (ds.length > 1 && ds[1] - ds[0] < ANCHOR_SUSPECT_LEN) suspects.push('多个候选离上次锚定位置都近');
|
|
1163
|
+
}
|
|
1164
|
+
if (suspects.length) {
|
|
1165
|
+
var cands = (hit.occ || []).map(function (o2, oi) { return { s: o2, score: hit.scores ? hit.scores[oi] : 0 }; })
|
|
1166
|
+
.sort(function (x, y) { return y.score - x.score; }).slice(0, 2)
|
|
1167
|
+
.map(function (c) {
|
|
1168
|
+
return { s: c.s, e: c.s + hit.q.length, text: idx.norm.substr(c.s, hit.q.length),
|
|
1169
|
+
prefix: idx.norm.slice(Math.max(0, c.s - 30), c.s),
|
|
1170
|
+
suffix: idx.norm.slice(c.s + hit.q.length, c.s + hit.q.length + 30) };
|
|
1171
|
+
});
|
|
1172
|
+
a.pendingAnchor = { reason: 'multi', quote: a.quote, newText: newText, suspects: suspects, candidates: cands, created: Date.now() };
|
|
1173
|
+
if (!Array.isArray(a.thread)) a.thread = [];
|
|
1174
|
+
a.thread.push({ role: 'ai', text: '⚠️ AI 未自动执行:引用「' + brief(a.quote) + '」在全文出现 ' + (hit.occ || []).length + ' 处(' + suspects.join('、') + ')。候选已在下方列出,确认迁移到哪一处后执行;放弃则本条批语保持原样。', time: Date.now() });
|
|
1175
|
+
pendingC.push(ed.id + '(同文 ' + (hit.occ || []).length + ' 处:' + suspects.join('、') + ')');
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
/* 无怀疑 → bestOccurrence 打分裁决(hit.s 即裁决结果),照常执行 */
|
|
1179
|
+
}
|
|
1180
|
+
var sp = idx.map[hit.s], ep = idx.map[hit.e - 1];
|
|
1181
|
+
if (!sp || !ep) { failed.push(ed.id + '(锚点映射失败)'); return; }
|
|
1182
|
+
var qStart = sp.off, qEnd = ep.off + ep.len;
|
|
1183
|
+
var replacedHtml = html.slice(qStart, qEnd);
|
|
1184
|
+
var oldQ = hit.q;
|
|
1185
|
+
strips.push({ o: replacedHtml, n: newText, b: html.slice(Math.max(0, qStart - 30), qStart), a: html.slice(qEnd, qEnd + 30) });
|
|
1186
|
+
html = html.slice(0, qStart) + newText + html.slice(qEnd); // 排队模式=虚拟替换:只为后续纸条上下文连续,最终不落盘
|
|
1187
|
+
/* 重算锚定:quote 换成新文本,prefix/suffix/ordinal 围绕新位置重建(排队模式正文未变,跳过) */
|
|
1188
|
+
var q2 = newText.replace(/\s+/g, ' ').trim();
|
|
1189
|
+
if (q2 && !queued) {
|
|
1190
|
+
var idx2 = buildHtmlIndex(html);
|
|
1191
|
+
var s2 = -1;
|
|
1192
|
+
for (var k = 0; k < idx2.map.length; k++) {
|
|
1193
|
+
if (idx2.map[k] && idx2.map[k].off >= qStart) { s2 = k; break; }
|
|
1194
|
+
}
|
|
1195
|
+
var occ2 = allIndices(idx2.norm, q2);
|
|
1196
|
+
if (s2 < 0 || idx2.norm.substr(s2, q2.length) !== q2) s2 = occ2.length ? occ2[0] : -1;
|
|
1197
|
+
if (s2 >= 0) {
|
|
1198
|
+
a.quote = q2;
|
|
1199
|
+
a.prefix = idx2.norm.slice(Math.max(0, s2 - 40), s2).replace(/^\s+/, '');
|
|
1200
|
+
a.suffix = idx2.norm.slice(s2 + q2.length, s2 + q2.length + 40).replace(/\s+$/, '');
|
|
1201
|
+
var ord = occ2.indexOf(s2);
|
|
1202
|
+
if (ord >= 0) a.ordinal = ord;
|
|
1203
|
+
/* v1.26.0:pos/docLen 快照(新位置+当时文档长度),供下次消歧做距离裁决 */
|
|
1204
|
+
a.pos = s2;
|
|
1205
|
+
a.docLen = idx2.norm.length;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
if (!Array.isArray(a.thread)) a.thread = [];
|
|
1209
|
+
if (queued) {
|
|
1210
|
+
a.thread.push({ role: 'ai', text: '📥 已排队:人类正在编辑正文,AI 暂不动笔;收笔后自动铺上(「' + brief(oldQ) + '」→「' + (q2 ? brief(newText) : '(已删除)') + '」)', time: Date.now() });
|
|
1211
|
+
queuedIds.push(ed.id);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
var note = ed.reply || ('✅ 已按批注修改正文:「' + brief(oldQ) + '」→「' + (q2 ? brief(newText) : '(已删除)') + '」' + (replacedHtml.indexOf('<') !== -1 ? '(原句含格式标签,替换后为纯文本)' : ''));
|
|
1215
|
+
a.thread.push({ role: 'ai', text: note, time: Date.now() });
|
|
1216
|
+
a.reply = note;
|
|
1217
|
+
a.status = 'answered';
|
|
1218
|
+
/* v1.37.2:本次权威落笔后,早先挂起的锚点待确认/失效出路一并作废(避免页面残留待点卡片) */
|
|
1219
|
+
delete a.pendingAnchor;
|
|
1220
|
+
delete a.lostAnchor;
|
|
1221
|
+
changed.push(ed.id);
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
/* 写前复验:读取期间磁盘又被写(浏览器抢先保存)→ 在最新状态上重放同一批修改
|
|
1225
|
+
(edit 的锚定本就按「当前正文重新定位」设计,重放即 v1.9 语义的自然延伸) */
|
|
1226
|
+
var diskNow = fs.readFileSync(htmlPath, 'utf8');
|
|
1227
|
+
if (diskNow !== t && attempt < 8) continue;
|
|
1228
|
+
if (diskNow !== t) throw new Error('并发写回冲突:重试 8 轮磁盘仍在变化,本轮未写盘(稍后重试即可)');
|
|
1229
|
+
|
|
1230
|
+
if (!changed.length && !queuedIds.length && !pendingC.length && !lost.length) {
|
|
1231
|
+
throw new Error('没有任何一条修改成功,文件未改动。失败原因:\n - ' + failed.join('\n - '));
|
|
1232
|
+
}
|
|
1233
|
+
o.rev = (o.rev || 0) + 1;
|
|
1234
|
+
var newEntry = queued
|
|
1235
|
+
? { rev: o.rev, time: Date.now(), op: 'AI正文改动排队 ' + queuedIds.length + ' 处(人类编辑中,收笔即铺)', n: o.annotations.length, snap: JSON.parse(JSON.stringify(o.annotations)) }
|
|
1236
|
+
: { rev: o.rev, time: Date.now(), op: 'AI执行修改 ' + changed.length + ' 处' + (pendingC.length ? '(待确认 ' + pendingC.length + ')' : '') + (lost.length ? '(失效 ' + lost.length + ')' : ''), n: o.annotations.length, snap: JSON.parse(JSON.stringify(o.annotations)) };
|
|
1237
|
+
if (strips.length && !queued) newEntry.bodyOps = strips;
|
|
1238
|
+
if (queued && strips.length) o.control.pendingBodyOps = (o.control.pendingBodyOps || []).concat(strips); // 待办槽:随 store 落盘,页面收笔时铺上
|
|
1239
|
+
var res = commitHistory(htmlPath, t, o, newEntry);
|
|
1240
|
+
var sideNote = res.sideOk
|
|
1241
|
+
? '(全量账本 → ' + HIST_DIR + '/' + path.basename(htmlPath) + '.hist.json)'
|
|
1242
|
+
: '(外置档案不可写,快照保留在主文件)';
|
|
1243
|
+
if (!html.match(storeRe)) throw new Error('修改后丢失批注区块,文件未写盘');
|
|
1244
|
+
var opens = (html.match(/<script[^>]*>/g) || []).length;
|
|
1245
|
+
var closes = (html.match(/<\/script>/g) || []).length;
|
|
1246
|
+
if (opens !== closes) throw new Error('script 标签不平衡(' + opens + '/' + closes + '),文件未写盘');
|
|
1247
|
+
if (queued) html = t; // 排队模式:正文一字不动,只落 store(旗子/待办槽/对话线)
|
|
1248
|
+
html = html.replace(storeRe, function (all, p1, p2, p3) {
|
|
1249
|
+
return p1 + '\n' + JSON.stringify(storePayload(o)).replace(/</g, '\\u003c') + '\n' + p3;
|
|
1250
|
+
});
|
|
1251
|
+
writeDocAtomic(htmlPath, html);
|
|
1252
|
+
appendWriteTicket(htmlPath, 'cli', o.rev);
|
|
1253
|
+
return { changed: changed, pendingC: pendingC, lost: lost, failed: failed, queuedIds: queuedIds, byId: byId, rev: o.rev, histLen: res.full.length, sideNote: sideNote };
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
var doneMsg = [];
|
|
1257
|
+
if (out.changed.length) doneMsg.push('已执行 ' + out.changed.length + ' 处正文修改');
|
|
1258
|
+
if (out.queuedIds.length) doneMsg.push('已排队 ' + out.queuedIds.length + ' 处(人类编辑正文期间 AI 不落笔,收笔自动铺上;铺不上会立即唤醒重做)');
|
|
1259
|
+
console.log(doneMsg.join(' · ') + ' · rev=' + out.rev + ' · 版本记录=' + out.histLen + ' ' + out.sideNote + (out.changed.length ? '(浏览器刷新后批注锚定已随新文本重算)' : ''));
|
|
1260
|
+
out.changed.forEach(function (id) {
|
|
1261
|
+
var a = out.byId[id];
|
|
1262
|
+
var last = (a.thread || [])[a.thread.length - 1];
|
|
1263
|
+
console.log(' ✓ ' + id + ' — ' + (last ? last.text : ''));
|
|
1264
|
+
});
|
|
1265
|
+
out.queuedIds.forEach(function (id) {
|
|
1266
|
+
var a = out.byId[id];
|
|
1267
|
+
var last = (a.thread || [])[a.thread.length - 1];
|
|
1268
|
+
console.log(' ⬇ ' + id + ' — ' + (last ? last.text : ''));
|
|
1269
|
+
});
|
|
1270
|
+
if (out.pendingC.length) {
|
|
1271
|
+
console.log('⏳ ' + out.pendingC.length + ' 条待确认(AI 未自动落笔,页面对话线已给候选与按钮):');
|
|
1272
|
+
out.pendingC.forEach(function (s) { console.log(' ? ' + s); });
|
|
1273
|
+
}
|
|
1274
|
+
if (out.lost.length) {
|
|
1275
|
+
console.log('⛔ ' + out.lost.length + ' 条锚定失效(页面已给出回退/放弃/追问出路):');
|
|
1276
|
+
out.lost.forEach(function (s) { console.log(' ! ' + s); });
|
|
1277
|
+
}
|
|
1278
|
+
if (out.failed.length) console.log('⚠ 未执行的批注id: ' + out.failed.join('; '));
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/* 读某文档的侧车账本(供 serve /__annot/hist 用):读文件取 DOC_ID → 归属校验读侧车;
|
|
1282
|
+
* 无档案时返回空账本形状(浏览器端零特判)。 */
|
|
1283
|
+
function histForFile(target) {
|
|
1284
|
+
var docId = docIdOf(fs.readFileSync(target, 'utf8'));
|
|
1285
|
+
var side = docId ? readSideHist(target, docId) : null;
|
|
1286
|
+
return side || { annotatableHist: 1, DOC_ID: docId || '', FILE_NAME: path.basename(target), rev: 0, entries: [] };
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/* ---------- 主题与自定义模板 ---------- */
|
|
1290
|
+
var THEME_FILE = 'annotatable.theme.css';
|
|
1291
|
+
var USER_TEMPLATE_FILE = 'annotatable.template.html';
|
|
1292
|
+
|
|
1293
|
+
/* v1.17.0 全局自定义层 ~/.annotatable/(template.html + theme.css)。
|
|
1294
|
+
* 模板优先级:--template 显式 > 目录内 annotatable.template.html > 全局默认 > 内置;
|
|
1295
|
+
* 主题优先级:目录内 theme.css > 全局 theme.css。
|
|
1296
|
+
* opts.home 可注入假目录供测试(与 installSkill 的 baseDir 同惯例)。 */
|
|
1297
|
+
function globalDir(opts) {
|
|
1298
|
+
return path.join((opts && opts.home) || require('os').homedir(), '.annotatable');
|
|
1299
|
+
}
|
|
1300
|
+
function globalTemplatePath(opts) { return path.join(globalDir(opts), 'annotatable.template.html'); }
|
|
1301
|
+
function globalThemePath(opts) { return path.join(globalDir(opts), THEME_FILE); }
|
|
1302
|
+
function loadThemeChain(dir, opts) {
|
|
1303
|
+
return loadTheme(dir) || loadTheme(globalDir(opts));
|
|
1304
|
+
}
|
|
1305
|
+
function resolveTemplate(dir, pkgRoot, opts) {
|
|
1306
|
+
opts = opts || {};
|
|
1307
|
+
if (opts.template && fs.existsSync(opts.template)) {
|
|
1308
|
+
return { tpl: fs.readFileSync(opts.template, 'utf8'), src: opts.template };
|
|
1309
|
+
}
|
|
1310
|
+
var local = path.join(dir, USER_TEMPLATE_FILE);
|
|
1311
|
+
if (fs.existsSync(local)) {
|
|
1312
|
+
return { tpl: fs.readFileSync(local, 'utf8'), src: local + '(自定义模板)' };
|
|
1313
|
+
}
|
|
1314
|
+
var g = globalTemplatePath(opts);
|
|
1315
|
+
if (fs.existsSync(g)) {
|
|
1316
|
+
return { tpl: fs.readFileSync(g, 'utf8'), src: g + '(全局默认模板)' };
|
|
1317
|
+
}
|
|
1318
|
+
return {
|
|
1319
|
+
tpl: fs.readFileSync(path.join(pkgRoot, 'templates', 'annotatable-doc.html'), 'utf8'),
|
|
1320
|
+
src: '内置模板'
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function loadTheme(dir) {
|
|
1325
|
+
var p = path.join(dir, THEME_FILE);
|
|
1326
|
+
if (!fs.existsSync(p)) return null;
|
|
1327
|
+
return fs.readFileSync(p, 'utf8');
|
|
1328
|
+
}
|
|
1329
|
+
function applyTheme(tpl, theme) {
|
|
1330
|
+
if (!theme) return tpl;
|
|
1331
|
+
var rest = theme;
|
|
1332
|
+
var rootMatch = theme.match(/:root\s*\{[^}]*\}/);
|
|
1333
|
+
if (rootMatch) {
|
|
1334
|
+
rest = theme.replace(rootMatch[0], '');
|
|
1335
|
+
tpl = tpl.replace(/:root\s*\{[^}]*\}/, rootMatch[0]); // 替换文档默认变量块
|
|
1336
|
+
}
|
|
1337
|
+
if (rest.trim()) tpl = tpl.replace('</style>', rest + '\n</style>'); // 追加覆盖样式
|
|
1338
|
+
return tpl;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/* init:把工作流文件装进目标目录(已存在的不覆盖) */
|
|
1342
|
+
function initKit(dir, pkgRoot) {
|
|
1343
|
+
dir = dir || process.cwd();
|
|
1344
|
+
var items = [
|
|
1345
|
+
['templates/AGENTS.md', 'AGENTS.md'],
|
|
1346
|
+
['templates/annotatable-doc.html', '可批注文档.html'],
|
|
1347
|
+
['templates/annotatable.theme.css', THEME_FILE],
|
|
1348
|
+
['agent/annot_read.js', 'agent/annot_read.js'],
|
|
1349
|
+
['agent/annot_reply.js', 'agent/annot_reply.js'],
|
|
1350
|
+
['agent/annot_edit.js', 'agent/annot_edit.js'],
|
|
1351
|
+
['agent/annot_answer.js', 'agent/annot_answer.js'],
|
|
1352
|
+
['agent/annot_poll.js', 'agent/annot_poll.js'],
|
|
1353
|
+
['lib/core.js', 'lib/core.js']
|
|
1354
|
+
];
|
|
1355
|
+
var created = [], skipped = [];
|
|
1356
|
+
items.forEach(function (it) {
|
|
1357
|
+
var src = path.join(pkgRoot, it[0]);
|
|
1358
|
+
var dst = path.join(dir, it[1]);
|
|
1359
|
+
if (fs.existsSync(dst)) { skipped.push(it[1]); return; }
|
|
1360
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
1361
|
+
fs.copyFileSync(src, dst);
|
|
1362
|
+
created.push(it[1]);
|
|
1363
|
+
});
|
|
1364
|
+
console.log('初始化完成 → ' + dir);
|
|
1365
|
+
created.forEach(function (f) { console.log(' + ' + f); });
|
|
1366
|
+
skipped.forEach(function (f) { console.log(' = 已存在,跳过: ' + f); });
|
|
1367
|
+
console.log('\n下一步:');
|
|
1368
|
+
console.log(' 1. 用 Edge/Chrome 打开 可批注文档.html,左下角绑定文件;');
|
|
1369
|
+
console.log(' 2. 人类划线批注后,agent 运行: node agent/annot_read.js <html路径> 查看待回应批注;');
|
|
1370
|
+
console.log(' 3. 生成 replies.json 后运行: node agent/annot_reply.js <html路径> replies.json 写回。');
|
|
1371
|
+
console.log(' 4. 想换外观:编辑 annotatable.theme.css(annotatable new 时自动应用)。');
|
|
1372
|
+
console.log('工作流细节见 AGENTS.md。');
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/* new:从模板新建可批注文档
|
|
1376
|
+
* 模板优先级见 resolveTemplate;主题:目录内/全局 annotatable.theme.css 存在则自动注入 */
|
|
1377
|
+
function newDoc(dir, name, pkgRoot, opts) {
|
|
1378
|
+
opts = opts || {};
|
|
1379
|
+
dir = dir || process.cwd();
|
|
1380
|
+
if (!/\.html$/i.test(name)) name += '.html';
|
|
1381
|
+
var dst = path.join(dir, name);
|
|
1382
|
+
if (fs.existsSync(dst)) throw new Error('文件已存在: ' + dst);
|
|
1383
|
+
|
|
1384
|
+
var rsl = resolveTemplate(dir, pkgRoot, opts);
|
|
1385
|
+
var tpl = rsl.tpl, tplSrc = rsl.src;
|
|
1386
|
+
var theme = loadThemeChain(dir, opts);
|
|
1387
|
+
tpl = applyTheme(tpl, theme);
|
|
1388
|
+
if (opts.lang === 'en') tpl = applyLangEn(tpl).html;
|
|
1389
|
+
|
|
1390
|
+
fs.writeFileSync(dst, tpl);
|
|
1391
|
+
// 每份文档独立的浏览器存储命名空间与正确文件名
|
|
1392
|
+
var docId = 'doc-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
1393
|
+
tpl = tpl.replace(/var DOC_ID\s*=\s*'[^']*';/, "var DOC_ID = '" + docId + "';");
|
|
1394
|
+
tpl = tpl.replace(/var FILE_NAME = '[^']*';/, "var FILE_NAME = '" + name + "';");
|
|
1395
|
+
/* v1.26.2:页签/侧栏标题 = 文件名(此前恒为模板占位「可批注文档(标题)」零信息;
|
|
1396
|
+
写入正文 h1 后由模板 boot 自愈接管为文章标题) */
|
|
1397
|
+
tpl = tpl.replace(/<title>[\s\S]*?<\/title>/, '<title>' + escHtml(name.replace(/\.html?$/i, '')) + '</title>');
|
|
1398
|
+
tpl = stampPageVersion(tpl, pkgRoot);
|
|
1399
|
+
fs.writeFileSync(dst, tpl);
|
|
1400
|
+
console.log('已创建可批注文档: ' + dst);
|
|
1401
|
+
console.log(' 模板来源: ' + tplSrc + (theme ? ' + 主题(' + THEME_FILE + ')' : ''));
|
|
1402
|
+
console.log('把内容写进各 section,然后用 Edge/Chrome 打开即可批注。');
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/* 导出默认主题文件供修改 */
|
|
1406
|
+
function exportTheme(dir, pkgRoot) {
|
|
1407
|
+
dir = dir || process.cwd();
|
|
1408
|
+
var dst = path.join(dir, THEME_FILE);
|
|
1409
|
+
if (fs.existsSync(dst)) throw new Error('已存在: ' + dst);
|
|
1410
|
+
fs.copyFileSync(path.join(pkgRoot, 'templates', 'annotatable.theme.css'), dst);
|
|
1411
|
+
console.log('已导出主题文件: ' + dst + '\n修改变量后,annotatable new 会自动应用。');
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/* 导出内置模板供深度改造 */
|
|
1415
|
+
function exportTemplate(dir, name, pkgRoot) {
|
|
1416
|
+
dir = dir || process.cwd();
|
|
1417
|
+
var dst = path.join(dir, name || USER_TEMPLATE_FILE);
|
|
1418
|
+
if (fs.existsSync(dst)) throw new Error('已存在: ' + dst);
|
|
1419
|
+
fs.copyFileSync(path.join(pkgRoot, 'templates', 'annotatable-doc.html'), dst);
|
|
1420
|
+
var g = globalTemplatePath({});
|
|
1421
|
+
console.log('已导出模板: ' + dst + '\n改完后命名为 annotatable.template.html 放在目录里,new 时自动使用;或用 --template 指定。');
|
|
1422
|
+
if (fs.existsSync(g)) console.log('(注意:已存在全局默认模板 ' + g + ',目录级文件与 --template 优先于它)');
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/* template --use:把用户自己的 HTML 一键设为全局默认模板(v1.17.0,整页变模板)。
|
|
1426
|
+
* 保留用户全部样式与结构:本地 <link> 样式内联;正文容器择优(article > main >
|
|
1427
|
+
* content 系 div > body 整体包裹)改为批注层锚点 <article id="annot-article">;
|
|
1428
|
+
* 我们的 :root 变量块插在最前(applyTheme 首 :root 交换命中,用户自己的 :root 在后仍胜出);
|
|
1429
|
+
* 批注层四件套不带 --annot- 前缀、不带 __ANNOT_NO_TOC__(new/md 完整能力)。
|
|
1430
|
+
* opts.home 可注入假目录供测试。 */
|
|
1431
|
+
function templateUse(srcPath, pkgRoot, opts) {
|
|
1432
|
+
opts = opts || {};
|
|
1433
|
+
srcPath = path.resolve(srcPath || '');
|
|
1434
|
+
if (!fs.existsSync(srcPath)) throw new Error('文件不存在: ' + srcPath);
|
|
1435
|
+
var orig = fs.readFileSync(srcPath, 'utf8');
|
|
1436
|
+
var srcDir = path.dirname(srcPath);
|
|
1437
|
+
var t = orig;
|
|
1438
|
+
|
|
1439
|
+
if (t.indexOf('<script id="annotations-store"') !== -1) {
|
|
1440
|
+
/* 已是可批注文档:直接收编,无需再装配 */
|
|
1441
|
+
t = orig;
|
|
1442
|
+
} else {
|
|
1443
|
+
/* 1) 本地 <link rel=stylesheet> 内联(远程/读不到的保留原标签) */
|
|
1444
|
+
t = t.replace(/<link\b[^>]*>/gi, function (tag) {
|
|
1445
|
+
if (!/rel\s*=\s*["']?stylesheet/i.test(tag)) return tag;
|
|
1446
|
+
var hm = tag.match(/href\s*=\s*["']([^"']+)["']/i);
|
|
1447
|
+
if (!hm || /^(https?:|data:|\/\/)/i.test(hm[1])) return tag;
|
|
1448
|
+
try {
|
|
1449
|
+
var css = fs.readFileSync(path.resolve(srcDir, hm[1]), 'utf8');
|
|
1450
|
+
return '<style data-from="' + hm[1].replace(/"/g, '"') + '">\n' + css + '\n</style>';
|
|
1451
|
+
} catch (e) { return tag; }
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
/* 2) viewport 兜底(批注层在窄屏可用) */
|
|
1455
|
+
if (!/name\s*=\s*["']?viewport/i.test(t)) {
|
|
1456
|
+
if (/<head[^>]*>/i.test(t)) {
|
|
1457
|
+
t = t.replace(/<head[^>]*>/i, function (all) { return all + '\n<meta name="viewport" content="width=device-width, initial-scale=1">'; });
|
|
1458
|
+
} else {
|
|
1459
|
+
t = '<meta name="viewport" content="width=device-width, initial-scale=1">\n' + t;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/* 3) 正文容器择优 → 批注层锚点 <article id="annot-article"> */
|
|
1464
|
+
var artCount = (t.match(/<article\b/gi) || []).length;
|
|
1465
|
+
if (artCount > 0) {
|
|
1466
|
+
if (artCount > 1) console.log(' ⚠ 页面含 ' + artCount + ' 个 article,锚定第一个(其余不进导航/批注区)');
|
|
1467
|
+
t = t.replace(/<article\b([^>]*)>/i, function (all, attrs) {
|
|
1468
|
+
return /(^|\s)id\s*=/i.test(attrs) ? all : '<article id="annot-article"' + attrs + '>';
|
|
1469
|
+
});
|
|
1470
|
+
} else {
|
|
1471
|
+
var mo = /<main\b[^>]*>/i.exec(t);
|
|
1472
|
+
var dm = !mo && /<(div|section)\b[^>]*(?:\bid\b|\bclass\b)\s*=\s*["'][^"']*(?:content|post|article|entry|markdown-body|rich-text)[^"']*["'][^>]*>/i.exec(t);
|
|
1473
|
+
if (mo) {
|
|
1474
|
+
var io = t.indexOf(mo[0]);
|
|
1475
|
+
var ic = t.toLowerCase().indexOf('</main>', io);
|
|
1476
|
+
if (ic === -1) throw new Error('页面有 <main> 但找不到 </main>,无法改造');
|
|
1477
|
+
var mAttrs = mo[0].slice(5, -1);
|
|
1478
|
+
t = t.slice(0, io) + '<article' + (/(^|\s)id\s*=/i.test(mAttrs) ? '' : ' id="annot-article"') +
|
|
1479
|
+
mAttrs + '>' + t.slice(io + mo[0].length, ic) + '</article>' + t.slice(ic + 7);
|
|
1480
|
+
} else if (dm) {
|
|
1481
|
+
var tag = dm[1], io2 = dm.index, openLen = dm[0].length;
|
|
1482
|
+
var re = new RegExp('<' + tag + '\\b|</' + tag + '>', 'gi');
|
|
1483
|
+
re.lastIndex = io2 + openLen;
|
|
1484
|
+
var depth = 1, mm, ic2 = -1;
|
|
1485
|
+
while ((mm = re.exec(t))) {
|
|
1486
|
+
depth += /^<\//i.test(mm[0]) ? -1 : 1;
|
|
1487
|
+
if (depth === 0) { ic2 = mm.index; break; }
|
|
1488
|
+
}
|
|
1489
|
+
if (ic2 === -1) throw new Error('正文容器 <' + tag + '> 找不到配对闭标签');
|
|
1490
|
+
var dAttrs = dm[0].slice(1 + tag.length, -1);
|
|
1491
|
+
t = t.slice(0, io2) + '<article' + (/(^|\s)id\s*=/i.test(dAttrs) ? '' : ' id="annot-article"') + dAttrs + '>' +
|
|
1492
|
+
t.slice(io2 + openLen, ic2) + '</article>' + t.slice(ic2 + tag.length + 3);
|
|
1493
|
+
} else {
|
|
1494
|
+
/* 无任何内容容器:整段 body 内容包进 article(该文件即一篇正文) */
|
|
1495
|
+
var bm = /<body[^>]*>/i.exec(t);
|
|
1496
|
+
var bClose = t.toLowerCase().lastIndexOf('</body>');
|
|
1497
|
+
if (!bm || bClose === -1) throw new Error('不是完整的 HTML 文档(缺 <body>/</body>)');
|
|
1498
|
+
t = t.slice(0, bm.index + bm[0].length) + '\n<article id="annot-article">\n' +
|
|
1499
|
+
t.slice(bm.index + bm[0].length, bClose) + '\n</article>\n' + t.slice(bClose);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/* 4) 装配批注层:变量块进 head 最前(首 :root 交换命中),其余在 </body> 前 */
|
|
1504
|
+
var tpl = fs.readFileSync(path.join(pkgRoot, 'templates', 'annotatable-doc.html'), 'utf8');
|
|
1505
|
+
var parts = annotParts(tpl);
|
|
1506
|
+
var headVars = '<style id="annot-tpl-vars">' + parts.varBlock + '</style>';
|
|
1507
|
+
var payload = '<style id="annot-tpl-css">\n' + parts.annotCSS + '\n</style>\n' +
|
|
1508
|
+
parts.skeleton + '\n' + annotStoreBlock() + '<script>\n' + parts.mainJs + '\n</' + 'script>';
|
|
1509
|
+
if (/<head[^>]*>/i.test(t)) {
|
|
1510
|
+
t = t.replace(/<head[^>]*>/i, function (all) { return all + '\n' + headVars; });
|
|
1511
|
+
} else {
|
|
1512
|
+
t = headVars + '\n' + t;
|
|
1513
|
+
}
|
|
1514
|
+
var bodyClose = t.toLowerCase().lastIndexOf('</body>');
|
|
1515
|
+
if (bodyClose === -1) throw new Error('不是完整的 HTML 文档(缺 </body>)');
|
|
1516
|
+
t = t.slice(0, bodyClose) + payload + '\n' + t.slice(bodyClose);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/* 相对路径引用在 ~/.annotatable 位置会失效,收集提示 */
|
|
1520
|
+
var warnRefs = [];
|
|
1521
|
+
(t.match(/(?:src|href)\s*=\s*["']([^"']+)["']/gi) || []).forEach(function (all) {
|
|
1522
|
+
var v = all.replace(/^(?:src|href)\s*=\s*["']/i, '').replace(/["']$/, '');
|
|
1523
|
+
if (v && !/^(https?:|data:|#|\/\/|mailto:|[A-Za-z]:)/.test(v) && warnRefs.indexOf(v) === -1) warnRefs.push(v);
|
|
1524
|
+
});
|
|
1525
|
+
if (warnRefs.length > 5) warnRefs = warnRefs.slice(0, 5).concat(['…']);
|
|
1526
|
+
|
|
1527
|
+
var gdir = globalDir(opts);
|
|
1528
|
+
fs.mkdirSync(gdir, { recursive: true });
|
|
1529
|
+
var dst = globalTemplatePath(opts);
|
|
1530
|
+
fs.writeFileSync(dst, t.replace(/\s+$/, '\n') + '\n<!-- ' + VER_TAG + pkgVersion(pkgRoot) + ' 全局默认模板 · annotatable template --reset 可撤销 -->\n');
|
|
1531
|
+
console.log('✅ 已把该 HTML 设为全局默认模板: ' + dst);
|
|
1532
|
+
console.log(' 来源: ' + srcPath);
|
|
1533
|
+
console.log(' 之后 annotatable new / md 默认即用这份模板(目录内 annotatable.template.html 或 --template 可覆盖)。');
|
|
1534
|
+
console.log(' 撤销: annotatable template --reset');
|
|
1535
|
+
if (warnRefs.length) console.log(' ⚠ 相对路径引用在新位置会失效,建议内联或改线上地址: ' + warnRefs.join(', '));
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/* 主题工坊页生成(v1.17.0):壳 templates/theme-studio.html + 当前模板全文。
|
|
1539
|
+
* 模板全文以 JSON 字符串内嵌(</script 统一转义为 <\/script,防脚本块提前闭合),
|
|
1540
|
+
* 工坊在浏览器端按 applyTheme 同规则把主题烤入模板副本做实时预览——单源不双写。
|
|
1541
|
+
* 由 `annotatable studio` 挂到 serve 的 GET /__annot/studio 内存直出,不落盘。 */
|
|
1542
|
+
function studioHtml(pkgRoot) {
|
|
1543
|
+
var tpl = fs.readFileSync(path.join(pkgRoot, 'templates', 'annotatable-doc.html'), 'utf8');
|
|
1544
|
+
var shell = fs.readFileSync(path.join(pkgRoot, 'templates', 'theme-studio.html'), 'utf8');
|
|
1545
|
+
var tplCss = tpl.slice(tpl.indexOf('<style>') + 7, tpl.indexOf('</style>'));
|
|
1546
|
+
var tplSrcJs = JSON.stringify(tpl).replace(/<\//g, '<\\/');
|
|
1547
|
+
return shell
|
|
1548
|
+
.replace('/*__TPL_CSS__*/', function () { return tplCss; })
|
|
1549
|
+
.replace('"__TPL_SRC__"', function () { return tplSrcJs; })
|
|
1550
|
+
.replace('__PKG_VER__', function () { return pkgVersion(pkgRoot); });
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/* template --reset:撤销全局默认模板(全局主题文件保留——那是主题工坊的产物,单独删) */
|
|
1554
|
+
function templateReset(opts) {
|
|
1555
|
+
opts = opts || {};
|
|
1556
|
+
var g = globalTemplatePath(opts);
|
|
1557
|
+
if (!fs.existsSync(g)) {
|
|
1558
|
+
console.log('未设置全局默认模板,无需撤销。');
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
fs.unlinkSync(g);
|
|
1562
|
+
console.log('已撤销全局默认模板(删除 ' + g + '),new / md 恢复内置模板。');
|
|
1563
|
+
var gt = globalThemePath(opts);
|
|
1564
|
+
if (fs.existsSync(gt)) console.log('全局主题 ' + gt + ' 仍生效,不需要时手动删除。');
|
|
1565
|
+
try { fs.rmdirSync(globalDir(opts)); } catch (e) { /* 目录非空则保留 */ }
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/* ---------- skill 副本版本自检(过期检测与自动升级) ----------
|
|
1569
|
+
* 版本唯一来源是 package.json;安装/升级时由 CLI 在副本末尾盖版本注释,
|
|
1570
|
+
* 模板本身不含标记,避免双源失同步。null=副本不存在,''=无标记(旧版安装)。 */
|
|
1571
|
+
var VER_TAG = 'annotatable-html v';
|
|
1572
|
+
function pkgVersion(pkgRoot) {
|
|
1573
|
+
try { return JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')).version || ''; }
|
|
1574
|
+
catch (e) { return ''; }
|
|
1575
|
+
}
|
|
1576
|
+
/* 生成页版本烤入(v1.26.1):模板占位 __ANNOT_VER__ → package.json 真实版本。
|
|
1577
|
+
* 每份页自带「出生时的层版本」,agent/人 grep meta 即可验层;页内无 meta = 本版之前的旧层页。 */
|
|
1578
|
+
function stampPageVersion(tpl, pkgRoot) {
|
|
1579
|
+
return tpl.replace(/__ANNOT_VER__/g, pkgVersion(pkgRoot) || 'unknown');
|
|
1580
|
+
}
|
|
1581
|
+
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
1582
|
+
function skillVersionOf(file) {
|
|
1583
|
+
if (!fs.existsSync(file)) return null;
|
|
1584
|
+
var m = fs.readFileSync(file, 'utf8').match(new RegExp('<!-- ' + VER_TAG + '([\\w.-]+) -->'));
|
|
1585
|
+
return m ? m[1] : '';
|
|
1586
|
+
}
|
|
1587
|
+
function stampVersion(file, ver) {
|
|
1588
|
+
var t = fs.readFileSync(file, 'utf8').replace(new RegExp('\\s*<!-- ' + VER_TAG + '[\\w.-]+ -->', 'g'), '');
|
|
1589
|
+
fs.writeFileSync(file, t.replace(/\s+$/, '\n') + '<!-- ' + VER_TAG + ver + ' -->\n');
|
|
1590
|
+
}
|
|
1591
|
+
/* CLI 启动自检:已装 skill 副本落后于当前包时返回一行提示 */
|
|
1592
|
+
function staleSkillNotice(pkgRoot, baseDir) {
|
|
1593
|
+
try {
|
|
1594
|
+
var os = require('os');
|
|
1595
|
+
var home = baseDir || os.homedir();
|
|
1596
|
+
var ver = pkgVersion(pkgRoot);
|
|
1597
|
+
var stale = [];
|
|
1598
|
+
['.zcode', '.claude'].forEach(function (d) {
|
|
1599
|
+
var f = path.join(home, d, 'skills', 'annotatable-html', 'SKILL.md');
|
|
1600
|
+
var v = skillVersionOf(f);
|
|
1601
|
+
if (v !== null && v !== ver) stale.push(f);
|
|
1602
|
+
});
|
|
1603
|
+
if (!stale.length) return '';
|
|
1604
|
+
return '⚠ 已安装的 skill 副本落后于当前包 v' + ver + '(' + stale.length + ' 份),运行 annotatable skill 可自动升级';
|
|
1605
|
+
} catch (e) { return ''; }
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/* 安装平台 skill + slash 命令(让「读批注」「/读批注」在任意目录自动触发)
|
|
1609
|
+
* 副本已存在且与包同版本则跳过,版本落后(含无标记旧副本)则自动覆盖升级;
|
|
1610
|
+
* baseDir 用于测试覆盖(默认 os.homedir()) */
|
|
1611
|
+
function installSkill(opts, pkgRoot, baseDir) {
|
|
1612
|
+
opts = opts || {};
|
|
1613
|
+
var os = require('os');
|
|
1614
|
+
var home = baseDir || os.homedir();
|
|
1615
|
+
var ver = pkgVersion(pkgRoot);
|
|
1616
|
+
var targets = [];
|
|
1617
|
+
if (opts.zcode || (!opts.zcode && !opts.claude)) targets.push({ name: 'ZCode', base: path.join(home, '.zcode') });
|
|
1618
|
+
if (opts.claude || (!opts.zcode && !opts.claude)) targets.push({ name: 'Claude Code', base: path.join(home, '.claude') });
|
|
1619
|
+
/* v1.37.1:~/.agents 是跨 harness 实体仓(atomcode 直读,codex 经符号链接共享)——收编进自动安装,
|
|
1620
|
+
消除每版手工盖章的掉队隐患。此处多投一份 AGENTS.md(atomcode 双文件公约);命令不投(slash 是 zcode/claude 公约)。 */
|
|
1621
|
+
targets.push({ name: 'agents(共享仓)', base: path.join(home, '.agents'), agentsHub: true });
|
|
1622
|
+
var srcSkill = path.join(pkgRoot, 'templates', 'SKILL.md');
|
|
1623
|
+
var cmdDir = path.join(pkgRoot, 'templates', 'commands');
|
|
1624
|
+
var installed = [], skipped = 0;
|
|
1625
|
+
targets.forEach(function (t) {
|
|
1626
|
+
var skillDst = path.join(t.base, 'skills', 'annotatable-html', 'SKILL.md');
|
|
1627
|
+
var sv = skillVersionOf(skillDst);
|
|
1628
|
+
if (sv === ver) {
|
|
1629
|
+
skipped++; console.log(' = skill 已是最新 v' + ver + ',跳过: ' + skillDst);
|
|
1630
|
+
} else {
|
|
1631
|
+
fs.mkdirSync(path.dirname(skillDst), { recursive: true });
|
|
1632
|
+
fs.copyFileSync(srcSkill, skillDst);
|
|
1633
|
+
stampVersion(skillDst, ver);
|
|
1634
|
+
installed.push(sv === null ? (t.name + ' skill → ' + skillDst)
|
|
1635
|
+
: (t.name + ' skill 升级 ' + (sv || '无版本标记(旧)') + ' → v' + ver + ':' + skillDst));
|
|
1636
|
+
}
|
|
1637
|
+
if (t.agentsHub) {
|
|
1638
|
+
var agSrc = path.join(pkgRoot, 'templates', 'AGENTS.md');
|
|
1639
|
+
var agDst = path.join(t.base, 'skills', 'annotatable-html', 'AGENTS.md');
|
|
1640
|
+
var av = skillVersionOf(agDst);
|
|
1641
|
+
if (av === ver) { skipped++; }
|
|
1642
|
+
else {
|
|
1643
|
+
fs.mkdirSync(path.dirname(agDst), { recursive: true });
|
|
1644
|
+
fs.copyFileSync(agSrc, agDst);
|
|
1645
|
+
stampVersion(agDst, ver);
|
|
1646
|
+
installed.push(av === null ? (t.name + ' AGENTS.md → ' + agDst)
|
|
1647
|
+
: (t.name + ' AGENTS.md 升级 ' + (av || '无版本标记(旧)') + ' → v' + ver));
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
// 命令独立于 skill 处理(升级时清理旧命令、安装新命令)
|
|
1651
|
+
if (fs.existsSync(cmdDir) && !t.agentsHub) {
|
|
1652
|
+
// 清理 v1.3.0 的旧命令文件(已被 html4agent.md 取代)
|
|
1653
|
+
['读批注.md', '可批注文档.md'].forEach(function (old) {
|
|
1654
|
+
var oldDst = path.join(t.base, 'commands', old);
|
|
1655
|
+
if (fs.existsSync(oldDst)) { fs.unlinkSync(oldDst); installed.push(t.name + ' 清理旧命令 /' + old.replace(/\.md$/, '')); }
|
|
1656
|
+
});
|
|
1657
|
+
fs.readdirSync(cmdDir).forEach(function (f) {
|
|
1658
|
+
var cdst = path.join(t.base, 'commands', f);
|
|
1659
|
+
var cv = skillVersionOf(cdst);
|
|
1660
|
+
if (cv !== null && cv === ver) { skipped++; return; }
|
|
1661
|
+
fs.mkdirSync(path.dirname(cdst), { recursive: true });
|
|
1662
|
+
fs.copyFileSync(path.join(cmdDir, f), cdst);
|
|
1663
|
+
stampVersion(cdst, ver);
|
|
1664
|
+
installed.push(cv === null ? (t.name + ' 命令 → /' + f.replace(/\.md$/, ''))
|
|
1665
|
+
: (t.name + ' 命令升级 /' + f.replace(/\.md$/, '') + ' → v' + ver));
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
});
|
|
1669
|
+
console.log('触发器安装完成:');
|
|
1670
|
+
installed.forEach(function (l) { console.log(' + ' + l); });
|
|
1671
|
+
if (skipped) console.log(' = 跳过已存在项 ' + skipped + ' 个');
|
|
1672
|
+
if (!installed.length && !skipped) console.log(' (无内容安装)');
|
|
1673
|
+
console.log('生效后(新开终端/新会话),任意目录可说「生成可批注报告」或 /html4agent 触发;批注后说「已批」即进入读批回答循环。');
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
function annotVarPrefix(css) {
|
|
1677
|
+
return css.replace(/--([A-Za-z][\w-]*)/g, function (all, name) {
|
|
1678
|
+
return /^annot-/.test(name) ? all : '--annot-' + name;
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/* v1.17.0:批注层四件套提取器——inject 与 template --use(整页变模板)共用,防双源失同步。
|
|
1683
|
+
* 返回原始切片(变量块/批注层样式均未加前缀),由调用方决定是否过 annotVarPrefix。
|
|
1684
|
+
* 新增 UI 元素必须放进模板的「批注层骨架 BEGIN/END」哨兵对内。 */
|
|
1685
|
+
function annotParts(tpl) {
|
|
1686
|
+
var sS = tpl.indexOf('<style>') + 7, sE = tpl.indexOf('</style>');
|
|
1687
|
+
var styleAll = tpl.slice(sS, sE);
|
|
1688
|
+
var aStart = styleAll.indexOf('/* ═══ 批注层样式 BEGIN');
|
|
1689
|
+
var aEnd = styleAll.indexOf('/* ═══ 批注层样式 END');
|
|
1690
|
+
if (aStart === -1 || aEnd === -1 || aEnd <= aStart) throw new Error('模板缺少批注层样式哨兵(BEGIN/END)');
|
|
1691
|
+
var annotCSS = styleAll.slice(aStart, aEnd);
|
|
1692
|
+
var rootM = tpl.match(/:root\s*\{[^}]*\}/);
|
|
1693
|
+
var varBlock = rootM ? rootM[0] : '';
|
|
1694
|
+
var jsS = tpl.lastIndexOf('<script>'), jsE = tpl.lastIndexOf('</script>');
|
|
1695
|
+
var mainJs = tpl.slice(jsS + 9, jsE);
|
|
1696
|
+
/* v1.12.1:骨架改按哨兵抽取。旧逻辑只切到 toast 为止,而 v1.12 的 Word 模式
|
|
1697
|
+
* 五件套(✏️/🧭/wordbar/nav/find)位于 popover 之后、toast 之前,
|
|
1698
|
+
* 旧 fab→toast 切割把它们留在骨架外——注入版缺按钮且 init 绑定事件时报错。 */
|
|
1699
|
+
var kS = tpl.indexOf('<!-- ═══ 批注层骨架 BEGIN');
|
|
1700
|
+
var kEm = tpl.indexOf('<!-- ═══ 批注层骨架 END');
|
|
1701
|
+
var skeleton;
|
|
1702
|
+
if (kS !== -1 && kEm !== -1 && kEm > kS) {
|
|
1703
|
+
skeleton = tpl.slice(tpl.indexOf('>', kS) + 1, tpl.lastIndexOf('<!--', kEm));
|
|
1704
|
+
} else {
|
|
1705
|
+
kS = tpl.indexOf('<button id="annot-fab"');
|
|
1706
|
+
var tIdx = tpl.indexOf('id="annot-toast"');
|
|
1707
|
+
var kE = tpl.indexOf('</div>', tIdx) + 6;
|
|
1708
|
+
skeleton = tpl.slice(kS, kE);
|
|
1709
|
+
}
|
|
1710
|
+
/* 骨架自带空 popover/toast;自定义模板若塞了演示内容,装配前清空 */
|
|
1711
|
+
skeleton = skeleton.replace(/(<div id="annot-popover"[^>]*>)[\s\S]*?<\/div>/, '$1</div>');
|
|
1712
|
+
skeleton = skeleton.replace(/(<div id="annot-toast"[^>]*>)[\s\S]*?<\/div>/, '$1</div>');
|
|
1713
|
+
return { annotCSS: annotCSS, varBlock: varBlock, skeleton: skeleton, mainJs: mainJs };
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function annotStoreBlock() {
|
|
1717
|
+
return '<!-- ═══ AI批注数据 BEGIN — agent 读取协议见 AGENTS.md ═══ -->\n' +
|
|
1718
|
+
'<script id="annotations-store" type="application/json">\n' +
|
|
1719
|
+
JSON.stringify({ rev: 0, annotations: [], history: [] }) + '\n</' + 'script>\n' +
|
|
1720
|
+
'<!-- ═══ AI批注数据 END ═══ -->\n';
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/* inject:给任意 HTML 注入批注层(CSS+UI骨架+脚本+空白store)
|
|
1724
|
+
* 目标保持原有外观;无 <article> 时给 <body> 盖章作为锚定根;
|
|
1725
|
+
* 自动备份原文件到同目录 .annotatable/(*.pre-inject.bak);已含批注层的文件拒绝重复注入。
|
|
1726
|
+
* 宿主隔离:批注层样式只取模板 BEGIN/END 哨兵区(不带目录/正文排版规则),
|
|
1727
|
+
* 自定义属性统一加 --annot- 前缀并自带定义块,杜绝与宿主 :root 变量互相覆盖;
|
|
1728
|
+
* 脚本置 __ANNOT_NO_TOC__ 标志,不接管宿主已有目录。 */
|
|
1729
|
+
function injectHtml(targetPath, pkgRoot, opts) {
|
|
1730
|
+
opts = opts || {};
|
|
1731
|
+
targetPath = path.resolve(targetPath);
|
|
1732
|
+
if (!fs.existsSync(targetPath)) throw new Error('文件不存在: ' + targetPath);
|
|
1733
|
+
var orig = fs.readFileSync(targetPath, 'utf8');
|
|
1734
|
+
var target = orig;
|
|
1735
|
+
/* 判定认真实的 store script 标签,而不是正文文字(讲到批注协议的文档会提到这个词) */
|
|
1736
|
+
if (target.indexOf('<script id="annotations-store"') !== -1) throw new Error('该文件已包含批注层,无需注入');
|
|
1737
|
+
|
|
1738
|
+
/* 从模板提取批注层四件套(注入态统一加 --annot- 前缀做宿主隔离) */
|
|
1739
|
+
var tpl = fs.readFileSync(path.join(pkgRoot, 'templates', 'annotatable-doc.html'), 'utf8');
|
|
1740
|
+
if (opts.lang === 'en') tpl = applyLangEn(tpl).html;
|
|
1741
|
+
var parts = annotParts(tpl);
|
|
1742
|
+
var annotCSS = annotVarPrefix(parts.annotCSS);
|
|
1743
|
+
var varBlock = parts.varBlock ? annotVarPrefix(parts.varBlock) : '';
|
|
1744
|
+
var mainJs = parts.mainJs;
|
|
1745
|
+
var skeleton = parts.skeleton;
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
/* DOC_ID / FILE_NAME 唯一化 + 注入环境标志(不接管宿主目录) */
|
|
1749
|
+
var docId = 'doc-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
1750
|
+
var fileName = path.basename(targetPath);
|
|
1751
|
+
mainJs = '(function(){try{window.__ANNOT_NO_TOC__=1}catch(e){}})();\n' + mainJs;
|
|
1752
|
+
mainJs = mainJs.replace(/var DOC_ID\s*=\s*'[^']*';/, "var DOC_ID = '" + docId + "';");
|
|
1753
|
+
mainJs = mainJs.replace(/var FILE_NAME = '[^']*';/, "var FILE_NAME = '" + fileName + "';");
|
|
1754
|
+
/* 版本烤入(v1.26.1):宿主 head 盖 meta(重注入则刷新),无 head 则静默跳过 */
|
|
1755
|
+
var injVer = pkgVersion(pkgRoot) || 'unknown';
|
|
1756
|
+
if (/<meta name="annotatable-html" content="[^"]*">/.test(target)) {
|
|
1757
|
+
target = target.replace(/<meta name="annotatable-html" content="[^"]*">/, '<meta name="annotatable-html" content="' + injVer + '">');
|
|
1758
|
+
} else {
|
|
1759
|
+
target = target.replace(/<head(\s[^>]*)?>/i, function (m) { return m + '\n<meta name="annotatable-html" content="' + injVer + '">'; });
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/* 锚定根:目标无 <article> 且无盖章时,给 <body> 加 id */
|
|
1763
|
+
if (!/<article[\s>]/.test(target) && target.indexOf('id="annot-article"') === -1) {
|
|
1764
|
+
target = target.replace(/<body([^>]*)>/, function (all, attrs) {
|
|
1765
|
+
return / id=/.test(attrs) ? all : '<body id="annot-article"' + attrs + '>';
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/* 主题:目标目录 > cwd > 全局 ~/.annotatable 的 annotatable.theme.css 自动应用(同样过前缀变换) */
|
|
1770
|
+
var theme = loadTheme(path.dirname(targetPath)) || loadTheme(process.cwd()) || loadTheme(globalDir(opts));
|
|
1771
|
+
var themeRoot = '', themeRest = '';
|
|
1772
|
+
if (theme) {
|
|
1773
|
+
var rm = theme.match(/:root\s*\{[^}]*\}/);
|
|
1774
|
+
themeRoot = rm ? annotVarPrefix(rm[0]) : '';
|
|
1775
|
+
themeRest = annotVarPrefix(rm ? theme.replace(rm[0], '') : theme);
|
|
1776
|
+
}
|
|
1777
|
+
var cssBlock = '<style id="annot-injected-css">\n' +
|
|
1778
|
+
[varBlock, themeRoot, annotCSS, themeRest].filter(Boolean).join('\n') + '\n</style>';
|
|
1779
|
+
|
|
1780
|
+
var store = annotStoreBlock();
|
|
1781
|
+
var payload = cssBlock + '\n' + skeleton + '\n' + store + '<script>\n' + mainJs + '\n</' + 'script>';
|
|
1782
|
+
|
|
1783
|
+
var bodyClose = target.lastIndexOf('</body>');
|
|
1784
|
+
if (bodyClose === -1) throw new Error('未找到 </body>,不是完整的 HTML 文档');
|
|
1785
|
+
/* 备份统一放同目录 .annotatable/(与版本历史外置档案同一处,不散落在文件旁边);重名加时间戳保护最早那份 */
|
|
1786
|
+
var bakDir = path.join(path.dirname(targetPath), HIST_DIR);
|
|
1787
|
+
fs.mkdirSync(bakDir, { recursive: true });
|
|
1788
|
+
var bakPath = path.join(bakDir, fileName + '.pre-inject.bak');
|
|
1789
|
+
if (fs.existsSync(bakPath)) {
|
|
1790
|
+
var d = new Date();
|
|
1791
|
+
var st = d.getFullYear() + ('0' + (d.getMonth() + 1)).slice(-2) + ('0' + d.getDate()).slice(-2) + '-' +
|
|
1792
|
+
('0' + d.getHours()).slice(-2) + ('0' + d.getMinutes()).slice(-2) + ('0' + d.getSeconds()).slice(-2);
|
|
1793
|
+
bakPath = path.join(bakDir, fileName + '.' + st + '.pre-inject.bak');
|
|
1794
|
+
}
|
|
1795
|
+
fs.writeFileSync(bakPath, orig);
|
|
1796
|
+
fs.writeFileSync(targetPath, target.slice(0, bodyClose) + payload + '\n' + target.slice(bodyClose));
|
|
1797
|
+
console.log('注入完成: ' + targetPath);
|
|
1798
|
+
console.log(' DOC_ID: ' + docId + ' · 原文件备份: ' + path.join(HIST_DIR, path.basename(bakPath)));
|
|
1799
|
+
console.log(' 用 Edge/Chrome 打开,点右侧 🔗 同步球绑定后即可划线批注。');
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/* ---------- v1.36.0 旧层页静默升级(touch = upgrade)----------
|
|
1803
|
+
* 背景:批注层在生成时整份烤进 HTML,包升级触达不了存量页——旧层页永远跑旧代码,
|
|
1804
|
+
* 新 UI/新防线全享受不到。这里把升级做成「agent 触碰即顺手换装」:
|
|
1805
|
+
* read/reply/poll/edit/answer 与 serve 启动自动执行,用户无感;annotatable upgrade 可显式批量。
|
|
1806
|
+
* 铁律:只换批注层——注入页=整个注入尾巴重建(store 原样搬回);模板页=样式/骨架哨兵区+主脚本三件。
|
|
1807
|
+
* 正文、store、DOC_ID、教学块、<title>、主题 :root 一个字节不动 → 锚点全保、rev 不变、伴生档案继续有效。
|
|
1808
|
+
* 安全阀:定位失败/疑似腐蚀/DOC_ID 缺失一律拒动;备份 .annotatable/<名>.pre-upgrade-<旧版>-<ts>.bak →
|
|
1809
|
+
* 持锁(与 serve 保存串行)锁内复验 → 原子写 → 写后读回比对,任一不过即放弃,绝不阻塞原操作。 */
|
|
1810
|
+
function layerVerOf(html) {
|
|
1811
|
+
var m = String(html || '').match(/<meta name="annotatable-html" content="([^"]*)">/);
|
|
1812
|
+
return m ? m[1] : '';
|
|
1813
|
+
}
|
|
1814
|
+
function verKey(v) { // '1.36.0' → 10306;非数值段按 0('unknown'/空串=最老)
|
|
1815
|
+
var p = String(v || '').split('.');
|
|
1816
|
+
return (parseInt(p[0], 10) || 0) * 10000 + (parseInt(p[1], 10) || 0) * 100 + (parseInt(p[2], 10) || 0);
|
|
1817
|
+
}
|
|
1818
|
+
function storeRegionOf(html) { // 含首尾 ═══ 注释的完整 store 区(byte-exact,注入页重建尾巴时原样搬回)
|
|
1819
|
+
var b = html.indexOf('<!-- ═══ AI批注数据 BEGIN');
|
|
1820
|
+
if (b === -1) return null;
|
|
1821
|
+
var e = html.indexOf('AI批注数据 END', b);
|
|
1822
|
+
if (e === -1) return null;
|
|
1823
|
+
var eEnd = html.indexOf('-->', e);
|
|
1824
|
+
return eEnd === -1 ? null : html.slice(b, eEnd + 3);
|
|
1825
|
+
}
|
|
1826
|
+
function upgradeLayerFile(targetPath, pkgRoot, opts) {
|
|
1827
|
+
opts = opts || {};
|
|
1828
|
+
targetPath = path.resolve(targetPath);
|
|
1829
|
+
var orig = fs.readFileSync(targetPath, 'utf8');
|
|
1830
|
+
var cur = pkgVersion(pkgRoot) || 'unknown';
|
|
1831
|
+
if (orig.indexOf('<script id="annotations-store"') === -1) return { status: 'skip', note: '非批注页' };
|
|
1832
|
+
if (orig.split('<script id="annotations-store"').length - 1 > 1 || orig.split('<button id="annot-fab"').length - 1 > 1)
|
|
1833
|
+
return { status: 'skip', note: '结构疑似腐蚀(骨架/store 重复),拒绝自动换层——先人工抢救数据' };
|
|
1834
|
+
var oldVer = layerVerOf(orig);
|
|
1835
|
+
if (oldVer === cur) return { status: 'current' };
|
|
1836
|
+
if (verKey(oldVer) > verKey(cur))
|
|
1837
|
+
return { status: 'skip', note: '页面层 v' + oldVer + ' 比当前包 v' + cur + ' 还新(他机新页/本机包未同步),拒绝降级' };
|
|
1838
|
+
var docIdM = orig.match(/var DOC_ID\s*=\s*'([^']*)'/);
|
|
1839
|
+
if (!docIdM || !docIdM[1]) return { status: 'skip', note: '找不到 DOC_ID(过旧或非标页),拒绝自动换层' };
|
|
1840
|
+
var docId = docIdM[1];
|
|
1841
|
+
var fnM = orig.match(/var FILE_NAME = '([^']*)'/);
|
|
1842
|
+
var fileName = fnM ? fnM[1] : path.basename(targetPath);
|
|
1843
|
+
var isEn = /var DOC_LANG = 'en';/.test(orig); // en 页换装后还得是 en
|
|
1844
|
+
var tplRaw = fs.readFileSync(path.join(pkgRoot, 'templates', 'annotatable-doc.html'), 'utf8');
|
|
1845
|
+
var tpl = isEn ? applyLangEn(tplRaw).html : tplRaw;
|
|
1846
|
+
|
|
1847
|
+
function stampMeta(html) {
|
|
1848
|
+
if (/<meta name="annotatable-html" content="[^"]*">/.test(html))
|
|
1849
|
+
return html.replace(/<meta name="annotatable-html" content="[^"]*">/, '<meta name="annotatable-html" content="' + cur + '">');
|
|
1850
|
+
return html.replace(/<head(\s[^>]*)?>/i, function (m) { return m + '\n<meta name="annotatable-html" content="' + cur + '">'; });
|
|
1851
|
+
}
|
|
1852
|
+
function stampIds(js) { // 盖回旧 DOC_ID/FILE_NAME;模板占位没盖成=语法结构漂移,宁可不做
|
|
1853
|
+
var out = js.replace(/var DOC_ID\s*=\s*'[^']*';/, "var DOC_ID = '" + docId + "';")
|
|
1854
|
+
.replace(/var FILE_NAME = '[^']*';/, "var FILE_NAME = '" + fileName + "';");
|
|
1855
|
+
if (out.indexOf("var DOC_ID = '" + docId + "';") === -1) throw new Error('DOC_ID 盖章失败(模板主脚本结构漂移)');
|
|
1856
|
+
return out;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
var out;
|
|
1860
|
+
var isInject = orig.split('<style id="annot-injected-css">').length - 1 === 1;
|
|
1861
|
+
if (isInject) {
|
|
1862
|
+
/* 注入页:尾巴 [annot-injected-css 起 → 主脚本 </script> 止] 整体重建(注入时就在 </body> 前) */
|
|
1863
|
+
var i0 = orig.indexOf('<style id="annot-injected-css">');
|
|
1864
|
+
var bodyClose = orig.lastIndexOf('</body>');
|
|
1865
|
+
var jsClose = bodyClose === -1 ? -1 : orig.lastIndexOf('</' + 'script>', bodyClose);
|
|
1866
|
+
var storeRegion = storeRegionOf(orig);
|
|
1867
|
+
if (bodyClose === -1 || i0 === -1 || jsClose === -1 || i0 > bodyClose ||
|
|
1868
|
+
!/^\s*$/.test(orig.slice(jsClose + 9, bodyClose)) || !storeRegion ||
|
|
1869
|
+
storeRegionOf(orig.slice(0, i0)) !== null || i0 < orig.indexOf('<body'))
|
|
1870
|
+
return { status: 'skip', note: '注入尾巴定位失败(尾部有非层内容或非标页),拒绝自动换层' };
|
|
1871
|
+
var parts = annotParts(tpl);
|
|
1872
|
+
var annotCSS = annotVarPrefix(parts.annotCSS);
|
|
1873
|
+
var varBlock = parts.varBlock ? annotVarPrefix(parts.varBlock) : '';
|
|
1874
|
+
var mainJs = stampIds('(function(){try{window.__ANNOT_NO_TOC__=1}catch(e){}})();\n' + parts.mainJs);
|
|
1875
|
+
var theme = loadTheme(path.dirname(targetPath)) || loadTheme(process.cwd()) || loadTheme(globalDir(opts));
|
|
1876
|
+
var themeRoot = '', themeRest = '';
|
|
1877
|
+
if (theme) {
|
|
1878
|
+
var rm = theme.match(/:root\s*\{[^}]*\}/);
|
|
1879
|
+
themeRoot = rm ? annotVarPrefix(rm[0]) : '';
|
|
1880
|
+
themeRest = annotVarPrefix(rm ? theme.replace(rm[0], '') : theme);
|
|
1881
|
+
}
|
|
1882
|
+
var cssBlock = '<style id="annot-injected-css">\n' + [varBlock, themeRoot, annotCSS, themeRest].filter(Boolean).join('\n') + '\n</style>';
|
|
1883
|
+
var payload = cssBlock + '\n' + parts.skeleton + '\n' + storeRegion + '\n<script>\n' + mainJs + '\n</' + 'script>';
|
|
1884
|
+
out = stampMeta(orig.slice(0, i0) + payload + '\n' + orig.slice(jsClose + 9));
|
|
1885
|
+
} else {
|
|
1886
|
+
/* 模板页(new/md/teach/--use 产物):三件哨兵区原位换装,正文/主题/标题不动 */
|
|
1887
|
+
function sentRegion(html, beginMark, endMark) {
|
|
1888
|
+
var b = html.indexOf(beginMark);
|
|
1889
|
+
if (b === -1) return null;
|
|
1890
|
+
var e = html.indexOf(endMark, b);
|
|
1891
|
+
return e === -1 ? null : { b: b, e: e + endMark.length };
|
|
1892
|
+
}
|
|
1893
|
+
var CSS_B = '/* ═══ 批注层样式 BEGIN', CSS_E = '批注层样式 END ═══ */';
|
|
1894
|
+
var SK_B = '<!-- ═══ 批注层骨架 BEGIN', SK_E = '批注层骨架 END ═══ -->';
|
|
1895
|
+
var cs = sentRegion(orig, CSS_B, CSS_E), cn = sentRegion(tpl, CSS_B, CSS_E);
|
|
1896
|
+
var ks = sentRegion(orig, SK_B, SK_E), kn = sentRegion(tpl, SK_B, SK_E);
|
|
1897
|
+
var jsS = orig.lastIndexOf('<script>'), jsE = jsS === -1 ? -1 : orig.indexOf('</' + 'script>', jsS);
|
|
1898
|
+
var tjS = tpl.lastIndexOf('<script>'), tjE = tpl.indexOf('</' + 'script>', tjS);
|
|
1899
|
+
if (!cs || !cn || !ks || !kn || jsS === -1 || jsE === -1 || tjS === -1 || tjE === -1 || ks.e > jsS)
|
|
1900
|
+
return { status: 'skip', note: '哨兵区/主脚本定位失败(v1.17 前旧层页),请重建壳升级' };
|
|
1901
|
+
var newJs = stampIds(tpl.slice(tjS, tjE + 9));
|
|
1902
|
+
/* 从后往前换,坐标不漂移 */
|
|
1903
|
+
out = orig.slice(0, jsS) + newJs + orig.slice(jsE + 9);
|
|
1904
|
+
out = out.slice(0, ks.b) + tpl.slice(kn.b, kn.e) + out.slice(ks.e);
|
|
1905
|
+
out = out.slice(0, cs.b) + tpl.slice(cn.b, cn.e) + out.slice(cs.e);
|
|
1906
|
+
out = stampMeta(out);
|
|
1907
|
+
}
|
|
1908
|
+
if (layerVerOf(out) !== cur) return { status: 'skip', note: '版本戳盖不上(无 <head>?),拒绝自动换层' };
|
|
1909
|
+
|
|
1910
|
+
/* 写前体检:store 必须逐字节一致;换装产物不得自带腐蚀项 */
|
|
1911
|
+
var oldStoreM = orig.match(/<script id="annotations-store" type="application\/json">[\s\S]*?<\/script>/);
|
|
1912
|
+
var newStoreM = out.match(/<script id="annotations-store" type="application\/json">[\s\S]*?<\/script>/);
|
|
1913
|
+
if (!oldStoreM || !newStoreM || oldStoreM[0] !== newStoreM[0]) return { status: 'skip', note: 'store 比对不一致,拒绝写入' };
|
|
1914
|
+
function rotten(t) {
|
|
1915
|
+
var bad = [];
|
|
1916
|
+
if (t.split('<button id="annot-fab"').length - 1 > 1) bad.push('骨架重复');
|
|
1917
|
+
try { JSON.parse((t.match(/<script id="annotations-store" type="application\/json">([\s\S]*?)<\/script>/) || [])[1]); }
|
|
1918
|
+
catch (e) { bad.push('store解析失败'); }
|
|
1919
|
+
return bad;
|
|
1920
|
+
}
|
|
1921
|
+
if (rotten(out).length) return { status: 'skip', note: '换装产物自检未过(' + rotten(out).join('/') + '),拒绝写入' };
|
|
1922
|
+
|
|
1923
|
+
/* 备份 + 持锁原子写 + 写后读回比对 */
|
|
1924
|
+
return withDocLock(targetPath, function () {
|
|
1925
|
+
var now = fs.readFileSync(targetPath, 'utf8');
|
|
1926
|
+
if (now !== orig) return { status: 'skip', note: '锁内发现文件刚被写过,本次放弃(下次触碰再升)' };
|
|
1927
|
+
var bakDir = path.join(path.dirname(targetPath), HIST_DIR);
|
|
1928
|
+
fs.mkdirSync(bakDir, { recursive: true });
|
|
1929
|
+
var d = new Date();
|
|
1930
|
+
var ts = d.getFullYear() + ('0' + (d.getMonth() + 1)).slice(-2) + ('0' + d.getDate()).slice(-2) + '-' +
|
|
1931
|
+
('0' + d.getHours()).slice(-2) + ('0' + d.getMinutes()).slice(-2) + ('0' + d.getSeconds()).slice(-2);
|
|
1932
|
+
var bakPath = path.join(bakDir, path.basename(targetPath) + '.pre-upgrade-' + (oldVer || 'old') + '-' + ts + '.bak');
|
|
1933
|
+
fs.writeFileSync(bakPath, orig);
|
|
1934
|
+
var tmp = targetPath + '.upgrading';
|
|
1935
|
+
fs.writeFileSync(tmp, out);
|
|
1936
|
+
fs.renameSync(tmp, targetPath);
|
|
1937
|
+
if (fs.readFileSync(targetPath, 'utf8') !== out) {
|
|
1938
|
+
fs.writeFileSync(targetPath, orig); // 读回不一致(极端:写坏)→ 回滚
|
|
1939
|
+
return { status: 'error', note: '写后读回不一致,已回滚' };
|
|
1940
|
+
}
|
|
1941
|
+
return { status: 'upgraded', from: oldVer || '(无版本戳)', to: cur, bak: bakPath };
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
/* agent 触碰即换装:六个读写命令与 serve 启动的统一钩子。任何失败只提示一行,绝不阻塞原操作。 */
|
|
1945
|
+
function silentUpgrade(htmlPath) {
|
|
1946
|
+
try {
|
|
1947
|
+
var r = upgradeLayerFile(htmlPath, path.join(__dirname, '..'));
|
|
1948
|
+
if (r && r.status === 'upgraded')
|
|
1949
|
+
console.log('ℹ 批注层已静默升级 ' + r.from + ' → v' + r.to + '(正文与批注原样保留 · 备份 ' + HIST_DIR + '/' + path.basename(r.bak) + ')');
|
|
1950
|
+
return r;
|
|
1951
|
+
} catch (e) {
|
|
1952
|
+
try { console.log('⚠ 批注层静默升级跳过(不影响本次操作): ' + (e.message || e)); } catch (e2) { /* stdout 关闭 */ }
|
|
1953
|
+
return null;
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
function upgradeDir(dirPath) { // serve 启动 / annotatable upgrade <目录>:扫一层目录逐页换装
|
|
1957
|
+
var n = 0, files;
|
|
1958
|
+
try { files = fs.readdirSync(dirPath); } catch (e) { return 0; }
|
|
1959
|
+
files.forEach(function (f) {
|
|
1960
|
+
if (!/\.html?$/i.test(f)) return;
|
|
1961
|
+
var r = silentUpgrade(path.join(dirPath, f));
|
|
1962
|
+
if (r && r.status === 'upgraded') n++;
|
|
1963
|
+
});
|
|
1964
|
+
return n;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/* ---------- v1.37.0 图纸页安全重生成(regen)+ 回复编辑礼让 ----------
|
|
1968
|
+
* 背景:页面从生成器图纸造出、又被人类在 Word 模式手改——重生成若直接按图纸重建正文,
|
|
1969
|
+
* 手改会被无声盖掉(全景页目录事件:人类删的目录被图纸原样盖回)。把安全管线产品化:
|
|
1970
|
+
* regenDoc(live, fresh) 报表:剥层取可见文本 diff,列出手改清单,不动盘
|
|
1971
|
+
* regenDoc(live, fresh, {go}) 上盘:fresh 注入当前层 + live 的 store/DOC_ID 原样搬回;
|
|
1972
|
+
* 有未收割手改 → 拒绝(--force 显式丢弃才放行)
|
|
1973
|
+
* 铁律:正文以盘为准——图纸必须先追认手改,永远不许倒过来。wordLock 活跃一律让路。
|
|
1974
|
+
* 回复礼让:人类举旗编辑期间 reply/answer 不落盘(退出码 6),agent 等收笔再发。 */
|
|
1975
|
+
function stripAnnotLayer(html) {
|
|
1976
|
+
var st = html.replace(/<meta name="annotatable-html" content="[^"]*">\n?/, '');
|
|
1977
|
+
var i0 = st.indexOf('<style id="annot-injected-css">');
|
|
1978
|
+
if (i0 === -1) return null;
|
|
1979
|
+
var bodyClose = st.lastIndexOf('</body>');
|
|
1980
|
+
var jsClose = bodyClose === -1 ? -1 : st.lastIndexOf('</' + 'script>', bodyClose);
|
|
1981
|
+
if (bodyClose === -1 || jsClose === -1 || jsClose < i0) return null;
|
|
1982
|
+
if (!/^\s*$/.test(st.slice(jsClose + 9, bodyClose))) return null;
|
|
1983
|
+
return st.slice(0, i0) + st.slice(jsClose + 9);
|
|
1984
|
+
}
|
|
1985
|
+
function bodyTextLines(html) { // 剥标签取可见文本行:Word 手改都是文本级,浏览器序列化噪音(属性顺序/自闭合形态)天然消失
|
|
1986
|
+
return String(html).split(/<[^>]*>/).map(function (s) {
|
|
1987
|
+
return s.replace(/ /g, ' ').replace(/\s+/g, ' ').trim();
|
|
1988
|
+
}).filter(function (s) { return s.length > 1; });
|
|
1989
|
+
}
|
|
1990
|
+
function wordLockActive(htmlText) {
|
|
1991
|
+
var o = readStore(htmlText);
|
|
1992
|
+
var wl = o && o.control && o.control.wordLock;
|
|
1993
|
+
return !!(wl && Date.now() - (wl.at || 0) < WORD_LOCK_TTL);
|
|
1994
|
+
}
|
|
1995
|
+
function rottenOf(t) {
|
|
1996
|
+
var bad = [];
|
|
1997
|
+
if (t.split('<button id="annot-fab"').length - 1 > 1) bad.push('骨架重复');
|
|
1998
|
+
try { JSON.parse((t.match(/<script id="annotations-store" type="application\/json">([\s\S]*?)<\/script>/) || [])[1]); }
|
|
1999
|
+
catch (e) { bad.push('store解析失败'); }
|
|
2000
|
+
return bad;
|
|
2001
|
+
}
|
|
2002
|
+
function regenDoc(livePath, freshPath, opts) {
|
|
2003
|
+
opts = opts || {};
|
|
2004
|
+
livePath = path.resolve(livePath);
|
|
2005
|
+
freshPath = path.resolve(freshPath);
|
|
2006
|
+
var live = fs.readFileSync(livePath, 'utf8');
|
|
2007
|
+
var fresh = fs.readFileSync(freshPath, 'utf8');
|
|
2008
|
+
if (live.indexOf('<script id="annotations-store"') === -1) throw new Error('live 不是可批注页(无 store 区块)');
|
|
2009
|
+
if (wordLockActive(live)) return { status: 'wordmode' };
|
|
2010
|
+
var liveBody = stripAnnotLayer(live);
|
|
2011
|
+
if (liveBody === null) throw new Error('live 不是注入页(找不到注入尾巴)——regen 目前只支持 inject 维护的图纸页');
|
|
2012
|
+
if (fresh.indexOf('<script id="annotations-store"') !== -1) throw new Error('fresh 应为图纸直出的无层纯页,请勿先 inject');
|
|
2013
|
+
if (!/<\/body>/i.test(fresh)) throw new Error('fresh 不是完整 HTML(无 </body>)');
|
|
2014
|
+
var a = bodyTextLines(liveBody), b = bodyTextLines(fresh);
|
|
2015
|
+
var sb = {};
|
|
2016
|
+
b.forEach(function (l) { sb[l] = 1; });
|
|
2017
|
+
var lost = a.filter(function (l) { return !sb[l]; });
|
|
2018
|
+
if (lost.length && !(opts.go && opts.force))
|
|
2019
|
+
return { status: 'manual-edits', lost: lost, wouldLose: !!(opts.go && !opts.force) };
|
|
2020
|
+
if (!opts.go) return { status: 'clean' };
|
|
2021
|
+
/* 上盘:fresh 落临时文件 → 注入当前层 → live 的 store/DOC_ID/FILE_NAME 搬回 → 断言 → 备份原子替换 */
|
|
2022
|
+
var tmp = path.join(path.dirname(livePath), path.basename(livePath) + '.regen-tmp.html');
|
|
2023
|
+
fs.writeFileSync(tmp, fresh);
|
|
2024
|
+
var fileName = path.basename(livePath);
|
|
2025
|
+
try {
|
|
2026
|
+
injectHtml(tmp, path.join(__dirname, '..'), { lang: /var DOC_LANG = 'en';/.test(live) ? 'en' : undefined });
|
|
2027
|
+
} catch (e) {
|
|
2028
|
+
try { fs.unlinkSync(tmp); } catch (e2) {}
|
|
2029
|
+
throw new Error('fresh 注入失败:' + (e.message || e));
|
|
2030
|
+
}
|
|
2031
|
+
var t = fs.readFileSync(tmp, 'utf8');
|
|
2032
|
+
var liveStoreM = live.match(/(<script id="annotations-store" type="application\/json">)([\s\S]*?)(<\/script>)/);
|
|
2033
|
+
var liveDoc = live.match(/var DOC_ID\s*=\s*'([^']*)'/);
|
|
2034
|
+
if (!liveStoreM || !liveDoc || !liveDoc[1]) {
|
|
2035
|
+
try { fs.unlinkSync(tmp); } catch (e2) {}
|
|
2036
|
+
throw new Error('live 的 store/DOC_ID 读取失败');
|
|
2037
|
+
}
|
|
2038
|
+
t = t.replace(/var DOC_ID\s*=\s*'[^']*';/, "var DOC_ID = '" + liveDoc[1] + "';");
|
|
2039
|
+
t = t.replace(/var FILE_NAME = '[^']*';/, "var FILE_NAME = '" + fileName + "';");
|
|
2040
|
+
t = t.replace(/(<script id="annotations-store" type="application\/json">)[\s\S]*?(<\/script>)/, '$1' + liveStoreM[2] + '$2');
|
|
2041
|
+
var newStoreM = t.match(/<script id="annotations-store" type="application\/json">[\s\S]*?<\/script>/);
|
|
2042
|
+
var chk = rottenOf(t);
|
|
2043
|
+
if (!newStoreM || newStoreM[0] !== liveStoreM[0] || chk.length) {
|
|
2044
|
+
try { fs.unlinkSync(tmp); } catch (e2) {}
|
|
2045
|
+
throw new Error('上盘前自检未过(store 不一致/' + chk.join('/') + '),fresh 已弃置');
|
|
2046
|
+
}
|
|
2047
|
+
fs.writeFileSync(tmp, t); // 搬回修改后的整页写回临时文件,再进锁替换
|
|
2048
|
+
return withDocLock(livePath, function () {
|
|
2049
|
+
var now = fs.readFileSync(livePath, 'utf8');
|
|
2050
|
+
if (now !== live) {
|
|
2051
|
+
try { fs.unlinkSync(tmp); } catch (e2) {}
|
|
2052
|
+
return { status: 'stale', note: '锁内发现 live 刚被写过,本次放弃(重新跑一遍 regen 即可)' };
|
|
2053
|
+
}
|
|
2054
|
+
var bakDir = path.join(path.dirname(livePath), HIST_DIR);
|
|
2055
|
+
fs.mkdirSync(bakDir, { recursive: true });
|
|
2056
|
+
var d = new Date();
|
|
2057
|
+
var ts = d.getFullYear() + ('0' + (d.getMonth() + 1)).slice(-2) + ('0' + d.getDate()).slice(-2) + '-' +
|
|
2058
|
+
('0' + d.getHours()).slice(-2) + ('0' + d.getMinutes()).slice(-2) + ('0' + d.getSeconds()).slice(-2);
|
|
2059
|
+
var bakPath = path.join(bakDir, fileName + '.pre-regen-' + ts + '.bak');
|
|
2060
|
+
fs.writeFileSync(bakPath, live);
|
|
2061
|
+
fs.renameSync(tmp, livePath);
|
|
2062
|
+
try { fs.unlinkSync(path.join(bakDir, path.basename(tmp) + '.pre-inject.bak')); } catch (e2) {}
|
|
2063
|
+
if (fs.readFileSync(livePath, 'utf8') !== t) {
|
|
2064
|
+
fs.writeFileSync(livePath, live);
|
|
2065
|
+
return { status: 'error', note: '写后读回不一致,已回滚' };
|
|
2066
|
+
}
|
|
2067
|
+
var st = readStore(t);
|
|
2068
|
+
return { status: 'swapped', rev: st ? st.rev : 0, ann: st ? st.annotations.length : 0, bak: bakPath };
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/* ---------- md:一键 Markdown → 可批注 HTML ---------- */
|
|
2073
|
+
/* 正文完全遵照原文(marked GFM:表格/删除线/任务列表/代码围栏照常),只叠加批注层。
|
|
2074
|
+
marked 惰性加载——其余命令不付 require 成本;headerIds/mangle 关掉,
|
|
2075
|
+
导航跳转走绝对坐标不需要 id,中文标题也不会被转成转义 id。表格外包 .table-wrap 套模板容器样式。 */
|
|
2076
|
+
|
|
2077
|
+
/* ---- v1.38.0 公式渲染:$...$ 与 $$...$$ 经 temml 编译为 MathML 烤进产物 ----
|
|
2078
|
+
* 产物仍零依赖:MathML 由浏览器原生渲染(Chrome 109+/Firefox/Safari),无运行时 JS/CSS/字体;
|
|
2079
|
+
* 编译发生在生成时,temml 是构建期依赖(与 marked 同待遇:包依赖、惰性加载)。
|
|
2080
|
+
* 降级链:temml 缺失或单条编译失败 → 该处保留 LaTeX 原文,绝不让坏公式毁掉整篇文档。
|
|
2081
|
+
* 防误伤:pre/code 整块豁免;只处理标签之间的文本段;行内 $...$ 两端不得是空白、
|
|
2082
|
+
* 须含字母/命令/花括号(成对美元金额「$5 和 $3」不命中)。 */
|
|
2083
|
+
function decodeMathEntities(s) {
|
|
2084
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
2085
|
+
.replace(/"/g, '"').replace(/'/g, "'");
|
|
2086
|
+
}
|
|
2087
|
+
function mdMathPass(html) {
|
|
2088
|
+
var temml;
|
|
2089
|
+
try { temml = require('./vendor/temml.cjs'); } catch (e) { return html; }
|
|
2090
|
+
if (!temml || typeof temml.renderToString !== 'function') return html;
|
|
2091
|
+
var stash = [];
|
|
2092
|
+
var h = html.replace(/<(pre|code)\b[^>]*>[\s\S]*?<\/\1>/gi, function (m) {
|
|
2093
|
+
stash.push(m); return '\u0000M' + stash.length + '\u0000';
|
|
2094
|
+
});
|
|
2095
|
+
var parts = h.split(/(<[^>]*>)/);
|
|
2096
|
+
for (var i = 0; i < parts.length; i += 2) { // 偶数下标 = 标签之间的文本段
|
|
2097
|
+
parts[i] = parts[i]
|
|
2098
|
+
.replace(/\$\$([^$]+?)\$\$/g, function (m, tex) {
|
|
2099
|
+
try {
|
|
2100
|
+
return temml.renderToString(decodeMathEntities(tex),
|
|
2101
|
+
{ displayMode: true, throwOnError: true, annotate: true });
|
|
2102
|
+
} catch (e) { return m; }
|
|
2103
|
+
})
|
|
2104
|
+
.replace(/\$([^$\n]+?)\$/g, function (m, tex) {
|
|
2105
|
+
var t = decodeMathEntities(tex);
|
|
2106
|
+
if (/^\s|\s$/.test(t)) return m; // 两端空白 → 更像美元金额
|
|
2107
|
+
if (!/[A-Za-z\\{]/.test(t)) return m; // 无字母/命令/花括号 → 不像公式
|
|
2108
|
+
try {
|
|
2109
|
+
return temml.renderToString(t,
|
|
2110
|
+
{ displayMode: false, throwOnError: true, annotate: true });
|
|
2111
|
+
} catch (e) { return m; }
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
h = parts.join('');
|
|
2115
|
+
return h.replace(/\u0000M(\d+)\u0000/g, function (m, n) { return stash[n - 1] || m; });
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
function mdToBody(mdText) {
|
|
2119
|
+
var marked = require('./vendor/marked.cjs');
|
|
2120
|
+
var fn = typeof marked.parse === 'function' ? marked.parse.bind(marked)
|
|
2121
|
+
: (marked && marked.marked && typeof marked.marked.parse === 'function') ? marked.marked.parse.bind(marked.marked)
|
|
2122
|
+
: typeof marked === 'function' ? marked : null;
|
|
2123
|
+
if (!fn) throw new Error('marked 加载失败,请重装依赖(npm install)');
|
|
2124
|
+
return mdMathPass(fn(mdText, { gfm: true, breaks: false, headerIds: false, mangle: false })
|
|
2125
|
+
.replace(/<table>/g, '<div class="table-wrap"><table>')
|
|
2126
|
+
.replace(/<\/table>/g, '</table></div>'));
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function mdDoc(srcPath, opts, pkgRoot) {
|
|
2130
|
+
opts = opts || {};
|
|
2131
|
+
var src = path.resolve(srcPath || '');
|
|
2132
|
+
if (!fs.existsSync(src)) throw new Error('找不到 Markdown 文件: ' + src);
|
|
2133
|
+
if (fs.statSync(src).isDirectory()) throw new Error('这是目录,不是 Markdown 文件: ' + src);
|
|
2134
|
+
var name = opts.out ||
|
|
2135
|
+
path.join(path.dirname(src), path.basename(src).replace(/\.(md|markdown|mdown|mkd)$/i, '') + '.html');
|
|
2136
|
+
if (!/\.html?$/i.test(name)) name += '.html';
|
|
2137
|
+
var dst = path.resolve(name); // 默认与 md 同目录同名;--out 可指定(相对路径按 cwd 解析)
|
|
2138
|
+
if (fs.existsSync(dst)) throw new Error('文件已存在: ' + dst);
|
|
2139
|
+
|
|
2140
|
+
var rsl = resolveTemplate(path.dirname(dst), pkgRoot, opts);
|
|
2141
|
+
var tpl = rsl.tpl, tplSrc = rsl.src;
|
|
2142
|
+
var theme = loadThemeChain(path.dirname(dst), opts);
|
|
2143
|
+
tpl = applyTheme(tpl, theme);
|
|
2144
|
+
if (opts.lang === 'en') tpl = applyLangEn(tpl).html;
|
|
2145
|
+
|
|
2146
|
+
var body = mdToBody(fs.readFileSync(src, 'utf8'));
|
|
2147
|
+
var mTitle = body.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
|
2148
|
+
var title = opts.title || (mTitle ? mTitle[1].replace(/<[^>]+>/g, '').trim() : '') ||
|
|
2149
|
+
path.basename(dst).replace(/\.html?$/i, '');
|
|
2150
|
+
tpl = tpl.replace(/<title>[\s\S]*?<\/title>/, '<title>' + title + '</title>');
|
|
2151
|
+
tpl = tpl.replace(/<article>[\s\S]*?<\/article>/, '<article>\n' + body + '\n </article>');
|
|
2152
|
+
|
|
2153
|
+
var docId = 'doc-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
2154
|
+
tpl = tpl.replace(/var DOC_ID\s*=\s*'[^']*';/, "var DOC_ID = '" + docId + "';");
|
|
2155
|
+
tpl = tpl.replace(/var FILE_NAME = '[^']*';/, "var FILE_NAME = '" + path.basename(dst) + "';");
|
|
2156
|
+
tpl = stampPageVersion(tpl, pkgRoot);
|
|
2157
|
+
|
|
2158
|
+
fs.writeFileSync(dst, tpl);
|
|
2159
|
+
console.log('已从 Markdown 创建可批注文档: ' + dst);
|
|
2160
|
+
console.log(' 来源: ' + src + ' · 模板: ' + tplSrc + (theme ? ' + 主题(' + THEME_FILE + ')' : ''));
|
|
2161
|
+
console.log('用 Edge/Chrome 打开即可批注与编辑;正文完全遵照原文,仅叠加批注层。');
|
|
2162
|
+
return dst;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
var path = require('path');
|
|
2166
|
+
|
|
2167
|
+
module.exports = {
|
|
2168
|
+
readPending: readPending, applyReplies: applyReplies,
|
|
2169
|
+
readAnswers: readAnswers, applyAnswers: applyAnswers, listAnswers: listAnswers, teachDoc: teachDoc,
|
|
2170
|
+
initKit: initKit, newDoc: newDoc,
|
|
2171
|
+
mdDoc: mdDoc, mdToBody: mdToBody, mdMathPass: mdMathPass,
|
|
2172
|
+
exportTheme: exportTheme, exportTemplate: exportTemplate,
|
|
2173
|
+
templateUse: templateUse, templateReset: templateReset,
|
|
2174
|
+
studioHtml: studioHtml, annotParts: annotParts,
|
|
2175
|
+
installSkill: installSkill, injectHtml: injectHtml,
|
|
2176
|
+
upgradeLayerFile: upgradeLayerFile, silentUpgrade: silentUpgrade, upgradeDir: upgradeDir, layerVerOf: layerVerOf,
|
|
2177
|
+
regenDoc: regenDoc, wordLockActive: wordLockActive,
|
|
2178
|
+
readStore: readStore, applyTheme: applyTheme, loadTheme: loadTheme,
|
|
2179
|
+
pkgVersion: pkgVersion, skillVersionOf: skillVersionOf,
|
|
2180
|
+
stampVersion: stampVersion, staleSkillNotice: staleSkillNotice,
|
|
2181
|
+
applyEdits: applyEdits, buildHtmlIndex: buildHtmlIndex, findQuoteHit: findQuoteHit,
|
|
2182
|
+
answersBlockOf: answersBlockOf, closeOrphanStreams: closeOrphanStreams, clearAnnStreamFlags: clearAnnStreamFlags,
|
|
2183
|
+
applyLangEn: applyLangEn,
|
|
2184
|
+
commonSuffixLength: commonSuffixLength, commonPrefixLength: commonPrefixLength, bestOccurrence: bestOccurrence,
|
|
2185
|
+
pendingState: pendingState, pollAnnotations: pollAnnotations, streamReply: streamReply, hasUserText: hasUserText,
|
|
2186
|
+
watchBeatPath: watchBeatPath, readWatchBeat: readWatchBeat, writeWatchBeat: writeWatchBeat,
|
|
2187
|
+
watchGapNote: watchGapNote, watchGapNoteFrom: watchGapNoteFrom,
|
|
2188
|
+
histSidePath: histSidePath, readSideHist: readSideHist, writeSideHist: writeSideHist, writeHistForFile: writeHistForFile,
|
|
2189
|
+
mergeHistory: mergeHistory, commitHistory: commitHistory, docIdOf: docIdOf,
|
|
2190
|
+
histCapsOf: histCapsOf,
|
|
2191
|
+
withDocLock: withDocLock, writeDocAtomic: writeDocAtomic, appendWriteTicket: appendWriteTicket,
|
|
2192
|
+
saveDocHtml: saveDocHtml, docLockPath: docLockPath, docWritesLogPath: docWritesLogPath,
|
|
2193
|
+
findLastRevWithQuote: findLastRevWithQuote, fuzzyFindByContext: fuzzyFindByContext, normSnippet: normSnippet,
|
|
2194
|
+
histForFile: histForFile, lintStructure: lintStructure,
|
|
2195
|
+
ANCHOR_SUSPECT_LEN: ANCHOR_SUSPECT_LEN,
|
|
2196
|
+
globalTemplatePath: globalTemplatePath, globalThemePath: globalThemePath, globalDir: globalDir,
|
|
2197
|
+
HIST_DIR: HIST_DIR, HIST_MAIN_MAX: HIST_MAIN_MAX, HIST_SIDECAR_MAX: HIST_SIDECAR_MAX, THEME_FILE: THEME_FILE
|
|
2198
|
+
};
|