修复: 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
@@ -0,0 +1,226 @@
<template>
<!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示,
静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token(防假进度规则2:无分母禁画百分比)。
U2 三层(设计 §三):活性=脉冲点(静默降速,永不伪装停止);位置=轮次/token 数字;
内容=阶段标签(最近工具映射)+已完成步数,静默档附加判断材料(不自动判死,决策留给用户)。
A8 双进度矛盾:输入区 agentic 实时进度条可见时(streaming 且 agentRound>0)
隐藏本快照徽章——两处同屏且轮数口径不同(实时 vs 每5轮快照)互相矛盾;
快照徽章只服务输入区不可见场景(托盘/单轮大工具静默期)。 -->
<div
v-if="taskProgress && !agenticProgressVisible"
class="ai-task-progress"
:class="silenceTier !== 'normal' ? `ai-task-progress--${silenceTier}` : ''"
:title="taskProgressHint"
>
<span class="ai-task-progress-dot"></span>
<span class="ai-task-progress-text">{{ taskProgressText }}</span>
<span v-if="stageText" class="ai-task-progress-stage">{{ stageText }}</span>
<span v-if="silenceSuffix" class="ai-task-progress-silence">{{ silenceSuffix }}</span>
<span
v-if="taskProgress.maxRounds > 0"
class="ai-task-progress-bar"
:title="$t('ai.taskProgress.barHint', { round: taskProgress.round, max: taskProgress.maxRounds })"
>
<span class="ai-task-progress-fill" :style="{ width: taskProgressPct + '%' }"></span>
</span>
</div>
</template>
<script setup lang="ts">
// TopBar 无人值守快照徽章(c1c 拆分自 TopBar.vue,零行为变更):
// 消费 useAiEvents 归集的 pendingTaskProgress(per-conv Map,终局即清):无快照不渲染。
// 切会话自动跟随(Map 按 activeId 取),多会话并发各显各的,不串台。
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../../stores/ai'
import { pendingTaskProgress } from '../../../composables/ai/useAiPendingState'
const { t } = useI18n()
const store = useAiStore()
const taskProgress = computed(() => {
const id = store.state.activeConversationId
if (!id) return null
return pendingTaskProgress.get(id) ?? null
})
// A8 双进度矛盾:输入区 agentic 实时进度条可见条件(与 AiChat.vue showAgenticProgress 同口径)——
// 可见时 TopBar 快照徽章让位隐藏(轮数口径不同:实时 vs 每5轮快照,同屏互相矛盾)
const agenticProgressVisible = computed(() =>
store.state.streaming && store.state.agentRound > 0,
)
const taskProgressText = computed(() => {
const p = taskProgress.value
if (!p) return ''
const tokens = p.tokensUsed >= 1000 ? `${(p.tokensUsed / 1000).toFixed(1)}k` : String(p.tokensUsed)
return p.maxRounds > 0
? t('ai.taskProgress.running', { round: p.round, max: p.maxRounds, tokens })
: t('ai.taskProgress.unlimited', { round: p.round, tokens })
})
const taskProgressPct = computed(() => {
const p = taskProgress.value
if (!p || p.maxRounds <= 0) return 0
return Math.min(100, Math.round((p.round / p.maxRounds) * 100))
})
// ── U2 进度语义三层补齐(设计 §三):内容层(最近动作)+静默期三档(§3.4) ──
// 规则1 兑现:下方定时器只算「距上次信号多久」,永不推进任何计数(轮次/步数只由事件写)。
// 规则4 兑现:活性=脉冲节奏(静默降速),位置=数字,分开渲染;永不自动判死(重入退避最长
// 15min 是合法等待),警示档只给判断材料,「疑似卡死可停止」的决策留给用户。
// 内容层数据源:工具卡挂在 assistant 消息上(useAiToolEvents),从消息流纯派生——零新事件
// 监听零协议改动。steps=当前消息流工具调用总数(压缩仅标记不清消息,计数不回退;清空上下文
// 重置属用户显式重开,语义自洽);last 优先取 running 卡(当前动作),无 running 取最后一张。
const toolActivity = computed(() => {
let steps = 0
let lastName: string | null = null
let runningName: string | null = null
for (const m of store.state.messages) {
const tcs = m.toolCalls
if (!tcs?.length) continue
steps += tcs.length
for (const tc of tcs) {
lastName = tc.name
if (tc.status === 'running') runningName = tc.name
}
}
return { steps, last: runningName ?? lastName }
})
// 阶段标签映射(设计 §3.3 同源词表桌面份):工具名→语义标签;未命中直显工具名
const STAGE_KEYS: Record<string, string> = {
read_file: 'read', read_symbol: 'read', search_files: 'read', list_directory: 'read',
write_file: 'edit', patch_file: 'edit',
run_command: 'run',
spawn_agent: 'agent',
}
const STAGE_PREFIXES: [string, string][] = [['knowledge_', 'knowledge'], ['idea_', 'knowledge'], ['task_', 'knowledge']]
function stageKeyOf(name: string): string | null {
if (STAGE_KEYS[name]) return STAGE_KEYS[name]
for (const [p, k] of STAGE_PREFIXES) if (name.startsWith(p)) return k
return null
}
const stageText = computed(() => {
const act = toolActivity.value
if (!act.last) return ''
const key = stageKeyOf(act.last)
const parts = [key ? t(`ai.taskProgress.stage.${key}`) : act.last]
if (act.steps > 0) parts.push(t('ai.taskProgress.steps', { n: act.steps }))
return parts.join(' · ')
})
// 静默期计时:基准=最近一次真实信号(AiTaskProgress.updatedAt 或消息流指纹变化时刻)。
// 指纹 watch 只在信号真实变化时刷新(规则5:同值重刷不产生新活性);文本增量/工具卡增减/
// 轮次推进都会被捕获。
const lastActivityAt = ref(Date.now())
watch(() => {
const msgs = store.state.messages
const last = msgs[msgs.length - 1]
return [msgs.length, last?.id, last?.content?.length, last?.toolCalls?.length, store.state.agentRound]
}, () => { lastActivityAt.value = Date.now() })
// 切会话用已知时间锚点重置(快照 updatedAt/末条消息时间),防「刚切回就看不到本就持续的静默」
watch(() => store.state.activeConversationId, () => {
const msgs = store.state.messages
const last = msgs[msgs.length - 1]
lastActivityAt.value = Math.max(taskProgress.value?.updatedAt ?? 0, last?.timestamp ?? 0)
})
// 15s 心跳只为刷新「距上次信号」展示(§3.4 档位阈值 2/10min,15s 粒度足够;规则1:定时器
// 不碰任何计数)
const nowTick = ref(Date.now())
let silenceTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
silenceTimer = setInterval(() => { nowTick.value = Date.now() }, 15_000)
})
onBeforeUnmount(() => {
if (silenceTimer) { clearInterval(silenceTimer); silenceTimer = null }
})
const silenceSecs = computed(() => {
const p = taskProgress.value
if (!p) return 0
return Math.max(0, Math.floor((nowTick.value - Math.max(p.updatedAt, lastActivityAt.value)) / 1000))
})
// §3.4 三档:<2min 正常 / 2-10min 长执行(脉冲降速) / >10min 警示(给判断材料不自动判死)
const silenceTier = computed<'normal' | 'long' | 'warn'>(() => {
const s = silenceSecs.value
if (s >= 600) return 'warn'
if (s >= 120) return 'long'
return 'normal'
})
const silenceSuffix = computed(() => {
if (!taskProgress.value) return ''
if (silenceTier.value === 'warn') return t('ai.taskProgress.silentShort', { n: Math.floor(silenceSecs.value / 60) })
if (silenceTier.value === 'long') return t('ai.taskProgress.longRun')
return ''
})
// 悬浮提示:警示档给完整判断材料(距上次活动 n 分钟+最后动作+出路),长执行档给成因提示,
// 正常档不弹(位置信息正文已有,避免无意义提示)
const taskProgressHint = computed(() => {
if (!taskProgress.value) return ''
if (silenceTier.value === 'warn') {
return t('ai.taskProgress.silentWarn', { n: Math.floor(silenceSecs.value / 60), tool: toolActivity.value.last ?? '-' })
}
if (silenceTier.value === 'long') return t('ai.taskProgress.longRunHint')
return ''
})
</script>
<style scoped>
/* 无人值守任务进度(设计 §4.3):绿点+轮次/token 文本+细进度条,与右侧徽章族同排 */
.ai-task-progress {
display: inline-flex;
align-items: center;
gap: 5px;
margin-right: 4px;
white-space: nowrap;
}
.ai-task-progress-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-success);
flex-shrink: 0;
animation: ai-task-progress-pulse 1.6s ease-in-out infinite;
}
.ai-task-progress-text {
font-size: 11px;
color: var(--df-text-secondary);
}
.ai-task-progress-bar {
display: inline-block;
width: 48px;
height: 3px;
border-radius: 2px;
background: color-mix(in srgb, var(--df-text-dim) 25%, transparent);
overflow: hidden;
}
.ai-task-progress-fill {
display: block;
height: 100%;
border-radius: 2px;
background: var(--df-success);
transition: width .4s var(--df-ease);
}
@keyframes ai-task-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* U2 静默期档位(设计 §3.4):长执行/警示=脉冲降速——活性层表达「慢」而非伪装「停」,
不自动判死;内容层/静默档文字用弱化色,视觉从属位置层数字 */
.ai-task-progress--long .ai-task-progress-dot,
.ai-task-progress--warn .ai-task-progress-dot {
animation-duration: 3.2s;
}
/* 警示档(静默>10min)状态点转琥珀:给判断材料,不自动判死 */
.ai-task-progress--warn .ai-task-progress-dot {
background: var(--df-warning);
}
.ai-task-progress-stage {
font-size: 11px;
color: var(--df-text-dim);
}
.ai-task-progress-silence {
font-size: 11px;
color: color-mix(in srgb, var(--df-warning) 70%, var(--df-text-secondary));
}
</style>