feat(web): add compact message actions
This commit is contained in:
parent
8cf08c0f23
commit
eedaf2c1fe
|
|
@ -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/);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue