修复: ai-chat 交互体验批(草稿备份/图片放大/目标完成态/淘汰边界条)

- 输入侧:编辑消息不再静默销毁草稿(备份/回填)、补全浮层滚随、贴图 loading 态+并发上限复核、mention 失焦收口、富文本图文同粘、上传失败提示
- 消息流:图片点击 lightbox 放大、复制钮 focus 可见、复制为图片降级文案据实、JSON 块恢复可选、bucketSig 改哈希防长会话卡顿、user 长消息折叠、工具卡流式滚尾
- 会话框架:后台任务面板入点外/Esc 收起编排、恢复任务行级 busy+失败 toast、菜单底部翻转、搜索态统一清、归档禁改名对齐、listbox 语义+方向键、状态点 title、窄侧栏容器查询
- 跨端:删除目标不再抹其余目标完成态(前后端 GoalEntry 全量传递+失败回滚)
- 边界条:MESSAGE_CAP 滚动淘汰不再静默,头部一键补齐完整会话,历史定位文案据实区分
This commit is contained in:
lxy
2026-10-25 22:32:49 +08:00
parent 3caf2d047f
commit 62776a30f1
38 changed files with 3848 additions and 2778 deletions
+159 -18
View File
@@ -287,6 +287,20 @@ async function handleMessagesScroll(): Promise<void> {
container.scrollTop = Math.max(diff, 0)
}
// ── MESSAGE_CAP 淘汰边界条(走查批):循环 loadMoreHistory 直到 hasMore 尽头,补齐被裁早史 ──
// 每页走既有游标守卫/去重/过期丢弃;滚动停在原地不追底。全部补齐(hasMore=false)才清零
// messagesPruned 收边界条;中途失败(异常)保留计数与入口,可再点续拉。
async function reloadFullHistory(): Promise<void> {
const cur = loadMoreCursor
if (cur.loading) return
while (cur.hasMore && cur.earliestSeq != null) {
const before = store.state.messages.length
const inserted = await loadMoreHistory()
if (!inserted || store.state.messages.length === before) break // 无进展防死循环
}
if (!cur.hasMore) store.state.messagesPruned = 0
}
watch(() => store.state.messages.length, onContentChange)
// currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
@@ -642,7 +656,10 @@ async function copyMsgAsImage(msg: AiMessage): Promise<void> {
}
try {
const { default: html2canvas } = await import('html2canvas-pro')
const canvas = await html2canvas(bubble, { backgroundColor: null, useCORS: true, scale: 2 })
// 底色合成:透明底 PNG 贴到浅色查看器会白字不可读;渲染前取 --df-bg 实值铺底,
// 深浅主题各自动随(token 缺失时回退 null 保持原透明行为)
const bg = getComputedStyle(document.documentElement).getPropertyValue('--df-bg').trim()
const canvas = await html2canvas(bubble, { backgroundColor: bg || 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')
if (typeof ClipboardItem !== 'undefined') {
@@ -660,7 +677,8 @@ async function copyMsgAsImage(msg: AiMessage): Promise<void> {
a.download = 'message.png'
a.click()
URL.revokeObjectURL(url)
emit('toast', { msg: t('aiChat.copiedAsImage'), type: 'info' })
// 降级下载文案如实区分:文件已落盘,不是「已复制到剪贴板」
emit('toast', { msg: t('aiChat.savedAsImageFile'), type: 'info' })
} catch {
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
}
@@ -757,6 +775,25 @@ async function onMdCodeCopyClick(e: MouseEvent): Promise<void> {
}, 1200)
}
// ── 消息图片 lightbox(用户附件图 .ai-msg-image / AI 渲染图 .ai-md img):容器级事件委托
// 点击开大图;遮罩范式对齐 .ai-agent-peek-mask(fixed 居中),点遮罩/Esc 关闭 ──
const lightboxSrc = ref('')
function closeImageLightbox(): void {
lightboxSrc.value = ''
}
/** 委托命中判定:消息区任一 img 即开大图;链接内图片不拦(让 <a> 跳转优先) */
function onImageLightboxClick(e: MouseEvent): void {
const img = (e.target as HTMLElement | null)?.closest('img')
if (!img || img.closest('a')) return
const src = img.getAttribute('src')
if (src) lightboxSrc.value = src
}
/** 消息区容器级 click 分发:先查图片 lightbox,再走代码块复制(两者 target 不相交) */
function onMessagesClick(e: MouseEvent): void {
onImageLightboxClick(e)
void onMdCodeCopyClick(e)
}
// ── 错误气泡操作入口 ──
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
@@ -898,17 +935,18 @@ function useToggleSet(): { ids: Ref<Set<string>>; toggle: (id: string) => void }
/// 展开的折叠段 key 集合(点击分隔条切换);默认折叠
const { ids: expandedSegmentIds, toggle: toggleSegment } = useToggleSet()
// M1:assistant 纯文本气泡超长折叠,避免单条 AI 回复撑爆对话区。
// 仅对「已完成(非流式)」assistant 文本生效;流式中的消息不折叠(delta 重排抖动)。
// M1:纯文本气泡超长折叠(assistant + user 都生效),避免单条回复撑爆对话区。
// 仅对「已完成(非流式)」文本生效;流式中的消息不折叠(delta 重排抖动)。
// 阈值 1500 字符:约 markdown 渲染后一屏(400px@13px/1.6≈35行)的源文本量,超此即判长。
// 折叠 max-height 同步 400px(见 CSS .ai-msg-bubble--collapsed,两处一致)。
const LONG_MSG_THRESHOLD = 1500
/// 展开的长消息 id 集合(点击底部按钮切换);默认折叠
const { ids: expandedLongIds, toggle: toggleLong } = useToggleSet()
function isLongMsg(msg: AiMessage): boolean {
return msg.role === 'assistant' && !!msg.content && msg.content.length > LONG_MSG_THRESHOLD
return !!msg.content && msg.content.length > LONG_MSG_THRESHOLD
}
/// 是否折叠展示该 assistant 文本气泡:长消息 + 未手动展开 + 非流式(当前未在流式生成)。
/// 是否折叠展示该长文本气泡:长消息 + 未手动展开 + 非流式(当前未在流式生成)。
/// 流式约束对 user 恒不触发(isLastAi 仅认 assistant),user 消息不参与流式生成。
function shouldCollapseLong(msg: AiMessage): boolean {
if (!isLongMsg(msg)) return false
if (expandedLongIds.value.has(msg.id)) return false
@@ -1087,12 +1125,25 @@ function needsTimeSep(ts: number, prevTs: number): boolean {
const _bucketCache = new Map<string, { items: RenderItem[]; endTs: number }>()
const BUCKET_CACHE_LIMIT = 128
/// 轻量字符串哈希(对齐 useStreamRenderer.ts simpleHash 范式,djb2,非加密):
/// bucketSig 每条消息重算,长文本全文拼 key 既慢又驻留大串,压成 length+hash
function simpleHash(s: string): number {
let h = 5381
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
return h >>> 0
}
/** 签名片段压缩:content/reasoning 用 length+hash 替代全文(内容变→哈希变,签名语义不变) */
function lenHash(v: string | null | undefined): string {
const s = v ?? ''
return s.length + ':' + simpleHash(s)
}
function bucketSig(msgs: AiMessage[], round: number, executing: boolean, prevTs: number, traceExpanded: boolean): string {
const parts: string[] = [round + '#' + (executing ? 1 : 0) + '#' + prevTs + '#' + (traceExpanded ? 1 : 0)]
for (const m of msgs) {
let s = m.id + ':' + m.role + ':' + (m.status ?? '') + ':' + (m.isError ? 1 : 0) + ':' + (m.isRetry ? 1 : 0)
+ ':' + (m.timestamp ?? 0) + ':' + (m.model ?? '')
+ ':' + (m.content ?? '') + ':' + (m.reasoning_content ?? '')
+ ':' + lenHash(m.content) + ':' + lenHash(m.reasoning_content)
+ ':' + (m.tokenUsage?.prompt ?? m.prompt_tokens ?? '')
+ ':' + (m.tokenUsage?.completion ?? m.completion_tokens ?? '')
+ ':' + (m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? '')
@@ -1585,11 +1636,13 @@ function closeTokenPopoverOnOutsideClick(): void {
}
document.addEventListener('click', closeTokenPopoverOnOutsideClick)
// 浮层 Esc 关闭:子代理轨迹浮层 + token 详情 popover(F6-3:后者原先仅外点关闭,
// 键盘用户开了面板只能点别处)。两者同时开着时逐层关(先浮层后 popover),Esc 不冒泡旁路。
// 浮层 Esc 关闭:消息图片 lightbox + 子代理轨迹浮层 + token 详情 popover(F6-3:后者原先仅
// 外点关闭,键盘用户开了面板只能点别处)。同时开着时逐层关(先 lightbox 后浮层后 popover),
// Esc 不冒泡旁路。
function closeOverlaysOnEsc(e: KeyboardEvent): void {
if (e.key !== 'Escape') return
if (agentPeekVisible.value) closeAgentTrace()
if (lightboxSrc.value) closeImageLightbox()
else if (agentPeekVisible.value) closeAgentTrace()
else if (tokenPopoverMsgId.value !== null) tokenPopoverMsgId.value = null
}
document.addEventListener('keydown', closeOverlaysOnEsc)
@@ -1607,10 +1660,10 @@ defineExpose({
<template>
<!-- Messages -->
<!-- UX第五轮 P2-G1:role="log"+aria-live=polite,读屏用户可感知流式新消息与回复更新 -->
<div class="ai-messages" ref="messagesContainer" role="log" aria-live="polite" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick">
<div class="ai-messages" ref="messagesContainer" role="log" aria-live="polite" @scroll="handleMessagesScroll" @click="onMessagesClick">
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
<EmptyState
v-if="store.state.messages.length === 0 && !store.state.streaming"
v-if="store.state.messages.length === 0 && !store.state.streaming && store.state.providersLoaded"
:has-provider="store.state.providers.length > 0"
@send-example-prompt="(i18nKey) => emit('send-example-prompt', i18nKey)"
@go-to-settings="emit('go-to-settings')"
@@ -1634,6 +1687,14 @@ defineExpose({
每项外层包 .ai-msg-slot(虚拟滚动已移除,现恒渲染;
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。
UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 -->
<!-- MESSAGE_CAP 淘汰边界条(走查批):滚动淘汰不再静默——头部提示被裁条数,点击循环
loadMoreHistory 补齐完整会话(复用 G3.5 分页链路:游标守卫+去重+过期丢弃)。 -->
<button
v-if="store.state.messagesPruned > 0"
class="ai-pruned-banner"
:disabled="loadMoreCursor.loading"
@click="reloadFullHistory"
>{{ $t('aiChat.prunedBanner', { n: store.state.messagesPruned }) }}</button>
<template v-for="item in renderItems" :key="item.key" v-memo="[item, store.state.streaming, isViewingGenerating, expandedThinkIds, expandedJsonIds, expandedLongIds, tokenPopoverMsgId, editingMsgId, agentPeekLoadingId, _mdRenderKey, taskTitleCache, item.kind === 'msg' && isLastAi(item.msg) && store.state.streaming ? decoratedStreamingBlocks : '']">
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
@@ -1775,8 +1836,11 @@ defineExpose({
class="ai-agent-fin"
:class="['ai-agent-fin--' + agentFinTone(item.card.status), { 'ai-agent-fin--notrace': agentFinNoTrace(item.card) }]"
role="button"
tabindex="0"
:title="agentFinNoTrace(item.card) ? $t('ai.agentFinished.noTrace') : $t('ai.agentFinished.viewHint')"
@click="openAgentTrace(item.card)"
@keydown.enter.prevent="openAgentTrace(item.card)"
@keydown.space.prevent="openAgentTrace(item.card)"
>
<span class="ai-agent-fin-dot"></span>
<!-- A-3 并行多子代理卡序号身份:agent-{n} 解析成功才渲染,防多卡轮次乱序误读 -->
@@ -1856,13 +1920,18 @@ defineExpose({
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
<div class="ai-msg-system-summary ai-md" v-html="renderMdDecorated(item.msg.content)"></div>
</div>
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) -->
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染)
M1 扩展:超长 user 文本折叠——阈值判定/折叠态/文案由父级统一持有,子组件纯展示 -->
<MessageItem
v-else-if="item.msg.role === 'user'"
:msg="item.msg"
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
:long-foldable="isLongMsg(item.msg)"
:long-folded="shouldCollapseLong(item.msg)"
:fold-label="collapsedLongLabel(item.msg)"
@copy="copyMsgContent"
@start-edit="(m) => emit('start-edit', m)"
@toggle-long="toggleLong(item.msg.id)"
/>
<!-- AI 消息 -->
@@ -2119,6 +2188,20 @@ defineExpose({
</div>
</div>
</div>
<!-- 消息图片 lightbox:用户附件图/AI 渲染图点击看大图;点遮罩或 Esc 关闭(范式同 peek 浮层,
模板根级兄弟,position:fixed 不受 .ai-messages 滚动容器 overflow 裁剪) -->
<div v-if="lightboxSrc" class="ai-img-lightbox-mask" @click.self="closeImageLightbox">
<img class="ai-img-lightbox-img" :src="lightboxSrc" :alt="$t('aiChat.lightboxAlt')" />
<button
class="ai-img-lightbox-close"
:title="$t('aiChat.lightboxClose')"
:aria-label="$t('aiChat.lightboxClose')"
@click="closeImageLightbox"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</template>
<style scoped>
@@ -2309,7 +2392,8 @@ defineExpose({
不渲染 markdown 大气泡刷屏。默认 max-height 80px + overflow-y:auto 可滚动,
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。
F1-2:灰字由硬编码 #b0b0b0 改 --df-text-dim——硬编码值在浅色主题白底上
对比度约 2.6:1 不可读;token 双主题各有值,弱化语义不变。 ── */
对比度约 2.6:1 不可读;token 双主题各有值,弱化语义不变。
UX 走查:恢复文本可选中(user-select:none 移除)——JSON 工具结果也是要能复制的内容。 ── */
.ai-msg-json {
max-height: 80px;
overflow-y: auto;
@@ -2322,7 +2406,6 @@ defineExpose({
line-height: 1.5;
color: var(--df-text-dim);
cursor: pointer;
user-select: none;
}
.ai-msg-json pre {
margin: 0;
@@ -2371,6 +2454,21 @@ defineExpose({
padding: 8px 12px;
}
/* ── MESSAGE_CAP 淘汰边界条(走查批):列表头部轻提示,一键补齐被裁早史 ── */
.ai-pruned-banner {
align-self: center;
margin: 4px 0;
padding: 4px 14px;
border: 1px dashed var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg-card);
color: var(--df-text-dim);
font-size: var(--df-fs-body);
cursor: pointer;
}
.ai-pruned-banner:hover { color: var(--df-accent); border-color: var(--df-accent); }
.ai-pruned-banner:disabled { opacity: 0.5; cursor: default; }
/* ── 轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转) ── */
.ai-trace-nav {
position: sticky;
@@ -2423,7 +2521,9 @@ defineExpose({
transition: opacity 0.2s;
flex-shrink: 0;
}
.ai-msg-ai:hover .ai-copy-btn--ai {
.ai-msg-ai:hover .ai-copy-btn--ai,
/* UX第五轮 P2-M1 同款键盘可达:Tab 聚到气泡内按钮/按钮自身聚焦时显形 */
.ai-msg-ai:focus-within .ai-copy-btn--ai {
opacity: 1;
}
.ai-copy-btn:hover {
@@ -2472,7 +2572,9 @@ defineExpose({
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) {
.ai-md :deep(.md-code-block:hover .md-code-copy),
/* 键盘可达(对齐 .ai-copy-btn focus 范式):Tab 聚到复制钮自身时显形 */
.ai-md :deep(.md-code-copy:focus-visible) {
opacity: 1;
}
.ai-md :deep(.md-code-copy:hover) {
@@ -3017,7 +3119,7 @@ defineExpose({
/* A-3 并行多子代理卡序号徽标:等宽小号弱化展示,解析失败不渲染(v-if 已守) */
.ai-agent-fin-seq {
flex-shrink: 0;
font-family: var(--df-font-mono, monospace);
font-family: var(--df-font-mono);
font-size: 11px;
opacity: 0.65;
}
@@ -3133,6 +3235,45 @@ defineExpose({
text-align: center;
padding: 24px 0;
}
/* ═══ 消息图片 lightbox(用户附件图/AI 渲染图点击看大图;遮罩范式对齐 .ai-agent-peek-mask) ═══ */
.ai-img-lightbox-mask {
position: fixed;
inset: 0;
z-index: 310;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
}
.ai-img-lightbox-img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--df-radius-md);
box-shadow: var(--df-shadow-md);
}
.ai-img-lightbox-close {
position: absolute;
top: 14px;
right: 14px;
display: flex;
padding: 6px;
border: none;
border-radius: var(--df-radius-sm);
background: rgba(0, 0, 0, 0.4);
color: #fff;
cursor: pointer;
}
.ai-img-lightbox-close:hover {
background: rgba(0, 0, 0, 0.6);
}
/* 可放大暗示:用户附件图光标在 MessageItem.vue scoped 内;AI markdown 图(v-html 注入,
非 scoped 受控)经 :deep 补同款 zoom-in */
.ai-md :deep(img) {
cursor: zoom-in;
}
.ai-agent-peek-msg {
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);