codebee 0.1.66 → 0.1.67

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/app/ui/app.js CHANGED
@@ -4315,6 +4315,7 @@ function drawTaskDetail(key, runs, appendFrom) {
4315
4315
  '<span class="role">' + esc(s.role) + "</span>" +
4316
4316
  '<span class="who">' + esc(t(s.agent_label || s.agent)) + "</span>" +
4317
4317
  (String(s.model || "").trim() ? '<span class="st-model" title="' + esc(t("实际派发模型")) + '">' + esc(String(s.model).trim()) + "</span>" : "") +
4318
+ (String(s.provider || "").trim() ? '<span class="st-prov" title="' + esc(t("实际用上游(厂商@网关)")) + '">' + esc(String(s.provider).trim()) + "</span>" : "") +
4318
4319
  '<span class="sum">' + esc((s.note ? "◆ " + s.note + " — " : "") + (s.summary || "")) + "</span>" +
4319
4320
  '<span class="dur">' + (s.duration_s != null ? s.duration_s + "s" : "") + "</span>" +
4320
4321
  statusChip(s.status) +
@@ -4757,6 +4758,7 @@ function renderRunDetailSteps(run, token) {
4757
4758
  '<span class="role">' + esc(s.role) + "</span>" +
4758
4759
  '<span class="who">' + esc(t(s.agent_label || s.agent)) + "</span>" +
4759
4760
  (s.model ? '<span class="st-model" title="' + esc(t("实际派发模型")) + '">' + esc(s.model) + "</span>" : "") +
4761
+ (String(s.provider || "").trim() ? '<span class="st-prov" title="' + esc(t("实际用上游(厂商@网关)")) + '">' + esc(String(s.provider).trim()) + "</span>" : "") +
4760
4762
  '<span class="sum">' + esc((s.note ? "◆ " + s.note + " — " : "") + (s.summary || "")) + "</span>" +
4761
4763
  '<span class="dur">' + (s.duration_s != null ? s.duration_s + "s" : "") + "</span>" +
4762
4764
  statusChip(s.status) +
@@ -4818,6 +4820,7 @@ async function renderRunDetail() {
4818
4820
  (run.error ? '<span class="stat err">' + errTag(run.error) + esc(run.error.slice(0, 200)) + "</span>" : "") +
4819
4821
  '<span class="stat tasksum hidden" id="rd-meta-task"></span>';
4820
4822
  if (S.detailSide) fillMetaTask(S.detailSide.stats || {}); // 缓存命中:轮询重画不闪丢累计组
4823
+ renderTimeoutRow(rcTask, run);
4821
4824
  renderPlan(run);
4822
4825
  renderRouting(run);
4823
4826
  renderChapterScores(run);
@@ -6189,9 +6192,174 @@ const FP_IMG = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg"
6189
6192
  const FP_TXT = new Set(["md", "txt", "log", "csv", "yml", "yaml", "ini", "toml", "json",
6190
6193
  "py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "svg", "bat", "sh", "ps1",
6191
6194
  "c", "h", "cpp", "hpp", "java", "go", "rs", "xml", "sql", "mqtt", "proto"]);
6195
+ const FP_DOCX = new Set(["docx"]); // 只认 OOXML;legacy .doc 二进制不在此列
6196
+ const FP_PDF = new Set(["pdf"]);
6197
+ const FP_XLSX = new Set(["xlsx"]); // 只认 OOXML;legacy .xls 二进制不在此列
6192
6198
 
6193
6199
  function _fpExt(name) { return (String(name).split(".").pop() || "").toLowerCase(); }
6194
6200
 
6201
+ /* docx 只读预览(试探功能):渲染器走 jsdelivr 懒加载(docx-preview 依赖
6202
+ * JSZip,两者只在首次打开 docx 时加载,平时零请求零开销);加载/渲染失败
6203
+ * 优雅回落到下载提示。渲染内容是用户自己工作目录的文件,信任面与网页成品
6204
+ * 预览页签一致。版本钉死,升级要走一次人工验证。 */
6205
+ let _docxLibs = null;
6206
+ function _loadDocxLibs() {
6207
+ if (_docxLibs) return _docxLibs;
6208
+ const load = (src) => new Promise((res, rej) => {
6209
+ const s = document.createElement("script");
6210
+ s.src = src; s.onload = () => res(); s.onerror = () => rej(new Error("load fail: " + src));
6211
+ document.head.appendChild(s);
6212
+ });
6213
+ _docxLibs = Promise.all([
6214
+ load("https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"),
6215
+ load("https://cdn.jsdelivr.net/npm/docx-preview@0.4.1/dist/docx-preview.min.js"),
6216
+ ]).then(() => {
6217
+ if (!window.docx || !window.JSZip) throw new Error("docx 渲染器未就绪");
6218
+ });
6219
+ _docxLibs.catch(() => { _docxLibs = null; }); // 失败不缓存,下次打开可重试
6220
+ return _docxLibs;
6221
+ }
6222
+
6223
+ async function _fpRenderDocx(url) {
6224
+ const el = _fpEnsure();
6225
+ try {
6226
+ const [r] = await Promise.all([fetch(url), _loadDocxLibs()]);
6227
+ if (!r.ok) throw new Error("HTTP " + r.status);
6228
+ if (!filePopIsOpen()) return;
6229
+ _fpSetBody('<div class="fp-docx"><div class="fp-docx-load">' + esc(t("渲染中…")) + "</div></div>");
6230
+ const box = el.querySelector(".fp-docx");
6231
+ await window.docx.renderAsync(await r.arrayBuffer(), box, null, { inWrapper: true });
6232
+ if (!filePopIsOpen()) return;
6233
+ const loader = box.querySelector(".fp-docx-load");
6234
+ if (loader) loader.remove();
6235
+ } catch (e) {
6236
+ if (filePopIsOpen())
6237
+ _fpSetBody('<div class="fp-hint">' + esc(t("docx 预览不可用(渲染器需联网首次加载);可下载后查看。")) + "</div>");
6238
+ }
6239
+ }
6240
+
6241
+ /* PDF 只读预览(WorkDSH「真任务以可读产物收尾」切片):pdf.js 走 jsdelivr
6242
+ * 懒加载,钉 3.11.174(最后带 UMD 构建的版本,4.x 转 ESM 不适用 script 注入);
6243
+ * 渲染进 canvas,按弹窗宽度适配;大 PDF 封顶 20 页防渲染爆内存,超页给下载
6244
+ * 提示。加载/渲染失败优雅回落下载提示,与 docx 同款。 */
6245
+ const FP_PDF_MAX_PAGES = 20;
6246
+ let _pdfLib = null;
6247
+ function _loadPdfLib() {
6248
+ if (_pdfLib) return _pdfLib;
6249
+ const base = "https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/legacy/build/";
6250
+ const load = (src) => new Promise((res, rej) => {
6251
+ const s = document.createElement("script");
6252
+ s.src = src; s.onload = () => res(); s.onerror = () => rej(new Error("load fail: " + src));
6253
+ document.head.appendChild(s);
6254
+ });
6255
+ _pdfLib = load(base + "pdf.min.js").then(() => {
6256
+ if (!window.pdfjsLib) throw new Error("pdf 渲染器未就绪");
6257
+ window.pdfjsLib.GlobalWorkerOptions.workerSrc = base + "pdf.worker.min.js";
6258
+ });
6259
+ _pdfLib.catch(() => { _pdfLib = null; }); // 失败不缓存,下次打开可重试
6260
+ return _pdfLib;
6261
+ }
6262
+
6263
+ async function _fpRenderPdf(url) {
6264
+ const el = _fpEnsure();
6265
+ try {
6266
+ const [r] = await Promise.all([fetch(url), _loadPdfLib()]);
6267
+ if (!r.ok) throw new Error("HTTP " + r.status);
6268
+ if (!filePopIsOpen()) return;
6269
+ _fpSetBody('<div class="fp-pdf"><div class="fp-docx-load">' + esc(t("渲染中…")) + "</div></div>");
6270
+ const box = el.querySelector(".fp-pdf");
6271
+ const doc = await window.pdfjsLib.getDocument({ data: await r.arrayBuffer() }).promise;
6272
+ if (!filePopIsOpen()) return;
6273
+ const loader = box.querySelector(".fp-docx-load");
6274
+ if (loader) loader.remove();
6275
+ const total = doc.numPages;
6276
+ const width = Math.max(320, (box.clientWidth || 800) - 8);
6277
+ for (let i = 1; i <= Math.min(total, FP_PDF_MAX_PAGES); i++) {
6278
+ const page = await doc.getPage(i);
6279
+ if (!filePopIsOpen()) return;
6280
+ const vp1 = page.getViewport({ scale: width / page.getViewport({ scale: 1 }).width });
6281
+ const canvas = document.createElement("canvas");
6282
+ canvas.className = "fp-pdf-page";
6283
+ canvas.width = Math.floor(vp1.width); canvas.height = Math.floor(vp1.height);
6284
+ await page.render({ canvasContext: canvas.getContext("2d"), viewport: vp1 }).promise;
6285
+ box.appendChild(canvas);
6286
+ }
6287
+ if (total > FP_PDF_MAX_PAGES)
6288
+ box.insertAdjacentHTML("beforeend", '<div class="fp-hint">' +
6289
+ esc(t("仅渲染前 {0} 页(共 {1} 页),完整内容请下载。")
6290
+ .replace("{0}", FP_PDF_MAX_PAGES).replace("{1}", total)) + "</div>");
6291
+ } catch (e) {
6292
+ if (filePopIsOpen())
6293
+ _fpSetBody('<div class="fp-hint">' + esc(t("PDF 预览不可用(渲染器需联网首次加载);可下载后查看。")) + "</div>");
6294
+ }
6295
+ }
6296
+
6297
+ /* xlsx 只读预览:SheetJS 走 jsdelivr 懒加载(钉 0.18.5,npm 末版);多工作表
6298
+ * 给页签切换;单元格按文本渲染(raw:false,日期/数字走显示值),200 行 ×
6299
+ * 40 列封顶,超限给下载提示。只认 OOXML 的 .xlsx——legacy .xls 仍走下载。 */
6300
+ const FP_XLS_MAX_ROWS = 200, FP_XLS_MAX_COLS = 40;
6301
+ let _xlsxLib = null;
6302
+ function _loadXlsxLib() {
6303
+ if (_xlsxLib) return _xlsxLib;
6304
+ const load = (src) => new Promise((res, rej) => {
6305
+ const s = document.createElement("script");
6306
+ s.src = src; s.onload = () => res(); s.onerror = () => rej(new Error("load fail: " + src));
6307
+ document.head.appendChild(s);
6308
+ });
6309
+ _xlsxLib = load("https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js")
6310
+ .then(() => { if (!window.XLSX) throw new Error("xlsx 渲染器未就绪"); });
6311
+ _xlsxLib.catch(() => { _xlsxLib = null; });
6312
+ return _xlsxLib;
6313
+ }
6314
+
6315
+ async function _fpRenderXlsx(url) {
6316
+ const el = _fpEnsure();
6317
+ try {
6318
+ const [r] = await Promise.all([fetch(url), _loadXlsxLib()]);
6319
+ if (!r.ok) throw new Error("HTTP " + r.status);
6320
+ if (!filePopIsOpen()) return;
6321
+ _fpSetBody('<div class="fp-xlsx"><div class="fp-docx-load">' + esc(t("渲染中…")) + "</div></div>");
6322
+ const box = el.querySelector(".fp-xlsx");
6323
+ const wb = window.XLSX.read(await r.arrayBuffer(), { type: "array" });
6324
+ if (!filePopIsOpen()) return;
6325
+ box.innerHTML = "";
6326
+ const tabs = document.createElement("div");
6327
+ tabs.className = "fp-xls-tabs";
6328
+ const pane = document.createElement("div");
6329
+ box.appendChild(tabs); box.appendChild(pane);
6330
+ const drawSheet = (idx) => {
6331
+ const ws = wb.Sheets[wb.SheetNames[idx]];
6332
+ const rows = window.XLSX.utils.sheet_to_json(ws, { header: 1, defval: "", raw: false });
6333
+ let html = '<table class="fp-xls"><tbody>';
6334
+ rows.slice(0, FP_XLS_MAX_ROWS).forEach((row, ri) => {
6335
+ const tag = ri === 0 ? "th" : "td";
6336
+ html += "<tr>" + row.slice(0, FP_XLS_MAX_COLS).map((c) =>
6337
+ "<" + tag + ">" + esc(String(c == null ? "" : c)) + "</" + tag + ">").join("") + "</tr>";
6338
+ });
6339
+ html += "</tbody></table>";
6340
+ if (rows.length > FP_XLS_MAX_ROWS)
6341
+ html += '<div class="fp-hint">' +
6342
+ esc(t("已截断:仅显示前 {0} 行 × {1} 列;完整内容请下载。")
6343
+ .replace("{0}", FP_XLS_MAX_ROWS).replace("{1}", FP_XLS_MAX_COLS)) + "</div>";
6344
+ pane.innerHTML = html;
6345
+ tabs.querySelectorAll(".fp-xls-tab").forEach((b, i) =>
6346
+ b.classList.toggle("active", i === idx));
6347
+ };
6348
+ wb.SheetNames.forEach((sn, i) => {
6349
+ const b = document.createElement("button");
6350
+ b.className = "ghost fp-xls-tab" + (i === 0 ? " active" : "");
6351
+ b.type = "button"; b.textContent = sn;
6352
+ b.addEventListener("click", () => drawSheet(i));
6353
+ tabs.appendChild(b);
6354
+ });
6355
+ if (!wb.SheetNames.length) throw new Error("empty workbook");
6356
+ drawSheet(0);
6357
+ } catch (e) {
6358
+ if (filePopIsOpen())
6359
+ _fpSetBody('<div class="fp-hint">' + esc(t("表格预览不可用(渲染器需联网首次加载);可下载后查看。")) + "</div>");
6360
+ }
6361
+ }
6362
+
6195
6363
  /* 按 URL 弹窗预览:图片 blob 直显、md 渲染、文本/代码等宽原文、json 美化、
6196
6364
  * 其余二进制只给下载。成品弹窗与目录文件弹窗共用;目录文件弹窗带 save
6197
6365
  * 上下文 → 额外给「编辑」,改完保存回工作目录(POST /api/dir/save)。 */
@@ -6239,6 +6407,9 @@ async function _fpPreviewUrl(url, name, size, opts) {
6239
6407
  el.querySelector(".fp-acts").insertAdjacentHTML("afterbegin", btns.join(""));
6240
6408
  return;
6241
6409
  }
6410
+ if (FP_DOCX.has(ext)) { await _fpRenderDocx(url); return; }
6411
+ if (FP_PDF.has(ext)) { await _fpRenderPdf(url); return; }
6412
+ if (FP_XLSX.has(ext)) { await _fpRenderXlsx(url); return; }
6242
6413
  _fpSetBody('<div class="fp-hint">' + esc(t("二进制文件不预览,可下载查看。")) + "</div>");
6243
6414
  } catch (e) {
6244
6415
  _fpSetBody('<div class="fp-hint">' + esc(t("内容读取失败:") + (e.message || e)) + "</div>");
@@ -6411,22 +6582,82 @@ function fmtSize(n) {
6411
6582
  /* 成品文件行(检查器「成品文件」与主栏「成果」分区共用一套标记):
6412
6583
  * fc-row:文件行 + 弹窗预览按钮同行排布,避免按钮独占一行参差不齐。
6413
6584
  * 点文件名与点「预览」同款 artPopup 弹窗,行为一致不 surprise。 */
6414
- function artifactsChips(runId, files) {
6585
+ function artifactsChips(runId, files, prevRun) {
6415
6586
  return files.map((f) => {
6416
6587
  const isTxt = FP_TXT.has(_fpExt(f.name)); // 文本/代码都有弹窗预览(按代码格式展示)
6588
+ // 与上一版对比(OpenCreator「版本化对比」借鉴):同名文本文件在上一版
6589
+ // run 也存在时给 diff 入口;prevRun=null(无历史/清单拉取失败)静默不给
6590
+ const diffable = isTxt && prevRun && prevRun.names.has(f.name);
6417
6591
  return '<span class="fc-row">' +
6418
6592
  '<a class="file-chip artifact-file-open" href="' + urlAuth("/api/runs/" + encodeURIComponent(runId) + "/file?name=" +
6419
6593
  encodeURIComponent(f.name)) + '" target="_blank" rel="noopener" ' +
6420
6594
  'title="' + esc(f.name + " · " + fmtSize(f.size)) + '" data-file-run="' + esc(runId) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
6421
6595
  '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
6422
- '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>" +
6596
+ '<span class="p">' + esc(f.name) + '</span><i>' + fmtSize(f.size) + "</i></a>" +
6423
6597
  (isTxt ? '<a class="file-chip prev artifact-file-preview" title="' + esc(t("查看内容")) +
6424
6598
  '" data-file-run="' + esc(runId) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
6425
6599
  '<svg class="ico" aria-hidden="true"><use href="#i-book"/></svg>' + t("预览") + "</a>" : "") +
6600
+ (diffable ? '<a class="file-chip prev artifact-file-diff" title="' + esc(t("与上一版对比")) +
6601
+ '" data-file-run="' + esc(runId) + '" data-prev-run="' + esc(prevRun.id) +
6602
+ '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
6603
+ '<svg class="ico" aria-hidden="true"><use href="#i-git-branch"/></svg>' + t("对比") + "</a>" : "") +
6426
6604
  "</span>";
6427
6605
  }).join("");
6428
6606
  }
6429
6607
 
6608
+ /* 两版 diff 弹窗正文(纯函数在 artdiff.js,node 直测):统一 diff 行 + 长
6609
+ * 相同段折叠;中段超限诚实给整块计数(不装作逐行对齐)。 */
6610
+ function artifactDiffBody(a, b) {
6611
+ const d = ArtifactDiff.diff(a, b);
6612
+ if (d.identical) return '<div class="fp-hint">' + esc(t("两版内容一致。")) + "</div>";
6613
+ const gap = (n) => '<div class="dl-gap">… ' + esc(t("{0} 行相同(已折叠)").replace("{0}", n)) + " …</div>";
6614
+ const ln = (cls, s, mark) => '<div class="dl-ln dl-' + cls + '"><i>' + mark + "</i>" + esc(s || "") + "</div>";
6615
+ let html = "";
6616
+ if (d.prefix) html += gap(d.prefix);
6617
+ if (d.oversized) {
6618
+ html += '<div class="dl-gap">' +
6619
+ esc(t("变更过大(旧 {0} 行 → 新 {1} 行),不逐行对齐;可分别打开两版查看。")
6620
+ .replace("{0}", d.oversized.a).replace("{1}", d.oversized.b)) + "</div>";
6621
+ } else {
6622
+ const rows = d.rows || [];
6623
+ let run = 0, runAt = 0; // 连续相同行折叠:≥6 行合并成一个 gap
6624
+ rows.forEach((r, idx) => {
6625
+ if (r.t === "=") { if (!run) runAt = idx; run++; return; }
6626
+ if (run < 6) for (let k = runAt; k < runAt + run; k++) html += ln("eq", rows[k].s, " ");
6627
+ else html += gap(run);
6628
+ run = 0;
6629
+ html += r.t === "+" ? ln("add", r.s, "+") : ln("del", r.s, "−");
6630
+ });
6631
+ if (run < 6) for (let k = runAt; k < runAt + run; k++) html += ln("eq", rows[k].s, " ");
6632
+ else if (run) html += gap(run);
6633
+ }
6634
+ if (d.suffix) html += gap(d.suffix);
6635
+ return '<pre class="art-diff">' + html + "</pre>";
6636
+ }
6637
+
6638
+ /* 对比上一版弹窗:取两版文本 → diff 渲染(复用成品弹窗骨架)。 */
6639
+ window.artDiffPopup = async function (runId, prevRunId, name) {
6640
+ _fpSaveCtx = null; _fpRawText = ""; _fpDirty = false; _fpCopyText = null;
6641
+ const nm = String(name).split("/").pop();
6642
+ _fpOpen(nm + " · " + t("与上一版对比"),
6643
+ '<i>' + esc(String(prevRunId).slice(0, 8)) + " → " + esc(String(runId).slice(0, 8)) + "</i>", "");
6644
+ _fpSetBody('<div class="fp-hint">' + esc(t("正在取两版内容…")) + "</div>");
6645
+ const u1 = urlAuth("/api/runs/" + encodeURIComponent(prevRunId) + "/file?name=" + encodeURIComponent(name));
6646
+ const u2 = urlAuth("/api/runs/" + encodeURIComponent(runId) + "/file?name=" + encodeURIComponent(name));
6647
+ try {
6648
+ const [r1, r2] = await Promise.all([fetch(u1), fetch(u2)]);
6649
+ if (!r1.ok || !r2.ok) throw new Error("HTTP " + (r1.ok ? r2.status : r1.status));
6650
+ const [a, b] = await Promise.all([r1.text(), r2.text()]);
6651
+ if (!filePopIsOpen()) return;
6652
+ if (a.length + b.length > 4 * 1024 * 1024)
6653
+ _fpSetBody('<div class="fp-hint">' + esc(t("文件过大,不在线对比;可分别打开两版查看。")) + "</div>");
6654
+ else _fpSetBody(artifactDiffBody(a, b));
6655
+ } catch (e) {
6656
+ if (filePopIsOpen())
6657
+ _fpSetBody('<div class="fp-hint">' + esc(t("内容读取失败:") + (e.message || e)) + "</div>");
6658
+ }
6659
+ };
6660
+
6430
6661
  async function loadArtifacts(runId) {
6431
6662
  // 双入口同源渲染:检查器「成品文件」TAB + 主栏详情「成果」分区。
6432
6663
  let d;
@@ -6457,7 +6688,20 @@ async function loadArtifacts(runId) {
6457
6688
  }
6458
6689
  const head = '<div class="files-head"><span class="sec-title">' + t("成品文件") + '</span>' +
6459
6690
  '<span class="wd" title="' + esc(t("点击复制")) + '" onclick="copyText(this.textContent)">' + esc(d.workdir) + "</span></div>";
6460
- const chips = artifactsChips(runId, files);
6691
+ // 上一版 run 的文件清单(详情上下文才有):同名文本文件给「对比」入口;
6692
+ // 拉不到/无历史静默降级——对比是增强不是闸门
6693
+ let prevRun = null;
6694
+ if (detailOwns && S.detailTaskKey) {
6695
+ const prev = (_taskRunsCache.get(S.detailTaskKey) || []).find((r) => r.id !== runId);
6696
+ if (prev) {
6697
+ try {
6698
+ const pd = await api("/api/runs/" + encodeURIComponent(prev.id) + "/files");
6699
+ if (pd && (pd.files || []).length)
6700
+ prevRun = { id: prev.id, names: new Set(pd.files.map((f2) => f2.name)) };
6701
+ } catch (e) { /* 上一版清单不可用就不给对比 */ }
6702
+ }
6703
+ }
6704
+ const chips = artifactsChips(runId, files, prevRun);
6461
6705
  if (box) box.innerHTML = head + '<div class="file-chips">' + chips + "</div>";
6462
6706
  if (mainBox) { mainBox.classList.remove("hidden");
6463
6707
  mainBox.innerHTML = head + '<div class="file-chips">' + chips + "</div>"; }
@@ -8038,6 +8282,56 @@ window.toggleRdModelMenu = toggleRdModelMenu;
8038
8282
  });
8039
8283
  })();
8040
8284
 
8285
+ // 任务总时限(秒)选项:任务信息弹层里的空闲可改项(2026-09-24 戍边骑奴案:
8286
+ // 12 章连载默认 1 小时在慢链天跑不完)。值不入轮询重建的 rd-meta,防 select 弹回。
8287
+ const TASK_TIMEOUT_CHOICES = [
8288
+ [1800, "30 分钟"], [3600, "1 小时"], [7200, "2 小时"], [10800, "3 小时"],
8289
+ [14400, "4 小时"], [21600, "6 小时"], [43200, "12 小时"], [86400, "24 小时"],
8290
+ ];
8291
+
8292
+ function timeoutLabel(s) {
8293
+ const hit = TASK_TIMEOUT_CHOICES.find((c) => c[0] === Number(s));
8294
+ if (hit) return hit[1];
8295
+ const m = Math.round(Number(s) / 60);
8296
+ return (m >= 60 ? (Math.round(m / 60 * 10) / 10) + " 小时" : m + " 分钟");
8297
+ }
8298
+
8299
+ function renderTimeoutRow(rcTask, run) {
8300
+ const row = $("rd-timeout-row"), sel = $("rd-timeout");
8301
+ if (!row || !sel) return;
8302
+ if (!rcTask) { row.classList.add("hidden"); return; }
8303
+ row.classList.remove("hidden");
8304
+ const cur = Number(rcTask.timeout_s || 3600);
8305
+ // 选项一次性构建;当前值不在档内时补一项,避免选中丢失
8306
+ const want = TASK_TIMEOUT_CHOICES.map((c) => c[0]).concat(
8307
+ TASK_TIMEOUT_CHOICES.some((c) => c[0] === cur) ? [] : [cur]).join(",");
8308
+ if (sel.dataset.choices !== want) {
8309
+ sel.dataset.choices = want;
8310
+ sel.innerHTML = TASK_TIMEOUT_CHOICES
8311
+ .map((c) => '<option value="' + c[0] + '">' + esc(t(c[1])) + "</option>").join("") +
8312
+ (TASK_TIMEOUT_CHOICES.some((c) => c[0] === cur)
8313
+ ? "" : '<option value="' + cur + '">' + esc(t("自定义 ") + timeoutLabel(cur)) + "</option>");
8314
+ }
8315
+ const busy = ["queued", "running"].indexOf(rcTask.status) >= 0;
8316
+ sel.disabled = busy;
8317
+ // 用户正操作(含失焦瞬间)不回写 value,防轮询把刚选的值弹回旧值
8318
+ if (document.activeElement !== sel) sel.value = String(cur);
8319
+ if (!sel.dataset.bound) {
8320
+ sel.dataset.bound = "1";
8321
+ sel.addEventListener("change", async () => {
8322
+ const task = (S.state && (S.state.tasks || []).find((x) => x.id === rcTask.id)) || rcTask;
8323
+ const patch = { timeout_s: Number(sel.value) || 3600 };
8324
+ if (task.rev != null) patch.expected_rev = task.rev;
8325
+ try {
8326
+ const data = await api("/api/tasks/" + encodeURIComponent(rcTask.id) + "/params", {
8327
+ method: "POST", operation: false, body: JSON.stringify(patch) });
8328
+ if (data && data.rev != null) task.rev = data.rev;
8329
+ task.timeout_s = patch.timeout_s; // 即时回显,不等轮询
8330
+ } catch (e) { toast(e && e.message ? e.message : "保存失败", true); }
8331
+ });
8332
+ }
8333
+ }
8334
+
8041
8335
  async function rdPrefsPush(what) {
8042
8336
  // what: mode|thinking|model(变更源);写任务参数(空闲态才允许)
8043
8337
  const task = (S.lastRun && S.lastRun.task_id && S.state &&
@@ -8051,16 +8345,29 @@ async function rdPrefsPush(what) {
8051
8345
  patch.direct_model = rdModelMv;
8052
8346
  patch.direct_agent = rdModelAgent;
8053
8347
  }
8348
+ // 修订 CAS(借鉴 WorkDSH 契约纪律):带上页面所见的任务修订号,别的窗口
8349
+ // 先改过则后端拒绝过期写入;本地提示后由轮询回填最新值——「我改成功了」
8350
+ // 的假象(实际已被人覆盖)就此消失。
8351
+ if (task.rev != null) patch.expected_rev = task.rev;
8054
8352
  try {
8055
- await api("/api/tasks/" + encodeURIComponent(task.id) + "/params", {
8353
+ const data = await api("/api/tasks/" + encodeURIComponent(task.id) + "/params", {
8056
8354
  method: "POST", operation: false, body: JSON.stringify(patch) });
8057
- } catch (e) { /* 运行中/网络失败不打断对话,发送前还有兜底 */ }
8355
+ if (data && data.rev != null) task.rev = data.rev; // 续用凭据:连推时后几次不再过期
8356
+ } catch (e) {
8357
+ if (String((e && e.message) || "").indexOf("已被其他窗口修改") >= 0) {
8358
+ toast(e.message, true); // 冲突要可见:静默吞掉=又一种假成功
8359
+ }
8360
+ /* 运行中/网络失败不打断对话,发送前还有兜底 */
8361
+ }
8058
8362
  }
8059
8363
 
8060
8364
  async function rdPrefsPushAll() {
8061
- // 发送前兜底:三个一起推(/chat 开新一轮前调用;失败静默——值已在前端,
8062
- // 下一轮 poll 的回填也不会覆盖用户刚选的值)
8063
- await Promise.all([rdPrefsPush("mode"), rdPrefsPush("thinking"), rdPrefsPush("model")]);
8365
+ // 发送前兜底:三个一起推(/chat 开新一轮前调用)。改为顺序推——三次都带
8366
+ // expected_rev,并发推会在第一次成功后让后两次全成过期写入被拒;
8367
+ // 值已在前端,失败静默同前,下一轮 poll 的回填也不会覆盖用户刚选的值。
8368
+ await rdPrefsPush("mode");
8369
+ await rdPrefsPush("thinking");
8370
+ await rdPrefsPush("model");
8064
8371
  }
8065
8372
 
8066
8373
  function drawChatAtts() {
@@ -10753,7 +11060,7 @@ function mkSetView(v) {
10753
11060
  function mkrFilterKey() {
10754
11061
  const q = (($("mkr-search") || {}).value || "").trim().toLowerCase();
10755
11062
  const srcSel = $("mkr-source");
10756
- return q + "|" + (srcSel ? srcSel.value : "");
11063
+ return q + "|" + (srcSel ? srcSel.value : "") + "|" + (S.mkrInst ? "inst" : "");
10757
11064
  }
10758
11065
 
10759
11066
  /* 搜索防抖:过滤在服务端对全量做,停 350ms 再发请求;只跟当前过滤键变化时重拉首页 */
@@ -10778,6 +11085,7 @@ async function mkrLoadPage(reset) {
10778
11085
  let url = "/api/market/remote?offset=" + (S.mkrOffset || 0) + "&limit=" + MKR_PAGE;
10779
11086
  if (srcSel && srcSel.value) url += "&source=" + encodeURIComponent(srcSel.value);
10780
11087
  if (q) url += "&q=" + encodeURIComponent(q);
11088
+ if (S.mkrInst) url += "&installed=1";
10781
11089
  S.mkrLoading = true;
10782
11090
  let data = null;
10783
11091
  try { data = await api(url); } catch (e) { data = null; }
@@ -10829,11 +11137,20 @@ function renderMarketRemote() {
10829
11137
  const times = (data.sources || []).map((s) => s.fetched_at).filter(Boolean);
10830
11138
  const meta = $("mkr-meta");
10831
11139
  if (meta) meta.textContent = times.length ? t("目录更新于 ") + times.join(" / ") : "";
11140
+ // 「已安装 N」入口:计数是全量已装(未过滤),点击切换只看已安装
11141
+ const instBtn = $("mkr-installed");
11142
+ if (instBtn) {
11143
+ instBtn.textContent = t("已安装 ") + (Number(data.installed_total) || 0);
11144
+ instBtn.classList.toggle("active", !!S.mkrInst);
11145
+ instBtn.setAttribute("aria-pressed", S.mkrInst ? "true" : "false");
11146
+ }
10832
11147
  const items = S.mkrAll || [];
10833
11148
  mkCount(t("已加载 ") + items.length + t(" / 共 ") + data.total + t(" 个"));
10834
11149
  if (!items.length) {
10835
11150
  grid.innerHTML = '<div class="empty">' + (data.total === 0 && !S.mkrOffset && !(($("mkr-search")||{}).value||"").trim() && !(srcSel||{}).value
10836
- ? t("外部目录还是空的,点「拉取更新」从公开生态获取。")
11151
+ ? (S.mkrInst
11152
+ ? t("还没有从外部目录安装过插件;退出过滤后逛逛目录,安装前会先给你看包内容。")
11153
+ : t("外部目录还是空的,点「拉取更新」从公开生态获取。"))
10837
11154
  : t("没有符合条件插件")) + "</div>";
10838
11155
  const more = $("mkr-more");
10839
11156
  if (more) more.classList.add("hidden");
@@ -10889,15 +11206,73 @@ async function mkrRefresh() {
10889
11206
  btn.textContent = old;
10890
11207
  }
10891
11208
 
11209
+ /* 两阶段安装(先看后装):① 预览——服务端下载+剥离式检查但不落盘,弹框展示
11210
+ * 包内容(文件清单/体量/剥离项/完整性);② 确认——带 token 安装那份已检内容,
11211
+ * 不重复下载。token 一次性,安装失败弹框保留可重试。 */
10892
11212
  async function mkrInstall(id) {
10893
- // 安装要现场下载插件包(zip/tarball,实测 10~60s),必须立刻给反馈,
10894
- // 否则用户以为点了没反应;按钮按 data-mk 定位,失败时由重拉页面复位
10895
11213
  const btn = document.querySelector('#mkr-grid button[data-mk="' + id + '"]');
10896
- if (btn) { btn.disabled = true; btn.textContent = t("安装中…"); }
10897
- toast(t("正在下载安装,插件包较大时需要约一分钟…"));
11214
+ if (btn) { btn.disabled = true; btn.textContent = t("检查中…"); }
11215
+ toast(t("正在下载插件包做安装前检查,包较大时需要约一分钟…"));
11216
+ let p;
10898
11217
  try {
10899
- const r = await api("/api/market/remote/install", { method: "POST",
11218
+ p = await api("/api/market/remote/preview", { method: "POST",
10900
11219
  body: JSON.stringify({ id }), timeout: 180000 });
11220
+ } catch (e) {
11221
+ toast(e.message, true);
11222
+ mkrLoadPage(true); // 失败重拉复位按钮
11223
+ return;
11224
+ }
11225
+ mkrShowInstallPreview(p);
11226
+ }
11227
+
11228
+ function _mkrIntegrityText(k) {
11229
+ const map = {
11230
+ verified: t("sha256 已校验"),
11231
+ unverified: t("无哈希(HTTPS 传输 + 纯技能白名单兜底)"),
11232
+ "git-ref": t("按 git ref 锁定"),
11233
+ "git-head": t("跟随仓库 HEAD"),
11234
+ };
11235
+ return map[k] || k || "";
11236
+ }
11237
+
11238
+ function mkrShowInstallPreview(p) {
11239
+ p = p || {};
11240
+ const rows = (p.file_list || []).map((f) =>
11241
+ '<tr><td title="' + esc(f.path) + '">' + esc(f.path) + '</td><td class="mkpv-chars">' + esc(mkCharsText(f.chars)) + "</td></tr>").join("");
11242
+ const moreF = p.files_total > (p.file_list || []).length
11243
+ ? '<tr><td colspan="2" class="mkpv-more">' + t("…等共 ") + p.files_total + t(" 个文件") + "</td></tr>" : "";
11244
+ const stripped = p.stripped || [];
11245
+ const strippedHtml = stripped.length
11246
+ ? '<div class="mkpv-sec">' + t("将自动剥离(不写入、不执行):") + "</div>" +
11247
+ '<ul class="mkpv-strip">' + stripped.map((s) => "<li>" + esc(s) + "</li>").join("") +
11248
+ (p.stripped_total > stripped.length
11249
+ ? '<li class="mkpv-more">' + t("…等 ") + p.stripped_total + t(" 个") + "</li>" : "") + "</ul>"
11250
+ : "";
11251
+ const skills = (p.skills || []).map((s) => '<span class="tag">' + esc(s) + "</span>").join("");
11252
+ openModal(t("安装前确认"),
11253
+ '<div class="mkpv">' +
11254
+ '<div class="mkpv-name">' + esc(p.title || p.id || "") + "</div>" +
11255
+ '<div class="mkpv-meta"><span>' + esc(t("来源:") + (p.source || "")) + "</span>" +
11256
+ (p.version ? "<span>v" + esc(p.version) + "</span>" : "") +
11257
+ "<span>" + esc(_mkrIntegrityText(p.integrity)) + "</span></div>" +
11258
+ (skills ? '<div class="mkpv-skills">' + t("包含技能:") + skills + "</div>" : "") +
11259
+ '<div class="mkpv-sum">' + t("共 ") + (p.files_total || 0) + t(" 个文件 · ") + esc(mkCharsText(p.total_chars || 0)) + "</div>" +
11260
+ (rows ? '<table class="mkpv-files"><thead><tr><th>' + t("文件") + "</th><th>" + t("体量") +
11261
+ "</th></tr></thead><tbody>" + rows + moreF + "</tbody></table>" : "") +
11262
+ strippedHtml +
11263
+ '<p class="hint">' + t("安装后运行任务时自动注入提示词;可到「经验库」启停或卸载。") + "</p>" +
11264
+ "</div>",
11265
+ '<button class="ghost" onclick="closeModal()">' + t("取消") + "</button>" +
11266
+ '<button class="primary" id="mkpv-ok" onclick="mkrInstallConfirm(\'' + esc(p.token || "") + '\',\'' + esc(p.id || "") + '\')">' + t("确认安装") + "</button>");
11267
+ }
11268
+
11269
+ async function mkrInstallConfirm(token, id) {
11270
+ const ok = $("mkpv-ok");
11271
+ if (ok) { ok.disabled = true; ok.textContent = t("安装中…"); }
11272
+ try {
11273
+ const r = await api("/api/market/remote/install", { method: "POST",
11274
+ body: JSON.stringify({ id, token }), timeout: 180000 });
11275
+ closeModal();
10901
11276
  if (r && r.already) toast(t("该插件已安装过"));
10902
11277
  else {
10903
11278
  // 剥离式安装:脚本/钩子/MCP 文件被剔除但技能装上了,如实告知剔除了什么
@@ -10907,8 +11282,13 @@ async function mkrInstall(id) {
10907
11282
  + (stripped.length > 3 ? t(" 等 ") + stripped.length + t(" 个文件") : "")
10908
11283
  : ""));
10909
11284
  }
10910
- } catch (e) { toast(e.message, true); }
10911
- mkrLoadPage(true); // 重装当前过滤页(已安装徽章要翻牌,失败也复位按钮)
11285
+ } catch (e) {
11286
+ toast(e.message, true);
11287
+ const ok2 = $("mkpv-ok");
11288
+ if (ok2) { ok2.disabled = false; ok2.textContent = t("确认安装"); }
11289
+ return; // 弹框保留:可重试或取消(token 未消费)
11290
+ }
11291
+ mkrLoadPage(true); // 已安装徽章要翻牌
10912
11292
  }
10913
11293
 
10914
11294
 
@@ -13119,6 +13499,12 @@ document.addEventListener("DOMContentLoaded", () => {
13119
13499
  const bindArtifactClicks = (root) => {
13120
13500
  if (!root) return;
13121
13501
  root.addEventListener("click", (e) => {
13502
+ const diff = e.target.closest(".artifact-file-diff[data-prev-run]");
13503
+ if (diff) { // 对比上一版:先于通用预览分支(chip 同样带 data-file-run/name)
13504
+ e.preventDefault();
13505
+ artDiffPopup(diff.dataset.fileRun, diff.dataset.prevRun, diff.dataset.fileName || "");
13506
+ return;
13507
+ }
13122
13508
  const file = e.target.closest("[data-file-run][data-file-name]");
13123
13509
  if (!file) return;
13124
13510
  e.preventDefault();
@@ -13541,6 +13927,10 @@ document.addEventListener("DOMContentLoaded", () => {
13541
13927
  mkrLoadPage(true);
13542
13928
  });
13543
13929
  $("mkr-source").addEventListener("change", () => mkrLoadPage(true));
13930
+ $("mkr-installed").addEventListener("click", () => {
13931
+ S.mkrInst = !S.mkrInst;
13932
+ mkrLoadPage(true);
13933
+ });
13544
13934
  $("mkr-refresh").addEventListener("click", mkrRefresh);
13545
13935
  $("mkr-more-btn").addEventListener("click", () => mkrLoadPage(false));
13546
13936
  mkrBindSentinel();
@@ -0,0 +1,74 @@
1
+ /* 成品两版对比的纯 diff 计算(OpenCreator「版本化对比」借鉴)。
2
+ * 独立文件无 DOM 依赖:app.js 用 window.ArtifactDiff 渲染,tests 用
3
+ * module.exports 直测。算法:公共前后缀修剪 + 中段 LCS(上限保护,
4
+ * 超限诚实降级为整块替换计数,不装作对齐了)。 */
5
+ (function (root, factory) {
6
+ var api = factory();
7
+ if (typeof window !== "undefined") window.ArtifactDiff = api;
8
+ if (typeof module !== "undefined" && module.exports) module.exports = api;
9
+ })(typeof self !== "undefined" ? self : this, function () {
10
+ "use strict";
11
+
12
+ var MID_CAP = 400; // 中段行数单边上限
13
+ var MID_PRODUCT_CAP = 160000; // LCS DP 格子上限(400×400)
14
+
15
+ /* LCS 回溯产出统一 diff 行:[{t:"="|"-"/"+", s:行文本}](只对中段)。 */
16
+ function lcsRows(A, B) {
17
+ var n = A.length, m = B.length;
18
+ var dp = [];
19
+ for (var k = 0; k <= n; k++) dp.push(new Uint32Array(m + 1));
20
+ for (var i = n - 1; i >= 0; i--)
21
+ for (var j = m - 1; j >= 0; j--)
22
+ dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1
23
+ : (dp[i + 1][j] >= dp[i][j + 1] ? dp[i + 1][j] : dp[i][j + 1]);
24
+ var rows = [];
25
+ i = 0; j = 0;
26
+ while (i < n && j < m) {
27
+ if (A[i] === B[j]) { rows.push({ t: "=", s: A[i] }); i++; j++; }
28
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { rows.push({ t: "-", s: A[i] }); i++; }
29
+ else { rows.push({ t: "+", s: B[j] }); j++; }
30
+ }
31
+ while (i < n) { rows.push({ t: "-", s: A[i] }); i++; }
32
+ while (j < m) { rows.push({ t: "+", s: B[j] }); j++; }
33
+ return rows;
34
+ }
35
+
36
+ /* 对外主口:旧版 a、新版 b → {
37
+ * prefix/suffix: 相同行数(渲染端折叠展示),
38
+ * identical: 布尔,两版一致,
39
+ * rows: 中段统一 diff 行(超限时为 null),
40
+ * oversized: {a, b} 超限时中段行数(诚实整块计数) } */
41
+ function diff(a, b) {
42
+ a = String(a == null ? "" : a);
43
+ b = String(b == null ? "" : b);
44
+ var A = a.split("\n"), B = b.split("\n");
45
+ var p = 0;
46
+ while (p < A.length && p < B.length && A[p] === B[p]) p++;
47
+ var s = 0;
48
+ while (s < A.length - p && s < B.length - p &&
49
+ A[A.length - 1 - s] === B[B.length - 1 - s]) s++;
50
+ var midA = A.slice(p, A.length - s), midB = B.slice(p, B.length - s);
51
+ var out = { prefix: p, suffix: s, identical: !midA.length && !midB.length,
52
+ rows: null, oversized: null };
53
+ if (out.identical) return out;
54
+ if (midA.length > MID_CAP || midB.length > MID_CAP ||
55
+ midA.length * midB.length > MID_PRODUCT_CAP) {
56
+ out.oversized = { a: midA.length, b: midB.length };
57
+ return out;
58
+ }
59
+ out.rows = lcsRows(midA, midB);
60
+ return out;
61
+ }
62
+
63
+ /* rows → 摘要计数(+x/-y,渲染端徽章用)。 */
64
+ function counts(d) {
65
+ var add = 0, del = 0;
66
+ if (d.oversized) return { add: d.oversized.b, del: d.oversized.a };
67
+ (d.rows || []).forEach(function (r) {
68
+ if (r.t === "+") add++; else if (r.t === "-") del++;
69
+ });
70
+ return { add: add, del: del };
71
+ }
72
+
73
+ return { diff: diff, counts: counts, lcsRows: lcsRows };
74
+ });