优化: 子代理体验与纪律注入

- 工具纪律段抽共享同源注入
- 子代理轨迹浮层与完成卡身份
- 分离窗会话切换修复
- 960s 统一出口报告不再蒸发
This commit is contained in:
lxy
2026-10-04 16:27:31 +08:00
parent b2d44f85e9
commit 01d231c39d
12 changed files with 1150 additions and 152 deletions
+241 -19
View File
@@ -690,7 +690,8 @@ function canOpenSettings(msg: AiMessage): boolean {
* 错误是否可重试(显隐「重试」按钮)。
* Fatal 类错误(鉴权/Provider 配置错——4xx/401/403/invalid_api_key)重试必再败,
* 隐藏重试按钮,引导用户走「去设置」。network/timeout/unknown 为瞬时错误,可重试。
* retryable 推断依据后端 error_type(本批前端推断;后端 AiStreamRetry/AiError 加 retryable 字段下批 df-ai 增强)。
* error_type 由后端 emit 点分类(2026-10-04 起 emit_fatal_error 对模型不存在/令牌无权/
* 参数拒绝类 400 归 provider_config);后端判不了的真 Unknown 仍可重试,维持现状。
*/
function canRetry(msg: AiMessage): boolean {
// 重试过渡气泡(isRetry)非终态错误,不提供重试入口(终态 AiError 才有重试按钮)
@@ -1077,11 +1078,22 @@ const renderItems = computed<RenderItem[]>(() => {
}
}
flushBucket()
// S1 子代理完成卡追加在消息流末尾:子代理在父轮内运行,完成语义上晚于当轮内容;
// S1 子代理完成卡:按完成时刻插到所属轮次桶尾,不恒贴消息流最底部——恒贴时用户继续对话,
// 早前轮次的卡被顶到后面内容之下,与所属轮次脱钩。归桶判据:finishedAt 落在哪桶的跨度内
// (trace.startTs ≤ finishedAt < 下一桶),卡即插到该桶 trace 卡之后与其同层(spawn 就发生
// 在这个桶里;桶内末条答复的落库时刻必晚于 finishedAt,时间比较天然落在这里)。
// 无时间语义的项(step/timesep/sep/agentFin)不作锚点,防插进时间线分隔的语义缝隙。
// 逐卡一项(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 })
const cards = [...getConvAgentFinished(store.state.activeConversationId)].sort((a, b) => a.finishedAt - b.finishedAt)
for (const card of cards) {
let at = items.length
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
const ts = it.kind === 'trace' ? it.startTs : it.kind === 'msg' ? it.msg.timestamp : 0
if (ts && ts <= card.finishedAt) { at = i + 1; break }
}
items.splice(at, 0, { kind: 'agentFin', key: 'agentfin-' + card.agentId, card })
}
return items
})
@@ -1094,18 +1106,49 @@ function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err
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')
// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应。
// stop_kind 结构化优先(根治启发式):后端终态枚举直达语义,截断三细分各自透出;
// 未知值兜底中性 completed 词不误报。无字段(旧后端)回退 summary 启发式:用户停止
// 级联取消子代理时 stop_reason=「已被取消(...)」并经 summary 终止说明透出(后端两处
// 取消检查点均写此中文字面量),按该特征分流为「已取消」;无此特征才是系统截断。
function agentFinStatusText(card: AgentFinishedCard): string {
const k = card.stop_kind
if (k) {
if (k === 'cancelled') return t('ai.agentFinished.cancelled')
if (k === 'max_rounds') return t('ai.agentFinished.truncMaxRounds')
if (k === 'wall_clock') return t('ai.agentFinished.truncWallClock')
if (k === 'token_budget') return t('ai.agentFinished.truncTokenBudget')
if (k === 'empty_convergence') return t('ai.agentFinished.noReport')
if (k === 'error') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed')
}
if (card.status === 'truncated') {
return card.summary.includes('已被取消')
? t('ai.agentFinished.cancelled')
: t('ai.agentFinished.truncated')
}
if (card.status === 'failed') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed')
}
// 启动失败型子代理(并发占满/父会话已删/provider 缺失)在进循环前就返回,子会话未落库,
// 点卡 peek 必报「对话不存在」死胡同;rounds=0 是前端可得的最稳特征(进循环后的失败
// 也至少有一轮落库记录,可正常 peek)。
function agentFinNoTrace(card: AgentFinishedCard): boolean {
return card.status === 'failed' && card.rounds === 0
}
// 摘要超长截断:单行细条承载,压平空白后按 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
}
// 被拒工具 chip(P2-1):前 2 枚枚举 + 「+N」收尾防长清单撑爆细条,完整清单经 hover title 查看
const AGENT_FIN_REJECTED_MAX = 2
function agentFinRejectedText(list: string[]): string {
const shown = list.slice(0, AGENT_FIN_REJECTED_MAX).join(' / ')
const rest = list.length - AGENT_FIN_REJECTED_MAX
return t('ai.agentFinished.rejected', { tools: rest > 0 ? `${shown} +${rest}` : shown })
}
// ── S3 子代理轨迹浮层(完成卡点击展开,只读) ──
// 状态全部组件局部:浮层关即弃、不缓存轨迹(每次点开重新 peek,宁少勿预留),
@@ -1114,10 +1157,17 @@ interface AgentPeekMsg {
role: 'user' | 'assistant' | 'tool' | 'system'
text: string
toolNames: string[]
// deny envelope 消息标记:命中子代理拒绝信封的 tool 结果,渲染为红字人话而非原始 JSON
denied?: boolean
}
const agentPeekVisible = ref(false)
const agentPeekLoading = ref(false)
// 加载中的卡 id:spinner 挂在具体完成卡上而非浮层(浮层此时尚未打开)
const agentPeekLoadingId = ref<string | null>(null)
const agentPeekMsgs = ref<AgentPeekMsg[]>([])
// 浮层头身份标识:agent-{n} 尾段 + 子会话标题(IPC title,子代理常为空则不显)+ 任务书首行,
// 多代理场景辨认「在看谁」;关浮层即弃
const agentPeekSubject = ref({ agent: '', convTitle: '', task: '', taskFull: '' })
// 单条内容截断上限:tool 结果(文件全文/长输出)可能巨大,简版渲染按 4000 字截断兜底,
// 完整数据仍在库,不做全文渲染(不要求富文本)。
const AGENT_PEEK_TEXT_MAX = 4000
@@ -1125,40 +1175,104 @@ const AGENT_PEEK_TEXT_MAX = 4000
/**
* 完成卡点击:peek 子会话轨迹并开浮层。
* agentId 即子会话 conv id(形态 {parent}::agent-{n},后端 spawn_agent 落库 origin='sub_agent')。
* 加载中/已开时忽略重复点击(防连点重复 IPC);失败走既有 toast 通道给可见反馈,不开空浮层。
* 启动失败无轨迹卡直接忽略(agentFinNoTrace);加载中/已开时忽略重复点击(防连点重复 IPC);
* 失败走既有 toast 通道给可见反馈,不开空浮层。
*/
async function openAgentTrace(card: AgentFinishedCard): Promise<void> {
if (agentFinNoTrace(card)) return
if (agentPeekVisible.value || agentPeekLoading.value) return
agentPeekLoading.value = true
agentPeekLoadingId.value = card.agentId
try {
const res = await aiApi.peekConversation(card.agentId)
const raw: any[] = typeof res.messages === 'string' ? JSON.parse(res.messages) : (res.messages || [])
// tool_call_id → 工具名映射:tool 消息只带 id,名字在前条 assistant.tool_calls 里
const toolNameById = new Map<string, string>()
for (const m of raw) {
if (Array.isArray(m.tool_calls)) {
for (const tc of m.tool_calls) {
if (tc?.id && tc?.function?.name) toolNameById.set(String(tc.id), String(tc.function.name))
}
}
}
agentPeekMsgs.value = raw.map((m) => {
const text = String(m.content || '')
const rawText = String(m.content || '')
const role = m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'system'
? m.role
: 'assistant'
return {
const msg: AgentPeekMsg = {
role,
text: text.length > AGENT_PEEK_TEXT_MAX ? text.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : text,
text: '',
toolNames: Array.isArray(m.tool_calls)
? m.tool_calls.map((tc: any) => tc?.function?.name || '').filter(Boolean)
: [],
}
if (role === 'tool') {
// 回显工具名 chip(与前条 assistant 消息的调用 chip 对得上,行内可读)
const callName = toolNameById.get(String(m.tool_call_id || ''))
if (callName) msg.toolNames.push(callName)
// deny envelope 先解析后截断(信封很小不会被截),命中转人话不走原文
const deny = parseDenyEnvelope(rawText)
if (deny) {
msg.denied = true
const risk = agentPeekRiskText(deny.risk_level)
msg.text = t('ai.agentTrace.deniedTool', { tool: deny.tool_name || callName || '?' })
+ (risk ? `(${risk})` : '')
+ (deny.message ? ` · ${deny.message}` : '')
return msg
}
}
msg.text = rawText.length > AGENT_PEEK_TEXT_MAX ? rawText.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : rawText
return msg
})
// 任务行取子会话首条 user 消息(即任务书)首行,取不到回退卡摘要;title 挂全文
const firstUser = raw.find((m) => m.role === 'user')
const taskLine = String(firstUser?.content || '').split('\n').map((s: string) => s.trim()).filter(Boolean)[0] || ''
const full = taskLine || (card.summary || '').replace(/\s+/g, ' ').trim()
agentPeekSubject.value = {
agent: card.agentId.split('::').pop() || card.agentId,
convTitle: String(res.title || '').trim(),
task: full.length > 120 ? full.slice(0, 120) + '…' : full,
taskFull: full,
}
agentPeekVisible.value = true
} catch (e) {
console.error('[AI] 拉取子代理轨迹失败:', e)
emit('toast', { msg: t('ai.agentTrace.loadFail'), type: 'error' })
} finally {
agentPeekLoading.value = false
agentPeekLoadingId.value = null
}
}
/** deny envelope 识别:与后端 is_sub_agent_deny_envelope 同构(status=rejected + reason=sub_agent_denied) */
function parseDenyEnvelope(text: string): { tool_name?: string; risk_level?: string; message?: string } | null {
const s = text.trim()
if (!s.startsWith('{')) return null
try {
const v = JSON.parse(s)
if (v?.status === 'rejected' && v?.reason === 'sub_agent_denied') {
return { tool_name: v.tool_name, risk_level: v.risk_level, message: v.message }
}
} catch {
// 截断/非 JSON 内容即非信封,按原文渲染
}
return null
}
/** 风险级别人话化(后端 RiskLevel serde lowercase,字面量 low/medium/high) */
function agentPeekRiskText(level: unknown): string {
if (level === 'low') return t('ai.agentTrace.riskLow')
if (level === 'medium') return t('ai.agentTrace.riskMedium')
if (level === 'high') return t('ai.agentTrace.riskHigh')
return ''
}
/** 关浮层即弃:清空局部消息态,不缓存轨迹 */
function closeAgentTrace(): void {
agentPeekVisible.value = false
agentPeekMsgs.value = []
agentPeekSubject.value = { agent: '', convTitle: '', task: '', taskFull: '' }
}
/** role → 本地化角色名(简版渲染,无头像气泡) */
@@ -1187,6 +1301,8 @@ watch(() => store.state.activeConversationId, () => {
setFollowing(true)
// 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开)
expandedTraceIds.value = new Set()
// 切会话关子代理轨迹浮层(浮层是组件级单例,不关则旧会话轨迹叠在新会话上)
closeAgentTrace()
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
@@ -1211,6 +1327,7 @@ watch(() => store.state.activeConversationId, () => {
onBeforeUnmount(() => {
cancelPendingRaf()
document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
document.removeEventListener('keydown', closeAgentTraceOnEsc)
})
// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部)
@@ -1219,6 +1336,12 @@ function closeTokenPopoverOnOutsideClick(): void {
}
document.addEventListener('click', closeTokenPopoverOnOutsideClick)
// 子代理轨迹浮层 Esc 关闭(与遮罩点击/X 同为关闭出口;仅浮层开着时消费,不干扰其他快捷键)
function closeAgentTraceOnEsc(e: KeyboardEvent): void {
if (e.key === 'Escape' && agentPeekVisible.value) closeAgentTrace()
}
document.addEventListener('keydown', closeAgentTraceOnEsc)
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
// 多次调用安全,确保子组件挂载即预热)。
loadMarkdown()
@@ -1321,16 +1444,26 @@ defineExpose({
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
<div
class="ai-agent-fin"
:class="'ai-agent-fin--' + agentFinTone(item.card.status)"
:class="['ai-agent-fin--' + agentFinTone(item.card.status), { 'ai-agent-fin--notrace': agentFinNoTrace(item.card) }]"
role="button"
:title="$t('ai.agentFinished.viewHint')"
:title="agentFinNoTrace(item.card) ? $t('ai.agentFinished.noTrace') : $t('ai.agentFinished.viewHint')"
@click="openAgentTrace(item.card)"
>
<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-status">{{ agentFinStatusText(item.card) }}</span>
<!-- 被拒工具 chip(P2-1):非空才渲染,琥珀警示层与 truncated 同色系;
单枚直显,多枚前 2 枚枚举 + N,完整清单 hover title -->
<span
v-if="item.card.rejected_tools?.length"
class="ai-agent-fin-rejected"
:title="item.card.rejected_tools.join('\n')"
>{{ agentFinRejectedText(item.card.rejected_tools) }}</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>
<!-- 回补卡 rounds/tokens 未知为 0,双零隐藏 meta 防「0 轮 · 0 tokens」噪音 -->
<span v-if="item.card.rounds || item.card.tokens" class="ai-agent-fin-meta">{{ $t('ai.agentFinished.meta', { rounds: item.card.rounds, tokens: formatTokensK(item.card.tokens) }) }}</span>
<!-- peek 加载微标:IPC 期间转圈,防「点了没反应」;挂在对应卡上,多卡各自反馈 -->
<span v-if="agentPeekLoadingId === item.card.agentId" class="ai-agent-fin-spin" aria-hidden="true"></span>
</div>
</div>
<!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) -->
@@ -1584,19 +1717,28 @@ defineExpose({
<div v-if="agentPeekVisible" class="ai-agent-peek-mask" @click.self="closeAgentTrace">
<div class="ai-agent-peek" role="dialog" :aria-label="$t('ai.agentTrace.title')">
<div class="ai-agent-peek-head">
<span class="ai-agent-peek-title">{{ $t('ai.agentTrace.title') }}</span>
<div class="ai-agent-peek-head-text">
<span class="ai-agent-peek-title">{{ $t('ai.agentTrace.title') }}</span>
<!-- 身份标识行:agent-{n} + 子会话标题 + 任务书首行,多代理场景辨认「在看谁」 -->
<div class="ai-agent-peek-subject" :title="agentPeekSubject.taskFull">
<span class="ai-agent-peek-agent">{{ agentPeekSubject.agent }}</span>
<span v-if="agentPeekSubject.convTitle" class="ai-agent-peek-conv">{{ agentPeekSubject.convTitle }}</span>
<span v-if="agentPeekSubject.task" class="ai-agent-peek-task">{{ agentPeekSubject.task }}</span>
</div>
</div>
<button class="ai-agent-peek-close" :title="$t('common.close')" @click="closeAgentTrace">
<svg width="12" height="12" 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>
<div class="ai-agent-peek-body">
<div v-if="agentPeekMsgs.length === 0" class="ai-agent-peek-empty">{{ $t('ai.agentTrace.empty') }}</div>
<!-- 简版卡片流:role 名 + 工具调用名 chips + 纯文本内容(不做完整 markdown 富渲染) -->
<!-- 简版卡片流:role 名 + 工具调用名 chips + 纯文本内容(不做完整 markdown 富渲染);
deny envelope 消息(denied)红字人话,不再直出原始 JSON -->
<div
v-for="(m, i) in agentPeekMsgs"
:key="i"
class="ai-agent-peek-msg"
:class="'ai-agent-peek-msg--' + m.role"
:class="['ai-agent-peek-msg--' + m.role, { 'ai-agent-peek-msg--denied': m.denied }]"
>
<div class="ai-agent-peek-role">
<span>{{ agentPeekRoleText(m.role) }}</span>
@@ -2450,6 +2592,40 @@ defineExpose({
font-family: var(--df-font-mono);
font-size: 10.5px;
}
/* 被拒工具 chip(P2-1):琥珀警示层与 truncated 同色系;限宽防长清单挤掉摘要行 */
.ai-agent-fin-rejected {
flex-shrink: 0;
max-width: 38%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 6px;
border-radius: 999px;
font-size: 10.5px;
background: color-mix(in srgb, var(--df-warning, #f0c75e) 16%, transparent);
color: var(--df-warning, #f0c75e);
}
/* 无轨迹(启动失败)卡:禁点视觉,hover 不提亮(cursor 维持默认) */
.ai-agent-fin--notrace {
cursor: default;
opacity: 0.72;
}
.ai-agent-fin--notrace:hover {
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
}
/* peek 拉取中的加载微标(挂在具体卡右端) */
.ai-agent-fin-spin {
flex-shrink: 0;
width: 10px;
height: 10px;
border: 1.5px solid color-mix(in srgb, var(--df-text-secondary) 35%, transparent);
border-top-color: var(--df-text-secondary);
border-radius: 50%;
animation: ai-agent-fin-spin 0.8s linear infinite;
}
@keyframes ai-agent-fin-spin {
to { transform: rotate(360deg); }
}
/* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */
.ai-agent-peek-mask {
@@ -2552,4 +2728,50 @@ defineExpose({
color: var(--df-text-secondary);
font-size: 11.5px;
}
/* 浮层头身份标识行:置于标题下方,窄容器内单行省略 */
.ai-agent-peek-head-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.ai-agent-peek-subject {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 11px;
color: var(--df-text-secondary);
}
.ai-agent-peek-agent {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10.5px;
color: var(--df-info);
background: var(--df-info-bg);
border-radius: var(--df-radius-sm);
padding: 1px 6px;
}
.ai-agent-peek-conv {
flex-shrink: 0;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-text);
}
.ai-agent-peek-task {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-text-dim);
}
/* deny envelope 消息:红字人话,置于 tool/system 弱化规则之后以覆盖其色 */
.ai-agent-peek-msg--denied {
border-left: 2px solid var(--df-danger);
}
.ai-agent-peek-msg--denied .ai-agent-peek-text {
color: var(--df-danger);
}
</style>