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