diff --git a/tests/frontend_preview.test.mjs b/tests/frontend_preview.test.mjs index 4054833..152ef38 100644 --- a/tests/frontend_preview.test.mjs +++ b/tests/frontend_preview.test.mjs @@ -78,3 +78,14 @@ test("artifact chips expose a compact file type and preview affordance", () => { assert.match(pageHtml, /\.art-chip-icon/); assert.match(pageHtml, /\.art-chip:focus-visible/); }); + +test("message actions copy raw content and revise by appending a new message", () => { + assert.match(chatJs, /class="msg-action msg-copy"[^>]+aria-label="复制消息"/); + assert.match(chatJs, /class="msg-action msg-revise"[^>]+aria-label="引用并修改消息"/); + assert.match(chatJs, /state\.loadedMessages\.find/); + assert.match(chatJs, /navigator\.clipboard\.writeText\(text\)/); + assert.match(chatJs, /function loadMessageIntoComposer\(text\)/); + assert.match(chatJs, /修改后发送将作为一条新消息追加/); + assert.match(pageHtml, /\.msg:hover \.msg-actions, \.msg:focus-within \.msg-actions/); + assert.match(pageHtml, /\.msg-action:focus-visible/); +}); diff --git a/web/static/dev.html b/web/static/dev.html index 680ed6e..3b6eb90 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -886,18 +886,26 @@ .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; } + /* 消息快捷操作:桌面悬停/键盘聚焦时显示;触屏无 hover 时保留淡态可发现性。 */ + .msg-actions { + display: flex; align-items: center; gap: 2px; min-height: 26px; margin-top: 4px; + 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-action { + display: inline-flex; align-items: center; justify-content: center; + width: 26px; height: 26px; padding: 4px; border: 0; border-radius: var(--r-sm); + color: var(--muted); background: transparent; cursor: pointer; + transition: color .12s, background .12s; + } + .msg-action:hover { color: var(--text); background: var(--code-bg); } + .msg-action:focus-visible { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; } + .msg-action.copied { color: #16a34a; } + .msg-action svg { + width: 16px; height: 16px; fill: none; stroke: currentColor; + stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } - .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 { diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 77d038b..18d4259 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1411,15 +1411,21 @@ function setTaskProgress(taskId, steps) { if (state.taskId === taskId) renderTaskProgressDock(normalized); } -// 卡片底部「复制」按钮 HTML。复制的是正文原文(Markdown 源),点击时经事件委托据 -// 卡片 data-idx 回 state.loadedMessages 取 payload.content —— 不从渲染后的 .body HTML 反推。 -// 直播卡不带此栏:流结束后 loadMessages 整屏重渲成持久卡,自然带上。 -function msgActionsHtml() { - return `
`; +const COPY_ICON = ``; +const REVISE_ICON = ``; +const CHECK_ICON = ``; + +// 消息操作保持轻量:复制取 DB payload 中的 Markdown 原文;用户的“修改”只把旧消息 +// 载入 composer,后续发送仍是 append-only 新消息,不改写历史、不重跑旧工具链。 +function msgActionsHtml(role) { + const copy = ``; + const canRevise = role === "user" && !channelCfg(state.taskMeta && state.taskMeta.channel); + const revise = canRevise + ? `` + : ""; + return `
${copy}${revise}
`; } -// 复制文本到剪贴板:优先 async Clipboard API(需 https/localhost),失败降级 execCommand。 -// 按钮态短暂反馈「✓ 已复制 / 复制失败」后复位。 async function copyTextToClipboard(text, btn) { if (!text) return; let ok = false; @@ -1440,16 +1446,37 @@ async function copyTextToClipboard(text, btn) { 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); + if (!ok) { + message("复制失败,请手动选择正文复制", "error"); + return; } + if (!btn) return; + if (!btn.dataset.originalHtml) btn.dataset.originalHtml = btn.innerHTML; + btn.innerHTML = CHECK_ICON; + btn.classList.add("copied"); + btn.setAttribute("aria-label", "已复制"); + clearTimeout(btn._copyTimer); + btn._copyTimer = setTimeout(() => { + btn.innerHTML = btn.dataset.originalHtml; + btn.classList.remove("copied"); + btn.setAttribute("aria-label", "复制消息"); + }, 1400); +} + +function loadMessageIntoComposer(text) { + const input = $("chat-input"); + if (!text || !input || input.readOnly) return; + input.focus(); + input.select(); + // 与提示词润色/语音转写共用浏览器原生 undo 栈,覆盖已有草稿后仍可 Ctrl+Z 撤销。 + let inserted = false; + try { inserted = document.execCommand("insertText", false, text); } catch (e) { /* fallback */ } + if (!inserted) { + input.value = text; + input.setSelectionRange(text.length, text.length); + } + syncOptimizeBtn(); + $("chat-hint").textContent = "已载入旧消息;修改后发送将作为一条新消息追加"; } // stickBottom:渲染完是否滚到底。首屏/发送等"看最新"路径保持默认 true; @@ -1646,10 +1673,10 @@ function renderMessages(msgs, { stickBottom = true } = {}) { } // 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 dock(见 setTaskProgress) } - // 复制栏:user 恒有正文;assistant 仅在有正文文本时挂(纯 tool_calls 轮无正文可复制) - const hasCopy = role === "user" - || (role === "assistant" && typeof p.content === "string" && p.content.trim()); - if (hasCopy) html += msgActionsHtml(); + if ((role === "user" || role === "assistant") + && typeof p.content === "string" && p.content.trim()) { + html += msgActionsHtml(role); + } // task_progress-only assistant rows feed the persistent progress dock. // Once that tool call is filtered, do not leave an empty assistant shell. if (role === "assistant" && !hasVisibleAssistantContent) continue; @@ -2382,13 +2409,20 @@ document.addEventListener("keydown", (e) => { // 视频走原生