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({
- +
+ + +
-
-
- + )} + {selectedExpert ? ( + + + {selectedExpert.name} + + + {selectedExpert.description} + + + ) : ( + 无专家 + )} + + - {open && ( -
- {listLoading && expertList.length === 0 ? ( -
- 加载中... -
- ) : ( - <> - {/* 无专家 option */} - - {expertList.length > 0 ? ( -
- {expertList.map(expert => { - const isSelected = selectedExpert?.name === expert.name - return ( - + {expertList.length > 0 ? ( +
+ {expertList.map(expert => { + const isSelected = selectedExpert?.name === expert.name + return ( + - ) - })} -
- ) : ( -
- -
- )} - - )} -
- )} -
- {error && ( - {error} +

+ {expert.description} +

+ {expert.path && ( +

+ {expert.path} +

+ )} +
+ + ) + })} +
+ ) : ( +
+ +
+ )} + + )} + )} + {error && ( + {error} + )} ) } 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 ( +
+
+ + + {open && ( +
+
+ {overridden + ? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)` + : `当前: 继承默认(${modelOptions?.current.provider ?? '-'}/${modelOptions?.current.model ?? '-'})`} +
+ +
+ + + +
+ + {error && ( +
{error}
+ )} + +
+ +
+ {draftChanged && ( + + )} + +
+
+
+ )} +
+ {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 {