codebee 0.1.4 → 0.1.5

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
@@ -488,10 +488,12 @@ function fmtModel(m) {
488
488
  return typeof m === "object" ? JSON.stringify(m) : String(m);
489
489
  }
490
490
 
491
- /* 极简 Markdown 渲染(标题/加粗/行内码/列表/表格/代码块) */
491
+ /* 极简 Markdown 渲染(标题/加粗/行内码/列表/表格/代码块)。
492
+ * 围栏代码块走 codeBlockHTML(行号表格+高亮+代码主题):裸 <pre> 只有
493
+ * --log-bg 底色没配浅字,暖色皮肤里深底配深字根本读不了。 */
492
494
  function md2html(md) {
493
495
  const lines = String(md || "").split(/\r?\n/);
494
- let html = [], inCode = false, inTable = false, listOpen = false;
496
+ let html = [], inCode = false, codeBuf = null, inTable = false, listOpen = false;
495
497
  const inline = (t) => esc(t)
496
498
  .replace(/`([^`]+)`/g, "<code>$1</code>")
497
499
  .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
@@ -501,10 +503,11 @@ function md2html(md) {
501
503
  const line = raw.replace(/\s+$/, "");
502
504
  if (line.startsWith("```")) {
503
505
  closeList(); closeTable();
504
- html.push(inCode ? "</pre>" : "<pre>");
506
+ if (inCode) { html.push(codeBlockHTML(codeBuf.join("\n"))); codeBuf = null; }
507
+ else codeBuf = [];
505
508
  inCode = !inCode; continue;
506
509
  }
507
- if (inCode) { html.push(esc(raw)); continue; }
510
+ if (inCode) { codeBuf.push(raw); continue; }
508
511
  if (/^\|(.+)\|\s*$/.test(line)) {
509
512
  const cells = line.slice(1, -1).split("|").map((s) => s.trim());
510
513
  if (/^[-: ]+$/.test(cells.join(""))) continue; // 分隔行
@@ -524,7 +527,7 @@ function md2html(md) {
524
527
  html.push("<p>" + inline(line) + "</p>");
525
528
  }
526
529
  closeList(); closeTable();
527
- if (inCode) html.push("</pre>");
530
+ if (inCode && codeBuf) html.push(codeBlockHTML(codeBuf.join("\n"))); // 未闭合围栏也按代码块收尾
528
531
  return html.join("\n");
529
532
  }
530
533
 
@@ -1263,6 +1266,10 @@ function provModelRow(pid, m, group) {
1263
1266
  esc((tm.error || "").slice(0, 24)) + "</span>") : "";
1264
1267
  const price = (m.price_in != null && m.price_out != null)
1265
1268
  ? '<span class="hint">¥' + esc(m.price_in) + "/¥" + esc(m.price_out) + "</span>" : "";
1269
+ const imgcap = '<span class="tag imgcap' + (m.image_in ? " ok" : "") + '" title="' +
1270
+ (m.image_in ? t("支持图片输入,点击关闭")
1271
+ : t("纯文本模型,点击开启图片输入(内置智能体传图以此为准)")) + '"' +
1272
+ ' onclick="toggleModelImage(\'' + esc(pid) + '\', \'' + esc(m.name) + '\')">' + t("图") + "</span>";
1266
1273
  const sel = modelSel(pid);
1267
1274
  return '<div class="prow' + (m.enabled ? "" : " off") + '" draggable="true" data-group="' +
1268
1275
  esc(group) + '" data-name="' + esc(m.name) + '">' +
@@ -1272,7 +1279,7 @@ function provModelRow(pid, m, group) {
1272
1279
  '<span class="drag" title="' + t("拖动调整优先级") + '"><svg class="ico" aria-hidden="true"><use href="#i-grip"></use></svg></span>' +
1273
1280
  '<span class="pprio">#' + m.priority + "</span>" +
1274
1281
  '<span class="pname" title="' + esc(m.name) + '">' + esc(m.name) + "</span>" +
1275
- price + tmHtml +
1282
+ price + imgcap + tmHtml +
1276
1283
  '<span class="row-ops">' +
1277
1284
  '<button class="ghost small row-op" onclick="testModelBtn(\'' + esc(pid) + '\', \'' + esc(m.name) + '\')">' + t("测试") + '</button>' +
1278
1285
  '<button class="danger small row-op" title="' + t("从列表删除:刷新/重新导入不会再带回,可在分组底部恢复") + '"' +
@@ -1359,6 +1366,21 @@ async function modelOp(pid, name, op) {
1359
1366
  poll();
1360
1367
  }
1361
1368
 
1369
+ function toggleModelImage(pid, name) {
1370
+ const p = (S.providers || []).find((x) => x.id === pid);
1371
+ const m = p && (p.models || []).find((x) => x.name === name);
1372
+ api("/api/models/model-caps", { method: "POST",
1373
+ body: JSON.stringify({ provider_id: pid, name, image_in: !(m && m.image_in) }) })
1374
+ .then(() => { S.modelsSig = null; poll(); })
1375
+ .catch((e) => toast(t("保存失败:") + e.message, true));
1376
+ }
1377
+
1378
+ /* 反查模型是否声明图片输入(绑定页 chip/列表只读徽标用) */
1379
+ function modelHasImage(pid, name) {
1380
+ const p = (S.providers || []).find((x) => x.id === pid);
1381
+ return !!(p && (p.models || []).some((x) => x.name === name && x.image_in));
1382
+ }
1383
+
1362
1384
  /* ---- 模型批量选择 ---- */
1363
1385
  function toggleModelSel(pid, name, on) {
1364
1386
  const s = modelSelSet(pid);
@@ -1786,6 +1808,9 @@ async function createTask() {
1786
1808
  try {
1787
1809
  const r = await api("/api/tasks", { method: "POST", body: JSON.stringify(payload) });
1788
1810
  msg.textContent = t("已创建,跳转运行页…");
1811
+ // 先把新任务刷进 state 再跳:chatEngineIsDirect 靠 S.state.tasks 判引擎,
1812
+ // 不刷的话对话页签不会就绪,自动选卡落不到「对话」
1813
+ try { await refreshState(); } catch (e) { /* 刷失败等轮询兜底 */ }
1789
1814
  jumpToRun(r.run_id);
1790
1815
  $("f-goal").value = "";
1791
1816
  S.atts = []; renderAttachChips(); // 附件已移交任务待提交区,清空本地列表
@@ -2083,7 +2108,7 @@ function bindCtxMenus() {
2083
2108
  items.push({ label: t("复制工作目录路径"), fn: () => revealPath("tasks", taskId, false) });
2084
2109
  if (runId) items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2085
2110
  const st = det.dataset.status || "";
2086
- if (st === "failed" || st === "cancelled") items.push({ label: t("↻ 重试任务"), fn: () => retryTask(taskId) });
2111
+ if (st === "failed" || st === "cancelled") items.push({ label: t("↻ 继续任务"), fn: () => retryTask(taskId) });
2087
2112
  // 与详情页同一套说法:失败/取消叫「编辑重试」,其余叫「基于此任务新建」
2088
2113
  items.push({ label: (st === "failed" || st === "cancelled") ? t("✎ 编辑重试") : t("基于此任务新建"),
2089
2114
  fn: () => newFromTask(taskId) });
@@ -3093,6 +3118,7 @@ function closeRun() {
3093
3118
  stopHiveTick();
3094
3119
  detailSideReset();
3095
3120
  $("run-detail").classList.add("hidden");
3121
+ renderChatNav(); // 解除 main.chat-fill(对话为主的固定高度),恢复外层滚动
3096
3122
  if (document.body.classList.contains("settings-mode")) {
3097
3123
  document.querySelector("#sub-runs .panel:first-child").classList.remove("hidden");
3098
3124
  } else {
@@ -3152,7 +3178,55 @@ function applyRdTabs() {
3152
3178
  });
3153
3179
  document.querySelectorAll("#run-detail .rd-pane").forEach((p) =>
3154
3180
  p.classList.toggle("hidden", p.dataset.pane !== S.rdTab));
3155
- }
3181
+ // 对话页签不放日志抽屉:会盖住贴底输入条(手动切来/自动选卡都覆盖)
3182
+ if (S.rdTab === "chat" && !$("rd-log").classList.contains("hidden")) window.rdLogClose();
3183
+ renderChatNav();
3184
+ }
3185
+
3186
+ /* 直连任务「对话为主」布局:收起常规页签条,右上角一排小胶囊按需打开
3187
+ * 蜂巢/步骤/成果等分区;离开对话时给「返回对话」入口(用户 2026-09-17 拍板:
3188
+ * 对话场景其它页签用处不大,默认关掉、需要再点开)。 */
3189
+ function renderChatNav() {
3190
+ const nav = $("rd-chat-nav"), detail = $("run-detail");
3191
+ if (!nav || !detail) return;
3192
+ const direct = !detail.classList.contains("hidden") && !!S.lastRun &&
3193
+ chatEngineIsDirect(S.lastRun);
3194
+ detail.classList.toggle("chat-mode", direct);
3195
+ // 对话为主时锁死外层滚动(main 是滚动根);离开详情必须解锚,否则任务列表滚不动
3196
+ const mainEl = document.querySelector("main");
3197
+ if (mainEl) mainEl.classList.toggle("chat-fill", direct);
3198
+ if (!direct) { nav.classList.add("hidden"); nav.innerHTML = ""; return; }
3199
+ const labels = { hive: t("蜂巢"), steps: t("步骤"), result: t("成果"),
3200
+ git: "Git", bible: t("圣经"), bookmeta: t("作品信息") };
3201
+ const avail = rdTabAvail();
3202
+ let pills = "";
3203
+ for (const k of ["hive", "steps", "result", "git", "bible", "bookmeta"]) {
3204
+ if (!avail[k]) continue;
3205
+ // 徽章直接镜像隐藏页签上的(蜂巢在岗数/步骤数/待裁决/成果数),不另设状态源
3206
+ const badge = document.querySelector('#rd-tabs .rd-tab[data-tab="' + k + '"] .rd-badge');
3207
+ pills += '<button class="cn-pill' + (S.rdTab === k ? " on" : "") +
3208
+ '" onclick="rdChatNavGo(\'' + k + '\')">' + labels[k] +
3209
+ (badge && badge.textContent ? "<b>" + esc(badge.textContent) + "</b>" : "") + "</button>";
3210
+ }
3211
+ nav.innerHTML = (S.rdTab !== "chat"
3212
+ ? '<button class="cn-back" onclick="rdChatNavBack()">' +
3213
+ '<svg class="ico" aria-hidden="true"><use href="#i-arrow-left"></use></svg>' +
3214
+ t("返回对话") + "</button>"
3215
+ : "") +
3216
+ '<span class="cn-pills">' + pills + "</span>";
3217
+ nav.classList.remove("hidden");
3218
+ }
3219
+ window.rdChatNavGo = function (tab) {
3220
+ S.rdTab = tab;
3221
+ S.rdTabPin = true; // 用户主动去看其它分区:别被自动选卡拽回对话
3222
+ applyRdTabs();
3223
+ };
3224
+ window.rdChatNavBack = function () {
3225
+ S.rdTab = "chat";
3226
+ S.rdTabPin = false;
3227
+ S.rdTabSig = "";
3228
+ applyRdTabs();
3229
+ };
3156
3230
 
3157
3231
  /* 徽章:蜂巢=在岗数(运行中)、步骤=总步数、版本=待裁决、成果=文件数(loadArtifacts 里刷) */
3158
3232
  function rdTabBadges() {
@@ -3173,8 +3247,11 @@ function rdTabBadges() {
3173
3247
  function rdTabsSync(ctx) {
3174
3248
  if (ctx) S._rdCtx = ctx;
3175
3249
  if (ctx && !S.rdTabPin) {
3250
+ // 对话页签可用性入签名:刚建的任务要等 state 刷进来 direct 才判定成立,
3251
+ // chat 从不可用变可用时必须触发一次重新选卡(否则落在步骤/蜂巢不跳对话)
3252
+ const chatAvail = $("rd-chat") && !$("rd-chat").classList.contains("hidden");
3176
3253
  const sig = (S.detailTaskKey || S.detailRunId || "") + "|" + (ctx.status || "") +
3177
- "|" + (ctx.gitState || "") + "|" + (ctx.running ? 1 : 0);
3254
+ "|" + (ctx.gitState || "") + "|" + (ctx.running ? 1 : 0) + "|" + (chatAvail ? 1 : 0);
3178
3255
  if (S.rdTabSig !== sig) {
3179
3256
  S.rdTabSig = sig;
3180
3257
  const avail = rdTabAvail();
@@ -3370,11 +3447,13 @@ const BOOKMETA_PLATFORMS = [
3370
3447
  ];
3371
3448
  const BOOKMETA_FIELDS = {
3372
3449
  fanqie: [["book_name", "作品名"], ["signing_mode", "签约模式"], ["target_reader", "目标读者"],
3373
- ["category", "分类"], ["tags_theme", "主题标签"], ["tags_role", "角色标签"],
3374
- ["tags_plot", "情节标签"], ["protagonist_1", "主角名1"],
3375
- ["protagonist_2", "主角名2"], ["summary", "作品简介"]],
3450
+ ["category", "主分类"], ["tags_theme", "主题标签"], ["tags_role", "角色标签"],
3451
+ ["tags_plot", "情节标签"], ["content_plot", "内容·情节"], ["content_emotion", "内容·情感"],
3452
+ ["content_character", "内容·人设"], ["content_world", "内容·世界观"],
3453
+ ["protagonist_1", "主角名1"], ["protagonist_2", "主角名2"], ["summary", "作品简介"]],
3376
3454
  qimao: [["book_name", "作品名称"], ["target_reader", "目标读者"], ["category_main", "一级分类"],
3377
- ["category_sub", "二级分类"], ["tags", "作品标签"], ["protagonist_1", "主角名1"],
3455
+ ["category_sub", "二级分类"], ["tags_style", "风格标签"], ["tags_role", "角色标签"],
3456
+ ["tags_plot", "情节标签"], ["tags_bg", "背景标签"], ["protagonist_1", "主角名1"],
3378
3457
  ["protagonist_2", "主角名2"], ["status", "作品状态"], ["summary", "作品简介"]],
3379
3458
  };
3380
3459
 
@@ -3486,6 +3565,7 @@ window.bmGen = async function (taskId, platform) {
3486
3565
  await api("/api/tasks/" + encodeURIComponent(taskId) + "/book-meta",
3487
3566
  { method: "POST", body: JSON.stringify({ platform }) });
3488
3567
  toast(t("已开始生成,完成后这里会自动更新"));
3568
+ await refreshState(); render(); // 立即翻到「生成中」,不等 SSE 推送/下一轮轮询
3489
3569
  } catch (e) { toast(t("生成失败:") + e.message, true); }
3490
3570
  };
3491
3571
 
@@ -3604,7 +3684,7 @@ function _renderGitSnapshot(run, task) {
3604
3684
  '<span class="chip failed">' + t("检出失败") + "</span>" +
3605
3685
  '<code class="git-branch">codebee/' + esc(tid || t("(无主运行)")) + t("(") + esc(t("未创建")) + t(")") + "</code></div>" +
3606
3686
  (lastErr ? '<div class="hint warn">' + esc(lastErr) + "</div>" : "") +
3607
- '<div class="hint">' + esc(t("处理后点「重试任务」,运行会重新检出任务分支。")) + "</div>";
3687
+ '<div class="hint">' + esc(t("处理后点「继续任务」,运行会重新检出任务分支。")) + "</div>";
3608
3688
  rdTabsSync();
3609
3689
  return;
3610
3690
  }
@@ -4021,7 +4101,7 @@ function drawInspector() {
4021
4101
  main.innerHTML = '<div class="insp-branch"><code>codebee/' + esc(d.task.id || "") +
4022
4102
  t("(") + esc(t("未创建")) + t(")") + '</code></div>' +
4023
4103
  (lastErr ? '<div class="hint warn">' + esc(lastErr) + "</div>" : "") +
4024
- '<span class="insp-hint">' + esc(t("处理后点「重试任务」,运行会重新检出任务分支。")) + "</span>";
4104
+ '<span class="insp-hint">' + esc(t("处理后点「继续任务」,运行会重新检出任务分支。")) + "</span>";
4025
4105
  } else {
4026
4106
  // 没指定代码版本:没有任务分支,卡里只留一句说明
4027
4107
  chipEl.classList.add("hidden");
@@ -4650,7 +4730,8 @@ const hiveTails = {}; // "runid|rel" -> 最近一行输出缓
4650
4730
  function stopHiveTick() { if (hiveTimer) { clearInterval(hiveTimer); hiveTimer = null; } }
4651
4731
 
4652
4732
  /* run.steps -> 阶段泳道流水线:规划→起草→评审→修订→打磨→合成,先后关系一眼可见;
4653
- * 运行中蜜蜂摆动+秒表走动;轨道光点流向下一阶段;点格即看实时输出。 */
4733
+ * 运行中蜜蜂摆动+秒表走动+蜜光呼吸;彗尾光点流向下一阶段;点格即看实时输出;
4734
+ * 入场瀑布只在步骤结构真变时重播(签名闸门,轮询空转不重建 DOM)。 */
4654
4735
  function hiveStage(role) {
4655
4736
  const r = String(role || "");
4656
4737
  if (/^(plan|outline)/.test(r)) return t("规划");
@@ -4711,7 +4792,7 @@ window.renderHive = function (run) {
4711
4792
  if (sub) sub.textContent = running.length
4712
4793
  ? t("在岗") + " " + running.length + " / " + steps.length : t("全部空闲");
4713
4794
  const activeIdx = lanes.map((l) => byStage[l].some((s) => s.status === "running")).lastIndexOf(true);
4714
- const cell = (s) => {
4795
+ const cell = (s, laneIdx, cellIdx) => {
4715
4796
  // 五归一:取消/超时的格子不再冒充「完成」——与步骤芯片同三色体系
4716
4797
  const st = s.status === "running" || s.status === "queued" ? "running"
4717
4798
  : s.status === "failed" ? "failed"
@@ -4727,7 +4808,10 @@ window.renderHive = function (run) {
4727
4808
  const title = [s.role, who, s.started_at ? t("开始于 ") + s.started_at : "",
4728
4809
  (s.note ? "◆ " + s.note : "")].filter(Boolean).join(" · ")
4729
4810
  + (concl && st !== "running" ? "\n" + t("结论:") + concl : "");
4730
- return '<div class="hive-cell st-' + st + '" title="' + esc(title) + '" ' +
4811
+ // --d:入场瀑布逐格延迟(泳道间 110ms + 同泳道逐格 45ms),样式端消费
4812
+ return '<div class="hive-cell st-' + st + (st === "running" ? " hc-breathe" : "") + '"' +
4813
+ ' style="--d:' + (laneIdx * 110 + cellIdx * 45) + 'ms"' +
4814
+ ' title="' + esc(title) + '" ' +
4731
4815
  'onclick="hiveOpenLog(\'' + esc(run.id) + "', '" + esc(s.log || "") + '\')">' +
4732
4816
  '<div class="hc-head">' +
4733
4817
  '<svg class="ico hc-bee" aria-hidden="true"><use href="#i-bee"></use></svg>' +
@@ -4737,17 +4821,19 @@ window.renderHive = function (run) {
4737
4821
  esc(tailText) + "</div>" +
4738
4822
  '<div class="hc-meta"><span class="hc-elapsed" data-started="' + esc(s.started_at || "") + '">' +
4739
4823
  (s.duration_s != null ? s.duration_s + "s" : hiveElapsed(s.started_at)) + "</span>" +
4740
- (st === "running" ? '<span class="hc-live">● ' + t("工作中") + "</span>" : "") +
4824
+ (st === "running" ? '<span class="hc-live"><i class="live-dot"></i>' + t("工作中") + "</span>" : "") +
4741
4825
  (st === "timeout" ? '<span class="hc-dead">⏱ ' + t("超时") + "</span>" : "") +
4742
4826
  (st === "cancelled" ? '<span class="hc-dead">' + t("已取消") + "</span>" : "") +
4743
4827
  "</div></div>";
4744
4828
  };
4745
- const dots = (list) => list.slice(-12).map((s) => {
4829
+ const dots = (list, laneIdx) => list.slice(-12).map((s, di) => {
4746
4830
  const dc = s.status === "running" ? "run" : s.status === "failed" ? "fail"
4747
4831
  : s.status === "cancelled" ? "cancel" : s.status === "timeout" ? "time" : "done";
4748
4832
  const stx = { running: t("运行中"), failed: t("失败"), cancelled: t("已取消"),
4749
4833
  timeout: t("超时"), done: t("完成") }[dc] || s.status;
4750
- return '<i class="ld ld-' + dc + '" title="' + esc((s.role || "") + " · " + stx) + '"></i>';
4834
+ // --d:与格子同款级联延迟——入场时整条轨道按执行顺序""地点亮(进度回放感)
4835
+ return '<i class="ld ld-' + dc + '" style="--d:' + (laneIdx * 110 + di * 45) + 'ms"' +
4836
+ ' title="' + esc((s.role || "") + " · " + stx) + '"></i>';
4751
4837
  }).join("");
4752
4838
  $("rd-hive-cells").innerHTML = lanes.map((stage, i) => {
4753
4839
  const list = byStage[stage];
@@ -4755,13 +4841,38 @@ window.renderHive = function (run) {
4755
4841
  // 无 running(终态回看):全部视为已完成泳道;有 running 时 activeIdx 之前算完成
4756
4842
  const cls = hasRun ? "lane-active"
4757
4843
  : (activeIdx === -1 || i < activeIdx ? "lane-done" : "lane-idle");
4844
+ // 泳道头视觉 v2:阶段序号徽章(01/02…流水线站序)+ 已完成进度(settled/total)
4845
+ const settled = list.filter((s) => !["running", "queued"].includes(s.status)).length;
4758
4846
  return '<div class="hive-lane ' + cls + '">' +
4759
- '<div class="lane-head"><span class="lane-name">' + esc(stage) + '</span>' +
4847
+ '<div class="lane-head">' +
4848
+ '<span class="lane-idx">' + String(i + 1).padStart(2, "0") + "</span>" +
4849
+ '<span class="lane-name">' + esc(stage) + '</span>' +
4760
4850
  '<span class="lane-n">×' + list.length + "</span>" +
4761
- (hasRun ? '<span class="lane-live">● ' + t("进行中") + "</span>" : "") + "</div>" +
4762
- '<div class="lane-track">' + dots(list) + "</div>" +
4763
- '<div class="lane-cells">' + list.slice(-8).map(cell).join("") + "</div></div>";
4851
+ (settled ? '<span class="lane-prog">' + settled + "/" + list.length + "</span>" : "") +
4852
+ (hasRun ? '<span class="lane-live"><i class="live-dot"></i>' + t("进行中") + "</span>" : "") + "</div>" +
4853
+ '<div class="lane-track">' + dots(list, i) + "</div>" +
4854
+ '<div class="lane-cells">' + list.slice(-8).map((s, ci) => cell(s, i, ci)).join("") + "</div></div>";
4764
4855
  }).join('<div class="lane-flow" aria-hidden="true"></div>');
4856
+ // 入场瀑布闸门:只按"结构"(泳道数/格数/状态构成)签名;运行中尾巴/秒表每 2s
4857
+ // 变化不能触重播。签名没变就不动 DOM——格子不闪、动画不重启、悬停不弹手。
4858
+ const sig = lanes.map((l) => l + ":" + byStage[l].length + "(" +
4859
+ byStage[l].map((s) => s.status[0] || "?").join("") + ")").join("|");
4860
+ const cellsBox = $("rd-hive-cells");
4861
+ if (cellsBox.dataset.hiveSig !== sig) {
4862
+ const fresh = cellsBox.dataset.hiveSig == null; // 首次展开详情也照播,先落框架再逐格亮起
4863
+ cellsBox.dataset.hiveSig = sig;
4864
+ // hive-enter 只在下一帧就摘(Rune/Hermes 式一闪而过的瀑布),全程保留会锁死
4865
+ // 入场帧;1400ms 定时是 RAF 被后台标签页冻结时的兜底。
4866
+ if (!fresh) cellsBox.classList.remove("hive-enter");
4867
+ cellsBox.classList.add("hive-enter");
4868
+ if (cellsBox._hiveEnterRAF) cancelAnimationFrame(cellsBox._hiveEnterRAF);
4869
+ cellsBox._hiveEnterRAF = requestAnimationFrame(() => {
4870
+ requestAnimationFrame(() => cellsBox.classList.remove("hive-enter"));
4871
+ cellsBox._hiveEnterRAF = null;
4872
+ });
4873
+ if (cellsBox._hiveEnterT) clearTimeout(cellsBox._hiveEnterT);
4874
+ cellsBox._hiveEnterT = setTimeout(() => cellsBox.classList.remove("hive-enter"), 1400);
4875
+ }
4765
4876
  const live = running.length && (run.status === "running" || run.status === "queued");
4766
4877
  if (hiveClock) { clearInterval(hiveClock); hiveClock = null; }
4767
4878
  if (live) {
@@ -4773,12 +4884,8 @@ window.renderHive = function (run) {
4773
4884
  el.textContent = hiveElapsed(el.dataset.started);
4774
4885
  });
4775
4886
  }, 1000);
4776
- // 点开任务第一眼就在干活:自动展开第一个在岗步骤的实时日志。
4777
- // 每 run 只自动开一次;用户手动收起后不再打扰。
4778
- if (S.hiveAutoLog !== run.id && !currentLog) {
4779
- const first = running.find((s) => s.log);
4780
- if (first) { S.hiveAutoLog = run.id; toggleLog(run.id, first.log); }
4781
- }
4887
+ // 日志抽屉一律手动打开(点蜂巢格/步骤行):自动弹开会盖住对话输入条,
4888
+ // 用户要求所有页面默认不展示日志(2026-09-17)
4782
4889
  } else stopHiveTick();
4783
4890
  rdTabsSync(); // 蜂巢显隐直接决定「蜂巢」标签可用性
4784
4891
  };
@@ -4993,6 +5100,38 @@ function chatEngineIsDirect(run) {
4993
5100
  return !!(t && t.engine === "direct");
4994
5101
  }
4995
5102
 
5103
+ /* 对话正文:``` 围栏渲染成真代码块(复用 codeBlockHTML:行号+高亮+代码主题),
5104
+ * 其余文本保持 pre-wrap 原样(行内 `code` 与 **加粗** 轻量翻译)。模型输出先
5105
+ * 全量 esc 再插标记;未闭合围栏按代码块收尾,流式输出中途也不撒裸反引号。 */
5106
+ function chatBodyHTML(text) {
5107
+ const lines = String(text || "").split(/\r?\n/);
5108
+ const out = [];
5109
+ let txt = [], code = null;
5110
+ const flushTxt = () => {
5111
+ if (!txt.length) return;
5112
+ out.push('<div class="chat-body">' + txt.map((l) => {
5113
+ let h = esc(l);
5114
+ h = h.replace(/`([^`]+)`/g, "<code>$1</code>");
5115
+ h = h.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
5116
+ return h;
5117
+ }).join("\n") + "</div>");
5118
+ txt = [];
5119
+ };
5120
+ for (const raw of lines) {
5121
+ const line = raw.replace(/\s+$/, "");
5122
+ if (line.startsWith("```")) {
5123
+ if (code) { out.push(codeBlockHTML(code.join("\n"))); code = null; }
5124
+ else { flushTxt(); code = []; }
5125
+ continue;
5126
+ }
5127
+ if (code) code.push(raw);
5128
+ else txt.push(raw);
5129
+ }
5130
+ if (code) out.push(codeBlockHTML(code.join("\n")));
5131
+ flushTxt();
5132
+ return out.join("");
5133
+ }
5134
+
4996
5135
  async function renderChat(run, active) {
4997
5136
  const box = $("rd-chat");
4998
5137
  if (!box) return;
@@ -5013,36 +5152,59 @@ async function renderChat(run, active) {
5013
5152
  // 指向当前详情正在展示的那条 run——拉取期间切了详情就丢弃响应
5014
5153
  if (!S.lastRun || S.lastRun.id !== runForFetch) return;
5015
5154
  const flow = $("rd-chat-flow");
5155
+ // 时间显示统一收敛到 HH:MM(日期在 meta 条里,全量戳塞气泡就是噪音);
5156
+ // 附件兼容 字符串路径 / {name|path} 对象 两种形态(对象直接拼会成 [object Object])
5157
+ const chatTime = (v) => { const s = String(v || "");
5158
+ return /^\d{4}-\d{2}-\d{2}[ T]/.test(s) ? s.slice(11, 16) : s; };
5159
+ const attName = (a) => { const p = typeof a === "string" ? a
5160
+ : ((a && (a.name || a.path)) || "");
5161
+ return String(p).split(/[\\/]/).pop() || ""; };
5016
5162
  flow.innerHTML = items.map((it) => {
5017
5163
  if (it.kind === "user") {
5018
- // 用户消息:右侧浅灰圆角块,元信息在块内顶部
5164
+ // 用户消息:右侧浅灰圆角块,元信息在块内顶部。
5165
+ // 附件胶囊可点开预览:data-att 带 _attachments/ 相对路径(老数据裸文件名
5166
+ // 在前端钉回 _attachments/,与服务端 norm_rel 同口径),事件委托绑在
5167
+ // rd-chat-flow 上——时间线整块 innerHTML 重建也不丢监听
5168
+ const atts = (it.attachments || []).map((a) => {
5169
+ const nm = attName(a);
5170
+ if (!nm) return null;
5171
+ let rel = typeof a === "string" ? a : ((a && (a.path || a.name)) || nm);
5172
+ rel = String(rel).replace(/\\/g, "/");
5173
+ if (!rel.startsWith("_attachments/")) rel = "_attachments/" + rel.split("/").pop();
5174
+ return { nm, rel };
5175
+ }).filter(Boolean);
5019
5176
  return '<div class="chat-row me">' +
5020
5177
  '<div class="chat-bubble me">' +
5021
- '<div class="chat-meta">' + esc(it.who || "") + " " + esc(it.at || "") +
5178
+ '<div class="chat-meta">' + esc(it.who || "") + " " + esc(chatTime(it.at)) +
5022
5179
  (it.consumed ? "" : " · " + t("待送达")) + "</div>" +
5023
5180
  esc(it.text || t("(仅附件)")) +
5024
- ((it.attachments || []).length
5025
- ? '<div class="chat-atts">' + it.attachments.map((a) =>
5026
- '<span class="att-chip2">' + esc(String(a).split(/[\\/]/).pop()) + "</span>").join("") + "</div>"
5181
+ (atts.length
5182
+ ? '<div class="chat-atts">' + atts.map((a) =>
5183
+ '<span class="att-chip2 att-open" data-att="' + esc(a.rel) +
5184
+ '" title="' + esc(t("点击查看")) + '">' + esc(a.nm) + "</span>").join("") + "</div>"
5027
5185
  : "") +
5028
5186
  "</div></div>";
5029
5187
  }
5030
5188
  // 协议尾行是给编排器判收的机器标记,不该出现在聊天正文里(真源在提示词约定)
5031
5189
  const body = String(it.text || "").replace(/\n*DIRECT_DONE:.*\s*$/s, "").trim();
5032
- // 运行中的步骤还没有正文(output/summary 都空):给占位,不渲染空气泡
5033
- const shown = body || (it.status === "running" ? t("正在执行…") : t("(本轮无文本输出)"));
5190
+ // 运行中还没有正文:三点打字动画(终态无正文才落「无文本输出」占位)
5191
+ const bodyHtml = body ? chatBodyHTML(body)
5192
+ : (it.status === "running"
5193
+ ? '<span class="chat-typing" aria-label="' + esc(t("正在执行…")) + '"><i></i><i></i><i></i></span>'
5194
+ : esc(t("(本轮无文本输出)")));
5195
+ // 元信息只留 名字+时间(+失败标记):路由/供应商细节属于「步骤」页签,塞这里就是噪音
5196
+ const metaBad = it.status === "failed" || it.status === "cancelled";
5034
5197
  // 智能体回复:头像 + 整幅正文(不套气泡框),元信息小字落在正文下方
5035
5198
  return '<div class="chat-row">' +
5036
5199
  '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="#i-bee"></use></svg></span>' +
5037
5200
  '<div class="chat-bubble agent">' +
5038
- '<pre class="chat-body">' + esc(shown) + "</pre>" +
5039
- '<div class="chat-meta">' + esc(it.who || "") + " " + esc(it.at || "") +
5040
- (it.note ? " · " + esc(it.note) : "") +
5041
- (it.status && it.status !== "done" ? " · " + esc(it.status) : "") + "</div></div></div>";
5201
+ '<div class="chat-body">' + bodyHtml + "</div>" +
5202
+ '<div class="chat-meta">' + esc(it.who || "") + " " + esc(chatTime(it.at)) +
5203
+ (metaBad ? " · " + t(it.status === "failed" ? "失败" : "已取消") : "") + "</div></div></div>";
5042
5204
  }).join("") || '<div class="hint">' + esc(t("还没有对话内容")) + "</div>";
5043
5205
  const hint = $("rd-chat-hint");
5044
5206
  if (hint) hint.textContent = active
5045
- ? t("运行中:消息随下一步送达(插不进正在跑的这一步)")
5207
+ ? t("运行中:新消息会排队,本轮回答完后依次送达")
5046
5208
  : t("已结束:发送后将自动开新一轮接着做");
5047
5209
  flow.scrollTop = flow.scrollHeight;
5048
5210
  }
@@ -5050,12 +5212,28 @@ async function renderChat(run, active) {
5050
5212
  function drawChatAtts() {
5051
5213
  const box = $("rd-chat-att-list");
5052
5214
  if (!box) return;
5215
+ // 点名字开预览(待提交区原文走 /api/attachments/<id>),× 才是移除
5053
5216
  box.innerHTML = chatAtts.map((a, i) =>
5054
- '<span class="att-chip2">' + esc(a.name) + '<b onclick="chatRemoveAtt(' + i + ')" title="' +
5055
- esc(t("移除")) + '">×</b></span>').join("");
5217
+ '<span class="att-chip2 att-view" data-att-id="' + esc(a.id || "") +
5218
+ '" data-att-name="' + esc(a.name) + '" title="' + esc(t("点击查看")) + '">' + esc(a.name) +
5219
+ '<b onclick="chatRemoveAtt(' + i + ')" title="' + esc(t("移除")) + '">×' +
5220
+ '</b></span>').join("");
5056
5221
  }
5057
5222
  window.chatRemoveAtt = function (i) { chatAtts.splice(i, 1); drawChatAtts(); };
5058
5223
 
5224
+ /* 对话附件点击预览:已落盘的走 run 工作目录 /api/runs/<id>/file(rel 为
5225
+ * _attachments/ 相对路径),待提交的走 /api/attachments/<id>;
5226
+ * 弹窗与成品预览同款——图片直接看图、文本看码、二进制给下载。 */
5227
+ window.chatAttPopup = function (rel) {
5228
+ if (!rel || !chatRunId) return;
5229
+ return _fpPreviewUrl("/api/runs/" + encodeURIComponent(chatRunId) +
5230
+ "/file?name=" + encodeURIComponent(rel), rel);
5231
+ };
5232
+ window.chatPendingPopup = function (id, name) {
5233
+ if (!id) return;
5234
+ return _fpPreviewUrl("/api/attachments/" + encodeURIComponent(id), name || "attachment");
5235
+ };
5236
+
5059
5237
  async function chatUploadFiles(files) {
5060
5238
  for (const f of files) {
5061
5239
  try {
@@ -5083,24 +5261,33 @@ window.chatSend = async function () {
5083
5261
  btn.disabled = true;
5084
5262
  try {
5085
5263
  if (active) {
5264
+ // 运行中:消息进信箱排队(时间线上带「待送达」标记),本轮跑完自动续轮送达
5086
5265
  await api("/api/runs/" + encodeURIComponent(chatRunId) + "/messages", {
5087
5266
  method: "POST",
5088
5267
  body: JSON.stringify({ text, attachments: chatAtts.map((a) => a.id) }),
5089
5268
  });
5090
- toast(t("已入箱:随下一步送达"));
5269
+ toast(t("已排队:随下一步一起送达"));
5091
5270
  } else {
5092
5271
  const r = await api("/api/runs/" + encodeURIComponent(chatRunId) + "/chat", {
5093
5272
  method: "POST",
5094
5273
  body: JSON.stringify({ text, attachments: chatAtts.map((a) => a.id) }),
5095
5274
  });
5096
5275
  toast(t("已开新一轮,接着做…"));
5097
- if (r && r.run_id) { chatSig = ""; jumpToRun(r.run_id); return; }
5276
+ if (r && r.run_id) {
5277
+ if (S.detailTaskKey) { S.taskSig = ""; renderTaskDetail(); }
5278
+ else jumpToRun(r.run_id);
5279
+ }
5098
5280
  }
5281
+ // 发送成功一律清空输入框(只清输入,时间线里的历史回复不动);
5282
+ // 此前 /chat 分支提前 return 跳过了清空,发出去的字一直留在框里
5099
5283
  ta.value = "";
5284
+ ta.style.height = "";
5100
5285
  chatAtts = []; drawChatAtts();
5101
5286
  chatSig = ""; // 强制重画时间线
5102
- const d = await api("/api/runs/" + encodeURIComponent(chatRunId));
5103
- if (d.run) renderChat(d.run, d.run.status === "running" || d.run.status === "queued");
5287
+ if (active) {
5288
+ const d = await api("/api/runs/" + encodeURIComponent(chatRunId));
5289
+ if (d.run) renderChat(d.run, d.run.status === "running" || d.run.status === "queued");
5290
+ }
5104
5291
  } catch (e) { toast(t("发送失败:") + e.message, true); }
5105
5292
  finally { btn.disabled = false; }
5106
5293
  };
@@ -5115,6 +5302,12 @@ function bindChat() {
5115
5302
  e.target.value = "";
5116
5303
  });
5117
5304
  send.addEventListener("click", window.chatSend);
5305
+ // 自动伸高:跟内容走、有上限(160px 后内部滚动);清空由 chatSend 归零。
5306
+ // 高度只由用户输入驱动,轮询重画不碰它——框框不再忽大忽小
5307
+ ta.addEventListener("input", () => {
5308
+ ta.style.height = "auto";
5309
+ ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
5310
+ });
5118
5311
  ta.addEventListener("keydown", (e) => {
5119
5312
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); window.chatSend(); }
5120
5313
  });
@@ -5128,6 +5321,19 @@ function bindChat() {
5128
5321
  return f && !f.name ? new File([f], "paste-" + Date.now() + ".png", { type: f.type }) : f;
5129
5322
  }).filter(Boolean));
5130
5323
  });
5324
+ // 附件胶囊点击预览:委托绑在静态容器上——时间线轮询整块重建 innerHTML、
5325
+ // 输入条胶囊增删重画,都不丢监听。时间线胶囊走已落盘文件,输入条胶囊
5326
+ // (× 移除按钮除外)走待提交区
5327
+ $("rd-chat-flow").addEventListener("click", (e) => {
5328
+ const chip = e.target.closest(".att-open");
5329
+ if (chip) window.chatAttPopup(chip.dataset.att);
5330
+ });
5331
+ const attBox = $("rd-chat-att-list");
5332
+ if (attBox) attBox.addEventListener("click", (e) => {
5333
+ if (e.target.closest("b")) return; // × 自己的 onclick 负责移除
5334
+ const chip = e.target.closest("[data-att-id]");
5335
+ if (chip) window.chatPendingPopup(chip.dataset.attId, chip.dataset.attName);
5336
+ });
5131
5337
  }
5132
5338
 
5133
5339
  /* ---------------------------------------------------------- 外观:皮肤 + 明暗(换肤) */
@@ -5136,6 +5342,7 @@ function bindChat() {
5136
5342
  * 皮肤改色只需要动 style.css,预览与真实界面不会各自漂移。 */
5137
5343
  const SKINS = [
5138
5344
  { id: "ocean", name: "深海", desc: "藏青底色 + 天蓝强调,夜间长时间盯任务更沉静(默认)" },
5345
+ { id: "hermes", name: "墨金", desc: "暖墨底色 + 金色发丝线,Hermes 式古典优雅" },
5139
5346
  { id: "classic", name: "经典", desc: "黑白灰 + 蓝色强调,ChatGPT 式清爽配色" },
5140
5347
  { id: "forest", name: "森野", desc: "墨绿底色 + 青翠强调,偏自然的护眼配色" },
5141
5348
  { id: "amber", name: "暖阳", desc: "暖棕底色 + 琥珀强调,纸感暖调" },
@@ -5698,6 +5905,8 @@ function bindModelBox(c, provId) {
5698
5905
  : ""));
5699
5906
  return '<span class="ochip' + (i === 0 ? " primary" : "") + '">' +
5700
5907
  "<b>" + (i === 0 ? t("主") : t("备")) + "</b>" + esc(pname) + " · " + esc(c2.m) +
5908
+ (modelHasImage(c2.p, c2.m)
5909
+ ? ' <span class="tag ok" title="' + esc(t("支持图片输入")) + '">' + t("图") + "</span>" : "") +
5701
5910
  (dead ? ' <span class="hint warn">⚠ ' + esc(t("协议不匹配,解析时跳过")) + "</span>" : badge) +
5702
5911
  (i > 0 ? '<button class="mini" data-m="' + esc(c2.m) + '" data-p="' + esc(c2.p) +
5703
5912
  '" title="' + t("设为主模型") + '" onclick="bindPromote(\'' + esc(c.id) + '\', this)">' +
@@ -5750,6 +5959,8 @@ function bindPanel(c) {
5750
5959
  return '<label class="oitem' + (dead ? " dim" : "") + '"><input type="checkbox" value="' + esc(m) + '" data-p="' + esc(g.id) + '"' +
5751
5960
  (has ? " checked" : "") + (dead ? " disabled" : "") +
5752
5961
  " onchange=\"bindPick('" + esc(c.id) + "', this, this.checked)\">" + esc(m) +
5962
+ (modelHasImage(g.id, m)
5963
+ ? ' <span class="tag ok" title="' + esc(t("支持图片输入")) + '">' + t("图") + "</span>" : "") +
5753
5964
  (dead ? ' <span class="hint warn">' + esc(t("无可用 wire,解析时跳过")) + "</span>" : "") +
5754
5965
  "</label>";
5755
5966
  }).join("") +
@@ -6957,9 +7168,24 @@ async function loadSelfupdate(force) {
6957
7168
  SU = await api("/api/selfupdate" + (force ? "?force=1" : ""));
6958
7169
  } catch (e) { SU = null; }
6959
7170
  renderSu();
7171
+ maybeWhatsnew();
6960
7172
  return SU;
6961
7173
  }
6962
7174
 
7175
+ /* 升级重启后的一次性「本次更新内容」:localStorage 记住已展示的版本,
7176
+ * 版本变化且有本地 changelog 小节时弹窗(首次使用只记账不弹)。 */
7177
+ function maybeWhatsnew() {
7178
+ if (!SU || !SU.current) return;
7179
+ const prev = localStorage.getItem("su.myver");
7180
+ localStorage.setItem("su.myver", SU.current);
7181
+ if (prev && prev !== SU.current && SU.whatsnew) {
7182
+ openModal(t("本次更新内容"),
7183
+ "<p class=\"hint\">" + t("已更新到") + " <b>v" + esc(SU.current) + "</b></p>" +
7184
+ "<pre class=\"su-notes\">" + esc(SU.whatsnew) + "</pre>",
7185
+ "<button class=\"small\" onclick=\"closeModal()\">" + t("知道了") + "</button>");
7186
+ }
7187
+ }
7188
+
6963
7189
  function renderSu() {
6964
7190
  const info = $("su-info");
6965
7191
  if (!info) return;
@@ -6970,6 +7196,10 @@ function renderSu() {
6970
7196
  if (SU.has_update) {
6971
7197
  html += "<p class=\"hint\"><b>" + t("发现新版本") + " v" + SU.latest +
6972
7198
  t(" ") + "<a href=\"#\" onclick=\"event.preventDefault();suApply()\">" + t("立即升级") + "</a></b></p>";
7199
+ if (SU.notes) {
7200
+ html += "<div class=\"hint\"><b>" + t("新版本更新内容") + t(":") + "</b>" +
7201
+ "<pre class=\"su-notes\">" + esc(SU.notes) + "</pre></div>";
7202
+ }
6973
7203
  } else if (SU.mode === "npm" && !SU.note) {
6974
7204
  html += "<p class=\"hint\">" + t("已是最新版。") + "</p>";
6975
7205
  }