diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 97bd6ae1..956195d2 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -659,19 +659,19 @@ const segments = computed(() => { const useSegmentedView = computed(() => segments.value.length > 1) const toolCallsMeta = computed(() => { - return props.message.metadata?.toolCalls || [] + return parsedMetadata.value?.toolCalls || [] }) const browserActionsMeta = computed(() => { - return props.message.metadata?.browserActions || [] + return parsedMetadata.value?.browserActions || [] }) const planMeta = computed(() => { - return props.message.metadata?.plan + return parsedMetadata.value?.plan }) const currentPhaseName = computed(() => { - const phase = props.message.metadata?.currentPhase + const phase = parsedMetadata.value?.currentPhase switch (phase) { case 'reasoning': return 'Reasoning' case 'action': return 'Executing tools' @@ -693,7 +693,9 @@ const truncateArgs = (args: string) => { // --- 审批面板 --- const pendingApproval = computed(() => { - return props.message.metadata?.pendingApproval || null + const approval = parsedMetadata.value?.pendingApproval + if (!approval || approval.status === 'expired') return null + return approval }) const approvalSeverityClass = computed(() => { @@ -724,7 +726,7 @@ const showExecutionPanel = computed(() => { if (role.value !== 'assistant') return false // 审批卡片有独立的渲染区域,但 execution panel 也应该在审批阶段展示上下文 return toolCallsMeta.value.length > 0 || !!planMeta.value - || (isGenerating.value && props.message.metadata?.currentPhase) + || (isGenerating.value && parsedMetadata.value?.currentPhase) || !!pendingApproval.value }) diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 9361c7d1..3871ca99 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -175,6 +175,44 @@ export function useChat(options: UseChatOptions): UseChatReturn { return metadata } + /** + * SSE 以 error/done 结束但审批实际上已失效时,收口残留的 awaiting_approval UI 状态。 + * 必须用 updateMessage 触发响应式更新,不能只改嵌套字段。 + */ + const expirePendingApprovals = (finalStatus: 'completed' | 'failed' | 'stopped') => { + for (const m of messages.value) { + if (m.role !== 'assistant') continue + const metadata = parseMetadata((m as any).metadata) + const pendingApproval = metadata?.pendingApproval + const hasPendingApproval = pendingApproval?.status === 'pending_approval' + const isAwaitingApprovalMsg = m.status === 'awaiting_approval' || metadata?.currentPhase === 'awaiting_approval' + if (!hasPendingApproval && !isAwaitingApprovalMsg) continue + if (m.id === undefined || m.id === null) continue + + const toolCalls = Array.isArray(metadata?.toolCalls) + ? metadata.toolCalls.map((tc: any) => ( + tc?.status === 'running' || tc?.status === 'awaiting_approval' + ? { ...tc, status: 'completed' } + : tc + )) + : metadata?.toolCalls + + updateMessage(m.id, { + ...m, + status: m.status === 'awaiting_approval' ? finalStatus : m.status, + metadata: { + ...metadata, + currentPhase: undefined, + runningToolName: undefined, + toolCalls, + pendingApproval: hasPendingApproval + ? { ...pendingApproval, status: 'expired' } + : pendingApproval, + }, + } as any) + } + } + // 消息管理 const { messages, @@ -384,6 +422,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { : data.status === 'stopped' ? 'stopped' : 'completed' if (data.status !== 'awaiting_approval') { phaseInfo.value = null + expirePendingApprovals(data.status === 'stopped' ? 'stopped' : 'completed') } // 兜底清理排队状态(如果 queued_input_started 已经处理了则这里是 no-op) @@ -431,6 +470,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { phaseInfo.value = null // 错误时清理排队状态,避免脏残留 messageQueue.clear() + expirePendingApprovals('failed') if (errorFired) return errorFired = true diff --git a/mateclaw-ui/src/utils/messageReconcile.ts b/mateclaw-ui/src/utils/messageReconcile.ts index a9de68a9..feff58cd 100644 --- a/mateclaw-ui/src/utils/messageReconcile.ts +++ b/mateclaw-ui/src/utils/messageReconcile.ts @@ -93,6 +93,9 @@ function mergeContentParts(localParts: any[], fetchedParts: any[]): any[] { return merged.sort((a, b) => (order[a.type] ?? 99) - (order[b.type] ?? 99)) } +/** 审批的已决状态(前端通过 SSE 实时更新,比后端持久化更新) */ +const RESOLVED_APPROVAL = new Set(['expired', 'approved', 'denied']) + function mergeMetadata(localMetaRaw: any, fetchedMetaRaw: any): Record { const localMeta = safeParseMeta(localMetaRaw) const fetchedMeta = safeParseMeta(fetchedMetaRaw) @@ -114,7 +117,16 @@ function mergeMetadata(localMetaRaw: any, fetchedMetaRaw: any): Record