fix(goal,ui): wire evaluating halo, followup ↻ mark, dedupe system-line icon

This commit is contained in:
matevip 2026-05-21 22:26:48 +08:00
parent c34e8290ac
commit 495a5c126d
4 changed files with 110 additions and 11 deletions

View File

@ -10,14 +10,16 @@
<!-- 头像 --> <!-- 头像 -->
<div class="msg-avatar" :class="`${role}-avatar`"> <div class="msg-avatar" :class="`${role}-avatar`">
<slot name="avatar"> <slot name="avatar">
<!-- RFC 48 Jobs-cut: when the assistant has an active goal, wrap <!-- When the assistant has an active goal, wrap the logo in
the logo in GoalAvatarRing so the progress ring + breathing GoalAvatarRing so the progress ring + breathing halo + hover
halo + hover tooltip all sit naturally around the avatar. tooltip all sit naturally around the avatar. The component
The component renders only the slot content when no goal renders only the slot content when no goal exists, so non-
exists, so non-goal turns look identical to before. --> goal turns look identical to before. The followup glyph
appears on messages that came from an auto-followup turn. -->
<GoalAvatarRing <GoalAvatarRing
v-if="role === 'assistant'" v-if="role === 'assistant'"
:conversation-id="message.conversationId" :conversation-id="message.conversationId"
:show-followup-mark="isFollowupTurn"
> >
<img src="/logo/mateclaw_logo_s.png" alt="" class="avatar-logo" /> <img src="/logo/mateclaw_logo_s.png" alt="" class="avatar-logo" />
</GoalAvatarRing> </GoalAvatarRing>
@ -444,6 +446,7 @@ import ToolCallSegment from './ToolCallSegment.vue'
import ThinkingSegment from './ThinkingSegment.vue' import ThinkingSegment from './ThinkingSegment.vue'
import ContentSegment from './ContentSegment.vue' import ContentSegment from './ContentSegment.vue'
import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue' import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue'
import { useGoalStore } from '@/stores/useGoalStore'
import PlanStepsPanel from './PlanStepsPanel.vue' import PlanStepsPanel from './PlanStepsPanel.vue'
import UserMessageContent from './UserMessageContent.vue' import UserMessageContent from './UserMessageContent.vue'
import type { BrowserAction } from './BrowserTimeline.vue' import type { BrowserAction } from './BrowserTimeline.vue'
@ -487,6 +490,19 @@ const avatarIcon = computed(() => {
return role.value === 'user' ? props.userIcon : props.assistantIcon 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<ChatErrorInfo | undefined>(() => props.message.errorInfo) const errorInfo = computed<ChatErrorInfo | undefined>(() => props.message.errorInfo)

View File

@ -345,6 +345,12 @@ export function useChat(options: UseChatOptions): UseChatReturn {
headers: streamHeaders, 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 ===== // ===== Async-task lifecycle bridge =====
// Generative tools (music / video / image) return a taskId synchronously and // Generative tools (music / video / image) return a taskId synchronously and
// finish asynchronously via `async_task_completed`. If the upstream provider // finish asynchronously via `async_task_completed`. If the upstream provider
@ -456,6 +462,13 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const assistantMessage = createAssistantMessage('', streamConversationId) const assistantMessage = createAssistantMessage('', streamConversationId)
;(assistantMessage as any)._turnId = activeTurnId ;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string 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) => { stream.on('warning', (data) => {
@ -529,6 +542,19 @@ export function useChat(options: UseChatOptions): UseChatReturn {
triggerAutoTts(streamConversationId, msg.content) 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) => { stream.on('done', (data) => {
@ -1590,11 +1616,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
}) })
// ===== Goal events (RFC 48) ===== // ===== Goal events =====
// Forward GoalEvaluationNode emissions to the goal store. The store // Forward goal evaluator emissions to the goal store. The store owns
// owns active-goal cache + the per-conv "evaluating" flag that drives // the active-goal cache + the per-conv "evaluating" flag that drives
// the avatar ring's breathing halo. // the avatar ring's breathing halo.
const goalStore = useGoalStore()
stream.on('goal_evaluated', (data) => { stream.on('goal_evaluated', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return

View File

@ -41,6 +41,19 @@ export const useGoalStore = defineStore('goal', () => {
at: number at: number
} | null>>({}) } | 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<Record<string, boolean>>({})
// 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<Record<string, Set<string>>>({})
const loading = ref(false) const loading = ref(false)
async function loadActiveForConversation(conversationId: string) { async function loadActiveForConversation(conversationId: string) {
@ -152,7 +165,12 @@ export const useGoalStore = defineStore('goal', () => {
break break
} }
case 'goal_followup': { 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 break
} }
case 'goal_completed': { case 'goal_completed': {
@ -246,12 +264,47 @@ export const useGoalStore = defineStore('goal', () => {
recentTerminalByConv.value[conversationId] = null 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<string>()
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 { return {
activeGoalByConv, activeGoalByConv,
evaluatingByConv, evaluatingByConv,
eventsByGoal, eventsByGoal,
dismissedPromptByConv, dismissedPromptByConv,
recentTerminalByConv, recentTerminalByConv,
pendingFollowupByConv,
followupMessageIdsByConv,
loading, loading,
loadActiveForConversation, loadActiveForConversation,
create, create,
@ -269,6 +322,9 @@ export const useGoalStore = defineStore('goal', () => {
clearDismissedPrompt, clearDismissedPrompt,
recentTerminal, recentTerminal,
clearRecentTerminal, clearRecentTerminal,
consumePendingFollowup,
markFollowupMessage,
isFollowupMessage,
} }
}) })

View File

@ -1065,7 +1065,9 @@ const goalTerminalForCurrent = computed(() =>
const goalSystemLineTitle = computed(() => { const goalSystemLineTitle = computed(() => {
const t = goalTerminalForCurrent.value const t = goalTerminalForCurrent.value
if (!t) return '' 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 goalSystemLineDetail = computed(() => {
const t = goalTerminalForCurrent.value const t = goalTerminalForCurrent.value