From 495a5c126d446e11137ab83ab5f07979b95bfc86 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 21 May 2026 22:26:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(goal,ui):=20wire=20evaluating=20halo,=20fol?= =?UTF-8?q?lowup=20=E2=86=BB=20mark,=20dedupe=20system-line=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/chat/MessageBubble.vue | 26 +++++++-- mateclaw-ui/src/composables/chat/useChat.ts | 33 +++++++++-- mateclaw-ui/src/stores/useGoalStore.ts | 58 ++++++++++++++++++- mateclaw-ui/src/views/ChatConsole.vue | 4 +- 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 9d1d1957..505f06df 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -10,14 +10,16 @@
- + @@ -444,6 +446,7 @@ import ToolCallSegment from './ToolCallSegment.vue' import ThinkingSegment from './ThinkingSegment.vue' import ContentSegment from './ContentSegment.vue' import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue' +import { useGoalStore } from '@/stores/useGoalStore' import PlanStepsPanel from './PlanStepsPanel.vue' import UserMessageContent from './UserMessageContent.vue' import type { BrowserAction } from './BrowserTimeline.vue' @@ -487,6 +490,19 @@ const avatarIcon = computed(() => { return role.value === 'user' ? props.userIcon : props.assistantIcon }) +// Followup attribution: an assistant message that opened right after a +// `goal_followup` SSE event belongs to an auto-followup turn. The chat +// composable stamps the message via goalStore on `message_start`; this +// computed reads it back so the ↻ glyph renders on exactly those turns. +const goalStore = useGoalStore() +const isFollowupTurn = computed(() => { + if (role.value !== 'assistant') return false + const cid = props.message.conversationId + const mid = props.message.id + if (!cid || mid == null) return false + return goalStore.isFollowupMessage(String(cid), String(mid)) +}) + // --- 错误卡片 --- const errorInfo = computed(() => props.message.errorInfo) diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 8ed603f7..60503ebc 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -345,6 +345,12 @@ export function useChat(options: UseChatOptions): UseChatReturn { headers: streamHeaders, }) + // Goal store is referenced from several stream handlers (message_start + // for followup attribution, message_complete for the evaluating halo, + // plus the dedicated goal_* events below). Resolve once up front so + // the handlers don't each pull their own copy. + const goalStore = useGoalStore() + // ===== Async-task lifecycle bridge ===== // Generative tools (music / video / image) return a taskId synchronously and // finish asynchronously via `async_task_completed`. If the upstream provider @@ -456,6 +462,13 @@ export function useChat(options: UseChatOptions): UseChatReturn { const assistantMessage = createAssistantMessage('', streamConversationId) ;(assistantMessage as any)._turnId = activeTurnId currentAssistantId.value = assistantMessage.id as string + + // Auto-followup attribution: if the goal evaluator just decided to + // inject a followup, the message that just opened belongs to that + // turn. Stamp it so MessageBubble can render the small ↻ glyph. + if (streamConversationId && goalStore.consumePendingFollowup(streamConversationId)) { + goalStore.markFollowupMessage(streamConversationId, String(assistantMessage.id)) + } }) stream.on('warning', (data) => { @@ -529,6 +542,19 @@ export function useChat(options: UseChatOptions): UseChatReturn { triggerAutoTts(streamConversationId, msg.content) } } + + // Goal-evaluator breathing halo: when an assistant message finishes + // and this conversation has an active goal, the backend's evaluation + // node runs next. Flip the per-conv flag so GoalAvatarRing paints the + // breathing halo until `goal_evaluated` resets it. Skip when no goal + // is active — the halo should be quiet for ordinary turns. + if ( + data.status === 'completed' + && streamConversationId + && goalStore.activeGoal(streamConversationId) + ) { + goalStore.markEvaluating(streamConversationId, true) + } }) stream.on('done', (data) => { @@ -1590,11 +1616,10 @@ export function useChat(options: UseChatOptions): UseChatReturn { } }) - // ===== Goal events (RFC 48) ===== - // Forward GoalEvaluationNode emissions to the goal store. The store - // owns active-goal cache + the per-conv "evaluating" flag that drives + // ===== Goal events ===== + // Forward goal evaluator emissions to the goal store. The store owns + // the active-goal cache + the per-conv "evaluating" flag that drives // the avatar ring's breathing halo. - const goalStore = useGoalStore() stream.on('goal_evaluated', (data) => { if (isStaleEvent(data)) return diff --git a/mateclaw-ui/src/stores/useGoalStore.ts b/mateclaw-ui/src/stores/useGoalStore.ts index 97cd159b..d0650548 100644 --- a/mateclaw-ui/src/stores/useGoalStore.ts +++ b/mateclaw-ui/src/stores/useGoalStore.ts @@ -41,6 +41,19 @@ export const useGoalStore = defineStore('goal', () => { at: number } | null>>({}) + // Per-conversation flag: "the goal evaluator just chose to inject a + // followup prompt, and the next assistant message that opens belongs + // to that followup turn." Consumed (cleared) by the chat composable's + // `message_start` handler so the message gets stamped exactly once. + const pendingFollowupByConv = ref>({}) + + // Assistant message IDs that came from auto-followup turns, grouped by + // conversation. MessageBubble reads this to show the small ↻ glyph on + // the avatar — the only visible signal that a turn was auto-triggered. + // Kept in memory only; on refetch the metadata persists server-side via + // the message's `metadata.fromFollowup` flag (handled by ChatHistory). + const followupMessageIdsByConv = ref>>({}) + const loading = ref(false) async function loadActiveForConversation(conversationId: string) { @@ -152,7 +165,12 @@ export const useGoalStore = defineStore('goal', () => { break } case 'goal_followup': { - // The next assistant turn will land soon; nothing to do for the ring. + // The next assistant turn will land soon. Flag the conversation + // so the chat composable can stamp the upcoming message as a + // followup turn when its `message_start` arrives. The ring keeps + // its evaluating state until message_complete fires for that + // followup turn — so the user sees breathe → still → breathe. + pendingFollowupByConv.value[conversationId] = true break } case 'goal_completed': { @@ -246,12 +264,47 @@ export const useGoalStore = defineStore('goal', () => { recentTerminalByConv.value[conversationId] = null } + // ==================== Followup attribution helpers ==================== + + /** + * Consume the pending-followup flag for this conversation if it's + * set, returning true when the caller should stamp the just-opened + * assistant message as a followup turn. Idempotent — calling twice + * returns false the second time. + */ + function consumePendingFollowup(conversationId: string): boolean { + if (!conversationId) return false + const pending = pendingFollowupByConv.value[conversationId] + if (pending) { + pendingFollowupByConv.value[conversationId] = false + return true + } + return false + } + + function markFollowupMessage(conversationId: string, messageId: string) { + if (!conversationId || !messageId) return + let set = followupMessageIdsByConv.value[conversationId] + if (!set) { + set = new Set() + followupMessageIdsByConv.value[conversationId] = set + } + set.add(messageId) + } + + function isFollowupMessage(conversationId: string, messageId: string): boolean { + if (!conversationId || !messageId) return false + return followupMessageIdsByConv.value[conversationId]?.has(messageId) ?? false + } + return { activeGoalByConv, evaluatingByConv, eventsByGoal, dismissedPromptByConv, recentTerminalByConv, + pendingFollowupByConv, + followupMessageIdsByConv, loading, loadActiveForConversation, create, @@ -269,6 +322,9 @@ export const useGoalStore = defineStore('goal', () => { clearDismissedPrompt, recentTerminal, clearRecentTerminal, + consumePendingFollowup, + markFollowupMessage, + isFollowupMessage, } }) diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 3d94c231..2650600d 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -1065,7 +1065,9 @@ const goalTerminalForCurrent = computed(() => const goalSystemLineTitle = computed(() => { const t = goalTerminalForCurrent.value if (!t) return '' - return t.status === 'completed' ? `🎉 ${t.title}` : `⚠ ${t.title}` + // The leading icon is owned by GoalSystemLine (✦ / ⚠) so we don't + // prepend one here — doing so produced "✦ 🎉 …" double-glyph titles. + return t.status === 'completed' ? `目标达成 · ${t.title}` : `这次的预算用完了 · ${t.title}` }) const goalSystemLineDetail = computed(() => { const t = goalTerminalForCurrent.value