ui(dev SPA): tool_call/result 卡片下加 artifact chip — 点击复用文件预览 modal,免再去右栏找

PROGRESS.md 同步。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-20 14:08:15 +08:00
parent c4fac2428b
commit ecff1d7858
2 changed files with 80 additions and 2 deletions

View File

@ -23,6 +23,7 @@
### 2026-05-20
- **dev SPA 对话内 tool_call/result 加 artifact chip(复用文件预览 modal)**:用户反馈"中间产物只能在右栏点,对话里不能直接预览/下载"。`web/static/dev.html` 新加两个 helper:`extractArtifactRels(text, workingDir)` 把文本里 `\` 一律归 `/`,正则锚定 `<working_dir>/...`(lead 边界字符类 `[\s"'\`/=:,()<>\[\]{}|]` 避免 `multi_proj_x` 误匹配,末段必须含 `.` 把目录滤掉),Set 去重;`renderArtifactBarHtml(rels)` 渲一行 `.art-chip` 小药丸(`📄 文件名`,前缀 emoji + hover 翻品牌红)。四个渲染点都插入 chip 条:① `renderMessages``role==="tool"` 历史卡;② `renderMessages` 的 assistant `tool_calls` 历史;③ `handleSseEvent``tool_call` 流式;④ `handleSseEvent``tool_result` 流式。`chat-stream` 上加点击委托 → `openFilePreview(rel)`,modal 内已带"下载"按钮所以 chip 不另开二级图标。**取舍**:路径识别限定 `working_dir/` 前缀(skill 脚本 `cd` 后只 print 纯相对路径的情况会漏抓,v1 误判控制代价);纯目录(末段无 `.`)直接跳过。**没动**:右栏文件面板、`openFilePreview` / `downloadFile` 接口(纯复用)、后端、DESIGN、RUN(对外行为零变化,纯 UI 增量)。
- **task 级「宪法」文件 (spec) 命名约定 + `spec_lock` → `spec` 简化**:同 working_dir 多 task 共享中间产物(`source/` / `sections/` / `figures/` 跨本子复用)是设计意图,但 spec 这种 task 1:1 宪法文件必须隔离 — 两本子 spec 直接撞。文件名约定 `<YYYY-MM-DD>-<task_short_id>-<task_name>.spec.md`:`task_short_id`(`task_id.hex[:8]`,永不变)作主锚,glob `*-<short_id>-*.spec.md` 字典序最大 = current;`<YYYY-MM-DD>` 让"重定调"写新文件而非 edit 覆盖,旧版自然成历史快照;`<task_name>` 写入作建时元数据,改 task.name 不 cascade(由 short_id 兜底定位)。`core/agent_builder.py::_build_system_prompt` 加 `task_id` / `today` 注入 + 命名约定段 — 所有 skill 共享一份约定文本,SKILL.md 不再重复;proposal / ppt SKILL.md 阶段一加"先 glob 检测已有 spec → 询问沿用/重定调"分支。`_lock` 后缀无信息量去掉(`templates/spec_lock.md` → `templates/spec.md` git mv 保历史)。**没动**:DB schema(无新字段)、`PATCH /v1/tasks/{id}` 改 name 入口(免 cascade)、其他中间产物扁平共享、quality_check.py(`--spec` 接路径,SKILL.md 拼对参数即可)。**反方案**(cascade rename / spec 入 PG / 物理 task 子目录)及"何时升级到 DB 化"信号见 DESIGN §7.9 取舍说明。
- **dev SPA 左 pane 折叠改 rail 模式 + 删 header 冗余按钮 + time-ago 锁宽完成跨行对齐**:用户反馈 ① "原来 zcbot 旁的折叠按钮不要了,没用处" + ② "数字对齐那块现在是不是每块内容左侧对齐?"(实际是右对齐但因 time-ago 宽度变化导致 N 条/N tok 右边界也跟着抖,跨行没真对齐)。两件套:① 折叠模式从「pane display:none」改 VS Code 范式 rail —— `body.left-collapsed #app.ready { grid-template-columns: 40px 1fr 320px }` + `#pane-left > * { display: none }`(藏全部直接子) + override 第一行 pane-head 重显且只留 `#pane-toggle-left`(`> *:not(#pane-toggle-left) { display: none }`,选择器特异性 2 ids 压 1 id);pane-head 第一行用 `position: static` 取消 sticky / `border-bottom: none` / `background: transparent` 看起来更像 rail 非"卡片"。按钮符号根据 `body.left-collapsed` 在 `applyLeftCollapsed` 里翻向(展开态 `` 折叠态 ``)。彻底删 `#hd-toggle-left` + `header .icon-btn` CSS 块,header 不再背 expand 入口的债。② time-ago 加 `flex-shrink: 0; text-align: right; min-width: 64px` 锁宽,**这才是真正解决跨行对齐的关键**:此前 `.num.right-group``margin-left: auto` 把 [N 条][N tok][time] 整组推右,但 time 自身宽度浮动 30~70px(刚刚 / 10 小时前 / 2025-12-05)→ time 左边界抖 → N tok 右边界抖 → N 条 右边界抖,逐级传染。锁 time 宽后整组位置稳定,槽内 `text-align: right` 才能让"条/tok"后缀跨行真正垂直对齐。删 `.badge .time-ago { flex-shrink: 0 }` 合并里的 time-ago(已独立给规则)。**没动**:fmtTokens / 桶分级 / tabular-nums / `.num min-width: 44px`(上一轮已正确)、右 pane / chat 中列。
- **dev SPA 任务行 meta 数字槽位跨行对齐 + 折叠按钮位置调整**:用户报"N 条 / N tok 数字宽窄不一,看着不齐";又说"折叠按钮应该贴刷新按钮"。两件套:① meta CSS 加 `font-variant-numeric: tabular-nums` + `align-items: baseline`,新 `.num` 子选择器 `flex-shrink: 0; text-align: right; min-width: 44px`(右对齐让 `条` / `tok` 后缀跨行垂直对齐);N 条 span 戴 `right-group` 类拿 `margin-left: auto`,把 [N 条][N tok][time-ago] 整组挤右侧,左侧只剩 badge + skill;原 time-ago 上的 inline `margin-left:auto` 移除避免双 push 失效。新 `fmtTokens(n)` helper:<1k 原数 / <10k `1.2k` / <1M `123k` / >=1M `1.2M`,bound 槽位宽度;`title=` hover 出 `123,456 tokens` 完整值(`Number.toLocaleString()`)。② 折叠按钮拆双入口 — `#pane-toggle-left` 放第一行 pane-head 紧贴刷新按钮(展开态用,点击折叠);`#hd-toggle-left` 留 header 但 `style="display:none"` 默认隐藏,仅折叠态显示(用户路径:折叠后 pane display:none → 无法在 pane 内点展开 → 必须 header 保留 expand 入口)。`applyLeftCollapsed(collapsed)` 控制 hd 按钮 display,两按钮共享 `toggleLeftCollapsed()` 实现;每按钮符号固定(pane 内 `` 一直是折叠方向,header 内 `` 一直是展开方向),不再翻向(语义更清)。**没动**:右 pane / chat 列宽、`/v1/tasks` 后端、id8 仍在 row title hover(上次改的不动)、CSS `.small` 等。

View File

@ -328,6 +328,23 @@
margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: 3px;
overflow-x: auto; max-height: 300px; white-space: pre-wrap;
}
/* ───── artifact chips(对话内点产物预览/下载) ───── */
.artifact-bar {
margin-top: 4px; display: flex; flex-wrap: wrap; gap: 4px;
font-family: ui-monospace, Consolas, monospace;
}
.art-chip {
font: inherit; font-size: 11px; line-height: 1.4;
padding: 2px 8px 2px 6px; border: 1px solid var(--border);
background: #fff; color: #555; border-radius: 999px; cursor: pointer;
max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
display: inline-flex; align-items: center; gap: 4px;
transition: color .12s, border-color .12s, background .12s;
}
.art-chip::before { content: "📄"; font-size: 11px; }
.art-chip:hover {
background: var(--accent-soft); border-color: var(--accent); color: var(--accent);
}
#chat-form {
border-top: 1px solid var(--border); padding: 10px; background: #fafafa;
@ -1282,9 +1299,11 @@ function renderMessages(msgs) {
const card = document.createElement("div");
card.className = "msg tool";
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
const wd = (state.taskMeta && state.taskMeta.working_dir) || "";
card.innerHTML = `
<div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)</summary><pre>${escapeHtml(txt || "")}</pre></details>
${renderArtifactBarHtml(extractArtifactRels(txt || "", wd))}
`;
wrap.appendChild(card);
continue;
@ -1297,6 +1316,7 @@ function renderMessages(msgs) {
html += `<div class="body">${renderMd(p.content)}</div>`;
}
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
const wd = (state.taskMeta && state.taskMeta.working_dir) || "";
for (const tc of p.tool_calls) {
const fn = (tc.function && tc.function.name) || "?";
let args = "";
@ -1305,6 +1325,7 @@ function renderMessages(msgs) {
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
html += `
<details class="tool-call"><summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
${renderArtifactBarHtml(extractArtifactRels(args, wd))}
`;
}
}
@ -1321,6 +1342,14 @@ $("chat-input").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
// 对话流里 artifact chip 的点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")
$("chat-stream").addEventListener("click", (e) => {
const chip = e.target.closest && e.target.closest(".art-chip");
if (!chip) return;
const rel = chip.dataset.rel;
if (rel) openFilePreview(rel);
});
async function sendMessage() {
if (!state.taskId) return;
const content = $("chat-input").value.trim();
@ -1460,16 +1489,24 @@ function handleSseEvent(ev, asstCard, ctx) {
} else if (t === "tool_call") {
const fn = (ev.data && ev.data.name) || "?";
const args = (ev.data && ev.data.arguments) || "";
const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2);
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}</pre>`;
det.innerHTML = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
asstCard.appendChild(det);
const wd = (state.taskMeta && state.taskMeta.working_dir) || "";
const barHtml = renderArtifactBarHtml(extractArtifactRels(argsStr, wd));
if (barHtml) asstCard.insertAdjacentHTML("beforeend", barHtml);
} else if (t === "tool_result") {
const txt = (ev.data && ev.data.result) || "";
const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2);
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>工具结果</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`;
det.innerHTML = `<summary>工具结果</summary><pre>${escapeHtml(txtStr)}</pre>`;
asstCard.appendChild(det);
const wd = (state.taskMeta && state.taskMeta.working_dir) || "";
const barHtml = renderArtifactBarHtml(extractArtifactRels(txtStr, wd));
if (barHtml) asstCard.insertAdjacentHTML("beforeend", barHtml);
scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
} else if (t === "cancelled") {
const badge = document.createElement("div");
@ -1857,6 +1894,46 @@ async function renameFile(rel, name, isDir) {
}
}
// ───── artifact 抽取(对话内 chip → 复用文件预览 modal) ─────
// 从 tool args / result 文本里抓 working_dir 下的文件路径,归一为 user_root 相对路径。
// 启发式:把 \ 一律归 /,然后找以 `<working_dir>/` 打头的串,要求最后一段含 . (像文件)。
function extractArtifactRels(text, workingDir) {
if (!text || !workingDir) return [];
const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, "");
if (!wd) return [];
const norm = String(text).replace(/\\+/g, "/");
const wdEsc = wd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// lead 边界:行首或非 path-字符;tail 截到空白/引号/括号等
const re = new RegExp(
"(?:^|[\\s\"'`/=:,()<>\\[\\]{}|])(" + wdEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)",
"g"
);
const seen = new Set();
const out = [];
let m;
while ((m = re.exec(norm)) !== null) {
let rel = m[1];
rel = rel.replace(/[.,;:!?)\]}>。,;:!?)]+$/, ""); // 剥尾标点(中英)
const tail = rel.slice(wd.length + 1);
if (!tail) continue;
const last = tail.split("/").pop() || "";
if (!last.includes(".")) continue; // 看着像目录的不挂 chip
if (seen.has(rel)) continue;
seen.add(rel);
out.push(rel);
}
return out;
}
function renderArtifactBarHtml(rels) {
if (!rels || !rels.length) return "";
const chips = rels.map((rel) => {
const name = rel.split("/").pop() || rel;
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览(可下载)">${escapeHtml(name)}</button>`;
}).join("");
return `<div class="artifact-bar">${chips}</div>`;
}
function downloadFile(rel) {
fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },