chore(llm): drop unused imports, add XIAOMI_MIMO cross-turn cache tests

This commit is contained in:
matevip 2026-05-21 17:24:53 +08:00
parent 7861f603eb
commit 9c5ad29d42
7 changed files with 380 additions and 27 deletions

View File

@ -1,9 +1,6 @@
package vip.mate.llm.chatmodel; package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
@ -34,7 +31,6 @@ import java.util.concurrent.ConcurrentHashMap;
* *
* @author MateClaw Team * @author MateClaw Team
*/ */
@Slf4j
public final class ReasoningContentCache { public final class ReasoningContentCache {
private static final long DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000L; // 24 hours private static final long DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000L; // 24 hours

View File

@ -82,11 +82,13 @@ class PatchReasoningContentTest {
@BeforeEach @BeforeEach
void clearRelay() { void clearRelay() {
AssistantThinkingRelay.clearAll(); AssistantThinkingRelay.clearAll();
ReasoningContentCache.clear();
} }
@AfterEach @AfterEach
void clearRelayAfter() { void clearRelayAfter() {
AssistantThinkingRelay.clearAll(); AssistantThinkingRelay.clearAll();
ReasoningContentCache.clear();
} }
// ---------- No-relay, no-thinking-mode path ---------- // ---------- No-relay, no-thinking-mode path ----------
@ -427,4 +429,73 @@ class PatchReasoningContentTest {
assertEquals(" ", out.messages().get(3).reasoningContent(), assertEquals(" ", out.messages().get(3).reasoningContent(),
"DEEPSEEK plain in-turn assistant gets ' ' as before"); "DEEPSEEK plain in-turn assistant gets ' ' as before");
} }
// ---------- XIAOMI_MIMO policy + cross-turn cache replay ----------
@Test
@DisplayName("XIAOMI_MIMO cross-turn tool_call: cache hit replays real reasoning_content")
void xiaomiMimoCrossTurn_replaysCachedReasoning() {
// Prior turn produced a tool_call with real thinking; NodeStreamingChatHelper
// stored it in the cache keyed by tool_call_id. On the next turn, the same
// assistant message is replayed as history with reasoning_content=null
// resolveCrossTurnReasoning must fetch the cached value before falling
// back to the policy's empty " ".
ReasoningContentCache.store(List.of("call_1"), "real-prior-thinking");
// Empty relay: no in-turn thinking (current turn hasn't produced one yet).
String token = AssistantThinkingRelay.stash(List.of(""), null);
ChatCompletionRequest req = request(List.of(
user("q1"),
assistantToolCall("a1", null), // i=1, cross-turn (1 <= 2), tool_call id="call_1"
user("q2") // i=2, lastUserIdx
), token);
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("xiaomi-mimo"));
assertEquals("real-prior-thinking", out.messages().get(1).reasoningContent(),
"XIAOMI_MIMO cross-turn tool_call must replay cached reasoning_content over the ' ' fallback");
}
@Test
@DisplayName("XIAOMI_MIMO cross-turn tool_call: cache miss falls back to ' '")
void xiaomiMimoCrossTurnCacheMiss_fallsBackToSpace() {
// No cache entry for call_1 the multi-turn path must still validate by
// injecting the policy's emptyFallback so MiMo doesn't 400.
String token = AssistantThinkingRelay.stash(List.of(""), null);
ChatCompletionRequest req = request(List.of(
user("q1"),
assistantToolCall("a1", null), // i=1, cross-turn, no cache entry
user("q2")
), token);
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("xiaomi-mimo"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"XIAOMI_MIMO cross-turn cache miss falls back to ' ' so the request still validates");
}
@Test
@DisplayName("XIAOMI_MIMO plain cross-turn assistant (no tool_calls) also patched via patchNonToolCall=true")
void xiaomiMimoCrossTurnPlainAssistant_patchedWithSpace() {
// XIAOMI_MIMO mirrors DEEPSEEK: patchNonToolCall=true means even plain
// text assistants in prior turns must carry reasoning_content. Cache
// can't help here (no tool_call_ids to key on) fallback is " ".
String token = AssistantThinkingRelay.stash(List.of("", ""), null);
ChatCompletionRequest req = request(List.of(
user("q1"),
new ChatCompletionMessage("plain a1", Role.ASSISTANT), // i=1, cross-turn, no tool_calls
user("q2"),
new ChatCompletionMessage("plain a2", Role.ASSISTANT) // i=3, in-turn, no tool_calls
), token);
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("xiaomi-mimo"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"XIAOMI_MIMO plain prior-turn assistant gets ' ' (patchNonToolCall=true + patchCrossTurn=true)");
assertEquals(" ", out.messages().get(3).reasoningContent(),
"XIAOMI_MIMO plain in-turn assistant gets ' ' (patchNonToolCall=true)");
}
} }

View File

@ -106,6 +106,11 @@
/> />
<div v-else class="conv-title" @dblclick.stop="startRename(conv)"> <div v-else class="conv-title" @dblclick.stop="startRename(conv)">
<span>{{ conv.title }}</span> <span>{{ conv.title }}</span>
<span
v-if="convGoalStatus(conv.conversationId) === 'active'"
class="conv-goal-dot"
:title="t('goal.sidebarActive', '此对话有正在进行的目标')"
></span>
<span <span
v-if="hasUnread(conv)" v-if="hasUnread(conv)"
class="conv-unread-dot" class="conv-unread-dot"
@ -193,6 +198,16 @@ import { channelIconUrl } from '@/utils/channelSource'
import { mcToast } from '@/composables/useMcToast' import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm' import { mcConfirm } from '@/components/common/useConfirm'
import type { Conversation, Agent } from '@/types' import type { Conversation, Agent } from '@/types'
import { useGoalStore } from '@/stores/useGoalStore'
const goalStore = useGoalStore()
/** Sidebar marker: a 6 px dot next to the conv title when that conv
* has an active goal. Color tracks status (orange = in progress).
* Falls back silently when the store hasn't loaded this conv yet. */
function convGoalStatus(cid: string): string | null {
const g = goalStore.activeGoalByConv?.[cid]
return g?.status ?? null
}
const props = defineProps<{ const props = defineProps<{
conversations: Conversation[] conversations: Conversation[]
@ -684,6 +699,22 @@ function onMenuSelect(item: DropdownMenuItem) {
vertical-align: middle; vertical-align: middle;
} }
/* Sidebar marker for "this conversation has an active goal". Same hue
* family as the avatar ring; sits at 60% opacity so it whispers rather
* than competes with unread / running indicators on the same row. */
.conv-goal-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--mc-primary, #d97757);
opacity: 0.7;
margin-left: 6px;
flex-shrink: 0;
vertical-align: middle;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mc-primary, #d97757) 18%, transparent);
}
.conv-item.is-running { .conv-item.is-running {
background: color-mix(in srgb, #fbbf24 8%, transparent); background: color-mix(in srgb, #fbbf24 8%, transparent);
} }

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

@ -5,11 +5,11 @@ import { goalApi, type Goal, type GoalEvent } from '@/api/index'
/** /**
* Per-conversation active goal cache + SSE event handlers. * Per-conversation active goal cache + SSE event handlers.
* *
* RFC 48 v3 Jobs-cut UI: the only visible affordances are the ring on the * The visible affordances are the ring on the assistant avatar
* assistant avatar (driven by `activeGoalByConv[cid]`) and the inline * (driven by `activeGoalByConv[cid]`) and the inline "set goal" prompt
* "set goal" prompt that appears after the first assistant reply. There * that appears after the first assistant reply. There is no banner,
* is no banner, no modal dialog, no drawer in the chat view. A separate * no modal dialog, no drawer in the chat view. A separate /goals admin
* /goals admin page (PR4b, optional) shows the full timeline. * page shows the full timeline.
*/ */
export const useGoalStore = defineStore('goal', () => { export const useGoalStore = defineStore('goal', () => {
// Map of conversationId -> active goal (or null). // Map of conversationId -> active goal (or null).
@ -23,6 +23,37 @@ export const useGoalStore = defineStore('goal', () => {
// Cached event timelines, keyed by goalId. // Cached event timelines, keyed by goalId.
const eventsByGoal = ref<Record<string, GoalEvent[]>>({}) const eventsByGoal = ref<Record<string, GoalEvent[]>>({})
// Per-conversation "the user said 'No thanks' to setting a goal" — keeps
// GoalSetInlinePrompt from re-appearing every turn. In-memory only
// (intentionally not persisted: a fresh session re-prompts so the user
// can change their mind on the next visit).
const dismissedPromptByConv = ref<Record<string, boolean>>({})
// Per-conversation snapshot of the most recent terminal event
// (completed / exhausted). ChatConsole reads this to render the
// GoalSystemLine in the message stream. Cleared when the user dismisses
// it or starts a new goal on the same conversation.
const recentTerminalByConv = ref<Record<string, {
status: 'completed' | 'exhausted'
title: string
score?: number | null
reason?: string
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<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) {
@ -134,25 +165,41 @@ 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': {
evaluatingByConv.value[conversationId] = false evaluatingByConv.value[conversationId] = false
if (goal) { // Capture the terminal snapshot BEFORE we null out the cache so
goal.status = 'completed' // the GoalSystemLine has a title + score to render.
if (data?.score != null) goal.completionScore = Number(data.score) recentTerminalByConv.value[conversationId] = {
status: 'completed',
title: goal?.title || '目标',
score: data?.score != null ? Number(data.score) : (goal?.completionScore ?? null),
at: Date.now(),
} }
// Refresh active so the empty-state prompt comes back into view. // Clear active so the inline "set goal" prompt can come back.
activeGoalByConv.value[conversationId] = null activeGoalByConv.value[conversationId] = null
// Re-enable the inline prompt on completion (the user just closed
// a goal; a follow-up task is plausible).
dismissedPromptByConv.value[conversationId] = false
break break
} }
case 'goal_exhausted': { case 'goal_exhausted': {
evaluatingByConv.value[conversationId] = false evaluatingByConv.value[conversationId] = false
if (goal) { recentTerminalByConv.value[conversationId] = {
goal.status = 'exhausted' status: 'exhausted',
title: goal?.title || '目标',
reason: data?.reason,
at: Date.now(),
} }
activeGoalByConv.value[conversationId] = null activeGoalByConv.value[conversationId] = null
dismissedPromptByConv.value[conversationId] = false
break break
} }
case 'goal_created': case 'goal_created':
@ -195,10 +242,69 @@ export const useGoalStore = defineStore('goal', () => {
return Math.max(0, Math.min(1, g.completionScore)) return Math.max(0, Math.min(1, g.completionScore))
} }
// ==================== Inline prompt + system line helpers ====================
function isPromptDismissed(conversationId: string): boolean {
return Boolean(dismissedPromptByConv.value[conversationId])
}
function dismissPrompt(conversationId: string) {
dismissedPromptByConv.value[conversationId] = true
}
function clearDismissedPrompt(conversationId: string) {
dismissedPromptByConv.value[conversationId] = false
}
function recentTerminal(conversationId: string) {
return recentTerminalByConv.value[conversationId] ?? null
}
function clearRecentTerminal(conversationId: string) {
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,
recentTerminalByConv,
pendingFollowupByConv,
followupMessageIdsByConv,
loading, loading,
loadActiveForConversation, loadActiveForConversation,
create, create,
@ -211,6 +317,14 @@ export const useGoalStore = defineStore('goal', () => {
isEvaluating, isEvaluating,
activeGoal, activeGoal,
progressFraction, progressFraction,
isPromptDismissed,
dismissPrompt,
clearDismissedPrompt,
recentTerminal,
clearRecentTerminal,
consumePendingFollowup,
markFollowupMessage,
isFollowupMessage,
} }
}) })

View File

@ -164,6 +164,31 @@
</div> </div>
</div> </div>
<!-- Terminal-state announcement after a goal completed or exhausted
in this conversation. Auto-dismisses when the user clicks × or
starts a new goal. -->
<GoalSystemLine
v-if="goalTerminalForCurrent && currentConversationId"
:variant="goalTerminalForCurrent.status"
:title="goalSystemLineTitle"
:detail="goalSystemLineDetail"
class="goal-system-line-slot"
@click.stop="onGoalSystemLineDismiss"
/>
<!-- Inline "set a goal?" invitation shown after the first assistant
reply when the conversation has no active goal and the user
hasn't dismissed it for this conv. -->
<GoalSetInlinePrompt
v-if="showGoalSetPrompt"
:conversation-id="currentConversationId"
:agent-id="String(selectedAgentId)"
:workspace-id="String(currentWorkspaceId || '1')"
:suggested-title="goalSuggestedTitle"
class="goal-set-prompt-slot"
@dismiss="onGoalPromptDismiss"
/>
<!-- 流式处理 Loading 消息和输入框之间 --> <!-- 流式处理 Loading 消息和输入框之间 -->
<StreamLoadingBar <StreamLoadingBar
:is-loading="isGenerating && !blockingPrompt" :is-loading="isGenerating && !blockingPrompt"
@ -259,6 +284,9 @@ import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
import { useKatexRenderer } from '@/composables/useKatexRenderer' import { useKatexRenderer } from '@/composables/useKatexRenderer'
import { useMermaidRenderer, handleMermaidDownload } from '@/composables/useMermaidRenderer' import { useMermaidRenderer, handleMermaidDownload } from '@/composables/useMermaidRenderer'
import { useGoalStore } from '@/stores/useGoalStore' import { useGoalStore } from '@/stores/useGoalStore'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import GoalSetInlinePrompt from '@/components/goal/GoalSetInlinePrompt.vue'
import GoalSystemLine from '@/components/goal/GoalSystemLine.vue'
// ============ Talk Mode ============ // ============ Talk Mode ============
const showTalkMode = ref(false) const showTalkMode = ref(false)
@ -1009,16 +1037,88 @@ watch([selectedAgentId, currentConversationId], () => {
syncRouteState() syncRouteState()
}) })
// RFC 48 load the active goal whenever the user switches conversation. // Load the active goal whenever the user switches conversation. The
// The avatar ring listens on goalStore.activeGoalByConv[cid]; without this // avatar ring listens on goalStore.activeGoalByConv[cid]; without this
// fetch the ring would only appear after an SSE event mutated the store. // fetch the ring would only appear after an SSE event mutated the store.
const goalStore = useGoalStore() const goalStore = useGoalStore()
const workspaceStoreForGoal = useWorkspaceStore()
const currentWorkspaceId = computed(() => workspaceStoreForGoal.currentWorkspaceId ?? '1')
watch(currentConversationId, async (cid) => { watch(currentConversationId, async (cid) => {
if (cid) { if (cid) {
await goalStore.loadActiveForConversation(cid) await goalStore.loadActiveForConversation(cid)
} }
}, { immediate: true }) }, { immediate: true })
// Derive props for the inline prompt + system-line slots that sit
// between MessageList and ChatInput. The prompt shows only when:
// 1) there's a current conversation, agent, and at least one assistant
// reply (otherwise the prompt is premature);
// 2) there's no active goal (the ring already covers active state);
// 3) the user hasn't dismissed the prompt on this conv;
// 4) we're not mid-stream (don't pop suggestions while the agent
// is still typing).
const goalTerminalForCurrent = computed(() =>
currentConversationId.value
? goalStore.recentTerminal(currentConversationId.value)
: null,
)
const goalSystemLineTitle = computed(() => {
const t = goalTerminalForCurrent.value
if (!t) return ''
// 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
if (!t) return ''
if (t.status === 'completed') {
return t.score != null
? `已完成 · final score ${t.score.toFixed(2)}`
: '已完成 · 总结已存入长期记忆'
}
// exhausted
if (t.reason === 'turn_budget') return '预算轮数用完。'
if (t.reason === 'llm_call_budget') return 'LLM 调用预算用完。'
return '预算耗尽。'
})
const showGoalSetPrompt = computed(() => {
if (!currentConversationId.value || !selectedAgentId.value) return false
if (isGenerating.value) return false
// Active goal? The ring covers that no need for a prompt.
if (goalStore.activeGoal(currentConversationId.value)) return false
// Recent terminal still showing? Let the user dismiss that first.
if (goalTerminalForCurrent.value) return false
if (goalStore.isPromptDismissed(currentConversationId.value)) return false
// Need at least one user assistant exchange so the prompt has
// context to derive a suggested title from.
const hasAssistantReply = messages.value.some(m => m.role === 'assistant')
return hasAssistantReply
})
// Build a sensible default title from the conversation's first user
// message. The user can always edit later via the goal page.
const goalSuggestedTitle = computed(() => {
const firstUser = messages.value.find(m => m.role === 'user')
const raw = (firstUser?.content || '').trim()
if (!raw) return '新目标'
// 80 char clip mirrors GoalController.create validation.
return raw.length > 80 ? raw.slice(0, 77) + '...' : raw
})
function onGoalPromptDismiss() {
if (currentConversationId.value) {
goalStore.dismissPrompt(currentConversationId.value)
}
}
function onGoalSystemLineDismiss() {
if (currentConversationId.value) {
goalStore.clearRecentTerminal(currentConversationId.value)
}
}
// Refetch agent capabilities (modalities + sidecar config) on agent change so // Refetch agent capabilities (modalities + sidecar config) on agent change so
// the multimodal routing hint above the input box can react synchronously when // the multimodal routing hint above the input box can react synchronously when
// the user attaches an image / video. // the user attaches an image / video.