修复: ai-chat 交互体验批(草稿备份/图片放大/目标完成态/淘汰边界条)
- 输入侧:编辑消息不再静默销毁草稿(备份/回填)、补全浮层滚随、贴图 loading 态+并发上限复核、mention 失焦收口、富文本图文同粘、上传失败提示 - 消息流:图片点击 lightbox 放大、复制钮 focus 可见、复制为图片降级文案据实、JSON 块恢复可选、bucketSig 改哈希防长会话卡顿、user 长消息折叠、工具卡流式滚尾 - 会话框架:后台任务面板入点外/Esc 收起编排、恢复任务行级 busy+失败 toast、菜单底部翻转、搜索态统一清、归档禁改名对齐、listbox 语义+方向键、状态点 title、窄侧栏容器查询 - 跨端:删除目标不再抹其余目标完成态(前后端 GoalEntry 全量传递+失败回滚) - 边界条:MESSAGE_CAP 滚动淘汰不再静默,头部一键补齐完整会话,历史定位文案据实区分
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user