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.