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',
|
expertsDelete: '/api/experts/delete',
|
||||||
expertsSelected: '/api/experts/selected',
|
expertsSelected: '/api/experts/selected',
|
||||||
expertsSelect: '/api/experts/select',
|
expertsSelect: '/api/experts/select',
|
||||||
|
sessionSelectModel: '/api/session/select-model',
|
||||||
|
sessionSelectedModel: '/api/session/selected-model',
|
||||||
} as const
|
} 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 || '切换专家失败' }
|
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' }
|
||||||
return { success: true }
|
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 { MessageList } from './MessageList'
|
||||||
import { MessageInput } from './MessageInput'
|
import { MessageInput } from './MessageInput'
|
||||||
import { ExpertSelector } from './ExpertSelector'
|
import { ExpertSelector } from './ExpertSelector'
|
||||||
|
import { ModelSelector } from './ModelSelector'
|
||||||
import type { ChatMessage, Attachment } from '../../types/protocol'
|
import type { ChatMessage, Attachment } from '../../types/protocol'
|
||||||
|
|
||||||
interface ChatContainerProps {
|
interface ChatContainerProps {
|
||||||
@ -47,12 +48,18 @@ export function ChatContainer({
|
|||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
|
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
|
||||||
</div>
|
</div>
|
||||||
<ExpertSelector
|
<div className="flex flex-wrap items-center gap-1 px-4 pt-2 max-w-5xl mx-auto w-full">
|
||||||
sessionId={sessionId ?? null}
|
<ExpertSelector
|
||||||
onManageExperts={onOpenSettings}
|
sessionId={sessionId ?? null}
|
||||||
onSelectionChange={setSelectedExpert}
|
onManageExperts={onOpenSettings}
|
||||||
settingsClosedTick={settingsClosedTick}
|
onSelectionChange={setSelectedExpert}
|
||||||
/>
|
settingsClosedTick={settingsClosedTick}
|
||||||
|
/>
|
||||||
|
<ModelSelector
|
||||||
|
sessionId={sessionId ?? null}
|
||||||
|
settingsClosedTick={settingsClosedTick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MessageInput
|
<MessageInput
|
||||||
onSend={onSendMessage}
|
onSend={onSendMessage}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
|
|||||||
@ -142,118 +142,116 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
|
|||||||
if (!sessionId) return null
|
if (!sessionId) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative shrink-0 px-4 pt-2 pb-0">
|
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
|
||||||
<div className="max-w-5xl mx-auto flex items-center gap-2">
|
<div className="relative">
|
||||||
<div className="relative">
|
<button
|
||||||
<button
|
onClick={handleToggleOpen}
|
||||||
onClick={handleToggleOpen}
|
disabled={loading}
|
||||||
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"
|
||||||
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}` : '未选中专家'}
|
||||||
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
|
>
|
||||||
>
|
{loading ? (
|
||||||
{loading ? (
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
|
) : (
|
||||||
) : (
|
<UserCheck
|
||||||
<UserCheck
|
className={`h-3.5 w-3.5 ${selectedExpert ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
|
||||||
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' : ''}`}
|
|
||||||
/>
|
/>
|
||||||
</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 && (
|
{open && (
|
||||||
<div
|
<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"
|
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 ? (
|
{listLoading && expertList.length === 0 ? (
|
||||||
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
|
<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" /> 加载中...
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 加载中...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 无专家 option */}
|
{/* 无专家 option */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSelect(null)}
|
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"
|
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" />
|
<UserCheck className="h-4 w-4 text-[var(--text-muted)] shrink-0" />
|
||||||
<span className="text-[var(--text-primary)]">无专家</span>
|
<span className="text-[var(--text-primary)]">无专家</span>
|
||||||
{!selectedExpert && (
|
{!selectedExpert && (
|
||||||
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
|
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{expertList.length > 0 ? (
|
{expertList.length > 0 ? (
|
||||||
<div className="border-t border-[var(--border-color)]">
|
<div className="border-t border-[var(--border-color)]">
|
||||||
{expertList.map(expert => {
|
{expertList.map(expert => {
|
||||||
const isSelected = selectedExpert?.name === expert.name
|
const isSelected = selectedExpert?.name === expert.name
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={expert.name}
|
key={expert.name}
|
||||||
onClick={() => handleSelect({ name: expert.name, description: expert.description })}
|
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"
|
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
|
||||||
>
|
>
|
||||||
<UserCheck
|
<UserCheck
|
||||||
className={`h-4 w-4 shrink-0 mt-0.5 ${isSelected ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
|
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="min-w-0 flex-1">
|
||||||
<div className="flex items-start gap-1.5">
|
<div className="flex items-start gap-1.5">
|
||||||
<span className="text-sm font-medium text-[var(--text-primary)] break-all">
|
<span className="text-sm font-medium text-[var(--text-primary)] break-all">
|
||||||
{expert.name}
|
{expert.name}
|
||||||
</span>
|
</span>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0 mt-0.5" />
|
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||||
)
|
{expert.description}
|
||||||
})}
|
</p>
|
||||||
</div>
|
{expert.path && (
|
||||||
) : (
|
<p
|
||||||
<div className="border-t border-[var(--border-color)]">
|
className="text-[10px] text-[var(--text-muted)]/60 truncate mt-1 font-mono"
|
||||||
<button
|
title={expert.path}
|
||||||
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"
|
{expert.path}
|
||||||
>
|
</p>
|
||||||
<Settings className="h-4 w-4" />
|
)}
|
||||||
<span>管理专家...</span>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)
|
||||||
)}
|
})}
|
||||||
</>
|
</div>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<div className="border-t border-[var(--border-color)]">
|
||||||
)}
|
<button
|
||||||
</div>
|
onClick={handleManage}
|
||||||
{error && (
|
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"
|
||||||
<span className="text-xs text-red-400 truncate">{error}</span>
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
<span>管理专家...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs text-red-400 truncate">{error}</span>
|
||||||
|
)}
|
||||||
</div>
|
</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 {
|
export interface ModelOptionsResponse {
|
||||||
providers: string[]
|
providers: string[]
|
||||||
models: string[]
|
models: string[]
|
||||||
|
/** 当前默认 agent 的 provider/model 名(前端用于在"继承默认"选项旁标注当前生效的模型) */
|
||||||
|
current: { provider: string, model: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CapabilityPolicy {
|
export interface CapabilityPolicy {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user