优化: 对话交互修复与界面完善
- 修输入法组词回车直发/分离窗误关/出错后按钮死锁 - 新建任务失败弹窗内联提示,会话切换乐观高亮 - 状态多选联动刷新不再冲空可见行 - 测试连接保留已配开关与权重,防抖按提供商分键 - 代码块复制/长回复折叠带量/队列回填/排队时钟 - 子代理完成卡渲染,死链卡片与引用清理 - 截图渲染换 pro 版依赖,样式 token 化收尾
This commit is contained in:
@@ -22,6 +22,7 @@ import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
||||
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
||||
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
|
||||
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
|
||||
import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
|
||||
import ToolCardList from '../ToolCardList.vue'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
import EmptyState from './EmptyState.vue'
|
||||
@@ -540,16 +541,18 @@ async function copyMsgContent(msg: AiMessage): Promise<void> {
|
||||
|
||||
// ── 消息气泡复制为图片(html2canvas 渲染 PNG 写入剪贴板) ──
|
||||
// 原生 ClipboardItem 即可写图片到剪贴板,故不加 Tauri 剪贴板插件(少装依赖)
|
||||
// 用 html2canvas-pro 替代 html2canvas:后者不支持 color-mix/oklch 现代色彩函数,渲染气泡样式会抛错
|
||||
async function copyMsgAsImage(msg: AiMessage): Promise<void> {
|
||||
// msg.id 经 CSS.escape 转义:含特殊字符的 id 在属性选择器里会匹配失败(引号内转义合法)
|
||||
const bubble = messagesContainer.value?.querySelector<HTMLElement>(
|
||||
`.ai-msg[data-msg-id="${msg.id}"] .ai-msg-bubble`,
|
||||
`.ai-msg[data-msg-id="${CSS.escape(msg.id)}"] .ai-msg-bubble`,
|
||||
)
|
||||
if (!bubble) {
|
||||
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { default: html2canvas } = await import('html2canvas')
|
||||
const { default: html2canvas } = await import('html2canvas-pro')
|
||||
const canvas = await html2canvas(bubble, { backgroundColor: null, useCORS: true, scale: 2 })
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||||
if (!blob) throw new Error('toBlob failed')
|
||||
@@ -574,6 +577,72 @@ async function copyMsgAsImage(msg: AiMessage): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── R4 代码块复制:渲染层 html 装饰 + 容器级事件委托 ──
|
||||
// 代码块经 marked 渲染后以 v-html 注入,模板层无法逐块插按钮;改为在 html 字符串层把
|
||||
// <pre><code> 包进 .md-code-block 并注入复制按钮(发生在 renderMd 的 DOMPurify 消毒之后,
|
||||
// 注入节点不经消毒通道,仅静态结构无用户内容,安全)。点击走容器级事件委托(单监听器,
|
||||
// 不逐块绑定)。结果 memo 缓存:v-html 按字符串相等决定是否重写 DOM,不缓存会导致组件每次
|
||||
// 重渲染都重设 innerHTML,打破 AC-EFF-S2-1「已完成块 DOM 稳定」优化。
|
||||
const _decoCache = new Map<string, string>()
|
||||
const DECO_CACHE_LIMIT = 800
|
||||
function decorateCodeHtml(raw: string): string {
|
||||
if (!raw || !raw.includes('<pre><code')) return raw
|
||||
const hit = _decoCache.get(raw)
|
||||
if (hit !== undefined) return hit
|
||||
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
|
||||
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界
|
||||
const out = raw.replace(
|
||||
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
||||
'<div class="md-code-block"><button type="button" class="md-code-copy">复制</button><pre><code$1>$2</code></pre></div>',
|
||||
)
|
||||
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
|
||||
_decoCache.set(raw, out)
|
||||
return out
|
||||
}
|
||||
/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
|
||||
function renderMdDecorated(raw: string): string {
|
||||
return decorateCodeHtml(renderMd(raw))
|
||||
}
|
||||
|
||||
// 流式分块同走装饰(computed 缓存:块 html 未变时返回同串,v-html 不触发 DOM 重写)
|
||||
const decoratedStreamingBlocks = computed(() =>
|
||||
streamingBlocks.value.map(b => ({ key: b.key, html: decorateCodeHtml(b.html) })),
|
||||
)
|
||||
|
||||
/** 容器级事件委托:点击 .md-code-copy 复制所属代码块原文(pre.textContent 自动还原 HTML 转义) */
|
||||
async function onMdCodeCopyClick(e: MouseEvent): Promise<void> {
|
||||
const btn = (e.target as HTMLElement | null)?.closest('button.md-code-copy') as HTMLButtonElement | null
|
||||
if (!btn) return
|
||||
const code = btn.parentElement?.querySelector('pre')?.textContent ?? ''
|
||||
// 必须在 click 同步链内发起 clipboard 调用(浏览器安全限制);非 secure context 走
|
||||
// execCommand 兜底(对齐 copyMsgContent 降级链),两者均失败如实反馈
|
||||
let ok = false
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(code)
|
||||
ok = true
|
||||
}
|
||||
} catch { /* 权限拒绝/中断,落入 execCommand 兜底 */ }
|
||||
if (!ok) {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = code
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try { ok = document.execCommand('copy') } catch { ok = false }
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
// 成功态短暂反馈(直接改按钮 DOM;下次 v-html 重渲染自然还原,无需状态机)
|
||||
const prev = btn.textContent
|
||||
btn.textContent = ok ? '已复制' : '复制失败'
|
||||
btn.classList.add(ok ? 'is-ok' : 'is-err')
|
||||
setTimeout(() => {
|
||||
btn.textContent = prev
|
||||
btn.classList.remove('is-ok', 'is-err')
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
// ── 错误气泡操作入口 ──
|
||||
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
|
||||
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
|
||||
@@ -710,6 +779,18 @@ function shouldCollapseLong(msg: AiMessage): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
|
||||
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
|
||||
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
|
||||
// 文案硬编码中文:i18n locale 文件本批锁定(他代理并行追加,不新增键),
|
||||
// 展开态仍复用既有 aiChat.collapse 键。
|
||||
const CHAR_PER_TOKEN_EST = 2.5
|
||||
function collapsedLongLabel(msg: AiMessage): string {
|
||||
const folded = Math.max(0, (msg.content?.length ?? 0) - LONG_MSG_THRESHOLD)
|
||||
const tokens = Math.max(1, Math.round(folded / CHAR_PER_TOKEN_EST))
|
||||
return `已折叠 ${folded} 字符 / 约 ${tokens} tokens,点击展开`
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354
|
||||
* isToolResultJson):assistant content 为纯 JSON 工具结果(trim 后以 {/[ 开头且 JSON.parse
|
||||
@@ -828,6 +909,8 @@ type RenderItem =
|
||||
| { kind: 'step'; key: string; round: number; stats: TurnCallStats }
|
||||
// UX 密度改造:相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线(替代 per-block 相对时间)
|
||||
| { kind: 'timesep'; key: string; ts: number }
|
||||
// S1 子代理完成卡(AiAgentFinished 事件):非消息数据(per-conv 存 aiShared),逐卡一项
|
||||
| { kind: 'agentFin'; key: string; card: AgentFinishedCard }
|
||||
|
||||
/**
|
||||
* 渲染项(A 过程压缩):在归档分段之上叠加轮次折叠。
|
||||
@@ -964,9 +1047,36 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
}
|
||||
}
|
||||
flushBucket()
|
||||
// S1 子代理完成卡追加在消息流末尾:子代理在父轮内运行,完成语义上晚于当轮内容;
|
||||
// 逐卡一项(key 按 agentId 稳定),同会话多子代理多卡共存,Vue diff 复用不闪。
|
||||
// 真相源是 aiShared.convAgentFinished(per-conv),切会话重进仍可见,非组件局部状态。
|
||||
for (const card of getConvAgentFinished(store.state.activeConversationId)) {
|
||||
items.push({ kind: 'agentFin', key: 'agentfin-' + card.agentId, card })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
// ── S1 子代理完成卡(AiAgentFinished) ──
|
||||
// 状态→色调三态:completed 中性(常态结果不抢视线)/ truncated 警示(结果不完整)/
|
||||
// failed 错误(需关注);未知的 status 字面量兜底中性,不误报成功也不误报失败。
|
||||
function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err' {
|
||||
if (status === 'truncated') return 'warn'
|
||||
if (status === 'failed') return 'err'
|
||||
return 'ok'
|
||||
}
|
||||
// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应
|
||||
function agentFinStatusText(status: AgentFinishedCard['status']): string {
|
||||
if (status === 'truncated') return t('ai.agentFinished.truncated')
|
||||
if (status === 'failed') return t('ai.agentFinished.failed')
|
||||
return t('ai.agentFinished.completed')
|
||||
}
|
||||
// 摘要超长截断:单行细条承载,压平空白后按 160 字截断(对齐轨迹卡 500 字口径的轻量版),
|
||||
// 全文经 title hover 兜底不丢信息。
|
||||
function agentFinSummary(summary: string): string {
|
||||
const s = (summary || '').replace(/\s+/g, ' ').trim()
|
||||
return s.length > 160 ? s.slice(0, 160) + '…' : s
|
||||
}
|
||||
|
||||
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
|
||||
const traceNav = computed(() =>
|
||||
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
|
||||
@@ -1025,7 +1135,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<!-- Messages -->
|
||||
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll">
|
||||
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick">
|
||||
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
|
||||
<EmptyState
|
||||
v-if="store.state.messages.length === 0 && !store.state.streaming"
|
||||
@@ -1085,7 +1195,7 @@ defineExpose({
|
||||
<div
|
||||
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
|
||||
class="ai-think-full ai-md"
|
||||
v-html="renderMd(m.content)"
|
||||
v-html="renderMdDecorated(m.content)"
|
||||
></div>
|
||||
</template>
|
||||
<ToolCardList
|
||||
@@ -1107,6 +1217,18 @@ defineExpose({
|
||||
<span class="ai-step-text">{{ stepText(item.stats, item.round) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
|
||||
警示/failed 错误),摘要单行截断 + 轮数 + tokens;骨架对齐 ai-step-line/ai-retry-line
|
||||
轻量细条范式,非消息数据不进气泡 -->
|
||||
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
|
||||
<div class="ai-agent-fin" :class="'ai-agent-fin--' + agentFinTone(item.card.status)">
|
||||
<span class="ai-agent-fin-dot"></span>
|
||||
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
|
||||
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card.status) }}</span>
|
||||
<span class="ai-agent-fin-summary" :title="item.card.summary">{{ agentFinSummary(item.card.summary) }}</span>
|
||||
<span class="ai-agent-fin-meta">{{ $t('ai.agentFinished.meta', { rounds: item.card.rounds, tokens: formatTokensK(item.card.tokens) }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) -->
|
||||
<div v-else-if="item.kind === 'timesep'" class="ai-time-sep">
|
||||
<span>{{ formatAbsolute(item.ts) }}</span>
|
||||
@@ -1164,7 +1286,7 @@ defineExpose({
|
||||
<!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠,见 messageSegments) -->
|
||||
<div v-if="item.msg.role === 'system'" class="ai-msg-system">
|
||||
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
|
||||
<div class="ai-msg-system-summary ai-md" v-html="renderMd(item.msg.content)"></div>
|
||||
<div class="ai-msg-system-summary ai-md" v-html="renderMdDecorated(item.msg.content)"></div>
|
||||
</div>
|
||||
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) -->
|
||||
<MessageItem
|
||||
@@ -1194,7 +1316,7 @@ defineExpose({
|
||||
@click="toggleJson(item.msg.id)"
|
||||
><pre>{{ item.msg.content }}</pre></div>
|
||||
<div
|
||||
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0)"
|
||||
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && decoratedStreamingBlocks.length > 0)"
|
||||
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
|
||||
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg), 'ai-msg-bubble--answer': item.final === true && !item.msg.isError }"
|
||||
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
|
||||
@@ -1203,18 +1325,18 @@ defineExpose({
|
||||
AC-EFF-S2-1(2026-08-09):条件改用 streamingBlocks.length 而非 store.state.currentText,
|
||||
消除「currentText 变化触发模板重渲染 + rAF 更新 streamingBlocks 触发重渲染」的双渲染
|
||||
(每次 delta 只经 rAF 更新 streamingBlocks 触发一次渲染)。 -->
|
||||
<template v-if="isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0">
|
||||
<div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div>
|
||||
<template v-if="isLastAi(item.msg) && store.state.streaming && decoratedStreamingBlocks.length > 0">
|
||||
<div v-for="block in decoratedStreamingBlocks" :key="block.key" v-html="block.html"></div>
|
||||
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
||||
</template>
|
||||
<!-- 完成/历史:整段v-html(不变) -->
|
||||
<div v-else v-html="renderMd(item.msg.content)"></div>
|
||||
<!-- M1:超长 assistant 文本折叠后的底部展开按钮(仅非流式 + 长消息显示) -->
|
||||
<!-- 完成/历史:整段v-html(不变,经装饰层注入代码块复制按钮) -->
|
||||
<div v-else v-html="renderMdDecorated(item.msg.content)"></div>
|
||||
<!-- M1/R3:超长 assistant 文本折叠条(折叠态带量:已折叠 N 字符/约 M tokens) -->
|
||||
<button
|
||||
v-if="isLongMsg(item.msg) && !(isLastAi(item.msg) && store.state.streaming)"
|
||||
class="ai-msg-bubble-toggle"
|
||||
@click.stop="toggleLong(item.msg.id)"
|
||||
>{{ expandedLongIds.has(item.msg.id) ? $t('aiChat.collapse') : $t('aiChat.expand') }}</button>
|
||||
>{{ shouldCollapseLong(item.msg) ? collapsedLongLabel(item.msg) : $t('aiChat.collapse') }}</button>
|
||||
<button
|
||||
v-if="item.msg.content"
|
||||
class="ai-copy-btn ai-copy-btn--ai"
|
||||
@@ -1664,6 +1786,45 @@ defineExpose({
|
||||
right: 30px;
|
||||
}
|
||||
|
||||
/* ── R4 代码块复制按钮:节点经 v-html 注入,非 scoped 受控,经模板锚点 .ai-md :deep 命中。
|
||||
悬浮显示(代码块 hover 才现,右上角);is-ok/is-err 为复制反馈态(常显 1.2s 后由 JS 还原)。 ── */
|
||||
.ai-md :deep(.md-code-block) {
|
||||
position: relative;
|
||||
}
|
||||
.ai-md :deep(.md-code-copy) {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: var(--df-text-dim);
|
||||
background: color-mix(in srgb, var(--df-bg) 82%, transparent);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s var(--df-ease), color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-md :deep(.md-code-block:hover .md-code-copy) {
|
||||
opacity: 1;
|
||||
}
|
||||
.ai-md :deep(.md-code-copy:hover) {
|
||||
color: var(--df-accent);
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
.ai-md :deep(.md-code-copy.is-ok) {
|
||||
opacity: 1;
|
||||
color: var(--df-success, #4caf50);
|
||||
border-color: var(--df-success, #4caf50);
|
||||
}
|
||||
.ai-md :deep(.md-code-copy.is-err) {
|
||||
opacity: 1;
|
||||
color: var(--df-danger);
|
||||
border-color: var(--df-danger);
|
||||
}
|
||||
|
||||
/* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
|
||||
.ai-msg-actions {
|
||||
display: flex;
|
||||
@@ -2092,4 +2253,62 @@ defineExpose({
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* S1 子代理完成卡(AiAgentFinished):三态色分层——completed 中性灰(常态结果不抢视线)
|
||||
/ truncated 琥珀(结果不完整,与 retry 细条同警示层)/ failed 红(需关注,对齐错误卡层)。
|
||||
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看 */
|
||||
.ai-agent-fin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: var(--df-msg-max-width);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--df-radius);
|
||||
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
.ai-agent-fin-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-fin--warn {
|
||||
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
|
||||
}
|
||||
.ai-agent-fin--warn .ai-agent-fin-dot {
|
||||
background: var(--df-warning, #f0c75e);
|
||||
}
|
||||
.ai-agent-fin--err {
|
||||
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||
}
|
||||
.ai-agent-fin--err .ai-agent-fin-dot {
|
||||
background: var(--df-danger);
|
||||
}
|
||||
.ai-agent-fin-label {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ai-agent-fin-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-fin--warn .ai-agent-fin-status {
|
||||
color: var(--df-warning, #f0c75e);
|
||||
}
|
||||
.ai-agent-fin--err .ai-agent-fin-status {
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-agent-fin-summary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-agent-fin-meta {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user