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

- 工具纪律段抽共享同源注入
- 子代理轨迹浮层与完成卡身份
- 分离窗会话切换修复
- 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
+11
View File
@@ -46,6 +46,8 @@
<!-- Input -->
<div class="ai-input-area">
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
单轮问答 agentRound 恒 0,不显示(避免误显)。
@@ -61,6 +63,8 @@
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- 无人值守安全停靠卡(AiTaskParked):继续=重新授权续跑,停止归档=不再续跑 -->
<TaskParkedCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
@@ -143,6 +147,8 @@ import ChatInput from './ai/ChatInput.vue'
import TopBar from './ai/TopBar.vue'
import MessageList from './ai/MessageList.vue'
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
import TaskParkedCard from './ai/TaskParkedCard.vue'
import TaskBoundaryCard from './ai/TaskBoundaryCard.vue'
import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm'
import { useToast } from '../composables/useToast'
@@ -326,6 +332,11 @@ function cancelEdit(): void {
// 注:Ctrl+K(UX-06 §3.1)已绑——Tauri WebView2 无浏览器地址栏,聚焦搜索框无冲突
function onGlobalKeydown(e: KeyboardEvent) {
const ctrl = e.ctrlKey || e.metaKey
// 回归修复(2026-10-04 走查):全局 Ctrl 快捷键补输入焦点守卫——事件源在输入控件内时
// Ctrl+N/R/L/K/B 全部让路(输入框打字/组合键不误触新建对话/重生成/清空等)。
// 写法对齐下方 Esc 分支的 closest target 守卫;Esc 无 ctrl 不走此守卫(其分支自带仲裁)。
const el = e.target as HTMLElement | null
if (ctrl && !!el?.closest?.('input, textarea, [contenteditable]')) return
// Ctrl+N: 新建对话(Tauri 下 preventDefault 可靠,无浏览器新窗口冲突)
if (ctrl && (e.key === 'n' || e.key === 'N')) {
e.preventDefault()
+33 -3
View File
@@ -10,8 +10,25 @@
import { useI18n } from 'vue-i18n'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
import { useAiStore } from '../../stores/ai'
const { t } = useI18n()
const store = useAiStore()
/**
* 前导 /技能名 解析(问题3 技能调用留痕的渲染侧):
* 落库文本形如 `/skill-name 正文`(后端 chat.rs user_content 统一加前缀),命中本机
* 已安装技能名单才渲染为技能 chip——普通以 / 开头的文本(路径、markdown)不误判。
* 返回 null = 非技能调用,走原纯文本路径零回归。
*/
function leadingSkill(msg: AiMessage): { name: string; rest: string } | null {
if (msg.role !== 'user') return null
const m = /^\/([A-Za-z0-9_-]+)(?:\s([\s\S]*))?$/.exec(msg.content)
if (!m) return null
const known = store.state.skills.some((s) => s.name === m[1])
if (!known) return null
return { name: m[1], rest: m[2] ?? '' }
}
defineProps<{
msg: AiMessage
@@ -89,9 +106,16 @@ function imgAlt(img: ImageContentPart): string {
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
<template v-for="(seg, idx) in segmentUserContent(msg)" :key="msg.id + '-seg-' + idx">
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
<span v-else class="ai-msg-chip" :class="'ai-msg-chip--' + seg.span.kind">{{ seg.span.label }}</span>
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
<template v-if="leadingSkill(msg)">
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: leadingSkill(msg)!.name })">⚡ /{{ leadingSkill(msg)!.name }}</span>
<template v-if="leadingSkill(msg)!.rest">{{ ' ' + leadingSkill(msg)!.rest }}</template>
</template>
<template v-else>
<template v-for="(seg, idx) in segmentUserContent(msg)" :key="msg.id + '-seg-' + idx">
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
<span v-else class="ai-msg-chip" :class="'ai-msg-chip--' + seg.span.kind">{{ seg.span.label }}</span>
</template>
</template>
<template v-if="msgImageParts(msg).length">
<div class="ai-msg-images">
@@ -153,6 +177,12 @@ function imgAlt(img: ImageContentPart): string {
background: var(--df-accent-bg, rgba(108,99,255,0.1));
color: var(--df-accent, #6c63ff);
}
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
.ai-msg-chip--skill {
background: var(--df-accent-bg, rgba(108,99,255,0.18));
color: var(--df-accent, #6c63ff);
font-weight: 600;
}
/* 图片 */
.ai-msg-images {
display: flex;
+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>
+25 -3
View File
@@ -9,6 +9,8 @@
@close="$emit('close')"
header-style="display:none"
>
<!-- 关联会话跳转反馈(P3-7):命中已删会话时抽屉保持打开,toast 就地提示 -->
<div v-if="toast.visible" class="drawer-toast" :class="'drawer-toast--' + toast.type">{{ toast.msg }}</div>
<div class="task-drawer" v-if="task">
<!-- 标题行:标题 + 状态 + 优先级 + 推进按钮 -->
<div class="dr-header">
@@ -205,7 +207,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const router = useRouter()
const store = useProjectStore()
const { showToast } = useToast()
const { toast, showToast } = useToast()
const { openConversation } = useAiConversations()
// 关联会话引用(S4:审计表反查,无关联=空数组 → 入口隐藏)
@@ -299,10 +301,15 @@ async function loadTaskConversations(tid: string) {
} catch { taskConversations.value = [] }
}
/** 点击关联会话:先关抽屉(面板在抽屉下层,不关会被遮挡),再走 openConversation 跳转 */
/** 点击关联会话:跳转成功才关抽屉(面板在抽屉下层,不关会被遮挡);命中已删会话时留原地
* 并 toast(UX走查 P3-7:原实现先关抽屉且静默兜底新建,用户落进空白对话无解释) */
async function handleOpenConversation(convId: string) {
const res = await openConversation(convId)
if (res === 'not_found') {
showToast(t('ai.convDeleted'), 'error')
return
}
emit('close')
await openConversation(convId)
}
async function loadChildren() {
@@ -402,4 +409,19 @@ async function confirmSubtask() {
word-break: break-all;
}
.conv-link:hover { text-decoration: underline; }
/* 抽屉内 toast(P3-7):fixed + 高 z-index 盖过 Arco Drawer 遮罩(内容被 teleport 到 body,作用域样式仍生效) */
.drawer-toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 8px 14px;
border-radius: var(--df-radius);
font-size: 12px;
z-index: 3000;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
max-width: 80%;
overflow-wrap: anywhere;
}
.drawer-toast--error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
</style>
+54
View File
@@ -624,6 +624,14 @@ export interface AgentFinishedCard {
status: AgentFinishedStatus
rounds: number
tokens: number
/** 被拒工具清单("工具名×次数",如 "write_file×3",后端 format_rejected_tools 产出)。
* 字段名保持事件 wire 格式 snake_case(summary 是后端拼好的全文,结构化清单只此一份,
* 卡面 160 字截断裁掉 summary 尾部时渲染端仍可扫描被拒事实);缺省=零拒绝。 */
rejected_tools?: string[]
/** 终止原因结构化字段(后端 AiAgentFinished.stop_kind,cancelled/wall_clock/max_rounds/
* token_budget/empty_convergence/error/converged)。缺省=旧后端未发,渲染端回退
* summary 启发式(向后兼容);实时事件到达时以此覆盖启发式判断。 */
stop_kind?: string
finishedAt: number
}
@@ -661,6 +669,52 @@ export function clearConvAgentFinished(convId: string | null | undefined): void
convAgentFinished.delete(convId)
}
/** ai_sub_agent_list 的单条轨迹元数据(wire shape 归口 api 层返回类型,此处只投影用) */
type SubAgentRef = Awaited<ReturnType<typeof aiApi.listSubAgents>>[number]
/**
* P2-2 完成卡回补:重启/分离窗回收后内存 Map 清零,子代理轨迹成无 UI 入口孤岛。
* 按父 conv 经 ai_sub_agent_list 反查子代理轨迹,重建已完成卡(轨迹存在即视为 completed;
* rejected_tools/rounds/tokens 后端该 IPC 不回,缺省=零拒绝/0,卡面隐藏 0 值 meta)。
*
* 幂等/去重/降级三保证:
* - 幂等:挂载恢复与每次 switchConversation 都会触发,agentId 键控 insert-only,重复调用收敛;
* - 去重:同 agentId 已有卡(实时事件先到/上轮回补过)跳过,不覆盖已有数据;
* 实时事件走 addConvAgentFinished 的 splice 覆盖语义,晚到仍会覆盖回补卡(实时数据优先)。
* - 降级:IPC 失败仅 console.warn 返回,调用方 fire-and-forget,不阻塞会话加载。
*/
export async function backfillConvAgentFinished(convId: string | null | undefined): Promise<void> {
if (!convId) return
let refs: SubAgentRef[]
try {
refs = await aiApi.listSubAgents(convId)
} catch (e) {
console.warn('[AI] 完成卡回补失败(静默降级):', convId, e)
return
}
if (refs.length === 0) return
let arr = convAgentFinished.get(convId)
if (!arr) {
arr = []
convAgentFinished.set(convId, arr)
}
for (const ref of refs) {
const agentId = ref.conversation_id
// agentId 即子会话 conv id({parent}::agent-{n} 全形,与实时事件 agent_id 同源),
// 与既有卡天然同键;无 id 的脏行跳过防空键卡
if (!agentId || arr.some(c => c.agentId === agentId)) continue
arr.push({
agentId,
// title 懒创建常为 None,回退显 agent-{n} 尾段(对齐轨迹浮层身份行切分逻辑)
summary: (ref.title || '').trim() || agentId.split('::').pop() || agentId,
status: 'completed',
rounds: 0,
tokens: 0,
finishedAt: Number(ref.created_at) || Date.now(),
})
}
}
// ── UX-P2-A3 审批超时错误气泡 per-conv 守卫 ──
//
// 背景:startApprovalTimer 到点把「审批超时已自动拒绝」错误气泡直接推进 getMessages()
+51 -1
View File
@@ -1,7 +1,9 @@
//! 窗口分离模式 — detach/reattach/resumeInDetached/closeDetachedWindow/dock/syncToMain/startFollowMain/stopFollowMain
//! + 跨窗切会话(emitDetachedOpenConversation/registerDetachedOpenConvListener)
//!
//! 模块级私有(不进 reactive):
//! - _unlistenMove / _unlistenResize: 主窗口 move/resize 跟随的 unlistener
//! - _openConvListenerRegistered: 跨窗切会话监听注册标记(幂等)
//!
//! 耦合:
//! - resumeInDetached / restoreGeneratingState 调 aiShared.nextMsgId 占位 ai 气泡 +
@@ -35,6 +37,54 @@ const TEXT_KEY_PREFIX = 'df-ai-text-'
function detachedLabel(convId: string): string {
return DETACHED_LABEL_PREFIX + convId
}
/** 分离窗口 label 判定(含旧版单 label 'ai-detached' 兼容) */
function isDetachedLabel(label: string): boolean {
return label === 'ai-detached' || label.startsWith(DETACHED_LABEL_PREFIX)
}
// ── 跨窗切会话(UX走查 2026-10-03 P1-1)──
// 主/分离窗口是独立 JS context(state 互不同步),主窗口点「关联会话」跳转时分离窗不跟随。
// 约定:发起方 emit 全局事件广播到全部 webview,仅分离窗(按 label 判定)注册消费,
// 主窗口 label 'main' 不注册,不会自发自收。
const OPEN_CONV_EVENT = 'ai-open-conversation'
/** 发起方(主窗口)侧:广播跨窗切会话事件;无分离窗存活时事件落空 */
export async function emitDetachedOpenConversation(convId: string): Promise<void> {
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(OPEN_CONV_EVENT, { convId })
} catch (e) {
console.error('[AI] 跨窗切会话事件发送失败:', e)
}
}
/** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口,
* 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。
* handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */
let _openConvListenerRegistered = false
export function registerDetachedOpenConvListener(handler: (convId: string) => Promise<void>): void {
if (_openConvListenerRegistered) return
_openConvListenerRegistered = true
void (async () => {
try {
const [{ listen }, { getCurrentWebviewWindow }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/webviewWindow'),
])
if (!isDetachedLabel(getCurrentWebviewWindow().label)) return
await listen<{ convId?: string }>(OPEN_CONV_EVENT, async (ev) => {
const convId = ev.payload?.convId
if (!convId) return
await handler(convId)
try { await getCurrentWebviewWindow().setFocus() } catch { /* 窗口关闭竞态忽略 */ }
})
} catch (e) {
_openConvListenerRegistered = false
console.error('[AI] 注册跨窗切会话监听失败:', e)
}
})()
}
function genKey(convId: string): string {
return GEN_KEY_PREFIX + convId
}
@@ -125,7 +175,7 @@ async function reattachPanel() {
const all = await WebviewWindow.getAll()
for (const w of all) {
const lbl = w.label
if (lbl === 'ai-detached' || lbl.startsWith(DETACHED_LABEL_PREFIX)) {
if (isDetachedLabel(lbl)) {
try { await w.close() } catch { /* 忽略单个窗口关闭失败 */ }
}
}
+5
View File
@@ -112,6 +112,11 @@ export function toolDisplayName(tc: AiToolCallInfo): string {
const host = httpHost(url)
return host ? `${tGlobal('aiTool.fetchUrlPrefix')} ${host}` : tGlobal('aiTool.fetchUrlFallback')
}
case 'spawn_agent': {
// 任务书截断展示(对齐后端 G1 GoalMeta「派发子代理」Truncate 25 字;shortCmd 顺带折叠多行任务书为单行)
const task = argString(tc.args, 'task')
return task ? tGlobal('aiTool.spawnAgentPrefix', { task: shortCmd(task, 25) }) : formatToolName(tc.name)
}
default: return formatToolName(tc.name)
}
}