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 { currentLocale } from '@/i18n'
import { useThemeStore } from '@/stores/useThemeStore'
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick'
import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick'
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
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 /
// memory surfaces. WikiPageViewer's own postprocess handles in-wiki
// clicks (those carry data-slug); this catches everything else.

View File

@ -46,34 +46,39 @@
<el-icon><WarningFilled /></el-icon>
<span>{{ $t('chat.iterationEmpty', { index: iter.index + 1 }) }}</span>
</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>
<ThinkingSegment v-for="t in iter.thinkings" :key="t.id" :segment="t" />
<ToolCallSegment v-for="tool in iter.tools" :key="tool.id" :segment="tool" />
<template v-for="c in iter.contents" :key="c.id">
<template v-for="seg in iter.items" :key="seg.id">
<ThinkingSegment v-if="seg.type === 'thinking' && debugMode" :segment="seg" />
<ToolCallSegment v-else-if="seg.type === 'tool_call'" :segment="seg" />
<template v-else-if="seg.type === 'content'">
<button
v-if="c.superseded"
v-if="seg.superseded"
class="superseded-toggle"
type="button"
@click="toggleSupersededSegment(c.id)"
@click="toggleSupersededSegment(seg.id)"
>
<el-icon><InfoFilled /></el-icon>
<span>{{ $t('chat.supersededPreviewCollapsed') }}</span>
<span class="superseded-toggle__action">
{{ isSupersededExpanded(c.id) ? $t('chat.collapse') : $t('chat.expand') }}
{{ isSupersededExpanded(seg.id) ? $t('chat.collapse') : $t('chat.expand') }}
</span>
</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>
<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>
<ContentSegment
v-if="!c.superseded || isSupersededExpanded(c.id)"
:segment="c"
:show-cursor="showCursor && c.status === 'running'"
:class="{ 'content-segment--superseded': c.superseded }"
v-if="!seg.superseded || isSupersededExpanded(seg.id)"
:segment="seg"
:show-cursor="showCursor && seg.status === 'running'"
:class="{ 'content-segment--superseded': seg.superseded }"
/>
</template>
</template>
</template>
</template>
</div>
@ -447,6 +452,8 @@ import ThinkingSegment from './ThinkingSegment.vue'
import ContentSegment from './ContentSegment.vue'
import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue'
import { useGoalStore } from '@/stores/useGoalStore'
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
import { storeToRefs } from 'pinia'
import PlanStepsPanel from './PlanStepsPanel.vue'
import UserMessageContent from './UserMessageContent.vue'
import type { BrowserAction } from './BrowserTimeline.vue'
@ -562,7 +569,13 @@ const hasContent = computed(() => {
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(() => {
@ -997,32 +1010,29 @@ const useSegmentedView = computed(() =>
const groupedIterations = computed(() => {
const segs = segments.value || []
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) {
return [{
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'),
}]
return [{ key: 'all', index: 0, empty: segs.length === 0, items: segs }]
}
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) {
const idx = s.iterationIndex ?? 0
if (!buckets.has(idx)) buckets.set(idx, { thinkings: [], tools: [], contents: [] })
const b = buckets.get(idx)!
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)
if (!buckets.has(idx)) buckets.set(idx, [])
buckets.get(idx)!.push(s)
}
return [...buckets.entries()]
.sort(([a], [b]) => a - b)
.map(([index, b]) => ({
.map(([index, items]) => ({
key: `iter-${index}`,
index,
empty: b.thinkings.length === 0 && b.tools.length === 0 && b.contents.length === 0,
...b,
empty: items.length === 0,
items,
}))
})

View File

@ -14,6 +14,8 @@ import { useMessages } from './useMessages'
import { useStream } from './useStream'
import { useMessageQueue } from './useMessageQueue'
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 { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
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 */
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 */
function resetCurrentTurnState() {
currentSegments.value = []
segIdCounter.value = 0
bufferedText = ''
bufferedThinking = ''
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
const msg = getMessage(currentAssistantId.value)
if (!msg) return
@ -236,6 +253,23 @@ export function useChat(options: UseChatOptions): UseChatReturn {
metadata: { ...metadata, segments: [...currentSegments.value] }
} 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)
/** Track which conversation the current stream belongs to */
let streamConversationId = ''
@ -388,7 +422,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('content_delta', (data) => {
if (isStaleEvent(data)) return
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)) {
streamPhase.value = 'streaming'
}
@ -418,7 +456,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// Suppress thinking display when thinkingLevel=off
if (options.thinkingLevel?.value === 'off') return
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') {
streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking'
}
@ -567,6 +609,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('done', (data) => {
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) {
const existingMsg = getMessage(currentAssistantId.value)
if (existingMsg?.status !== 'failed') {
@ -702,6 +748,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
let errorFired = false
stream.on('error', (data) => {
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
// surface the actual reason ("无权操作该会话" etc.) instead of the generic
// 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 { authApi } from '@/api/index'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
const router = useRouter()
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const systemSettingsStore = useSystemSettingsStore()
const loading = ref(false)
const showPassword = ref(false)
const errorMsg = ref('')
@ -82,6 +84,11 @@ async function handleLogin() {
localStorage.setItem('userId', String(data.id || '1'))
localStorage.setItem('username', data.username || form.username)
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
// lands on /chat (their only capability) and member+ on /dashboard.
try {

View File

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