mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(chat): 工具框乱序 + 流式/调试开关失效 + 思考堆积修复
This commit is contained in:
parent
0112af7105
commit
fb93f99425
@ -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.
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
76
mateclaw-ui/src/stores/useSystemSettingsStore.ts
Normal file
76
mateclaw-ui/src/stores/useSystemSettingsStore.ts
Normal 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))
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 = ''
|
||||
|
||||
Loading…
Reference in New Issue
Block a user