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 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<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.
* Called only from successful assembly paths ({@link #assembleResult}

View File

@ -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<String> ids = msg.toolCalls().stream()
.map(tc -> tc.id())
.filter(id -> id != null && !id.isEmpty())
.toList();
return ReasoningContentCache.get(ids);
}
// ==================== 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),
/**
* 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" 但不匹配上述族
* 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;

View File

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

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(" "));
}
@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`">
<slot name="avatar">
<!-- When the assistant has an active goal, wrap the logo in
GoalAvatarRing so the progress ring + breathing halo + hover
tooltip all sit naturally around the avatar. The component
renders only the slot content when no goal exists, so non-
goal turns look identical to before. The followup glyph
appears on messages that came from an auto-followup turn. -->
<!-- RFC 48 Jobs-cut: when the assistant has an active goal, wrap
the logo in GoalAvatarRing so the progress ring + breathing
halo + hover tooltip all sit naturally around the avatar.
The component renders only the slot content when no goal
exists, so non-goal turns look identical to before. -->
<GoalAvatarRing
v-if="role === 'assistant'"
:conversation-id="message.conversationId"
:show-followup-mark="isFollowupTurn"
>
<img src="/logo/mateclaw_logo_s.png" alt="" class="avatar-logo" />
</GoalAvatarRing>
@ -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<ChatErrorInfo | undefined>(() => props.message.errorInfo)

View File

@ -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

View File

@ -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<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)
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<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 {
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,
}
})

View File

@ -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