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