mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
chore(llm): drop unused imports, add XIAOMI_MIMO cross-turn cache tests
This commit is contained in:
parent
7861f603eb
commit
9c5ad29d42
@ -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
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,6 +106,11 @@
|
||||
/>
|
||||
<div v-else class="conv-title" @dblclick.stop="startRename(conv)">
|
||||
<span>{{ conv.title }}</span>
|
||||
<span
|
||||
v-if="convGoalStatus(conv.conversationId) === 'active'"
|
||||
class="conv-goal-dot"
|
||||
:title="t('goal.sidebarActive', '此对话有正在进行的目标')"
|
||||
></span>
|
||||
<span
|
||||
v-if="hasUnread(conv)"
|
||||
class="conv-unread-dot"
|
||||
@ -193,6 +198,16 @@ import { channelIconUrl } from '@/utils/channelSource'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
import { mcConfirm } from '@/components/common/useConfirm'
|
||||
import type { Conversation, Agent } from '@/types'
|
||||
import { useGoalStore } from '@/stores/useGoalStore'
|
||||
|
||||
const goalStore = useGoalStore()
|
||||
/** Sidebar marker: a 6 px dot next to the conv title when that conv
|
||||
* has an active goal. Color tracks status (orange = in progress).
|
||||
* Falls back silently when the store hasn't loaded this conv yet. */
|
||||
function convGoalStatus(cid: string): string | null {
|
||||
const g = goalStore.activeGoalByConv?.[cid]
|
||||
return g?.status ?? null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
conversations: Conversation[]
|
||||
@ -684,6 +699,22 @@ function onMenuSelect(item: DropdownMenuItem) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Sidebar marker for "this conversation has an active goal". Same hue
|
||||
* family as the avatar ring; sits at 60% opacity so it whispers rather
|
||||
* than competes with unread / running indicators on the same row. */
|
||||
.conv-goal-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--mc-primary, #d97757);
|
||||
opacity: 0.7;
|
||||
margin-left: 6px;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mc-primary, #d97757) 18%, transparent);
|
||||
}
|
||||
|
||||
.conv-item.is-running {
|
||||
background: color-mix(in srgb, #fbbf24 8%, transparent);
|
||||
}
|
||||
|
||||
@ -10,14 +10,16 @@
|
||||
<!-- 头像 -->
|
||||
<div class="msg-avatar" :class="`${role}-avatar`">
|
||||
<slot name="avatar">
|
||||
<!-- 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. -->
|
||||
<!-- 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. -->
|
||||
<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>
|
||||
@ -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<ChatErrorInfo | undefined>(() => props.message.errorInfo)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<Record<string, GoalEvent[]>>({})
|
||||
|
||||
// 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<Record<string, boolean>>({})
|
||||
|
||||
// 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<Record<string, {
|
||||
status: 'completed' | 'exhausted'
|
||||
title: string
|
||||
score?: number | null
|
||||
reason?: string
|
||||
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) {
|
||||
@ -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<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,
|
||||
@ -211,6 +317,14 @@ export const useGoalStore = defineStore('goal', () => {
|
||||
isEvaluating,
|
||||
activeGoal,
|
||||
progressFraction,
|
||||
isPromptDismissed,
|
||||
dismissPrompt,
|
||||
clearDismissedPrompt,
|
||||
recentTerminal,
|
||||
clearRecentTerminal,
|
||||
consumePendingFollowup,
|
||||
markFollowupMessage,
|
||||
isFollowupMessage,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -164,6 +164,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal-state announcement after a goal completed or exhausted
|
||||
in this conversation. Auto-dismisses when the user clicks × or
|
||||
starts a new goal. -->
|
||||
<GoalSystemLine
|
||||
v-if="goalTerminalForCurrent && currentConversationId"
|
||||
:variant="goalTerminalForCurrent.status"
|
||||
:title="goalSystemLineTitle"
|
||||
:detail="goalSystemLineDetail"
|
||||
class="goal-system-line-slot"
|
||||
@click.stop="onGoalSystemLineDismiss"
|
||||
/>
|
||||
|
||||
<!-- Inline "set a goal?" invitation shown after the first assistant
|
||||
reply when the conversation has no active goal and the user
|
||||
hasn't dismissed it for this conv. -->
|
||||
<GoalSetInlinePrompt
|
||||
v-if="showGoalSetPrompt"
|
||||
:conversation-id="currentConversationId"
|
||||
:agent-id="String(selectedAgentId)"
|
||||
:workspace-id="String(currentWorkspaceId || '1')"
|
||||
:suggested-title="goalSuggestedTitle"
|
||||
class="goal-set-prompt-slot"
|
||||
@dismiss="onGoalPromptDismiss"
|
||||
/>
|
||||
|
||||
<!-- 流式处理 Loading 栏(消息和输入框之间) -->
|
||||
<StreamLoadingBar
|
||||
:is-loading="isGenerating && !blockingPrompt"
|
||||
@ -259,6 +284,9 @@ import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
|
||||
import { useKatexRenderer } from '@/composables/useKatexRenderer'
|
||||
import { useMermaidRenderer, handleMermaidDownload } from '@/composables/useMermaidRenderer'
|
||||
import { useGoalStore } from '@/stores/useGoalStore'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
import GoalSetInlinePrompt from '@/components/goal/GoalSetInlinePrompt.vue'
|
||||
import GoalSystemLine from '@/components/goal/GoalSystemLine.vue'
|
||||
|
||||
// ============ Talk Mode ============
|
||||
const showTalkMode = ref(false)
|
||||
@ -1009,16 +1037,88 @@ watch([selectedAgentId, currentConversationId], () => {
|
||||
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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user