diff --git a/CHANGELOG.md b/CHANGELOG.md index ddd86bd..6c277c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.58.27 — 2026-07-15 + +- 对话卡片新增「复制」按钮:鼠标悬停在任意一条消息上即出现,一键把该条正文原文(Markdown 源码,不带渲染样式)复制到剪贴板,复制成功会短暂提示「✓ 已复制」。 + ## 0.58.26 — 2026-07-15 - 点「停止」中断回复后,对话底部此前什么都不剩(一闪即被收尾刷新冲掉);现在会留下一条居中的「已停止 · 你中断了上次运行」提示,刷新页面、切换任务再回来都在,直到下次发消息才消失。 diff --git a/PROGRESS.md b/PROGRESS.md index ce77ff6..09faf40 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-07 +- **07-15 / 0.58.27**:**对话卡片「复制」按钮(仿 GPT/DeepSeek 悬停操作栏)**(用户提议:卡片上加复制等按钮)。`renderMessages` 给每条 user 卡、以及有正文的 assistant 卡末尾挂 `.msg-actions` 栏(纯 tool_calls 轮 / error 卡不挂),默认 `opacity:0` 悬停/`focus-within` 才现、触屏(`hover:none`)常显淡态。复制取的是**正文原文**:点击经 `#chat-stream` 既有 click 委托据卡片 `data-idx` 回 `state.loadedMessages` 取 `payload.content`(Markdown 源,**不从渲染后的 `.body` HTML 反推**),`navigator.clipboard.writeText` 主路径 + `execCommand("copy")` 降级,按钮短暂反馈「✓ 已复制/复制失败」后复位。直播卡不带此栏——流结束后 `loadMessages` 整屏重渲成持久卡自然带上,省一处直播态特判。**范围收敛的理由**:后端是纯追加式会话,无「重跑最后一轮 / 截断历史 / 编辑分叉」语义,故本期只做零后端耦合的复制;重新生成 / 编辑重发若无后端截断支持会变成「复读上一句 / 历史里又追加一条」的语义欺骗,留待后端补重跑/截断能力再单独立项。只改 `chat.js`(生成+委托+剪贴板)+ `dev.html`(CSS),对外无契约变化。 - **07-15 / 0.58.26**:**用户停止后「已停止」持久提示(补齐 cancel 终态,与 error 卡对称)**(用户报:点停止后对话底部什么都不剩)。根因同 `2a1bc25d` 案:cancel 时前端 live-append 的 `已停止` badge 会被紧随的收尾 `loadMessages` 整屏重建(`renderMessages` 先 `innerHTML=""`)冲掉——闪一下即消失;而 error 之所以稳,是因为它落 `run_status="error"` 持久态 + `renderPersistedRunError` 每次重渲重派生,cancel 却在收尾直接回 `idle` 不留痕。**方案 B(架构对称):把 cancel 提升为一等持久终态**——`run_status` 是 `Text` 列(非 enum,**无 migration**),`agent.run` 在任一取消路径都 `return "[cancelled]"`(`loop.py`),`_run_agent_bg` 据此收尾写 `run_status="cancelled"`(否则 `idle`)。前端 `renderPersistedRunError`→`renderPersistedRunTerminal` 扩成同时认 error/cancelled:cancelled 渲居中 muted 虚线条「已停止 · 你中断了上次运行」(良性态,不用错误卡的醒目样式),刷新/切任务仍在,下次起新 run(`post_message` 写 running)覆盖清掉。删掉会被冲掉的 live badge(及其死 CSS `.cancelled-badge`)。**未选方案 A(客户端临时 flag)**:它会把刚被 `2a1bc25d` 修掉的「终态挂 live-only DOM」反模式请回来(刷新即死、跨标签页不通、对 broker 的 DB 态机制不可见),且与 10 行外的 error 路径不对称。兼容性:`run_status` 加新值是纯增量(不改既有值语义),蓝绿旧实例读到 `cancelled` 落到 `in ("running","cancelling")` 判断的 else 当良性态、旧前端只认 `error`→优雅退化到今天行为、不崩;全部 run_status 消费点(起 run 409 门/reaper/SSE is_active/cancel 端点/clear_messages)逐一核对 cancelled 均按 idle/error 类良性态处理。filter allowlist + DESIGN §7.4 尾句 + models.py 注释同步。 - **07-15 / 0.58.25**:**skill 定向模型:已选国际旗舰模型时豁免切换**(用户反馈:ppt 等 skill 会 pin `glm.pro52`,但用户若已主动选了 Claude/GPT 就不该被切走)。`core/agent_builder.py::_skill_model_switch` 在算出目标 profile 后加一道判据:current profile 的 family==`unifyllm`(网关族,含 Claude/GPT/Gemini)→ 返回 `None` 不切。定向 pin 本意是给较弱的国产默认模型托底产物质量,已在旗舰模型上的用户尊重其选择、不降级。判据放在 DB 写入前(纯字符串检查,无副作用);其余取舍(只信内置 skill / 跳档位门控 / 不自动切回)不变。DESIGN §3.5 记该豁免。对外无契约变化,不影响国产默认档用户的既有热切行为。 - **07-15 / 0.58.24**:**畸形 tool_call arguments 就地抢救(salvage),命中即省一次非流式重试**(失败聚集面板同形态跨 4 个工具复发:`write` 21 次/`edit` 8 次/`run_python` 5 次/`shell` 5 次(24h),逐条核对形态一致:`Expecting value: line 1 column 1 (char 0)`——char-0 是 provider 在 wire 上把别处正文字节错标成该 tool_call 的 arguments delta 粘的垃圾前缀,尾部真 JSON 完好并跑到串尾;定层已证 provider-wire 脏、拼接层无辜,见 memory)。机制**工具无关**:`allowed_keys` 每工具从 `executor.schemas()` 的 properties 现取(write/edit/run_python/shell 的 key 集各异但同一套逻辑覆盖,新增工具自动生效)。此前 `_stream_llm` 对畸形轮整轮丢弃 + 降级非流式重 roll(自愈无终态失败,但 ~9% 轮次几十秒延迟 + 重复大生成烧 token)。现在 `_malformed_tool_calls` 命中后**先试就地抢救**:纯函数 `core/salvage.py::salvage_tool_arguments(raw, allowed_keys)` 左→右扫每个 `{` 试 `json.loads(raw[i:])`,第一个满足 (a) parse-to-end 成功 (b) 非空 dict (c) 顶层 key ⊆ 该工具 schema 参数名 的即采纳;双护栏:(a) 挡尾部截断的半个 JSON(拒→回落重试,不冒险写残缺),(c) 挡前缀里自洽的旁支 JSON。`AgentLoop._try_salvage_response` 全有或全无:本轮所有畸形 tool_call 都能抠出才就地改写 `.arguments` 当轮继续,任一抠不出则一个都不动、原样走既有 `_log_malformed_args`+ 非流式重试(零回退风险)。抢救成功记 `usage_events` kind=`tool_salvaged`(`record_salvaged_tool_call`,units={tool,len,salvaged_len,head},cost 0)——与 tool_malformed 成对区分「真丢弃」vs「抢救回」;toolfail 巡检 `where kind='tool_malformed'/'run_error'` 精确匹配,salvage 是恢复不算失败、自然不进面板/告警。纯函数单列 `core/salvage.py`(不 import litellm)是刻意的:loop.py 顶层 import litellm 在本机导入期卡死,把守卫逻辑拆出来 `tests/test_salvage.py` 才能无 litellm 依赖跑(含线上真样本夹具 + 截断/多键/旁支 JSON/尾残渣边界 10 用例)。盲区:provider-wire 抖动本机复现不出、/verify 端到端跑不了,靠单测 + 生产灰度观察。additive 内部机制,不碰对外契约。 diff --git a/core/__init__.py b/core/__init__.py index e44779d..d6def31 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.26" +__version__ = "0.58.27" diff --git a/web/static/dev.html b/web/static/dev.html index c52b2dd..9aff4d9 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -763,6 +763,19 @@ .msg .body a, .md-render a { color: var(--accent); } .msg .body img, .md-render img { max-width: 100%; } .msg .body hr, .md-render hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; } + /* 卡片底部操作栏(仿 GPT/DeepSeek):默认隐,悬停/聚焦现;触屏无 hover → 常显淡态 */ + .msg-actions { display: flex; gap: 6px; margin-top: 6px; opacity: 0; transition: opacity .12s; } + .msg:hover .msg-actions, .msg:focus-within .msg-actions { opacity: 1; } + .msg.user .msg-actions { justify-content: flex-end; } + .msg-actions button { + font: inherit; font-size: 11px; line-height: 1; color: var(--muted); + background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm); + padding: 3px 8px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; + transition: background .12s, color .12s, border-color .12s; + } + .msg-actions button:hover { color: var(--text); background: var(--code-bg); border-color: var(--muted); } + .msg-actions button.copied { color: #16a34a; border-color: #16a34a; background: transparent; } + @media (hover: none) { .msg-actions { opacity: .55; } } .tool-call { margin-top: 6px; font-family: var(--mono); font-size: 12px; } .tool-call summary { cursor: pointer; padding: 4px 6px; background: var(--code-bg); border-radius: var(--r-sm); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 10b72b5..1d404eb 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1105,6 +1105,47 @@ function setTaskProgress(taskId, steps) { if (state.taskId === taskId) renderTaskProgressDock(normalized); } +// 卡片底部「复制」按钮 HTML。复制的是正文原文(Markdown 源),点击时经事件委托据 +// 卡片 data-idx 回 state.loadedMessages 取 payload.content —— 不从渲染后的 .body HTML 反推。 +// 直播卡不带此栏:流结束后 loadMessages 整屏重渲成持久卡,自然带上。 +function msgActionsHtml() { + return `
`; +} + +// 复制文本到剪贴板:优先 async Clipboard API(需 https/localhost),失败降级 execCommand。 +// 按钮态短暂反馈「✓ 已复制 / 复制失败」后复位。 +async function copyTextToClipboard(text, btn) { + if (!text) return; + let ok = false; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + ok = true; + } + } catch (e) { /* 落降级路径 */ } + if (!ok) { + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.cssText = "position:fixed;top:0;left:0;opacity:0;"; + document.body.appendChild(ta); + ta.select(); + ok = document.execCommand("copy"); + ta.remove(); + } catch (e) { ok = false; } + } + if (btn) { + if (!btn.dataset.label) btn.dataset.label = btn.textContent; + btn.textContent = ok ? "✓ 已复制" : "复制失败"; + btn.classList.toggle("copied", ok); + clearTimeout(btn._copyTimer); + btn._copyTimer = setTimeout(() => { + btn.textContent = btn.dataset.label; + btn.classList.remove("copied"); + }, 1400); + } +} + function renderMessages(msgs) { const wrap = $("chat-stream"); _msgScrollObserver.disconnect(); // 旧 sentinel 随 innerHTML 清掉,先断开避免悬挂 observe @@ -1242,6 +1283,10 @@ function renderMessages(msgs) { } // 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 dock(见 setTaskProgress) } + // 复制栏:user 恒有正文;assistant 仅在有正文文本时挂(纯 tool_calls 轮无正文可复制) + const hasCopy = role === "user" + || (role === "assistant" && typeof p.content === "string" && p.content.trim()); + if (hasCopy) html += msgActionsHtml(); card.innerHTML = html; highlightIn(card); wrap.appendChild(card); @@ -1856,6 +1901,15 @@ document.addEventListener("keydown", (e) => { // 视频走原生