From b0b1a656f0a0981ca73732054f9e9cfef34312ab Mon Sep 17 00:00:00 2001 From: xiaolfeng Date: Wed, 23 Sep 2026 21:58:57 +0800 Subject: [PATCH 1/4] =?UTF-8?q?refactor(=E6=8E=A7=E5=88=B6=E5=8F=B0):=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84LLM=E5=8F=8C=E6=A0=8F=E4=B8=93=E5=B1=9E?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E4=B8=8E=E9=A1=B9=E7=9B=AE=E6=8A=BD?= =?UTF-8?q?=E5=B1=89=E5=9C=A8=E4=BD=8D=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Model 与 Provider 专属 Master-Detail 双栏工作台,彻底消灭大表单局促弹窗与遮罩 - 重构项目创建与编辑表单,移除硬编码路径强依赖,升级为 Tag 胶囊列表管理 - 项目抽屉支持查看概览与在位编辑双模式无缝切换,列表编辑按钮直接联动右侧抽屉 - 简化系统设置路由状态,移除冗余弹窗与键盘监听逻辑 --- web/src/components/llm/model-workbench.tsx | 470 +++++++++++++++ web/src/components/llm/provider-workbench.tsx | 413 +++++++++++++ web/src/components/project/create-dialog.tsx | 187 ++++-- web/src/components/project/detail-sheet.tsx | 552 +++++++++++++----- web/src/components/project/edit-dialog.tsx | 94 ++- web/src/routes/console/project/index.tsx | 27 +- web/src/routes/console/settings.tsx | 179 +----- 7 files changed, 1546 insertions(+), 376 deletions(-) create mode 100644 web/src/components/llm/model-workbench.tsx create mode 100644 web/src/components/llm/provider-workbench.tsx diff --git a/web/src/components/llm/model-workbench.tsx b/web/src/components/llm/model-workbench.tsx new file mode 100644 index 0000000..dc91c54 --- /dev/null +++ b/web/src/components/llm/model-workbench.tsx @@ -0,0 +1,470 @@ +import { useEffect, useMemo, useState } from 'react' +import { Plus, Search, Trash2 } from 'lucide-react' +import { Button } from '@lumina/components/ui/button' +import { Input } from '@lumina/components/ui/input' +import { Label } from '@lumina/components/ui/label' +import { Badge } from '@lumina/components/ui/badge' +import { Switch } from '@lumina/components/ui/switch' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@lumina/components/ui/select' +import { ProviderOption } from '#/components/llm/provider-option' +import { + useCreateModel, + useDeleteModel, + useModels, + useProviders, + useUpdateModel, +} from '#/hooks/useLlmConfig' +import { ConfirmDeleteDialog } from '#/components/confirm-delete-dialog' +import { SkeletonTable } from '#/components/skeleton-table' +import type { Model } from '#/lib/models/response/llm' +import { toast } from 'sonner' + +export function ModelWorkbench() { + const { data: modelsData, isLoading: modelsLoading } = useModels() + const { data: providersData, isLoading: providersLoading } = useProviders() + const createMutation = useCreateModel() + const updateMutation = useUpdateModel() + const deleteMutation = useDeleteModel() + + const rawModels = modelsData?.data?.items ?? [] + const providers = providersData?.data?.items ?? [] + + // 当前选中的模型,null 表示「新建模式」 + const [selectedId, setSelectedId] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [modelToDelete, setModelToDelete] = useState(null) + + // 表单状态 + const [providerId, setProviderId] = useState('') + const [modelName, setModelName] = useState('') + const [displayName, setDisplayName] = useState('') + const [maxTokens, setMaxTokens] = useState('32000') + const [contextWindow, setContextWindow] = useState('128000') + const [temperature, setTemperature] = useState('0.3') + const [description, setDescription] = useState('') + const [isActive, setIsActive] = useState(true) + + // 过滤后的模型列表 + const filteredModels = useMemo(() => { + if (!searchTerm.trim()) return rawModels + const t = searchTerm.toLowerCase().trim() + return rawModels.filter( + (m) => + m.model_name.toLowerCase().includes(t) || + m.display_name.toLowerCase().includes(t), + ) + }, [rawModels, searchTerm]) + + const selectedModel = useMemo( + () => rawModels.find((m) => m.id === selectedId) ?? null, + [rawModels, selectedId], + ) + + // 初始化或切换选中项时回填表单 + useEffect(() => { + if (selectedModel) { + setProviderId(selectedModel.provider_id) + setModelName(selectedModel.model_name) + setDisplayName(selectedModel.display_name) + setMaxTokens(String(selectedModel.max_tokens)) + setContextWindow(String(selectedModel.context_window)) + setTemperature(String(selectedModel.temperature)) + setDescription(selectedModel.description || '') + setIsActive(selectedModel.is_active) + } else { + // 默认切换到新建模式,自动绑定第一个 Provider + setProviderId(providers[0]?.id || '') + setModelName('') + setDisplayName('') + setMaxTokens('32000') + setContextWindow('128000') + setTemperature('0.3') + setDescription('') + setIsActive(true) + } + }, [selectedModel, providers]) + + // 默认选中第一个已有模型,若无模型则为新建模式 + useEffect(() => { + if (rawModels.length > 0 && selectedId === null && !searchTerm) { + setSelectedId(rawModels[0].id) + } + }, [rawModels, selectedId, searchTerm]) + + const isCreating = selectedId === null + + const handleStartCreate = () => { + setSelectedId(null) + setProviderId(providers[0]?.id || '') + setModelName('') + setDisplayName('') + setMaxTokens('32000') + setContextWindow('128000') + setTemperature('0.3') + setDescription('') + setIsActive(true) + } + + const handleSave = () => { + if (!providerId || !modelName.trim() || !displayName.trim()) { + toast.error('表单校验未通过', { + description: 'Provider、模型标识与显示名称为必填项', + }) + return + } + + if (isCreating) { + createMutation.mutate( + { + provider_id: providerId, + model_name: modelName.trim(), + display_name: displayName.trim(), + max_tokens: parseInt(maxTokens, 10) || 32000, + context_window: parseInt(contextWindow, 10) || 128000, + temperature: parseFloat(temperature) || 0.3, + description: description.trim() || '', + }, + { + onSuccess: (res) => { + toast.success('模型创建成功', { + description: `已成功配置模型 [${displayName}]`, + }) + if (res.data?.id) { + setSelectedId(res.data.id) + } + }, + onError: (err) => { + toast.error('创建失败', { description: err.message }) + }, + }, + ) + } else if (selectedModel) { + updateMutation.mutate( + { + id: selectedModel.id, + data: { + provider_id: providerId, + model_name: modelName.trim(), + display_name: displayName.trim(), + max_tokens: parseInt(maxTokens, 10) || 32000, + context_window: parseInt(contextWindow, 10) || 128000, + temperature: parseFloat(temperature) || 0.3, + is_active: isActive, + description: description.trim(), + }, + }, + { + onSuccess: () => { + toast.success('模型已更新', { description: `配置已生效同步` }) + }, + onError: (err) => { + toast.error('更新失败', { description: err.message }) + }, + }, + ) + } + } + + const handleDeleteClick = (e: React.MouseEvent, m: Model) => { + e.stopPropagation() + setModelToDelete(m) + setDeleteDialogOpen(true) + } + + if (modelsLoading || providersLoading) { + return + } + + return ( +
+ {/* ── 左侧:模型 Master 列表工作区 (占 5 列) ── */} +
+ {/* 顶部工具栏 */} +
+
+ + setSearchTerm(e.target.value)} + placeholder="搜索模型名称或标识..." + className="h-8 pl-8 text-xs bg-foam" + /> +
+ +
+ + {/* 模型列表项 */} +
+ {filteredModels.length === 0 ? ( +
+ 暂无匹配的模型。点击右上角「新建模型」添加。 +
+ ) : ( + filteredModels.map((m) => { + const isSelected = selectedId === m.id + const p = providers.find((item) => item.id === m.provider_id) + return ( +
setSelectedId(m.id)} + className={`group relative flex flex-col gap-1.5 p-3.5 border transition-all cursor-pointer ${ + isSelected + ? 'border-lagoon bg-foam shadow-sm border-l-4' + : 'border-line bg-surface hover:bg-sand/60' + }`} + > +
+
+ + {m.display_name} + + + {m.model_name} + +
+
+ + {m.is_active ? '已启用' : '已停用'} + + +
+
+ + {/* 胶囊参数行 */} +
+ + {p?.name || '未知 Provider'} + + • + {m.context_window.toLocaleString()} ctx + • + T={m.temperature} +
+
+ ) + }) + )} +
+
+ + {/* ── 右侧:专属编辑与创建工作台 (占 7 列) ── */} +
+ {/* 头部标题区 */} +
+
+ +

+ {isCreating + ? '新建 LLM 模型配置' + : `配置模型 · ${selectedModel?.display_name || ''}`} +

+
+ + {isCreating ? 'CREATE' : 'EDIT'} + +
+ + {/* 表单主体 */} +
+
+ + +
+ +
+
+ + setModelName(e.target.value)} + placeholder="如 gpt-4o / claude-3-5-sonnet" + className="bg-sand/30 font-mono text-xs" + /> +
+
+ + setDisplayName(e.target.value)} + placeholder="如 GPT-4o Omni / Claude 3.5 Sonnet" + className="bg-sand/30 text-xs" + /> +
+
+ +
+
+ + setMaxTokens(e.target.value)} + className="bg-sand/30 font-mono text-xs" + /> +
+
+ + setContextWindow(e.target.value)} + className="bg-sand/30 font-mono text-xs" + /> +
+
+ + setTemperature(e.target.value)} + className="bg-sand/30 font-mono text-xs" + /> +
+
+ +
+ +