新增: 待发箱抽屉与对话体验前端批

- 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装
- U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死
- 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底)
- 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传
This commit is contained in:
lxy
2026-10-11 07:38:00 +08:00
parent 31510d3555
commit 3818a63522
22 changed files with 1151 additions and 19 deletions
+125 -2
View File
@@ -156,13 +156,22 @@
<div class="ai-bottom-tools-right">
<!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示,
静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token。
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token(防假进度规则2:无分母禁画百分比)。
U2 三层(设计 §三):活性=脉冲点(静默降速,永不伪装停止);位置=轮次/token 数字;
内容=阶段标签(最近工具映射)+已完成步数,静默档附加判断材料(不自动判死,决策留给用户)。
A8 双进度矛盾:输入区 agentic 实时进度条可见时(streaming 且 agentRound>0)
隐藏本快照徽章——两处同屏且轮数口径不同(实时 vs 每5轮快照)互相矛盾;
快照徽章只服务输入区不可见场景(托盘/单轮大工具静默期)。 -->
<div v-if="taskProgress && !agenticProgressVisible" class="ai-task-progress">
<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"
@@ -589,6 +598,106 @@ const taskProgressPct = computed(() => {
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 ''
})
// 🎯 对话目标面板(对话透明化 L1:Goal visibility)
const goals = ref<GoalEntry[]>([])
const goalsExpanded = ref(false)
@@ -1077,6 +1186,20 @@ onBeforeUnmount(() => {
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;
}
.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));
}
/* ═══ Provider Bar (header 中间区;div→button 后归零默认样式) ═══ */
.ai-provider-bar {
display: inline-flex;