fix(chat): 工具框乱序 + 流式/调试开关失效 + 思考堆积修复

This commit is contained in:
matevip 2026-06-09 16:04:41 +08:00
parent 0112af7105
commit fb93f99425
6 changed files with 186 additions and 34 deletions

View File

@ -14,6 +14,7 @@ import en from 'element-plus/es/locale/lang/en'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { currentLocale } from '@/i18n' import { currentLocale } from '@/i18n'
import { useThemeStore } from '@/stores/useThemeStore' import { useThemeStore } from '@/stores/useThemeStore'
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick' import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick'
import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick' import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick'
import McConfirmHost from '@/components/common/McConfirmHost.vue' import McConfirmHost from '@/components/common/McConfirmHost.vue'
@ -21,6 +22,11 @@ import McConfirmHost from '@/components/common/McConfirmHost.vue'
// Initialize theme applies .dark class to <html> immediately // Initialize theme applies .dark class to <html> immediately
useThemeStore() useThemeStore()
// Load runtime settings (streamEnabled / debugMode) so the chat flow honors
// them. localStorage cache makes them available instantly; this refreshes
// from the backend in the background.
useSystemSettingsStore().load()
// Global click delegator for [[wikilinks]] rendered into chat / docs / // Global click delegator for [[wikilinks]] rendered into chat / docs /
// memory surfaces. WikiPageViewer's own postprocess handles in-wiki // memory surfaces. WikiPageViewer's own postprocess handles in-wiki
// clicks (those carry data-slug); this catches everything else. // clicks (those carry data-slug); this catches everything else.

View File

@ -46,34 +46,39 @@
<el-icon><WarningFilled /></el-icon> <el-icon><WarningFilled /></el-icon>
<span>{{ $t('chat.iterationEmpty', { index: iter.index + 1 }) }}</span> <span>{{ $t('chat.iterationEmpty', { index: iter.index + 1 }) }}</span>
</div> </div>
<!-- Render each iteration's segments in their original emission
order (thinking / tool_call / content interleaved exactly as
the model produced them) so tool boxes never reorder. -->
<template v-else> <template v-else>
<ThinkingSegment v-for="t in iter.thinkings" :key="t.id" :segment="t" /> <template v-for="seg in iter.items" :key="seg.id">
<ToolCallSegment v-for="tool in iter.tools" :key="tool.id" :segment="tool" /> <ThinkingSegment v-if="seg.type === 'thinking' && debugMode" :segment="seg" />
<template v-for="c in iter.contents" :key="c.id"> <ToolCallSegment v-else-if="seg.type === 'tool_call'" :segment="seg" />
<template v-else-if="seg.type === 'content'">
<button <button
v-if="c.superseded" v-if="seg.superseded"
class="superseded-toggle" class="superseded-toggle"
type="button" type="button"
@click="toggleSupersededSegment(c.id)" @click="toggleSupersededSegment(seg.id)"
> >
<el-icon><InfoFilled /></el-icon> <el-icon><InfoFilled /></el-icon>
<span>{{ $t('chat.supersededPreviewCollapsed') }}</span> <span>{{ $t('chat.supersededPreviewCollapsed') }}</span>
<span class="superseded-toggle__action"> <span class="superseded-toggle__action">
{{ isSupersededExpanded(c.id) ? $t('chat.collapse') : $t('chat.expand') }} {{ isSupersededExpanded(seg.id) ? $t('chat.collapse') : $t('chat.expand') }}
</span> </span>
</button> </button>
<div v-if="c.repetitionWarning && (!c.superseded || isSupersededExpanded(c.id))" class="repetition-warning"> <div v-if="seg.repetitionWarning && (!seg.superseded || isSupersededExpanded(seg.id))" class="repetition-warning">
<el-icon><WarningFilled /></el-icon> <el-icon><WarningFilled /></el-icon>
<span class="repetition-warning__text">{{ $t('chat.contentRepetitionWarning') }}</span> <span class="repetition-warning__text">{{ $t('chat.contentRepetitionWarning') }}</span>
<span v-if="c.truncatedChars" class="repetition-warning__meta">({{ c.truncatedChars }} chars)</span> <span v-if="seg.truncatedChars" class="repetition-warning__meta">({{ seg.truncatedChars }} chars)</span>
</div> </div>
<ContentSegment <ContentSegment
v-if="!c.superseded || isSupersededExpanded(c.id)" v-if="!seg.superseded || isSupersededExpanded(seg.id)"
:segment="c" :segment="seg"
:show-cursor="showCursor && c.status === 'running'" :show-cursor="showCursor && seg.status === 'running'"
:class="{ 'content-segment--superseded': c.superseded }" :class="{ 'content-segment--superseded': seg.superseded }"
/> />
</template> </template>
</template>
</template> </template>
</template> </template>
</div> </div>
@ -447,6 +452,8 @@ 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 { useGoalStore } from '@/stores/useGoalStore'
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
import { storeToRefs } from 'pinia'
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'
@ -562,7 +569,13 @@ const hasContent = computed(() => {
return !!(textPart?.text || props.message.content) return !!(textPart?.text || props.message.content)
}) })
const showThinkingPanel = computed(() => !!thinkingContent.value) // Debug mode gates whether the model's reasoning ("thinking") is surfaced.
// Off (default) keeps the transcript focused on tool activity + the answer,
// directly addressing the "thinking piles up" complaint. Tool-call boxes stay
// visible (they auto-collapse) so the user still sees what the agent did.
const { debugMode } = storeToRefs(useSystemSettingsStore())
const showThinkingPanel = computed(() => debugMode.value && !!thinkingContent.value)
// //
const thinkingDuration = computed(() => { const thinkingDuration = computed(() => {
@ -997,32 +1010,29 @@ const useSegmentedView = computed(() =>
const groupedIterations = computed(() => { const groupedIterations = computed(() => {
const segs = segments.value || [] const segs = segments.value || []
const anyTagged = segs.some(s => typeof s.iterationIndex === 'number') const anyTagged = segs.some(s => typeof s.iterationIndex === 'number')
// Untagged (legacy / persisted-without-iterationIndex) messages render as a
// single bucket but still in their ORIGINAL emission order, never split by
// type. Splitting into thinking/tool/content arrays was what made a tool box
// jump above or below its surrounding text depending on the message.
if (!anyTagged) { if (!anyTagged) {
return [{ return [{ key: 'all', index: 0, empty: segs.length === 0, items: segs }]
key: 'all',
index: 0,
empty: false,
thinkings: segs.filter(s => s.type === 'thinking'),
tools: segs.filter(s => s.type === 'tool_call'),
contents: segs.filter(s => s.type === 'content'),
}]
} }
const buckets = new Map<number, { thinkings: MessageSegment[]; tools: MessageSegment[]; contents: MessageSegment[] }>() // Group by iteration for visual separation, but keep each bucket's segments
// in their original array order (segments[] is already in emission order, so
// a tool call stays exactly where the model emitted it relative to content).
const buckets = new Map<number, MessageSegment[]>()
for (const s of segs) { for (const s of segs) {
const idx = s.iterationIndex ?? 0 const idx = s.iterationIndex ?? 0
if (!buckets.has(idx)) buckets.set(idx, { thinkings: [], tools: [], contents: [] }) if (!buckets.has(idx)) buckets.set(idx, [])
const b = buckets.get(idx)! buckets.get(idx)!.push(s)
if (s.type === 'thinking') b.thinkings.push(s)
else if (s.type === 'tool_call') b.tools.push(s)
else if (s.type === 'content') b.contents.push(s)
} }
return [...buckets.entries()] return [...buckets.entries()]
.sort(([a], [b]) => a - b) .sort(([a], [b]) => a - b)
.map(([index, b]) => ({ .map(([index, items]) => ({
key: `iter-${index}`, key: `iter-${index}`,
index, index,
empty: b.thinkings.length === 0 && b.tools.length === 0 && b.contents.length === 0, empty: items.length === 0,
...b, items,
})) }))
}) })

View File

@ -14,6 +14,8 @@ import { useMessages } from './useMessages'
import { useStream } from './useStream' import { useStream } from './useStream'
import { useMessageQueue } from './useMessageQueue' import { useMessageQueue } from './useMessageQueue'
import { useGoalStore } from '@/stores/useGoalStore' import { useGoalStore } from '@/stores/useGoalStore'
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
import { storeToRefs } from 'pinia'
import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData, DelegationNode, DelegationToolEntry, PlanMeta } from '@/types' import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData, DelegationNode, DelegationToolEntry, PlanMeta } from '@/types'
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError' import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
import { http } from '@/api' import { http } from '@/api'
@ -216,15 +218,30 @@ export function useChat(options: UseChatOptions): UseChatReturn {
/** Unique ID for the current turn — prevents flushSegmentsToMessage from writing stale segments to a new message */ /** Unique ID for the current turn — prevents flushSegmentsToMessage from writing stale segments to a new message */
let activeTurnId = '' let activeTurnId = ''
// When the user disables "stream response", the turn is still consumed over
// SSE (so tools / approval / events all work) but the on-screen message is
// held back and revealed once on completion instead of token-by-token. These
// buffers hold the text/thinking deltas until the reveal at stream end.
const { streamEnabled } = storeToRefs(useSystemSettingsStore())
let bufferedText = ''
let bufferedThinking = ''
/** Reset streaming state for the current turn — must be called before creating a new assistant placeholder */ /** Reset streaming state for the current turn — must be called before creating a new assistant placeholder */
function resetCurrentTurnState() { function resetCurrentTurnState() {
currentSegments.value = [] currentSegments.value = []
segIdCounter.value = 0 segIdCounter.value = 0
bufferedText = ''
bufferedThinking = ''
activeTurnId = `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` activeTurnId = `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
} }
/** Sync current segments into the assistant message metadata (used for real-time rendering) */ /**
const flushSegmentsToMessage = () => { * Sync current segments into the assistant message metadata (used for
* real-time rendering). When streaming is disabled we skip the live writes
* and only flush once at stream end (force=true) so nothing renders mid-turn.
*/
const flushSegmentsToMessage = (force = false) => {
if (!force && !streamEnabled.value) return
if (!currentAssistantId.value || currentSegments.value.length === 0) return if (!currentAssistantId.value || currentSegments.value.length === 0) return
const msg = getMessage(currentAssistantId.value) const msg = getMessage(currentAssistantId.value)
if (!msg) return if (!msg) return
@ -236,6 +253,23 @@ export function useChat(options: UseChatOptions): UseChatReturn {
metadata: { ...metadata, segments: [...currentSegments.value] } metadata: { ...metadata, segments: [...currentSegments.value] }
} as any) } as any)
} }
/**
* Reveal a buffered (non-streamed) turn: commit the accumulated text/thinking
* to the message and flush segments. Safe to call on done / stopped / error.
*/
const revealBufferedTurn = () => {
if (!currentAssistantId.value) return
if (bufferedThinking) {
appendMessageContent(currentAssistantId.value, bufferedThinking, 'thinking')
bufferedThinking = ''
}
if (bufferedText) {
appendMessageContent(currentAssistantId.value, bufferedText, 'text')
bufferedText = ''
}
flushSegmentsToMessage(true)
}
const heartbeat = ref<HeartbeatData | null>(null) const heartbeat = ref<HeartbeatData | null>(null)
/** Track which conversation the current stream belongs to */ /** Track which conversation the current stream belongs to */
let streamConversationId = '' let streamConversationId = ''
@ -388,7 +422,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('content_delta', (data) => { stream.on('content_delta', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
if (currentAssistantId.value) { if (currentAssistantId.value) {
appendMessageContent(currentAssistantId.value, data.delta || '', 'text') if (streamEnabled.value) {
appendMessageContent(currentAssistantId.value, data.delta || '', 'text')
} else {
bufferedText += data.delta || ''
}
if (['thinking', 'reasoning', 'drafting_answer', 'preparing_context'].includes(streamPhase.value)) { if (['thinking', 'reasoning', 'drafting_answer', 'preparing_context'].includes(streamPhase.value)) {
streamPhase.value = 'streaming' streamPhase.value = 'streaming'
} }
@ -418,7 +456,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// Suppress thinking display when thinkingLevel=off // Suppress thinking display when thinkingLevel=off
if (options.thinkingLevel?.value === 'off') return if (options.thinkingLevel?.value === 'off') return
if (currentAssistantId.value) { if (currentAssistantId.value) {
appendMessageContent(currentAssistantId.value, data.delta || '', 'thinking') if (streamEnabled.value) {
appendMessageContent(currentAssistantId.value, data.delta || '', 'thinking')
} else {
bufferedThinking += data.delta || ''
}
if (streamPhase.value !== 'summarizing_observations') { if (streamPhase.value !== 'summarizing_observations') {
streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking' streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking'
} }
@ -567,6 +609,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('done', (data) => { stream.on('done', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
// Non-streamed turn: reveal the buffered content/segments now, before the
// server-annotation merge below reads metadata.segments.
if (!streamEnabled.value) revealBufferedTurn()
if (currentAssistantId.value) { if (currentAssistantId.value) {
const existingMsg = getMessage(currentAssistantId.value) const existingMsg = getMessage(currentAssistantId.value)
if (existingMsg?.status !== 'failed') { if (existingMsg?.status !== 'failed') {
@ -702,6 +748,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
let errorFired = false let errorFired = false
stream.on('error', (data) => { stream.on('error', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
// Surface whatever was buffered before the failure so a non-streamed turn
// doesn't vanish entirely on error.
if (!streamEnabled.value) revealBufferedTurn()
// Always carry data.message as rawMessage, so the inline error card can // Always carry data.message as rawMessage, so the inline error card can
// surface the actual reason ("无权操作该会话" etc.) instead of the generic // surface the actual reason ("无权操作该会话" etc.) instead of the generic
// unknown.description template. classifyBackendError already does this // unknown.description template. classifyBackendError already does this

View File

@ -0,0 +1,76 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
import { ref } from 'vue'
import { settingsApi } from '@/api'
/**
* Holds the runtime-consumable slice of system settings so the chat flow can
* actually honor toggles like "stream response" and "debug mode". Previously
* `streamEnabled` / `debugMode` were written to the backend by the settings
* page but never read anywhere the switches were dead. This store is the
* single source the rest of the app reads from.
*
* A localStorage mirror makes the values available on the very first render
* (before the /settings GET resolves) so there is no flicker of the wrong
* behavior on a hard reload.
*/
const STORAGE_KEY = 'mateclaw-system-settings'
interface CachedSettings {
streamEnabled: boolean
debugMode: boolean
}
function readCache(): CachedSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
return {
streamEnabled: parsed.streamEnabled !== false, // default true
debugMode: parsed.debugMode === true, // default false
}
}
} catch { /* ignore */ }
return { streamEnabled: true, debugMode: false }
}
export const useSystemSettingsStore = defineStore('systemSettings', () => {
const cached = readCache()
// Whether the chat UI renders tokens incrementally (true) or buffers the
// turn and reveals it once on completion (false).
const streamEnabled = ref<boolean>(cached.streamEnabled)
// Whether thinking blocks and tool-call internals are shown. Off = only the
// final answer plus collapsed summaries (keeps the transcript clean).
const debugMode = ref<boolean>(cached.debugMode)
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
streamEnabled: streamEnabled.value,
debugMode: debugMode.value,
}))
} catch { /* ignore */ }
}
/** Apply a settings object (from /settings GET or the settings page save). */
function apply(settings: Partial<CachedSettings> | null | undefined) {
if (!settings) return
if (typeof settings.streamEnabled === 'boolean') streamEnabled.value = settings.streamEnabled
if (typeof settings.debugMode === 'boolean') debugMode.value = settings.debugMode
persist()
}
/** Fetch from the backend once at app start. */
async function load() {
try {
const res: any = await settingsApi.get()
apply(res?.data)
} catch { /* keep cached defaults */ }
}
return { streamEnabled, debugMode, apply, load }
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useSystemSettingsStore, import.meta.hot))
}

View File

@ -62,10 +62,12 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { authApi } from '@/api/index' import { authApi } from '@/api/index'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore' import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const systemSettingsStore = useSystemSettingsStore()
const loading = ref(false) const loading = ref(false)
const showPassword = ref(false) const showPassword = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
@ -82,6 +84,11 @@ async function handleLogin() {
localStorage.setItem('userId', String(data.id || '1')) localStorage.setItem('userId', String(data.id || '1'))
localStorage.setItem('username', data.username || form.username) localStorage.setItem('username', data.username || form.username)
localStorage.setItem('role', data.role || 'user') localStorage.setItem('role', data.role || 'user')
// Now authenticated load runtime settings (streamEnabled / debugMode) so
// the saved preferences take effect on the first turn. The app-boot load()
// runs before login and 401s, so without this the chat would fall back to
// defaults until the user opened the Settings page.
systemSettingsStore.load()
// Resolve capabilities before deciding the landing route so a viewer // Resolve capabilities before deciding the landing route so a viewer
// lands on /chat (their only capability) and member+ on /dashboard. // lands on /chat (their only capability) and member+ on /dashboard.
try { try {

View File

@ -205,9 +205,11 @@ import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { settingsApi } from '@/api' import { settingsApi } from '@/api'
import { applyLocale } from '@/i18n' import { applyLocale } from '@/i18n'
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
import type { SystemSettings } from '@/types' import type { SystemSettings } from '@/types'
const { t } = useI18n() const { t } = useI18n()
const systemSettingsStore = useSystemSettingsStore()
const savedTip = ref('') const savedTip = ref('')
// API Key // API Key
@ -234,6 +236,8 @@ onMounted(async () => {
async function loadSettings() { async function loadSettings() {
const res: any = await settingsApi.get() const res: any = await settingsApi.get()
Object.assign(settings, res.data || {}) Object.assign(settings, res.data || {})
// Keep the runtime store in sync so chat honors the latest toggles.
systemSettingsStore.apply(settings)
// API Key // API Key
serperApiKeyInput.value = '' serperApiKeyInput.value = ''
tavilyApiKeyInput.value = '' tavilyApiKeyInput.value = ''