mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +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 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.
|
||||||
|
|||||||
@ -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,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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 { 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 {
|
||||||
|
|||||||
@ -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 = ''
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user