From 9c5ad29d42b442c29c64a316f794040aa7f7d5c1 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 21 May 2026 17:24:53 +0800 Subject: [PATCH] chore(llm): drop unused imports, add XIAOMI_MIMO cross-turn cache tests --- .../llm/chatmodel/ReasoningContentCache.java | 4 - .../chatmodel/PatchReasoningContentTest.java | 71 +++++++++ .../components/chat/ConversationSidebar.vue | 31 ++++ .../src/components/chat/MessageBubble.vue | 26 +++- mateclaw-ui/src/composables/chat/useChat.ts | 33 ++++- mateclaw-ui/src/stores/useGoalStore.ts | 138 ++++++++++++++++-- mateclaw-ui/src/views/ChatConsole.vue | 104 ++++++++++++- 7 files changed, 380 insertions(+), 27 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningContentCache.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningContentCache.java index 35c93544..c6cfa2eb 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningContentCache.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningContentCache.java @@ -1,9 +1,6 @@ package vip.mate.llm.chatmodel; -import lombok.extern.slf4j.Slf4j; - import java.util.List; -import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** @@ -34,7 +31,6 @@ import java.util.concurrent.ConcurrentHashMap; * * @author MateClaw Team */ -@Slf4j public final class ReasoningContentCache { private static final long DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000L; // 24 hours diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java index f8c96473..6cde388c 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java @@ -82,11 +82,13 @@ class PatchReasoningContentTest { @BeforeEach void clearRelay() { AssistantThinkingRelay.clearAll(); + ReasoningContentCache.clear(); } @AfterEach void clearRelayAfter() { AssistantThinkingRelay.clearAll(); + ReasoningContentCache.clear(); } // ---------- No-relay, no-thinking-mode path ---------- @@ -427,4 +429,73 @@ class PatchReasoningContentTest { assertEquals(" ", out.messages().get(3).reasoningContent(), "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)"); + } } diff --git a/mateclaw-ui/src/components/chat/ConversationSidebar.vue b/mateclaw-ui/src/components/chat/ConversationSidebar.vue index c548583b..6c3c4a89 100644 --- a/mateclaw-ui/src/components/chat/ConversationSidebar.vue +++ b/mateclaw-ui/src/components/chat/ConversationSidebar.vue @@ -106,6 +106,11 @@ />
{{ conv.title }} +
- + @@ -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 1f7b3433..d0650548 100644 --- a/mateclaw-ui/src/stores/useGoalStore.ts +++ b/mateclaw-ui/src/stores/useGoalStore.ts @@ -5,11 +5,11 @@ import { goalApi, type Goal, type GoalEvent } from '@/api/index' /** * Per-conversation active goal cache + SSE event handlers. * - * RFC 48 v3 Jobs-cut UI: the only visible affordances are the ring on the - * assistant avatar (driven by `activeGoalByConv[cid]`) and the inline - * "set goal" prompt that appears after the first assistant reply. There - * is no banner, no modal dialog, no drawer in the chat view. A separate - * /goals admin page (PR4b, optional) shows the full timeline. + * The visible affordances are the ring on the assistant avatar + * (driven by `activeGoalByConv[cid]`) and the inline "set goal" prompt + * that appears after the first assistant reply. There is no banner, + * no modal dialog, no drawer in the chat view. A separate /goals admin + * page shows the full timeline. */ export const useGoalStore = defineStore('goal', () => { // Map of conversationId -> active goal (or null). @@ -23,6 +23,37 @@ export const useGoalStore = defineStore('goal', () => { // Cached event timelines, keyed by goalId. const eventsByGoal = ref>({}) + // 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>({}) + + // 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>({}) + + // 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) { @@ -134,25 +165,41 @@ 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': { evaluatingByConv.value[conversationId] = false - if (goal) { - goal.status = 'completed' - if (data?.score != null) goal.completionScore = Number(data.score) + // Capture the terminal snapshot BEFORE we null out the cache so + // the GoalSystemLine has a title + score to render. + 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 + // Re-enable the inline prompt on completion (the user just closed + // a goal; a follow-up task is plausible). + dismissedPromptByConv.value[conversationId] = false break } case 'goal_exhausted': { evaluatingByConv.value[conversationId] = false - if (goal) { - goal.status = 'exhausted' + recentTerminalByConv.value[conversationId] = { + status: 'exhausted', + title: goal?.title || '目标', + reason: data?.reason, + at: Date.now(), } activeGoalByConv.value[conversationId] = null + dismissedPromptByConv.value[conversationId] = false break } case 'goal_created': @@ -195,10 +242,69 @@ export const useGoalStore = defineStore('goal', () => { 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() + 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, @@ -211,6 +317,14 @@ export const useGoalStore = defineStore('goal', () => { isEvaluating, activeGoal, progressFraction, + isPromptDismissed, + dismissPrompt, + clearDismissedPrompt, + recentTerminal, + clearRecentTerminal, + consumePendingFollowup, + markFollowupMessage, + isFollowupMessage, } }) diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 7862a220..2650600d 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -164,6 +164,31 @@
+ + + + + + { syncRouteState() }) -// RFC 48 — load the active goal whenever the user switches conversation. -// The avatar ring listens on goalStore.activeGoalByConv[cid]; without this +// Load the active goal whenever the user switches conversation. The +// avatar ring listens on goalStore.activeGoalByConv[cid]; without this // fetch the ring would only appear after an SSE event mutated the store. const goalStore = useGoalStore() +const workspaceStoreForGoal = useWorkspaceStore() +const currentWorkspaceId = computed(() => workspaceStoreForGoal.currentWorkspaceId ?? '1') watch(currentConversationId, async (cid) => { if (cid) { await goalStore.loadActiveForConversation(cid) } }, { 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 // the multimodal routing hint above the input box can react synchronously when // the user attaches an image / video.