优化: 子代理体验与纪律注入
- 工具纪律段抽共享同源注入 - 子代理轨迹浮层与完成卡身份 - 分离窗会话切换修复 - 960s 统一出口报告不再蒸发
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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 { /* 忽略单个窗口关闭失败 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user