diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 005c14b..2a82237 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -18,6 +18,8 @@ export const API = {
expertsDelete: '/api/experts/delete',
expertsSelected: '/api/experts/selected',
expertsSelect: '/api/experts/select',
+ sessionSelectModel: '/api/session/select-model',
+ sessionSelectedModel: '/api/session/selected-model',
} as const
/**
diff --git a/web/src/api/experts.ts b/web/src/api/experts.ts
index a2b3474..05983b2 100644
--- a/web/src/api/experts.ts
+++ b/web/src/api/experts.ts
@@ -55,3 +55,23 @@ export async function selectExpert(sessionId: string, expertName: string | null)
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' }
return { success: true }
}
+
+/** 设置(或清除)session 的用户模型覆盖。provider/model 均为空时清除覆盖(继承默认) */
+export async function selectModel(sessionId: string, provider: string | null, model: string | null): Promise<{ success: boolean; error?: string }> {
+ const resp = await fetch(API.sessionSelectModel, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ session_id: sessionId, provider, model }),
+ })
+ const data = await resp.json().catch(() => ({}))
+ if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' }
+ return { success: true }
+}
+
+/** 读取 session 当前的用户模型覆盖。provider/model 均为 null 表示未设置(继承默认) */
+export async function getSelectedModel(sessionId: string): Promise<{ provider: string | null; model: string | null }> {
+ const params = new URLSearchParams({ session_id: sessionId })
+ const resp = await fetch(`${API.sessionSelectedModel}?${params}`)
+ if (!resp.ok) return { provider: null, model: null }
+ return resp.json()
+}
diff --git a/web/src/components/Chat/ChatContainer.tsx b/web/src/components/Chat/ChatContainer.tsx
index 66ddb9e..e0e329d 100644
--- a/web/src/components/Chat/ChatContainer.tsx
+++ b/web/src/components/Chat/ChatContainer.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { MessageList } from './MessageList'
import { MessageInput } from './MessageInput'
import { ExpertSelector } from './ExpertSelector'
+import { ModelSelector } from './ModelSelector'
import type { ChatMessage, Attachment } from '../../types/protocol'
interface ChatContainerProps {
@@ -47,12 +48,18 @@ export function ChatContainer({
-
+
+
+
+
-
-
-
)
}
diff --git a/web/src/components/Chat/ModelSelector.tsx b/web/src/components/Chat/ModelSelector.tsx
new file mode 100644
index 0000000..d2333e0
--- /dev/null
+++ b/web/src/components/Chat/ModelSelector.tsx
@@ -0,0 +1,248 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react'
+import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts'
+import type { ModelOptionsResponse } from '../Settings/types'
+
+interface ModelSelectorProps {
+ sessionId: string | null
+ /** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */
+ settingsClosedTick?: number
+ /** 选择变化回调(参数为生效的 provider/model,未覆盖时为 current 默认) */
+ onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void
+}
+
+export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange }: ModelSelectorProps) {
+ const [modelOptions, setModelOptions] = useState
(null)
+ const [userProvider, setUserProvider] = useState(null)
+ const [userModel, setUserModel] = useState(null)
+ const [open, setOpen] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(null)
+
+ // 草稿:用户在 dropdown 中暂存的选择,点击应用后才提交
+ const [draftProvider, setDraftProvider] = useState('')
+ const [draftModel, setDraftModel] = useState('')
+
+ const containerRef = useRef(null)
+
+ // 刷新当前会话的用户模型覆盖
+ const refreshSelection = useCallback(() => {
+ if (!sessionId) {
+ setUserProvider(null)
+ setUserModel(null)
+ return
+ }
+ setLoading(true)
+ setError(null)
+ getSelectedModel(sessionId)
+ .then(data => {
+ setUserProvider(data.provider)
+ setUserModel(data.model)
+ })
+ .catch(() => {
+ setUserProvider(null)
+ setUserModel(null)
+ })
+ .finally(() => setLoading(false))
+ }, [sessionId])
+
+ // 加载模型选项(全局缓存,仅加载一次)
+ useEffect(() => {
+ if (modelOptions) return
+ listModelOptions().then(data => {
+ if (data) setModelOptions(data)
+ })
+ }, [modelOptions])
+
+ // sessionId 变化时刷新用户选择
+ useEffect(() => {
+ refreshSelection()
+ }, [refreshSelection])
+
+ // 设置弹窗关闭时刷新(处理 config.json 中 provider/model 变更)
+ useEffect(() => {
+ if (settingsClosedTick === undefined) return
+ refreshSelection()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [settingsClosedTick])
+
+ // 计算生效模型并通知父组件
+ const overridden = userProvider !== null || userModel !== null
+ const effectiveProvider = userProvider ?? modelOptions?.current.provider ?? ''
+ const effectiveModel = userModel ?? modelOptions?.current.model ?? ''
+ useEffect(() => {
+ if (!modelOptions) return
+ onSelectionChange?.({
+ provider: effectiveProvider,
+ model: effectiveModel,
+ overridden,
+ })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [effectiveProvider, effectiveModel, overridden, modelOptions])
+
+ // 点击外部关闭 dropdown
+ useEffect(() => {
+ if (!open) return
+ const handler = (e: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ setOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [open])
+
+ const handleToggleOpen = () => {
+ const next = !open
+ setOpen(next)
+ if (next) {
+ // 打开时刷新选项与当前选择,同步草稿
+ if (!modelOptions) {
+ listModelOptions().then(data => { if (data) setModelOptions(data) })
+ }
+ refreshSelection()
+ setDraftProvider(userProvider ?? '')
+ setDraftModel(userModel ?? '')
+ }
+ }
+
+ const handleApply = async () => {
+ if (!sessionId) return
+ const provider = draftProvider.trim() || null
+ const model = draftModel.trim() || null
+ setSaving(true)
+ setError(null)
+ try {
+ const result = await selectModel(sessionId, provider, model)
+ if (!result.success) {
+ setError(result.error || '切换模型失败')
+ setTimeout(() => setError(null), 3000)
+ return
+ }
+ setUserProvider(provider)
+ setUserModel(model)
+ setOpen(false)
+ } catch {
+ setError('网络错误,切换模型失败')
+ setTimeout(() => setError(null), 3000)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleReset = () => {
+ setDraftProvider('')
+ setDraftModel('')
+ }
+
+ if (!sessionId) return null
+
+ // 草稿是否与已保存状态不同(用于启用"应用"按钮)
+ const draftChanged =
+ (draftProvider || null) !== (userProvider ?? null) ||
+ (draftModel || null) !== (userModel ?? null)
+
+ const buttonLabel = overridden
+ ? `${effectiveProvider}/${effectiveModel}`
+ : `默认 ${modelOptions?.current.provider ?? ''}/${modelOptions?.current.model ?? ''}`
+
+ return (
+
+
+
+ {loading ? (
+
+ ) : (
+
+ )}
+
+ {buttonLabel}
+
+
+
+
+ {open && (
+
+
+ {overridden
+ ? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)`
+ : `当前: 继承默认(${modelOptions?.current.provider ?? '-'}/${modelOptions?.current.model ?? '-'})`}
+
+
+
+
+
+
+
+
+ {error && (
+
{error}
+ )}
+
+
+
+ 重置为默认
+
+
+ {draftChanged && (
+
+ )}
+
+ {saving ? : }
+ 应用
+
+
+
+
+ )}
+
+ {error && (
+
{error}
+ )}
+
+ )
+}
diff --git a/web/src/components/Settings/types.ts b/web/src/components/Settings/types.ts
index 049953c..7ec7005 100644
--- a/web/src/components/Settings/types.ts
+++ b/web/src/components/Settings/types.ts
@@ -56,6 +56,8 @@ export interface ToolsListResponse {
export interface ModelOptionsResponse {
providers: string[]
models: string[]
+ /** 当前默认 agent 的 provider/model 名(前端用于在"继承默认"选项旁标注当前生效的模型) */
+ current: { provider: string, model: string }
}
export interface CapabilityPolicy {