mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
fix(goal,ui): wire evaluating halo, followup ↻ mark, dedupe system-line icon
This commit is contained in:
parent
c34e8290ac
commit
495a5c126d
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user