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:
parent
a825b10c48
commit
649ea2998d
@ -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
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
248
web/src/components/Chat/ModelSelector.tsx
Normal file
248
web/src/components/Chat/ModelSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user