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

- 工具纪律段抽共享同源注入
- 子代理轨迹浮层与完成卡身份
- 分离窗会话切换修复
- 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
+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)
}
}