Files
mai-bot/dashboard/src/routes/config/model.tsx

1615 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Badge } from '@/components/ui/badge'
import { Plus, Pencil, Trash2, Save, Search, Info, Power, Check, ChevronsUpDown, RefreshCw, Loader2, GraduationCap, Share2, AlertTriangle, Settings, Lock, Unlock } from 'lucide-react'
import { getModelConfig, getModelConfigSchema, updateModelConfig } from '@/lib/config-api'
import type { ConfigSchema } from '@/types/config-schema'
import { useToast } from '@/hooks/use-toast'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { HelpTooltip } from '@/components/ui/help-tooltip'
import { RestartOverlay } from '@/components/restart-overlay'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { ExtraParamsDialog } from '@/components/ui/extra-params-dialog'
import { SharePackDialog } from '@/components/share-pack-dialog'
// 导入模块化的类型定义和组件
import type { ModelInfo, ProviderConfig, ModelTaskConfig, TaskConfig } from './model/types'
/** Unwrap backend `{ success, config }` envelope to get the actual config */
function unwrapModelConfig(data: unknown): Record<string, unknown> {
if (data && typeof data === 'object' && 'config' in data) {
return (data as { config: Record<string, unknown> }).config
}
return data as Record<string, unknown>
}
import { TaskConfigCard, Pagination, ModelTable, ModelCardList } from './model/components'
import { useModelTour, useModelFetcher, useModelAutoSave } from './model/hooks'
// 主导出组件:包装 RestartProvider
export function ModelConfigPage() {
return (
<RestartProvider>
<ModelConfigPageContent />
</RestartProvider>
)
}
// 内部实现组件
function ModelConfigPageContent() {
const [models, setModels] = useState<ModelInfo[]>([])
const [providers, setProviders] = useState<string[]>([])
const [providerConfigs, setProviderConfigs] = useState<ProviderConfig[]>([])
const [modelNames, setModelNames] = useState<string[]>([])
const [taskConfig, setTaskConfig] = useState<ModelTaskConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [autoSaving, setAutoSaving] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [editingModel, setEditingModel] = useState<ModelInfo | null>(null)
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [extraParamsDialogOpen, setExtraParamsDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deletingIndex, setDeletingIndex] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [selectedModels, setSelectedModels] = useState<Set<number>>(new Set())
const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false)
const [taskConfigSchema, setTaskConfigSchema] = useState<ConfigSchema | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [jumpToPage, setJumpToPage] = useState('')
const [advancedTemperatureMode, setAdvancedTemperatureMode] = useState(false)
const [advancedTaskSettingsVisible, setAdvancedTaskSettingsVisible] = useState(false)
// 模型 Combobox 状态
const [modelComboboxOpen, setModelComboboxOpen] = useState(false)
// 嵌入模型警告相关状态
const [embeddingWarningOpen, setEmbeddingWarningOpen] = useState(false)
const previousEmbeddingModelsRef = useRef<string[]>([])
const pendingEmbeddingUpdateRef = useRef<{ field: keyof TaskConfig; value: string[] | number } | null>(null)
// 任务配置问题检查状态
const [invalidModelRefs, setInvalidModelRefs] = useState<{ taskName: string; invalidModels: string[] }[]>([])
const [emptyTasks, setEmptyTasks] = useState<string[]>([])
// 表单验证错误状态
const [formErrors, setFormErrors] = useState<{
name?: string
api_provider?: string
model_identifier?: string
}>({})
const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart()
// Tour 引导 (使用 hook 封装的逻辑)
const { startTour: handleStartTour, isRunning: tourIsRunning } = useModelTour({
onCloseEditDialog: () => setEditDialogOpen(false),
})
// 自动保存 (使用 hook 封装的逻辑)
const { clearTimers: clearAutoSaveTimers, initialLoadRef } = useModelAutoSave({
models,
taskConfig,
onSavingChange: setAutoSaving,
onUnsavedChange: setHasUnsavedChanges,
})
// 检查任务配置问题
const checkTaskConfigIssues = useCallback((taskConf: ModelTaskConfig | null, modelList: ModelInfo[]) => {
if (!taskConf) return
const modelNameSet = new Set(modelList.map(m => m.name))
const invalidRefs: { taskName: string; invalidModels: string[] }[] = []
const emptyTaskList: string[] = []
for (const [key, task] of Object.entries(taskConf)) {
if (!task) continue
// 检查是否有模型
if (!task.model_list || task.model_list.length === 0) {
if (key !== 'learner') {
emptyTaskList.push(key)
}
continue
}
// 检查是否引用了不存在的模型
const invalid = task.model_list.filter(modelName => !modelNameSet.has(modelName))
if (invalid.length > 0) {
invalidRefs.push({ taskName: key, invalidModels: invalid })
}
}
setInvalidModelRefs(invalidRefs)
setEmptyTasks(emptyTaskList)
}, [])
// 加载配置
const loadConfig = useCallback(async () => {
try {
setLoading(true)
const [result, schemaResult] = await Promise.all([getModelConfig(), getModelConfigSchema()])
if (!result.success) {
toast({
title: '加载失败',
description: result.error,
variant: 'destructive',
})
setLoading(false)
return
}
const config = unwrapModelConfig(result.data)
const modelList = (config.models as ModelInfo[]) || []
setModels(modelList)
setModelNames(modelList.map((m) => m.name))
const providerList = (config.api_providers as ProviderConfig[]) || []
setProviders(providerList.map((p) => p.name))
setProviderConfigs(providerList)
const taskConf = (config.model_task_config as ModelTaskConfig) || null
setTaskConfig(taskConf)
// 解析 model_task_config 的 schema
if (schemaResult.success && schemaResult.data) {
const schema = (schemaResult.data as unknown as Record<string, unknown>).schema as ConfigSchema
setTaskConfigSchema(schema.nested?.model_task_config ?? null)
}
// 检查任务配置问题
checkTaskConfigIssues(taskConf, modelList)
// 初始化上一次的 embedding 模型列表
const embeddingModels = taskConf?.embedding?.model_list || []
previousEmbeddingModelsRef.current = [...embeddingModels]
setHasUnsavedChanges(false)
initialLoadRef.current = false
} catch (error) {
console.error('加载配置失败:', error)
} finally {
setLoading(false)
}
}, [initialLoadRef, checkTaskConfigIssues])
// 初始加载
useEffect(() => {
loadConfig()
}, [loadConfig])
// 获取指定提供商的配置
const getProviderConfig = useCallback((providerName: string): ProviderConfig | undefined => {
return providerConfigs.find(p => p.name === providerName)
}, [providerConfigs])
// 模型列表获取 (使用 hook 封装的逻辑)
const {
availableModels,
fetchingModels,
modelFetchError,
matchedTemplate,
fetchModelsForProvider,
clearModels,
} = useModelFetcher({ getProviderConfig })
// 当选择的提供商变化时,获取模型列表
useEffect(() => {
if (editDialogOpen && editingModel?.api_provider) {
fetchModelsForProvider(editingModel.api_provider)
}
}, [editDialogOpen, editingModel?.api_provider, fetchModelsForProvider])
// 重启麦麦
const handleRestart = async () => {
await triggerRestart()
}
// 一键删除所有无效模型引用
const handleRemoveInvalidRefs = useCallback(() => {
if (!taskConfig) return
const modelNameSet = new Set(models.map(m => m.name))
const newTaskConfig: ModelTaskConfig = {}
// 遍历所有任务,过滤掉无效的模型引用
for (const [key, task] of Object.entries(taskConfig)) {
if (task && task.model_list) {
newTaskConfig[key] = { ...task, model_list: task.model_list.filter(modelName => modelNameSet.has(modelName)) }
} else {
newTaskConfig[key] = task
}
}
setTaskConfig(newTaskConfig)
setInvalidModelRefs([])
toast({
title: '清理完成',
description: '已删除所有无效的模型引用',
})
}, [taskConfig, models, toast])
// 清理模型中的 null 值TOML 不支持 null
const cleanModelForSave = (model: ModelInfo): ModelInfo => {
const cleaned: ModelInfo = {
model_identifier: model.model_identifier,
name: model.name,
api_provider: model.api_provider,
price_in: model.price_in ?? 0,
price_out: model.price_out ?? 0,
force_stream_mode: model.force_stream_mode ?? false,
extra_params: model.extra_params ?? {},
}
// 只有在有值时才添加可选字段
if (model.temperature != null) {
cleaned.temperature = model.temperature
}
if (model.max_tokens != null) {
cleaned.max_tokens = model.max_tokens
}
return cleaned
}
// 保存并重启
const handleSaveAndRestart = async () => {
try {
setSaving(true)
clearAutoSaveTimers()
const resultGet = await getModelConfig()
if (!resultGet.success) {
toast({
title: '保存失败',
description: resultGet.error,
variant: 'destructive',
})
setSaving(false)
return
}
const config = unwrapModelConfig(resultGet.data)
// 清理每个模型中的 null 值
config.models = models.map(cleanModelForSave)
config.model_task_config = taskConfig
const resultUpdate = await updateModelConfig(config)
if (!resultUpdate.success) {
toast({
title: '保存失败',
description: resultUpdate.error,
variant: 'destructive',
})
setSaving(false)
return
}
setHasUnsavedChanges(false)
toast({
title: '保存成功',
description: '正在重启麦麦...',
})
await handleRestart()
} catch (error) {
console.error('保存配置失败:', error)
toast({
title: '保存失败',
description: (error as Error).message,
variant: 'destructive',
})
setSaving(false)
}
}
// 保存配置(手动保存)
const saveConfig = async () => {
try {
setSaving(true)
// 先取消自动保存定时器
clearAutoSaveTimers()
const resultGet = await getModelConfig()
if (!resultGet.success) {
toast({
title: '保存失败',
description: resultGet.error,
variant: 'destructive',
})
setSaving(false)
return
}
const config = unwrapModelConfig(resultGet.data)
// 清理每个模型中的 null 值
config.models = models.map(cleanModelForSave)
config.model_task_config = taskConfig
const resultUpdate = await updateModelConfig(config)
if (!resultUpdate.success) {
toast({
title: '保存失败',
description: resultUpdate.error,
variant: 'destructive',
})
setSaving(false)
return
}
setHasUnsavedChanges(false)
toast({
title: '保存成功',
description: '模型配置已保存',
})
await loadConfig() // 重新加载以更新模型名称列表
} catch (error) {
console.error('保存配置失败:', error)
toast({
title: '保存失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
// 打开编辑对话框
const openEditDialog = (model: ModelInfo | null, index: number | null) => {
// 清除表单验证错误
setFormErrors({})
setEditingModel(
model || {
model_identifier: '',
name: '',
api_provider: providers[0] || '',
price_in: 0,
price_out: 0,
temperature: null,
max_tokens: null,
force_stream_mode: false,
extra_params: {},
}
)
setEditingIndex(index)
setEditDialogOpen(true)
}
// 保存编辑
const handleSaveEdit = () => {
if (!editingModel) return
// 验证必填项
const errors: { name?: string; api_provider?: string; model_identifier?: string } = {}
if (!editingModel.name?.trim()) {
errors.name = '请输入模型名称'
} else {
// 检查名称是否与现有模型重复
const isDuplicate = models.some((m, index) => {
// 编辑时排除自身
if (editingIndex !== null && index === editingIndex) {
return false
}
return m.name.trim().toLowerCase() === editingModel.name.trim().toLowerCase()
})
if (isDuplicate) {
errors.name = '模型名称已存在,请使用其他名称'
}
}
if (!editingModel.api_provider?.trim()) {
errors.api_provider = '请选择 API 提供商'
}
if (!editingModel.model_identifier?.trim()) {
errors.model_identifier = '请输入模型标识符'
}
if (Object.keys(errors).length > 0) {
setFormErrors(errors)
return
}
// 清除错误状态
setFormErrors({})
// 填充空值的默认值,并移除 null 值的可选字段TOML 不支持 null
const modelToSave: ModelInfo = {
model_identifier: editingModel.model_identifier,
name: editingModel.name,
api_provider: editingModel.api_provider,
price_in: editingModel.price_in ?? 0,
price_out: editingModel.price_out ?? 0,
force_stream_mode: editingModel.force_stream_mode ?? false,
extra_params: editingModel.extra_params ?? {},
}
// 只有在有值时才添加可选字段
if (editingModel.temperature != null) {
modelToSave.temperature = editingModel.temperature
}
if (editingModel.max_tokens != null) {
modelToSave.max_tokens = editingModel.max_tokens
}
let newModels: ModelInfo[]
let oldModelName: string | null = null
if (editingIndex !== null) {
// 记录旧的模型名称,用于更新任务配置
oldModelName = models[editingIndex].name
newModels = [...models]
newModels[editingIndex] = modelToSave
} else {
newModels = [...models, modelToSave]
}
setModels(newModels)
setModelNames(newModels.map((m) => m.name))
// 如果模型名称发生变化,更新任务配置中对该模型的引用
if (oldModelName && oldModelName !== modelToSave.name && taskConfig) {
const updateModelList = (list: string[]): string[] => {
return list.map(name => name === oldModelName ? modelToSave.name : name)
}
const newTaskConfig: ModelTaskConfig = {}
for (const [key, task] of Object.entries(taskConfig)) {
newTaskConfig[key] = { ...task, model_list: updateModelList(task?.model_list || []) }
}
setTaskConfig(newTaskConfig)
}
setEditDialogOpen(false)
setEditingModel(null)
setEditingIndex(null)
// 提示用户配置将自动保存
toast({
title: editingIndex !== null ? '模型已更新' : '模型已添加',
description: '配置将在 2 秒后自动保存,或点击右上角"保存配置"按钮立即保存',
})
}
// 处理编辑对话框关闭
const handleEditDialogClose = (open: boolean) => {
if (!open && editingModel) {
// 关闭时填充默认值
const updatedModel = {
...editingModel,
price_in: editingModel.price_in ?? 0,
price_out: editingModel.price_out ?? 0,
}
setEditingModel(updatedModel)
}
setEditDialogOpen(open)
}
// 打开删除确认对话框
const openDeleteDialog = (index: number) => {
setDeletingIndex(index)
setDeleteDialogOpen(true)
}
// 确认删除模型
const handleConfirmDelete = () => {
if (deletingIndex !== null) {
const newModels = models.filter((_, i) => i !== deletingIndex)
setModels(newModels)
setModelNames(newModels.map((m) => m.name))
// 重新检查任务配置问题
checkTaskConfigIssues(taskConfig, newModels)
toast({
title: '删除成功',
description: '配置将在 2 秒后自动保存,或点击右上角"保存配置"按钮立即保存',
})
}
setDeleteDialogOpen(false)
setDeletingIndex(null)
}
// 切换单个模型选择
const toggleModelSelection = (index: number) => {
const newSelected = new Set(selectedModels)
if (newSelected.has(index)) {
newSelected.delete(index)
} else {
newSelected.add(index)
}
setSelectedModels(newSelected)
}
// 全选/取消全选
const toggleSelectAll = () => {
if (selectedModels.size === filteredModels.length) {
setSelectedModels(new Set())
} else {
const allIndices = filteredModels.map((_, idx) =>
models.findIndex(m => m === filteredModels[idx])
)
setSelectedModels(new Set(allIndices))
}
}
// 打开批量删除确认对话框
const openBatchDeleteDialog = () => {
if (selectedModels.size === 0) {
toast({
title: '提示',
description: '请先选择要删除的模型',
variant: 'default',
})
return
}
setBatchDeleteDialogOpen(true)
}
// 确认批量删除
const handleConfirmBatchDelete = () => {
const deletedCount = selectedModels.size
const newModels = models.filter((_, index) => !selectedModels.has(index))
setModels(newModels)
setModelNames(newModels.map((m) => m.name))
// 重新检查任务配置问题
checkTaskConfigIssues(taskConfig, newModels)
setSelectedModels(new Set())
setBatchDeleteDialogOpen(false)
toast({
title: '批量删除成功',
description: `已删除 ${deletedCount} 个模型,配置将在 2 秒后自动保存`,
})
}
// 更新任务配置
const updateTaskConfig = (
taskName: string,
field: keyof TaskConfig,
value: string[] | number | string
) => {
if (!taskConfig) return
// 检测 embedding 模型列表变化
if (taskName === 'embedding' && field === 'model_list' && Array.isArray(value)) {
const previousModels = previousEmbeddingModelsRef.current
const newModels = value as string[]
const hasChanges =
previousModels.length !== newModels.length ||
previousModels.some(model => !newModels.includes(model)) ||
newModels.some(model => !previousModels.includes(model))
if (hasChanges && previousModels.length > 0) {
pendingEmbeddingUpdateRef.current = { field, value }
setEmbeddingWarningOpen(true)
return
}
}
// 正常更新配置
const newTaskConfig = {
...taskConfig,
[taskName]: {
...taskConfig[taskName],
[field]: value,
},
}
setTaskConfig(newTaskConfig)
// 重新检查任务配置问题
checkTaskConfigIssues(newTaskConfig, models)
// 如果是 embedding 模型列表,更新 ref
if (taskName === 'embedding' && field === 'model_list' && Array.isArray(value)) {
previousEmbeddingModelsRef.current = [...(value as string[])]
}
}
// 确认更新嵌入模型
const handleConfirmEmbeddingChange = () => {
if (!taskConfig || !pendingEmbeddingUpdateRef.current) return
const { field, value } = pendingEmbeddingUpdateRef.current
const newTaskConfig = {
...taskConfig,
embedding: {
...taskConfig.embedding,
[field]: value,
},
}
setTaskConfig(newTaskConfig)
// 重新检查任务配置问题
checkTaskConfigIssues(newTaskConfig, models)
// 更新 ref
if (field === 'model_list' && Array.isArray(value)) {
previousEmbeddingModelsRef.current = [...(value as string[])]
}
// 清理
pendingEmbeddingUpdateRef.current = null
setEmbeddingWarningOpen(false)
toast({
title: '嵌入模型已更新',
description: '建议重新生成知识库向量以确保最佳匹配精度',
})
}
// 取消更新嵌入模型
const handleCancelEmbeddingChange = () => {
pendingEmbeddingUpdateRef.current = null
setEmbeddingWarningOpen(false)
}
// 过滤模型列表
const filteredModels = models.filter((model) => {
if (!searchQuery) return true
const query = searchQuery.toLowerCase()
return (
model.name.toLowerCase().includes(query) ||
model.model_identifier.toLowerCase().includes(query) ||
model.api_provider.toLowerCase().includes(query)
)
})
// 分页逻辑
const totalPages = Math.ceil(filteredModels.length / pageSize)
const paginatedModels = filteredModels.slice(
(page - 1) * pageSize,
page * pageSize
)
// 页码跳转
const handleJumpToPage = () => {
const targetPage = parseInt(jumpToPage)
if (targetPage >= 1 && targetPage <= totalPages) {
setPage(targetPage)
setJumpToPage('')
}
}
// 检查模型是否被任务使用
const isModelUsed = (modelName: string): boolean => {
if (!taskConfig) return false
return Object.values(taskConfig).some(task => task?.model_list?.includes(modelName))
}
if (loading) {
return (
<ScrollArea className="h-full">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">...</p>
</div>
</div>
</ScrollArea>
)
}
return (
<ScrollArea className="h-full">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 页面标题 */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base"></p>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<SharePackDialog
trigger={
<Button variant="outline" size="sm" className="flex-1 sm:flex-none">
<Share2 className="mr-2 h-4 w-4" />
</Button>
}
/>
<Button
onClick={saveConfig}
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
size="sm"
variant="outline"
className="flex-1 sm:flex-none sm:min-w-[120px]"
>
<Save className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
{saving ? '保存中...' : autoSaving ? '自动保存中...' : hasUnsavedChanges ? '保存配置' : '已保存'}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={saving || autoSaving || isRestarting}
size="sm"
className="flex-1 sm:flex-none sm:min-w-[120px]"
>
<Power className="mr-2 h-4 w-4" />
{isRestarting ? '重启中...' : hasUnsavedChanges ? '保存并重启' : '重启麦麦'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>
{hasUnsavedChanges
? '当前有未保存的配置更改。点击确认将先保存配置,然后重启麦麦使新配置生效。重启过程中麦麦将暂时离线。'
: '即将重启麦麦主程序。重启过程中麦麦将暂时离线,配置将在重启后生效。'
}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={hasUnsavedChanges ? handleSaveAndRestart : handleRestart}>
{hasUnsavedChanges ? '保存并重启' : '确认重启'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* 重启提示 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong>"保存并重启"
</AlertDescription>
</Alert>
{/* 无效模型引用警告 */}
{invalidModelRefs.length > 0 && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="flex items-start justify-between gap-4">
<div className="flex-1">
<strong></strong>
<div className="mt-2 space-y-1">
{invalidModelRefs.map(({ taskName, invalidModels }) => (
<div key={taskName} className="text-sm">
<strong>{taskName}</strong> : {invalidModels.join(', ')}
</div>
))}
</div>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 bg-background hover:bg-accent"
onClick={handleRemoveInvalidRefs}
>
</Button>
</AlertDescription>
</Alert>
)}
{/* 空任务警告 */}
{emptyTasks.length > 0 && (
<Alert variant="default" className="border-yellow-500/50 bg-yellow-500/10">
<AlertTriangle className="h-4 w-4 text-yellow-600" />
<AlertDescription>
<strong className="text-yellow-600"></strong>
<div className="mt-2 text-sm">
{emptyTasks.join('、')}
</div>
</AlertDescription>
</Alert>
)}
{/* 新手引导入口 - 仅在桌面端显示,移动端隐藏 */}
<Alert className="hidden lg:flex border-primary/30 bg-primary/5 cursor-pointer hover:bg-primary/10 transition-colors" onClick={handleStartTour}>
<GraduationCap className="h-4 w-4 text-primary" />
<AlertDescription className="flex items-center justify-between">
<span>
<strong className="text-primary"></strong>
</span>
<Button variant="outline" size="sm" className="ml-4 shrink-0">
</Button>
</AlertDescription>
</Alert>
{/* 标签页 */}
<Tabs defaultValue="models" className="w-full">
<TabsList className="grid w-full max-w-full sm:max-w-md grid-cols-2">
<TabsTrigger value="models"></TabsTrigger>
<TabsTrigger value="tasks" data-tour="tasks-tab-trigger"></TabsTrigger>
</TabsList>
{/* 模型配置标签页 */}
<TabsContent value="models" className="space-y-4 mt-0">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
<p className="text-sm text-muted-foreground">
</p>
<div className="flex gap-2 w-full sm:w-auto">
{selectedModels.size > 0 && (
<Button
onClick={openBatchDeleteDialog}
size="sm"
variant="destructive"
className="w-full sm:w-auto"
>
<Trash2 className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
({selectedModels.size})
</Button>
)}
<Button onClick={() => openEditDialog(null, null)} size="sm" variant="outline" className="w-full sm:w-auto" data-tour="add-model-button">
<Plus className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
</Button>
</div>
</div>
{/* 搜索框 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<div className="relative w-full sm:flex-1 sm:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索模型名称、标识符或提供商..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{searchQuery && (
<p className="text-sm text-muted-foreground whitespace-nowrap">
{filteredModels.length}
</p>
)}
</div>
{/* 模型列表 - 移动端卡片视图 */}
<ModelCardList
paginatedModels={paginatedModels}
allModels={models}
onEdit={openEditDialog}
onDelete={openDeleteDialog}
isModelUsed={isModelUsed}
searchQuery={searchQuery}
/>
{/* 模型列表 - 桌面端表格视图 */}
<ModelTable
paginatedModels={paginatedModels}
allModels={models}
filteredModels={filteredModels}
selectedModels={selectedModels}
onEdit={openEditDialog}
onDelete={openDeleteDialog}
onToggleSelection={toggleModelSelection}
onToggleSelectAll={toggleSelectAll}
isModelUsed={isModelUsed}
searchQuery={searchQuery}
/>
{/* 分页 - 使用模块化组件 */}
<Pagination
page={page}
pageSize={pageSize}
totalItems={filteredModels.length}
jumpToPage={jumpToPage}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onJumpToPageChange={setJumpToPage}
onJumpToPage={handleJumpToPage}
onSelectionClear={() => setSelectedModels(new Set())}
/>
</TabsContent>
{/* 模型任务配置标签页 */}
<TabsContent value="tasks" className="space-y-6 mt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
使
</p>
{taskConfigSchema?.fields.some((field) => field.advanced) && (
<Button
type="button"
variant={advancedTaskSettingsVisible ? 'default' : 'outline'}
size="sm"
onClick={() => setAdvancedTaskSettingsVisible((current) => !current)}
>
</Button>
)}
</div>
{taskConfig && taskConfigSchema && (
<div className="grid gap-4 sm:gap-6">
{taskConfigSchema.fields
.filter(f => f.type === 'object' && (advancedTaskSettingsVisible || !f.advanced))
.map((field, index) => {
const desc = field.description || field.name
const commaIdx = desc.search(/[,]/)
const title = commaIdx > 0 ? desc.slice(0, commaIdx).trim() : desc
const subtitle = commaIdx > 0 ? desc.slice(commaIdx + 1).trim() : ''
return (
<TaskConfigCard
key={field.name}
title={`${title} (${field.name})`}
description={subtitle}
taskConfig={taskConfig[field.name] ?? { model_list: [] }}
modelNames={modelNames}
onChange={(f, value) => updateTaskConfig(field.name, f, value)}
advanced={field.advanced}
{...(index === 0 ? { dataTour: 'task-model-select' } : {})}
/>
)
})}
</div>
)}
</TabsContent>
</Tabs>
{/* 编辑模型对话框 */}
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogClose}>
<DialogContent
className="max-w-[95vw] sm:max-w-2xl"
data-tour="model-dialog"
preventOutsideClose={tourIsRunning}
confirmOnEnter
>
<DialogHeader>
<DialogTitle>
{editingIndex !== null ? '编辑模型' : '添加模型'}
</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4 py-4">
<div className="grid gap-2" data-tour="model-name-input">
<Label htmlFor="model_name" className={formErrors.name ? 'text-destructive' : ''}> *</Label>
<Input
id="model_name"
value={editingModel?.name || ''}
onChange={(e) => {
setEditingModel((prev) =>
prev ? { ...prev, name: e.target.value } : null
)
if (formErrors.name) {
setFormErrors((prev) => ({ ...prev, name: undefined }))
}
}}
placeholder="例如: qwen3-30b"
className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
/>
{formErrors.name ? (
<p className="text-xs text-destructive">{formErrors.name}</p>
) : (
<p className="text-xs text-muted-foreground">
</p>
)}
</div>
<div className="grid gap-2" data-tour="model-provider-select">
<Label htmlFor="api_provider" className={formErrors.api_provider ? 'text-destructive' : ''}>API *</Label>
<Select
value={editingModel?.api_provider || ''}
onValueChange={(value) => {
setEditingModel((prev) =>
prev ? { ...prev, api_provider: value } : null
)
// 清空模型列表和错误状态,等待 useEffect 重新获取
clearModels()
if (formErrors.api_provider) {
setFormErrors((prev) => ({ ...prev, api_provider: undefined }))
}
}}
>
<SelectTrigger id="api_provider" className={formErrors.api_provider ? 'border-destructive focus-visible:ring-destructive' : ''}>
<SelectValue placeholder="选择提供商" />
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
</SelectItem>
))}
</SelectContent>
</Select>
{formErrors.api_provider && (
<p className="text-xs text-destructive">{formErrors.api_provider}</p>
)}
</div>
<div className="grid gap-2" data-tour="model-identifier-input">
<div className="flex items-center justify-between">
<Label htmlFor="model_identifier" className={formErrors.model_identifier ? 'text-destructive' : ''}> *</Label>
{matchedTemplate?.modelFetcher && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{matchedTemplate.display_name}
</Badge>
<Button
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={() => editingModel?.api_provider && fetchModelsForProvider(editingModel.api_provider, true)}
disabled={fetchingModels}
>
{fetchingModels ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
</div>
)}
</div>
{/* 模型标识符 Combobox */}
{matchedTemplate?.modelFetcher ? (
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modelComboboxOpen}
className="w-full justify-between font-normal"
disabled={fetchingModels || !!modelFetchError}
>
{fetchingModels ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
...
</span>
) : modelFetchError ? (
<span className="text-muted-foreground text-sm"></span>
) : editingModel?.model_identifier ? (
<span className="truncate">{editingModel.model_identifier}</span>
) : (
<span className="text-muted-foreground">...</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
<Command>
<CommandInput placeholder="搜索模型..." />
<ScrollArea className="h-[300px]">
<CommandList className="max-h-none overflow-visible">
<CommandEmpty>
{modelFetchError ? (
<div className="py-4 px-2 text-center space-y-2">
<p className="text-sm text-destructive">{modelFetchError}</p>
{!modelFetchError.includes('API Key') && (
<Button
variant="link"
size="sm"
onClick={() => editingModel?.api_provider && fetchModelsForProvider(editingModel.api_provider, true)}
>
</Button>
)}
</div>
) : (
'未找到匹配的模型'
)}
</CommandEmpty>
<CommandGroup heading="可用模型">
{availableModels.map((model) => (
<CommandItem
key={model.id}
value={model.id}
onSelect={() => {
setEditingModel((prev) =>
prev ? { ...prev, model_identifier: model.id } : null
)
setModelComboboxOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
editingModel?.model_identifier === model.id ? 'opacity-100' : 'opacity-0'
}`}
/>
<div className="flex flex-col">
<span>{model.id}</span>
{model.name !== model.id && (
<span className="text-xs text-muted-foreground">{model.name}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="手动输入">
<CommandItem
value="__manual_input__"
onSelect={() => {
setModelComboboxOpen(false)
// 聚焦到手动输入框(如果需要的话可以实现)
}}
>
<Pencil className="mr-2 h-4 w-4" />
...
</CommandItem>
</CommandGroup>
</CommandList>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
id="model_identifier"
value={editingModel?.model_identifier || ''}
onChange={(e) => {
setEditingModel((prev) =>
prev ? { ...prev, model_identifier: e.target.value } : null
)
if (formErrors.model_identifier) {
setFormErrors((prev) => ({ ...prev, model_identifier: undefined }))
}
}}
placeholder="Qwen/Qwen3-30B-A3B-Instruct-2507"
className={formErrors.model_identifier ? 'border-destructive focus-visible:ring-destructive' : ''}
/>
)}
{/* 表单验证错误提示 */}
{formErrors.model_identifier && (
<p className="text-xs text-destructive">{formErrors.model_identifier}</p>
)}
{/* 模型获取错误提示 */}
{modelFetchError && matchedTemplate?.modelFetcher && !formErrors.model_identifier && (
<Alert variant="destructive" className="mt-2 py-2">
<Info className="h-4 w-4" />
<AlertDescription className="text-xs">
{modelFetchError}
</AlertDescription>
</Alert>
)}
{/* 手动输入区域 - 当使用 Combobox 时也显示一个可编辑的输入框 */}
{matchedTemplate?.modelFetcher && (
<Input
value={editingModel?.model_identifier || ''}
onChange={(e) => {
setEditingModel((prev) =>
prev ? { ...prev, model_identifier: e.target.value } : null
)
if (formErrors.model_identifier) {
setFormErrors((prev) => ({ ...prev, model_identifier: undefined }))
}
}}
placeholder="或手动输入模型标识符"
className={`mt-2 ${formErrors.model_identifier ? 'border-destructive focus-visible:ring-destructive' : ''}`}
/>
)}
{!formErrors.model_identifier && (
<p className="text-xs text-muted-foreground">
{modelFetchError
? '请手动输入模型标识符,或前往"模型提供商配置"检查 API Key'
: matchedTemplate?.modelFetcher
? `已识别为 ${matchedTemplate.display_name},支持自动获取模型列表`
: 'API 提供商提供的模型 ID'}
</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="price_in"> (¥/M token)</Label>
<Input
id="price_in"
type="number"
step="0.1"
min="0"
value={editingModel?.price_in ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseFloat(e.target.value)
setEditingModel((prev) =>
prev
? { ...prev, price_in: val }
: null
)
}}
placeholder="默认: 0"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="price_out"> (¥/M token)</Label>
<Input
id="price_out"
type="number"
step="0.1"
min="0"
value={editingModel?.price_out ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseFloat(e.target.value)
setEditingModel((prev) =>
prev
? { ...prev, price_out: val }
: null
)
}}
placeholder="默认: 0"
/>
</div>
</div>
{/* 模型级别温度 */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="enable_model_temperature" className="cursor-pointer"></Label>
<HelpTooltip
content={
<div className="space-y-2">
<p className="font-medium">Temperature</p>
<p></p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li><strong>0.1-0.3</strong></li>
<li><strong>0.5-0.7</strong></li>
<li><strong>0.8-1.0</strong></li>
<li><strong>1.0-2.0</strong></li>
</ul>
</div>
}
side="right"
maxWidth="400px"
/>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
id="enable_model_temperature"
checked={editingModel?.temperature != null}
onCheckedChange={(checked) => {
if (checked) {
setEditingModel((prev) => prev ? { ...prev, temperature: 0.5 } : null)
} else {
setEditingModel((prev) => prev ? { ...prev, temperature: null } : null)
}
}}
/>
</div>
{editingModel?.temperature != null && (
<div className="space-y-3 pt-2 border-t">
<div className="flex items-center justify-between gap-3">
<Label className="text-sm"></Label>
<div className="flex items-center gap-2">
<Input
type="number"
value={editingModel.temperature}
onChange={(e) => {
const value = parseFloat(e.target.value)
if (!isNaN(value) && value >= 0 && value <= 2) {
setEditingModel((prev) => prev ? { ...prev, temperature: value } : null)
}
}}
onBlur={(e) => {
const value = parseFloat(e.target.value)
if (isNaN(value) || value < 0) {
setEditingModel((prev) => prev ? { ...prev, temperature: 0 } : null)
} else if (value > 2) {
setEditingModel((prev) => prev ? { ...prev, temperature: 2 } : null)
}
}}
step={0.01}
min={0}
max={2}
className="w-20 h-8 text-sm text-right tabular-nums"
/>
<Button
variant="outline"
size="sm"
onClick={() => setAdvancedTemperatureMode(!advancedTemperatureMode)}
className="h-8 px-2"
title={advancedTemperatureMode ? "切换到基础模式 (0-1)" : "解锁高级范围 (0-2)"}
>
{advancedTemperatureMode ? (
<Unlock className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground tabular-nums">0</span>
<Slider
value={[editingModel.temperature]}
onValueChange={(values) =>
setEditingModel((prev) =>
prev ? { ...prev, temperature: values[0] } : null
)
}
min={0}
max={advancedTemperatureMode ? 2 : 1}
step={advancedTemperatureMode ? 0.05 : 0.1}
className="flex-1"
/>
<span className="text-xs text-muted-foreground tabular-nums">{advancedTemperatureMode ? '2' : '1'}</span>
</div>
{advancedTemperatureMode && (
<Alert className="bg-amber-500/10 border-amber-500/20 [&>svg+div]:translate-y-0">
<AlertTriangle className="h-4 w-4 text-amber-500" />
<AlertDescription className="text-xs text-amber-600 dark:text-amber-400">
&gt; 1 使
</AlertDescription>
</Alert>
)}
<p className="text-xs text-muted-foreground">
{advancedTemperatureMode
? "较低0.1-0.5产生确定输出中等0.5-1.0平衡创造性较高1.0-2.0)产生极度随机输出"
: "较低的温度0.1-0.3产生更确定的输出较高的温度0.7-1.0)产生更多样化的输出"
}
</p>
</div>
)}
</div>
{/* 模型级别最大 Token */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="enable_model_max_tokens" className="cursor-pointer"> Token</Label>
<HelpTooltip
content={
<div className="space-y-2">
<p className="font-medium"> Token</p>
<p>1 token 0.75 0.5 </p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li><strong>512-1024</strong></li>
<li><strong>2048-4096</strong></li>
<li><strong>8192+</strong></li>
</ul>
</div>
}
side="right"
maxWidth="400px"
/>
</div>
<p className="text-xs text-muted-foreground">
Token
</p>
</div>
<Switch
id="enable_model_max_tokens"
checked={editingModel?.max_tokens != null}
onCheckedChange={(checked) => {
if (checked) {
// 启用时设置默认值 2048
setEditingModel((prev) => prev ? { ...prev, max_tokens: 2048 } : null)
} else {
// 禁用时清除
setEditingModel((prev) => prev ? { ...prev, max_tokens: null } : null)
}
}}
/>
</div>
{editingModel?.max_tokens != null && (
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center justify-between">
<Label className="text-sm"> Token </Label>
<Input
type="number"
min="1"
max="128000"
value={editingModel.max_tokens}
onChange={(e) => {
const val = parseInt(e.target.value)
if (!isNaN(val) && val >= 1) {
setEditingModel((prev) => prev ? { ...prev, max_tokens: val } : null)
}
}}
className="w-28 h-8 text-sm"
/>
</div>
<p className="text-xs text-muted-foreground">
token
</p>
</div>
)}
</div>
<div className="flex items-center space-x-2">
<Switch
id="force_stream_mode"
checked={editingModel?.force_stream_mode || false}
onCheckedChange={(checked) =>
setEditingModel((prev) =>
prev ? { ...prev, force_stream_mode: checked } : null
)
}
/>
<Label htmlFor="force_stream_mode" className="cursor-pointer">
</Label>
</div>
{/* 额外参数 */}
<div className="space-y-2">
<Label className="text-sm font-medium"></Label>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1 justify-start h-9"
onClick={() => setExtraParamsDialogOpen(true)}
>
<Settings className="h-4 w-4 mr-2" />
{Object.keys(editingModel?.extra_params || {}).length > 0 ? (
<span>
{Object.keys(editingModel?.extra_params || {}).length}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</Button>
</div>
{Object.keys(editingModel?.extra_params || {}).length > 0 && (
<div className="text-xs text-muted-foreground px-1">
{Object.keys(editingModel?.extra_params || {})
.slice(0, 3)
.map((key) => (
<span key={key} className="inline-block mr-2">
<code className="px-1.5 py-0.5 bg-muted rounded">{key}</code>
</span>
))}
{Object.keys(editingModel?.extra_params || {}).length > 3 && (
<span>...</span>
)}
</div>
)}
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)} data-tour="model-cancel-button">
</Button>
<Button data-dialog-action="confirm" onClick={handleSaveEdit} data-tour="model-save-button"></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{deletingIndex !== null ? models[deletingIndex]?.name : ''}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 批量删除确认对话框 */}
<AlertDialog open={batchDeleteDialogOpen} onOpenChange={setBatchDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{selectedModels.size}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmBatchDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 嵌入模型更换警告对话框 */}
<AlertDialog open={embeddingWarningOpen} onOpenChange={setEmbeddingWarningOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3 text-sm">
<p>
<strong className="text-foreground"></strong>
</p>
<ul className="space-y-2 ml-4 list-disc text-muted-foreground">
<li></li>
<li></li>
<li></li>
</ul>
<p className="text-foreground font-medium">
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelEmbeddingChange}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmEmbeddingChange}
className="bg-amber-600 hover:bg-amber-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 额外参数编辑弹窗 */}
<ExtraParamsDialog
open={extraParamsDialogOpen}
onOpenChange={setExtraParamsDialogOpen}
value={editingModel?.extra_params || {}}
onChange={(params) =>
setEditingModel((prev) =>
prev ? { ...prev, extra_params: params } : null
)
}
/>
{/* 重启遮罩层 */}
<RestartOverlay />
</div>
</ScrollArea>
)
}