diff --git a/PROGRESS.md b/PROGRESS.md index 1895852..ec3f1e0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-05-20 +- **dev SPA 对话内 tool_call/result 加 artifact chip(复用文件预览 modal)**:用户反馈"中间产物只能在右栏点,对话里不能直接预览/下载"。`web/static/dev.html` 新加两个 helper:`extractArtifactRels(text, workingDir)` 把文本里 `\` 一律归 `/`,正则锚定 `/...`(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 直接撞。文件名约定 `--.spec.md`:`task_short_id`(`task_id.hex[:8]`,永不变)作主锚,glob `*--*.spec.md` 字典序最大 = current;`` 让"重定调"写新文件而非 edit 覆盖,旧版自然成历史快照;`` 写入作建时元数据,改 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` 等。 diff --git a/web/static/dev.html b/web/static/dev.html index 69266bb..59d2c20 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -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 = `
工具调用 · ${escapeHtml(p.name || "")}
结果(${(txt || "").length} 字符)
${escapeHtml(txt || "")}
+ ${renderArtifactBarHtml(extractArtifactRels(txt || "", wd))} `; wrap.appendChild(card); continue; @@ -1297,6 +1316,7 @@ function renderMessages(msgs) { html += `
${renderMd(p.content)}
`; } 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 += `
工具调用:${escapeHtml(fn)}
${escapeHtml(args)}
+ ${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 = `工具调用:${escapeHtml(fn)}
${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}
`; + det.innerHTML = `工具调用:${escapeHtml(fn)}
${escapeHtml(argsStr)}
`; 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 = `工具结果
${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}
`; + det.innerHTML = `工具结果
${escapeHtml(txtStr)}
`; 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 相对路径。 +// 启发式:把 \ 一律归 /,然后找以 `/` 打头的串,要求最后一段含 . (像文件)。 +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 ``; + }).join(""); + return `
${chips}
`; +} + function downloadFile(rel) { fetch("/v1/files/download?path=" + encodeURIComponent(rel), { headers: { "Authorization": "Bearer " + state.token },