feat(web): add compact message actions

This commit is contained in:
caoqianming 2026-08-04 11:33:27 +08:00
parent 8cf08c0f23
commit eedaf2c1fe
3 changed files with 88 additions and 35 deletions

View File

@ -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-icon/);
assert.match(pageHtml, /\.art-chip:focus-visible/); 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/);
});

View File

@ -886,18 +886,26 @@
.msg .body a, .md-render a { color: var(--accent); } .msg .body a, .md-render a { color: var(--accent); }
.msg .body img, .md-render img { max-width: 100%; } .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; } .msg .body hr, .md-render hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
/* 卡片底部操作栏(仿 GPT/DeepSeek):默认隐,悬停/聚焦现;触屏无 hover → 常显淡态 */ /* 消息快捷操作:桌面悬停/键盘聚焦时显示;触屏无 hover 时保留淡态可发现性。 */
.msg-actions { display: flex; gap: 6px; margin-top: 6px; opacity: 0; transition: opacity .12s; } .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:hover .msg-actions, .msg:focus-within .msg-actions { opacity: 1; }
.msg.user .msg-actions { justify-content: flex-end; } .msg.user .msg-actions { justify-content: flex-end; }
.msg-actions button { .msg-action {
font: inherit; font-size: 11px; line-height: 1; color: var(--muted); display: inline-flex; align-items: center; justify-content: center;
background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm); width: 26px; height: 26px; padding: 4px; border: 0; border-radius: var(--r-sm);
padding: 3px 8px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; color: var(--muted); background: transparent; cursor: pointer;
transition: background .12s, color .12s, border-color .12s; 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; } } @media (hover: none) { .msg-actions { opacity: .55; } }
.tool-call { margin-top: 6px; font-family: var(--mono); font-size: 12px; } .tool-call { margin-top: 6px; font-family: var(--mono); font-size: 12px; }
.tool-call summary { .tool-call summary {

View File

@ -1411,15 +1411,21 @@ function setTaskProgress(taskId, steps) {
if (state.taskId === taskId) renderTaskProgressDock(normalized); if (state.taskId === taskId) renderTaskProgressDock(normalized);
} }
// 卡片底部「复制」按钮 HTML。复制的是正文原文(Markdown 源),点击时经事件委托据 const COPY_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"></rect><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"></path></svg>`;
// 卡片 data-idx 回 state.loadedMessages 取 payload.content —— 不从渲染后的 .body HTML 反推。 const REVISE_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"></path></svg>`;
// 直播卡不带此栏:流结束后 loadMessages 整屏重渲成持久卡,自然带上。 const CHECK_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12 4 4L19 6"></path></svg>`;
function msgActionsHtml() {
return `<div class="msg-actions"><button type="button" class="msg-copy" title="复制正文">复制</button></div>`; // 消息操作保持轻量:复制取 DB payload 中的 Markdown 原文;用户的“修改”只把旧消息
// 载入 composer,后续发送仍是 append-only 新消息,不改写历史、不重跑旧工具链。
function msgActionsHtml(role) {
const copy = `<button type="button" class="msg-action msg-copy" aria-label="复制消息" title="复制">${COPY_ICON}</button>`;
const canRevise = role === "user" && !channelCfg(state.taskMeta && state.taskMeta.channel);
const revise = canRevise
? `<button type="button" class="msg-action msg-revise" aria-label="引用并修改消息" title="修改后作为新消息发送">${REVISE_ICON}</button>`
: "";
return `<div class="msg-actions">${copy}${revise}</div>`;
} }
// 复制文本到剪贴板:优先 async Clipboard API(需 https/localhost),失败降级 execCommand。
// 按钮态短暂反馈「✓ 已复制 / 复制失败」后复位。
async function copyTextToClipboard(text, btn) { async function copyTextToClipboard(text, btn) {
if (!text) return; if (!text) return;
let ok = false; let ok = false;
@ -1440,16 +1446,37 @@ async function copyTextToClipboard(text, btn) {
ta.remove(); ta.remove();
} catch (e) { ok = false; } } catch (e) { ok = false; }
} }
if (btn) { if (!ok) {
if (!btn.dataset.label) btn.dataset.label = btn.textContent; message("复制失败,请手动选择正文复制", "error");
btn.textContent = ok ? "✓ 已复制" : "复制失败"; return;
btn.classList.toggle("copied", ok); }
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); clearTimeout(btn._copyTimer);
btn._copyTimer = setTimeout(() => { btn._copyTimer = setTimeout(() => {
btn.textContent = btn.dataset.label; btn.innerHTML = btn.dataset.originalHtml;
btn.classList.remove("copied"); btn.classList.remove("copied");
btn.setAttribute("aria-label", "复制消息");
}, 1400); }, 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; // stickBottom:渲染完是否滚到底。首屏/发送等"看最新"路径保持默认 true;
@ -1646,10 +1673,10 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
} }
// 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 dock(见 setTaskProgress) // 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 dock(见 setTaskProgress)
} }
// 复制栏:user 恒有正文;assistant 仅在有正文文本时挂(纯 tool_calls 轮无正文可复制) if ((role === "user" || role === "assistant")
const hasCopy = role === "user" && typeof p.content === "string" && p.content.trim()) {
|| (role === "assistant" && typeof p.content === "string" && p.content.trim()); html += msgActionsHtml(role);
if (hasCopy) html += msgActionsHtml(); }
// task_progress-only assistant rows feed the persistent progress dock. // task_progress-only assistant rows feed the persistent progress dock.
// Once that tool call is filtered, do not leave an empty assistant shell. // Once that tool call is filtered, do not leave an empty assistant shell.
if (role === "assistant" && !hasVisibleAssistantContent) continue; if (role === "assistant" && !hasVisibleAssistantContent) continue;
@ -2382,13 +2409,20 @@ document.addEventListener("keydown", (e) => {
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal — // 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
// 弹个 modal 反而打断播放,不如交给浏览器。 // 弹个 modal 反而打断播放,不如交给浏览器。
$("chat-stream").addEventListener("click", (e) => { $("chat-stream").addEventListener("click", (e) => {
const copyBtn = e.target.closest && e.target.closest(".msg-copy"); const messageAction = e.target.closest && e.target.closest(".msg-action");
if (copyBtn) { if (messageAction) {
const card = copyBtn.closest(".msg[data-idx]"); const card = messageAction.closest(".msg[data-idx]");
const idx = card && card.dataset.idx; const idx = card && card.dataset.idx;
const m = idx != null && state.loadedMessages.find((x) => String(x.idx) === String(idx)); const stored = idx != null
const text = m && m.payload && typeof m.payload.content === "string" ? m.payload.content : ""; && state.loadedMessages.find((x) => String(x.idx) === String(idx));
copyTextToClipboard(text, copyBtn); const text = stored && stored.payload && typeof stored.payload.content === "string"
? stored.payload.content
: "";
if (messageAction.classList.contains("msg-copy")) {
void copyTextToClipboard(text, messageAction);
} else if (messageAction.classList.contains("msg-revise")) {
loadMessageIntoComposer(text);
}
return; return;
} }
const askBtn = e.target.closest && e.target.closest(".ask-option"); const askBtn = e.target.closest && e.target.closest(".ask-option");