feat(web): 集成 ModelSelector 实现主 agent 模型选择 UI

新建 ModelSelector 组件(参考 ExpertSelector 交互模式): 显示当前生效模型,下拉支持 provider/model 选择,草稿暂存+应用提交,重置为默认。

ChatContainer 用共享 flex 容器并排渲染 ExpertSelector 和 ModelSelector,ExpertSelector 简化外层 wrapper(去除冗余 padding/max-width)。

API 层: 新增 sessionSelectedModel 端点常量和 getSelectedModel 函数; types.ts 为 ModelOptionsResponse 添加 current 字段。

按钮语义: 已覆盖时显示 provider/model,未覆盖时显示 '默认 provider/model'。
This commit is contained in:
oudecheng 2026-07-30 23:25:14 +08:00
parent a825b10c48
commit 649ea2998d
6 changed files with 389 additions and 112 deletions

View File

@ -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
/**

View File

@ -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()
}

View File

@ -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({
<div className="flex-1 overflow-hidden relative">
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
</div>
<ExpertSelector
sessionId={sessionId ?? null}
onManageExperts={onOpenSettings}
onSelectionChange={setSelectedExpert}
settingsClosedTick={settingsClosedTick}
/>
<div className="flex flex-wrap items-center gap-1 px-4 pt-2 max-w-5xl mx-auto w-full">
<ExpertSelector
sessionId={sessionId ?? null}
onManageExperts={onOpenSettings}
onSelectionChange={setSelectedExpert}
settingsClosedTick={settingsClosedTick}
/>
<ModelSelector
sessionId={sessionId ?? null}
settingsClosedTick={settingsClosedTick}
/>
</div>
<MessageInput
onSend={onSendMessage}
onStop={onStop}

View File

@ -142,118 +142,116 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
if (!sessionId) return null
return (
<div ref={containerRef} className="relative shrink-0 px-4 pt-2 pb-0">
<div className="max-w-5xl mx-auto flex items-center gap-2">
<div className="relative">
<button
onClick={handleToggleOpen}
disabled={loading}
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
) : (
<UserCheck
className={`h-3.5 w-3.5 ${selectedExpert ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
)}
{selectedExpert ? (
<span className="flex items-center gap-1 min-w-0">
<span className="text-[var(--text-primary)] font-medium truncate max-w-[120px]">
{selectedExpert.name}
</span>
<span className="text-[var(--text-muted)] truncate max-w-[180px]">
{selectedExpert.description}
</span>
</span>
) : (
<span className="text-[var(--text-muted)]"></span>
)}
<ChevronDown
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
<div className="relative">
<button
onClick={handleToggleOpen}
disabled={loading}
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
) : (
<UserCheck
className={`h-3.5 w-3.5 ${selectedExpert ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
</button>
)}
{selectedExpert ? (
<span className="flex items-center gap-1 min-w-0">
<span className="text-[var(--text-primary)] font-medium truncate max-w-[120px]">
{selectedExpert.name}
</span>
<span className="text-[var(--text-muted)] truncate max-w-[180px]">
{selectedExpert.description}
</span>
</span>
) : (
<span className="text-[var(--text-muted)]"></span>
)}
<ChevronDown
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden"
>
{listLoading && expertList.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> ...
</div>
) : (
<>
{/* 无专家 option */}
<button
onClick={() => handleSelect(null)}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck className="h-4 w-4 text-[var(--text-muted)] shrink-0" />
<span className="text-[var(--text-primary)]"></span>
{!selectedExpert && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
)}
</button>
{expertList.length > 0 ? (
<div className="border-t border-[var(--border-color)]">
{expertList.map(expert => {
const isSelected = selectedExpert?.name === expert.name
return (
<button
key={expert.name}
onClick={() => handleSelect({ name: expert.name, description: expert.description })}
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck
className={`h-4 w-4 shrink-0 mt-0.5 ${isSelected ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-1.5">
<span className="text-sm font-medium text-[var(--text-primary)] break-all">
{expert.name}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0 mt-0.5" />
)}
</div>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
{expert.description}
</p>
{expert.path && (
<p
className="text-[10px] text-[var(--text-muted)]/60 truncate mt-1 font-mono"
title={expert.path}
>
{expert.path}
</p>
{open && (
<div
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden"
>
{listLoading && expertList.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> ...
</div>
) : (
<>
{/* 无专家 option */}
<button
onClick={() => handleSelect(null)}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck className="h-4 w-4 text-[var(--text-muted)] shrink-0" />
<span className="text-[var(--text-primary)]"></span>
{!selectedExpert && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
)}
</button>
{expertList.length > 0 ? (
<div className="border-t border-[var(--border-color)]">
{expertList.map(expert => {
const isSelected = selectedExpert?.name === expert.name
return (
<button
key={expert.name}
onClick={() => handleSelect({ name: expert.name, description: expert.description })}
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck
className={`h-4 w-4 shrink-0 mt-0.5 ${isSelected ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-1.5">
<span className="text-sm font-medium text-[var(--text-primary)] break-all">
{expert.name}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0 mt-0.5" />
)}
</div>
</button>
)
})}
</div>
) : (
<div className="border-t border-[var(--border-color)]">
<button
onClick={handleManage}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-secondary)] hover:text-[var(--accent-cyan)] hover:bg-[var(--bg-hover)] transition-colors"
>
<Settings className="h-4 w-4" />
<span>...</span>
</button>
</div>
)}
</>
)}
</div>
)}
</div>
{error && (
<span className="text-xs text-red-400 truncate">{error}</span>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
{expert.description}
</p>
{expert.path && (
<p
className="text-[10px] text-[var(--text-muted)]/60 truncate mt-1 font-mono"
title={expert.path}
>
{expert.path}
</p>
)}
</div>
</button>
)
})}
</div>
) : (
<div className="border-t border-[var(--border-color)]">
<button
onClick={handleManage}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-secondary)] hover:text-[var(--accent-cyan)] hover:bg-[var(--bg-hover)] transition-colors"
>
<Settings className="h-4 w-4" />
<span>...</span>
</button>
</div>
)}
</>
)}
</div>
)}
</div>
{error && (
<span className="text-xs text-red-400 truncate">{error}</span>
)}
</div>
)
}

View File

@ -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<ModelOptionsResponse | null>(null)
const [userProvider, setUserProvider] = useState<string | null>(null)
const [userModel, setUserModel] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
// 草稿:用户在 dropdown 中暂存的选择,点击应用后才提交
const [draftProvider, setDraftProvider] = useState<string>('')
const [draftModel, setDraftModel] = useState<string>('')
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
<div className="relative">
<button
onClick={handleToggleOpen}
disabled={loading}
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
title={overridden ? `用户覆盖: ${effectiveProvider}/${effectiveModel}` : `继承默认: ${effectiveProvider}/${effectiveModel}`}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
) : (
<Cpu
className={`h-3.5 w-3.5 ${overridden ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
)}
<span className={`truncate max-w-[200px] ${overridden ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]'}`}>
{buttonLabel}
</span>
<ChevronDown
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-80 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden p-3 space-y-3"
>
<div className="text-xs text-[var(--text-muted)]">
{overridden
? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)`
: `当前: 继承默认(${modelOptions?.current.provider ?? '-'}/${modelOptions?.current.model ?? '-'}`}
</div>
<div className="space-y-2">
<label className="block text-xs font-medium text-[var(--text-secondary)]">
Provider
<select
value={draftProvider}
onChange={e => setDraftProvider(e.target.value)}
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
>
<option value="">{modelOptions ? `${modelOptions.current.provider}` : ''}</option>
{(modelOptions?.providers ?? []).map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</label>
<label className="block text-xs font-medium text-[var(--text-secondary)]">
Model
<select
value={draftModel}
onChange={e => setDraftModel(e.target.value)}
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
>
<option value="">{modelOptions ? `${modelOptions.current.model}` : ''}</option>
{(modelOptions?.models ?? []).map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</label>
</div>
{error && (
<div className="text-xs text-red-400 truncate">{error}</div>
)}
<div className="flex items-center justify-between gap-2 pt-1">
<button
onClick={handleReset}
disabled={saving}
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
</button>
<div className="flex items-center gap-2">
{draftChanged && (
<Check className="h-3 w-3 text-[var(--accent-cyan)] animate-pulse" />
)}
<button
onClick={handleApply}
disabled={saving || !draftChanged}
className="inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/30 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</button>
</div>
</div>
</div>
)}
</div>
{error && (
<span className="text-xs text-red-400 truncate">{error}</span>
)}
</div>
)
}

View File

@ -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 {