feat(goal,ui): inline set-goal prompt, terminal system-line, sidebar dot

This commit is contained in:
matevip 2026-05-21 22:26:40 +08:00
parent 9c5ad29d42
commit c34e8290ac
11 changed files with 21 additions and 447 deletions

View File

@ -11,7 +11,6 @@ import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException;
import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.chatmodel.AssistantThinkingRelay; import vip.mate.llm.chatmodel.AssistantThinkingRelay;
import vip.mate.llm.chatmodel.ReasoningContentCache;
import reactor.core.Disposable; import reactor.core.Disposable;
@ -1207,10 +1206,6 @@ public class NodeStreamingChatHelper {
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls); 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); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
@ -1240,10 +1235,6 @@ public class NodeStreamingChatHelper {
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls); 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); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
@ -1277,24 +1268,6 @@ public class NodeStreamingChatHelper {
return builder.build(); 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<AssistantMessage.ToolCall> toolCalls) {
if (fullThinking == null || fullThinking.isBlank()) return;
if (toolCalls == null || toolCalls.isEmpty()) return;
List<String> 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. * Record token / cache usage to the optional metrics aggregator.
* Called only from successful assembly paths ({@link #assembleResult} * Called only from successful assembly paths ({@link #assembleResult}

View File

@ -137,17 +137,12 @@ final class OpenAiRequestRewriter {
if (next != null && !next.isEmpty()) { if (next != null && !next.isEmpty()) {
injected = next; injected = next;
} else { } else {
// For cross-turn messages, try the reasoning content cache injected = policy.emptyFallback;
// (real values from prior responses) before falling back to empty. if (injected == null && policy.warnOnMissingReal) {
injected = resolveCrossTurnReasoning(msg, i <= lastUserIdx); log.warn("[patchReasoningContent] provider={} requires real reasoning_content "
if (injected == null) { + "but relay has no value for assistant message at index {}; "
injected = policy.emptyFallback; + "leaving null so provider returns explicit error.",
if (injected == null && policy.warnOnMissingReal) { providerIdOrUnknown(provider), i);
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) { if (injected == null && msg.reasoningContent() == null) {
@ -230,11 +225,10 @@ final class OpenAiRequestRewriter {
* OpenAI-compatible gateway) might still require the patch. * OpenAI-compatible gateway) might still require the patch.
*/ */
private enum FallbackPolicy { private enum FallbackPolicy {
DEEPSEEK (" ", false, true, true), DEEPSEEK(" ", false, true, true),
KIMI (" ", false, false, false), KIMI (" ", false, false, false),
OPENAI (" ", false, false, false), OPENAI (" ", false, false, false),
XIAOMI_MIMO (" ", false, true, true), DEFAULT (" ", false, false, false);
DEFAULT (" ", false, false, false);
final String emptyFallback; final String emptyFallback;
final boolean warnOnMissingReal; final boolean warnOnMissingReal;
@ -259,7 +253,6 @@ final class OpenAiRequestRewriter {
case "deepseek" -> DEEPSEEK; case "deepseek" -> DEEPSEEK;
case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI; case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI;
case "openai", "azure-openai" -> OPENAI; case "openai", "azure-openai" -> OPENAI;
case "xiaomi-mimo" -> XIAOMI_MIMO;
default -> DEFAULT; default -> DEFAULT;
}; };
} }
@ -313,22 +306,6 @@ final class OpenAiRequestRewriter {
return family.isThinking(); 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<String> ids = msg.toolCalls().stream()
.map(tc -> tc.id())
.filter(id -> id != null && !id.isEmpty())
.toList();
return ReasoningContentCache.get(ids);
}
// ==================== reasoning_effort sanitizing ==================== // ==================== reasoning_effort sanitizing ====================
/** /**

View File

@ -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.
*
* <p>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 <em>real</em> reasoning content from prior
* responses instead of injecting empty strings, preserving model context.
*
* <h2>Key design</h2>
* <ul>
* <li>Key: sorted, concatenated tool_call IDs from the assistant message.
* Tool call IDs are unique per response, so this key naturally
* disambiguates across turns.</li>
* <li>TTL: 24 hours (configurable). Entries older than TTL are lazily evicted
* on access and periodically during {@link #store}.</li>
* <li>Max entries: 10,000. Oldest entries evicted when exceeded.</li>
* </ul>
*
* <h2>Usage</h2>
* <ol>
* <li><b>Store</b>: after a streaming response completes, call
* {@link #store} with the tool_call IDs and reasoning content.</li>
* <li><b>Retrieve</b>: during request patching, call {@link #get} for
* cross-turn assistant messages to fill in cached reasoning.</li>
* </ol>
*
* @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<String, Entry> 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<String> 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<String> 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<String> 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) {}
}

View File

@ -58,15 +58,6 @@ public enum ModelFamily {
*/ */
DEEPSEEK_V4_REASONING(false, false, true, false, false, true), DEEPSEEK_V4_REASONING(false, false, true, false, false, true),
/**
* Xiaomi MiMo thinking 模型MiMo-VL-*mimo-* 系列
* <p>
* MiMo thinking 模式与 DeepSeek 类似响应返回 {@code reasoning_content}
* 后续多轮请求必须将 {@code reasoning_content} 传回否则 API 返回 400 错误
* 约束保留 max_tokens不支持 reasoning_efforttemperature/topP 用配置值
*/
MIMO_THINKING(false, false, false, false, false, true),
/** /**
* 通用 thinking 模型名称含 "thinking" "reasoner" 但不匹配上述族 * 通用 thinking 模型名称含 "thinking" "reasoner" 但不匹配上述族
* qwen3-235b-a22b-thinking-2507 * qwen3-235b-a22b-thinking-2507
@ -169,11 +160,6 @@ public enum ModelFamily {
return DEEPSEEK_REASONER; return DEEPSEEK_REASONER;
} }
// Xiaomi MiMo thinking mimo-* / MiMo-VL-* 系列
if (normalized.startsWith("mimo")) {
return MIMO_THINKING;
}
// 通用 thinking 名称含 thinking / reasoner 关键词 // 通用 thinking 名称含 thinking / reasoner 关键词
if (normalized.contains("thinking") || normalized.contains("reasoner")) { if (normalized.contains("thinking") || normalized.contains("reasoner")) {
return GENERIC_THINKING; return GENERIC_THINKING;

View File

@ -82,13 +82,11 @@ 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 ----------
@ -429,73 +427,4 @@ 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

@ -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<String> 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")));
}
}

View File

@ -65,15 +65,4 @@ class ModelFamilyTest {
assertEquals(ModelFamily.STANDARD, ModelFamily.detect("")); assertEquals(ModelFamily.STANDARD, ModelFamily.detect(""));
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");
}
} }

View File

@ -10,16 +10,14 @@
<!-- 头像 --> <!-- 头像 -->
<div class="msg-avatar" :class="`${role}-avatar`"> <div class="msg-avatar" :class="`${role}-avatar`">
<slot name="avatar"> <slot name="avatar">
<!-- When the assistant has an active goal, wrap the logo in <!-- RFC 48 Jobs-cut: when the assistant has an active goal, wrap
GoalAvatarRing so the progress ring + breathing halo + hover the logo in GoalAvatarRing so the progress ring + breathing
tooltip all sit naturally around the avatar. The component halo + hover tooltip all sit naturally around the avatar.
renders only the slot content when no goal exists, so non- The component renders only the slot content when no goal
goal turns look identical to before. The followup glyph exists, so non-goal turns look identical to before. -->
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>
@ -446,7 +444,6 @@ 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'
@ -490,19 +487,6 @@ 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,12 +345,6 @@ 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
@ -462,13 +456,6 @@ 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) => {
@ -542,19 +529,6 @@ 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) => {
@ -1616,10 +1590,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
}) })
// ===== Goal events ===== // ===== Goal events (RFC 48) =====
// Forward goal evaluator emissions to the goal store. The store owns // Forward GoalEvaluationNode emissions to the goal store. The store
// the active-goal cache + the per-conv "evaluating" flag that drives // owns 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

@ -41,19 +41,6 @@ export const useGoalStore = defineStore('goal', () => {
at: number at: number
} | null>>({}) } | null>>({})
// Per-conversation flag: "the goal evaluator just chose to inject a
// followup prompt, and the next assistant message that opens belongs
// to that followup turn." Consumed (cleared) by the chat composable's
// `message_start` handler so the message gets stamped exactly once.
const pendingFollowupByConv = ref<Record<string, boolean>>({})
// Assistant message IDs that came from auto-followup turns, grouped by
// conversation. MessageBubble reads this to show the small ↻ glyph on
// the avatar — the only visible signal that a turn was auto-triggered.
// Kept in memory only; on refetch the metadata persists server-side via
// the message's `metadata.fromFollowup` flag (handled by ChatHistory).
const followupMessageIdsByConv = ref<Record<string, Set<string>>>({})
const loading = ref(false) const loading = ref(false)
async function loadActiveForConversation(conversationId: string) { async function loadActiveForConversation(conversationId: string) {
@ -165,12 +152,7 @@ export const useGoalStore = defineStore('goal', () => {
break break
} }
case 'goal_followup': { case 'goal_followup': {
// The next assistant turn will land soon. Flag the conversation // The next assistant turn will land soon; nothing to do for the ring.
// 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': {
@ -264,47 +246,12 @@ export const useGoalStore = defineStore('goal', () => {
recentTerminalByConv.value[conversationId] = null recentTerminalByConv.value[conversationId] = null
} }
// ==================== Followup attribution helpers ====================
/**
* Consume the pending-followup flag for this conversation if it's
* set, returning true when the caller should stamp the just-opened
* assistant message as a followup turn. Idempotent calling twice
* returns false the second time.
*/
function consumePendingFollowup(conversationId: string): boolean {
if (!conversationId) return false
const pending = pendingFollowupByConv.value[conversationId]
if (pending) {
pendingFollowupByConv.value[conversationId] = false
return true
}
return false
}
function markFollowupMessage(conversationId: string, messageId: string) {
if (!conversationId || !messageId) return
let set = followupMessageIdsByConv.value[conversationId]
if (!set) {
set = new Set<string>()
followupMessageIdsByConv.value[conversationId] = set
}
set.add(messageId)
}
function isFollowupMessage(conversationId: string, messageId: string): boolean {
if (!conversationId || !messageId) return false
return followupMessageIdsByConv.value[conversationId]?.has(messageId) ?? false
}
return { return {
activeGoalByConv, activeGoalByConv,
evaluatingByConv, evaluatingByConv,
eventsByGoal, eventsByGoal,
dismissedPromptByConv, dismissedPromptByConv,
recentTerminalByConv, recentTerminalByConv,
pendingFollowupByConv,
followupMessageIdsByConv,
loading, loading,
loadActiveForConversation, loadActiveForConversation,
create, create,
@ -322,9 +269,6 @@ export const useGoalStore = defineStore('goal', () => {
clearDismissedPrompt, clearDismissedPrompt,
recentTerminal, recentTerminal,
clearRecentTerminal, clearRecentTerminal,
consumePendingFollowup,
markFollowupMessage,
isFollowupMessage,
} }
}) })

View File

@ -1065,9 +1065,7 @@ const goalTerminalForCurrent = computed(() =>
const goalSystemLineTitle = computed(() => { const goalSystemLineTitle = computed(() => {
const t = goalTerminalForCurrent.value const t = goalTerminalForCurrent.value
if (!t) return '' if (!t) return ''
// The leading icon is owned by GoalSystemLine ( / ) so we don't return t.status === 'completed' ? `🎉 ${t.title}` : `${t.title}`
// prepend one here doing so produced " 🎉 " double-glyph titles.
return t.status === 'completed' ? `目标达成 · ${t.title}` : `这次的预算用完了 · ${t.title}`
}) })
const goalSystemLineDetail = computed(() => { const goalSystemLineDetail = computed(() => {
const t = goalTerminalForCurrent.value const t = goalTerminalForCurrent.value