From fb93f994252a48f6e398d9c4e0d79808e430af4b Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 9 Jun 2026 16:04:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E5=B7=A5=E5=85=B7=E6=A1=86?= =?UTF-8?q?=E4=B9=B1=E5=BA=8F=20+=20=E6=B5=81=E5=BC=8F/=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E5=A4=B1=E6=95=88=20+=20=E6=80=9D=E8=80=83?= =?UTF-8?q?=E5=A0=86=E7=A7=AF=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mateclaw-ui/src/App.vue | 6 ++ .../src/components/chat/MessageBubble.vue | 70 +++++++++-------- mateclaw-ui/src/composables/chat/useChat.ts | 57 +++++++++++++- .../src/stores/useSystemSettingsStore.ts | 76 +++++++++++++++++++ mateclaw-ui/src/views/Login.vue | 7 ++ .../src/views/Settings/System/index.vue | 4 + 6 files changed, 186 insertions(+), 34 deletions(-) create mode 100644 mateclaw-ui/src/stores/useSystemSettingsStore.ts diff --git a/mateclaw-ui/src/App.vue b/mateclaw-ui/src/App.vue index 708a4b0e..d294bad5 100644 --- a/mateclaw-ui/src/App.vue +++ b/mateclaw-ui/src/App.vue @@ -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 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. diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 400971ac..643be22c 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -46,34 +46,39 @@ {{ $t('chat.iterationEmpty', { index: iter.index + 1 }) }} + @@ -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() + // 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() 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, })) }) diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index a9f10aa9..93da4593 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -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(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 diff --git a/mateclaw-ui/src/stores/useSystemSettingsStore.ts b/mateclaw-ui/src/stores/useSystemSettingsStore.ts new file mode 100644 index 00000000..1a645c64 --- /dev/null +++ b/mateclaw-ui/src/stores/useSystemSettingsStore.ts @@ -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(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(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 | 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)) +} diff --git a/mateclaw-ui/src/views/Login.vue b/mateclaw-ui/src/views/Login.vue index 9b5e4add..17515cea 100644 --- a/mateclaw-ui/src/views/Login.vue +++ b/mateclaw-ui/src/views/Login.vue @@ -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 { diff --git a/mateclaw-ui/src/views/Settings/System/index.vue b/mateclaw-ui/src/views/Settings/System/index.vue index 45e3eef9..f805e8b3 100644 --- a/mateclaw-ui/src/views/Settings/System/index.vue +++ b/mateclaw-ui/src/views/Settings/System/index.vue @@ -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 = ''