From c34e8290ac6a5259a91a77ef1b00386d5647c982 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 21 May 2026 22:26:40 +0800 Subject: [PATCH] feat(goal,ui): inline set-goal prompt, terminal system-line, sidebar dot --- .../agent/graph/NodeStreamingChatHelper.java | 27 ----- .../llm/chatmodel/OpenAiRequestRewriter.java | 43 ++----- .../llm/chatmodel/ReasoningContentCache.java | 112 ------------------ .../java/vip/mate/llm/model/ModelFamily.java | 14 --- .../chatmodel/PatchReasoningContentTest.java | 71 ----------- .../chatmodel/ReasoningContentCacheTest.java | 69 ----------- .../vip/mate/llm/model/ModelFamilyTest.java | 11 -- .../src/components/chat/MessageBubble.vue | 26 +--- mateclaw-ui/src/composables/chat/useChat.ts | 33 +----- mateclaw-ui/src/stores/useGoalStore.ts | 58 +-------- mateclaw-ui/src/views/ChatConsole.vue | 4 +- 11 files changed, 21 insertions(+), 447 deletions(-) delete mode 100644 mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningContentCache.java delete mode 100644 mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningContentCacheTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index 988da757..09018a14 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -11,7 +11,6 @@ import org.springframework.ai.chat.prompt.Prompt; import org.springframework.web.reactive.function.client.WebClientResponseException; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.llm.chatmodel.AssistantThinkingRelay; -import vip.mate.llm.chatmodel.ReasoningContentCache; import reactor.core.Disposable; @@ -1207,10 +1206,6 @@ public class NodeStreamingChatHelper { AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls); - // Cache reasoning_content for MiMo-style providers that require it on - // subsequent turns. - cacheReasoningContent(fullThinking, finalToolCalls); - recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); return new StreamResult(fullContent, fullThinking, assembledMessage, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, @@ -1240,10 +1235,6 @@ public class NodeStreamingChatHelper { AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls); - // Cache reasoning_content for MiMo-style providers that require it on - // subsequent turns. The cache replays real values instead of empty strings. - cacheReasoningContent(fullThinking, finalToolCalls); - recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); return new StreamResult(fullContent, fullThinking, assembledMessage, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, @@ -1277,24 +1268,6 @@ public class NodeStreamingChatHelper { return builder.build(); } - /** - * Store reasoning content in the cache for cross-turn replay. - * Only caches when there are tool calls (MiMo requires reasoning_content - * specifically on assistant messages with tool_calls). - */ - private static void cacheReasoningContent(String fullThinking, - List toolCalls) { - if (fullThinking == null || fullThinking.isBlank()) return; - if (toolCalls == null || toolCalls.isEmpty()) return; - List ids = toolCalls.stream() - .map(AssistantMessage.ToolCall::id) - .filter(id -> id != null && !id.isEmpty()) - .toList(); - if (!ids.isEmpty()) { - ReasoningContentCache.store(ids, fullThinking); - } - } - /** * Record token / cache usage to the optional metrics aggregator. * Called only from successful assembly paths ({@link #assembleResult} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java index 38e932e6..a2250b4e 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java @@ -137,17 +137,12 @@ final class OpenAiRequestRewriter { if (next != null && !next.isEmpty()) { injected = next; } else { - // For cross-turn messages, try the reasoning content cache - // (real values from prior responses) before falling back to empty. - injected = resolveCrossTurnReasoning(msg, i <= lastUserIdx); - if (injected == null) { - injected = policy.emptyFallback; - if (injected == null && policy.warnOnMissingReal) { - log.warn("[patchReasoningContent] provider={} requires real reasoning_content " - + "but relay has no value for assistant message at index {}; " - + "leaving null so provider returns explicit error.", - providerIdOrUnknown(provider), i); - } + injected = policy.emptyFallback; + if (injected == null && policy.warnOnMissingReal) { + log.warn("[patchReasoningContent] provider={} requires real reasoning_content " + + "but relay has no value for assistant message at index {}; " + + "leaving null so provider returns explicit error.", + providerIdOrUnknown(provider), i); } } if (injected == null && msg.reasoningContent() == null) { @@ -230,11 +225,10 @@ final class OpenAiRequestRewriter { * OpenAI-compatible gateway) might still require the patch. */ private enum FallbackPolicy { - DEEPSEEK (" ", false, true, true), - KIMI (" ", false, false, false), - OPENAI (" ", false, false, false), - XIAOMI_MIMO (" ", false, true, true), - DEFAULT (" ", false, false, false); + DEEPSEEK(" ", false, true, true), + KIMI (" ", false, false, false), + OPENAI (" ", false, false, false), + DEFAULT (" ", false, false, false); final String emptyFallback; final boolean warnOnMissingReal; @@ -259,7 +253,6 @@ final class OpenAiRequestRewriter { case "deepseek" -> DEEPSEEK; case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI; case "openai", "azure-openai" -> OPENAI; - case "xiaomi-mimo" -> XIAOMI_MIMO; default -> DEFAULT; }; } @@ -313,22 +306,6 @@ final class OpenAiRequestRewriter { return family.isThinking(); } - /** - * Look up cached reasoning content for cross-turn assistant messages. - * Returns the cached value, or {@code null} if no cache hit (caller falls - * back to the policy's empty fallback). - */ - private static String resolveCrossTurnReasoning( - OpenAiApi.ChatCompletionMessage msg, boolean isCrossTurn) { - if (!isCrossTurn) return null; - if (msg.toolCalls() == null || msg.toolCalls().isEmpty()) return null; - List ids = msg.toolCalls().stream() - .map(tc -> tc.id()) - .filter(id -> id != null && !id.isEmpty()) - .toList(); - return ReasoningContentCache.get(ids); - } - // ==================== reasoning_effort sanitizing ==================== /** 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 deleted file mode 100644 index c6cfa2eb..00000000 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningContentCache.java +++ /dev/null @@ -1,112 +0,0 @@ -package vip.mate.llm.chatmodel; - -import java.util.List; -import java.util.concurrent.ConcurrentHashMap; - -/** - * Static singleton cache for MiMo-style {@code reasoning_content} replay. - * - *

MiMo (and similar providers) require {@code reasoning_content} on assistant - * messages that carry {@code tool_calls}. When the conversation spans multiple - * turns, the cache replays the real reasoning content from prior - * responses instead of injecting empty strings, preserving model context. - * - *

Key design

- *
    - *
  • Key: sorted, concatenated tool_call IDs from the assistant message. - * Tool call IDs are unique per response, so this key naturally - * disambiguates across turns.
  • - *
  • TTL: 24 hours (configurable). Entries older than TTL are lazily evicted - * on access and periodically during {@link #store}.
  • - *
  • Max entries: 10,000. Oldest entries evicted when exceeded.
  • - *
- * - *

Usage

- *
    - *
  1. Store: after a streaming response completes, call - * {@link #store} with the tool_call IDs and reasoning content.
  2. - *
  3. Retrieve: during request patching, call {@link #get} for - * cross-turn assistant messages to fill in cached reasoning.
  4. - *
- * - * @author MateClaw Team - */ -public final class ReasoningContentCache { - - private static final long DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000L; // 24 hours - private static final int DEFAULT_MAX_ENTRIES = 10_000; - private static final long EVICT_INTERVAL_MS = 5 * 60 * 1000L; // 5 minutes - - private static final ConcurrentHashMap MAP = new ConcurrentHashMap<>(); - private static volatile long lastEvictMs = System.currentTimeMillis(); - - private ReasoningContentCache() {} - - /** - * Cache reasoning content for a set of tool_call IDs. - * - * @param toolCallIds tool call IDs from the assistant message (must not be null/empty) - * @param reasoningContent the real reasoning content to cache (must not be blank) - */ - public static void store(List toolCallIds, String reasoningContent) { - if (toolCallIds == null || toolCallIds.isEmpty()) return; - if (reasoningContent == null || reasoningContent.isBlank()) return; - - String key = makeKey(toolCallIds); - MAP.put(key, new Entry(reasoningContent, System.currentTimeMillis())); - - maybeEvict(); - } - - /** - * Retrieve cached reasoning content for the given tool_call IDs. - * - * @return cached reasoning content, or {@code null} if not found or expired - */ - public static String get(List toolCallIds) { - if (toolCallIds == null || toolCallIds.isEmpty()) return null; - - String key = makeKey(toolCallIds); - Entry entry = MAP.get(key); - if (entry == null) return null; - - if (System.currentTimeMillis() - entry.storedAtMs > DEFAULT_MAX_AGE_MS) { - MAP.remove(key); - return null; - } - return entry.reasoningContent; - } - - /** Clear all cached entries. */ - public static void clear() { - MAP.clear(); - } - - /** Current cache size (for diagnostics). */ - public static int size() { - return MAP.size(); - } - - private static String makeKey(List toolCallIds) { - return String.join("|", toolCallIds.stream().sorted().toList()); - } - - private static void maybeEvict() { - long now = System.currentTimeMillis(); - if (now - lastEvictMs < EVICT_INTERVAL_MS) return; - lastEvictMs = now; - - // Remove expired entries - MAP.entrySet().removeIf(e -> now - e.getValue().storedAtMs > DEFAULT_MAX_AGE_MS); - - // Remove oldest if over limit - if (MAP.size() > DEFAULT_MAX_ENTRIES) { - MAP.entrySet().stream() - .sorted((a, b) -> Long.compare(a.getValue().storedAtMs, b.getValue().storedAtMs)) - .limit(MAP.size() - DEFAULT_MAX_ENTRIES) - .forEach(e -> MAP.remove(e.getKey())); - } - } - - private record Entry(String reasoningContent, long storedAtMs) {} -} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java index 3511e4c0..e9f42a5c 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java @@ -58,15 +58,6 @@ public enum ModelFamily { */ DEEPSEEK_V4_REASONING(false, false, true, false, false, true), - /** - * Xiaomi MiMo thinking 模型:MiMo-VL-*、mimo-* 系列。 - *

- * MiMo 的 thinking 模式与 DeepSeek 类似:响应返回 {@code reasoning_content}, - * 后续多轮请求必须将 {@code reasoning_content} 传回,否则 API 返回 400 错误。 - * 约束:保留 max_tokens;不支持 reasoning_effort;temperature/topP 用配置值。 - */ - MIMO_THINKING(false, false, false, false, false, true), - /** * 通用 thinking 模型(名称含 "thinking" 或 "reasoner" 但不匹配上述族): * 如 qwen3-235b-a22b-thinking-2507 @@ -169,11 +160,6 @@ public enum ModelFamily { return DEEPSEEK_REASONER; } - // Xiaomi MiMo thinking 族:mimo-* / MiMo-VL-* 系列 - if (normalized.startsWith("mimo")) { - return MIMO_THINKING; - } - // 通用 thinking 族:名称含 thinking / reasoner 关键词 if (normalized.contains("thinking") || normalized.contains("reasoner")) { return GENERIC_THINKING; 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 6cde388c..f8c96473 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,13 +82,11 @@ class PatchReasoningContentTest { @BeforeEach void clearRelay() { AssistantThinkingRelay.clearAll(); - ReasoningContentCache.clear(); } @AfterEach void clearRelayAfter() { AssistantThinkingRelay.clearAll(); - ReasoningContentCache.clear(); } // ---------- No-relay, no-thinking-mode path ---------- @@ -429,73 +427,4 @@ 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-server/src/test/java/vip/mate/llm/chatmodel/ReasoningContentCacheTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningContentCacheTest.java deleted file mode 100644 index a5410199..00000000 --- a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningContentCacheTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package vip.mate.llm.chatmodel; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -class ReasoningContentCacheTest { - - @AfterEach - void cleanup() { - ReasoningContentCache.clear(); - } - - @Test - @DisplayName("Store and retrieve reasoning content by tool_call IDs") - void storeAndGet() { - List ids = List.of("call_1", "call_2"); - ReasoningContentCache.store(ids, "thinking content here"); - - assertEquals("thinking content here", ReasoningContentCache.get(ids)); - } - - @Test - @DisplayName("Cache key is order-independent (sorted tool_call IDs)") - void orderIndependent() { - ReasoningContentCache.store(List.of("call_b", "call_a"), "content"); - - assertEquals("content", ReasoningContentCache.get(List.of("call_a", "call_b"))); - } - - @Test - @DisplayName("Miss returns null") - void cacheMiss() { - assertNull(ReasoningContentCache.get(List.of("nonexistent"))); - } - - @Test - @DisplayName("Empty/null tool_call IDs are no-ops") - void emptyIds() { - ReasoningContentCache.store(List.of(), "content"); - ReasoningContentCache.store(null, "content"); - assertEquals(0, ReasoningContentCache.size()); - } - - @Test - @DisplayName("Blank/null reasoning content is not cached") - void blankContent() { - ReasoningContentCache.store(List.of("call_1"), ""); - ReasoningContentCache.store(List.of("call_1"), " "); - ReasoningContentCache.store(List.of("call_1"), null); - assertEquals(0, ReasoningContentCache.size()); - } - - @Test - @DisplayName("Clear removes all entries") - void clearAll() { - ReasoningContentCache.store(List.of("call_1"), "content1"); - ReasoningContentCache.store(List.of("call_2"), "content2"); - assertEquals(2, ReasoningContentCache.size()); - - ReasoningContentCache.clear(); - assertEquals(0, ReasoningContentCache.size()); - assertNull(ReasoningContentCache.get(List.of("call_1"))); - } -} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java index 4b1fc07b..707c0175 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java @@ -65,15 +65,4 @@ class ModelFamilyTest { assertEquals(ModelFamily.STANDARD, ModelFamily.detect("")); assertEquals(ModelFamily.STANDARD, ModelFamily.detect(" ")); } - - @Test - @DisplayName("Xiaomi MiMo models → MIMO_THINKING (reasoning_content relay required)") - void mimo_thinking() { - assertEquals(ModelFamily.MIMO_THINKING, ModelFamily.detect("mimo-v2-flash")); - assertEquals(ModelFamily.MIMO_THINKING, ModelFamily.detect("MiMo-VL-7B-RL")); - assertTrue(ModelFamily.MIMO_THINKING.isThinking(), - "Mimo must be flagged as thinking so reasoning_content is patched"); - assertFalse(ModelFamily.MIMO_THINKING.supportsReasoningEffort(), - "Mimo does not accept the reasoning_effort parameter"); - } } diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 505f06df..9d1d1957 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -10,16 +10,14 @@

- + @@ -446,7 +444,6 @@ 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' @@ -490,19 +487,6 @@ 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 60503ebc..8ed603f7 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -345,12 +345,6 @@ 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 @@ -462,13 +456,6 @@ 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) => { @@ -542,19 +529,6 @@ 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) => { @@ -1616,10 +1590,11 @@ export function useChat(options: UseChatOptions): UseChatReturn { } }) - // ===== Goal events ===== - // Forward goal evaluator emissions to the goal store. The store owns - // the active-goal cache + the per-conv "evaluating" flag that drives + // ===== Goal events (RFC 48) ===== + // Forward GoalEvaluationNode emissions to the goal store. The store + // owns 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 d0650548..97cd159b 100644 --- a/mateclaw-ui/src/stores/useGoalStore.ts +++ b/mateclaw-ui/src/stores/useGoalStore.ts @@ -41,19 +41,6 @@ export const useGoalStore = defineStore('goal', () => { 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>({}) - - // 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) { @@ -165,12 +152,7 @@ export const useGoalStore = defineStore('goal', () => { break } case 'goal_followup': { - // 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 + // The next assistant turn will land soon; nothing to do for the ring. break } case 'goal_completed': { @@ -264,47 +246,12 @@ export const useGoalStore = defineStore('goal', () => { 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, @@ -322,9 +269,6 @@ export const useGoalStore = defineStore('goal', () => { clearDismissedPrompt, recentTerminal, clearRecentTerminal, - consumePendingFollowup, - markFollowupMessage, - isFollowupMessage, } }) diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 2650600d..3d94c231 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -1065,9 +1065,7 @@ const goalTerminalForCurrent = computed(() => 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}` + return t.status === 'completed' ? `🎉 ${t.title}` : `⚠ ${t.title}` }) const goalSystemLineDetail = computed(() => { const t = goalTerminalForCurrent.value