- {initialData &&
{t('llm.form.idLabel', { id: initialData.id })}}
-
-
-
+
+
+
+
{initialData ? t('llm.form.editTitle') : t('llm.form.createTitle')}
+
{t('llm.form.subtitle')}
+ {initialData && (
+
{t('llm.form.idLabel', { id: initialData.id })}
+ )}
- {/* Content: Left-Right layout */}
-
- >
+
);
}
diff --git a/backend/admin/src/app/admin/llm/components/wizard/form-stepper.tsx b/backend/admin/src/app/admin/llm/components/wizard/form-stepper.tsx
new file mode 100644
index 00000000..d988e9ec
--- /dev/null
+++ b/backend/admin/src/app/admin/llm/components/wizard/form-stepper.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import React from 'react';
+import { Check } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+export interface StepMeta {
+ key: string;
+ title: string;
+ description: string;
+}
+
+interface FormStepperProps {
+ steps: StepMeta[];
+ currentStep: number;
+ visitedSteps: number[];
+ onStepClick: (index: number) => void;
+}
+
+export function FormStepper({ steps, currentStep, visitedSteps, onStepClick }: FormStepperProps) {
+ return (
+
+ {steps.map((step, index) => {
+ const isCurrent = index === currentStep;
+ const isCompleted = index < currentStep;
+ const isVisited = visitedSteps.includes(index);
+ return (
+ -
+
+ {index < steps.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/backend/admin/src/app/admin/llm/components/wizard/step-basic.tsx b/backend/admin/src/app/admin/llm/components/wizard/step-basic.tsx
new file mode 100644
index 00000000..5ed5a1ae
--- /dev/null
+++ b/backend/admin/src/app/admin/llm/components/wizard/step-basic.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import React, { useState } from 'react';
+import Image from 'next/image';
+import { useTranslation } from 'react-i18next';
+import type { UseFormReturn } from 'react-hook-form';
+import {
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { X, ExternalLink } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { PROVIDER_OPTIONS, FormValues } from '../../schema';
+
+interface StepBasicProps {
+ form: UseFormReturn
;
+ actions?: React.ReactNode;
+}
+
+export function StepBasic({ form, actions }: StepBasicProps) {
+ const { t } = useTranslation();
+ const [tagInput, setTagInput] = useState("");
+
+ return (
+
+
+
+
+ {t('llm.form.basic.title')}
+ {t('llm.form.basic.description')}
+
+ {actions}
+
+
+
+ (
+
+ {t('llm.form.basic.brand')}
+
+
+ {PROVIDER_OPTIONS.map((option) => {
+ const selected = field.value === option.value;
+ return (
+
+ );
+ })}
+
+
+ {t('llm.form.basic.brandHint')}
+
+
+ )}
+ />
+
+
+
+
+ );
+}
diff --git a/backend/admin/src/app/admin/llm/components/wizard/step-connection.tsx b/backend/admin/src/app/admin/llm/components/wizard/step-connection.tsx
new file mode 100644
index 00000000..2fb9c291
--- /dev/null
+++ b/backend/admin/src/app/admin/llm/components/wizard/step-connection.tsx
@@ -0,0 +1,125 @@
+'use client';
+
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { UseFormReturn } from 'react-hook-form';
+import {
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import { ChevronDown, Eye, EyeOff, Settings2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { FormValues } from '../../schema';
+
+interface StepConnectionProps {
+ form: UseFormReturn;
+ actions?: React.ReactNode;
+}
+
+export function StepConnection({ form, actions }: StepConnectionProps) {
+ const { t } = useTranslation();
+ const [showApiKey, setShowApiKey] = useState(false);
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+
+ return (
+
+
+
+
+ {t('llm.form.connection.title')}
+ {t('llm.form.connection.description')}
+
+ {actions}
+
+
+
+ (
+
+ {t('llm.form.connection.baseUrl')}
+
+
+
+ {t('llm.form.connection.baseUrlDescription')}
+
+
+ )}
+ />
+
+ (
+
+ {t('llm.form.connection.apiKey')}
+
+
+
+
+
+
+
+
+ )}
+ />
+
+
+
+
+
+
+ (
+
+
+
+
+
+
+ )}
+ />
+
+
+
+
+ );
+}
diff --git a/backend/admin/src/app/admin/llm/components/wizard/step-models.tsx b/backend/admin/src/app/admin/llm/components/wizard/step-models.tsx
new file mode 100644
index 00000000..7e1e7f76
--- /dev/null
+++ b/backend/admin/src/app/admin/llm/components/wizard/step-models.tsx
@@ -0,0 +1,348 @@
+'use client';
+
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useFieldArray, type UseFormReturn } from 'react-hook-form';
+import api from '@/lib/axios';
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { useToast } from '@/components/ui/use-toast';
+import { Plus, Trash2, X, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react';
+import { PRESET_COST_DIMENSIONS, MODEL_TYPE_OPTIONS, FormValues } from '../../schema';
+
+interface StepModelsProps {
+ form: UseFormReturn;
+ modelCosts: Record>;
+ setModelCosts: React.Dispatch>>>;
+ actions?: React.ReactNode;
+}
+
+export function StepModels({ form, modelCosts, setModelCosts, actions }: StepModelsProps) {
+ const { t } = useTranslation();
+ const { toast } = useToast();
+ const [isSyncingOllama, setIsSyncingOllama] = useState(false);
+ const [expandedModels, setExpandedModels] = useState>({});
+
+ const { fields, append, remove, replace } = useFieldArray({
+ control: form.control,
+ name: "models",
+ });
+
+ // 同步本地 Ollama 模型列表:调用后端代理 GET /api/tags,
+ // 合并保留已有同名条目的别名/类型配置;cost 配置仍由 modelCosts state 按名匹配。
+ const handleSyncOllamaModels = async () => {
+ try {
+ setIsSyncingOllama(true);
+ const baseUrl = (form.getValues('base_url') || '').trim() || 'http://localhost:11434';
+ const res = await api.post('/admin/llm-providers/ollama-models', { base_url: baseUrl });
+ if (!res.data?.success) {
+ toast({
+ variant: 'destructive',
+ title: '同步失败',
+ description: res.data?.message || '未能连接本地 Ollama 服务',
+ });
+ return;
+ }
+ const remoteNames: string[] = res.data.models || [];
+ if (remoteNames.length === 0) {
+ toast({
+ variant: 'destructive',
+ title: '本地未发现模型',
+ description: '请先使用 `ollama pull ` 拉取至少一个模型',
+ });
+ return;
+ }
+ // 按 value 合并:本地列表为准,同名条目保留已有 type/display_name
+ const existing = new Map(
+ (form.getValues('models') || []).map((m: { value: string; type?: string; display_name?: string }) => [m.value, m]),
+ );
+ const merged = remoteNames.map((name) => existing.get(name) || { value: name, type: 'language', display_name: '' });
+ replace(merged);
+ toast({
+ title: '同步成功',
+ description: `已从 Ollama 拉取 ${remoteNames.length} 个模型`,
+ });
+ } catch (err: any) {
+ toast({
+ variant: 'destructive',
+ title: '同步出错',
+ description: err?.message || '请检查 Base URL 与后端服务',
+ });
+ } finally {
+ setIsSyncingOllama(false);
+ }
+ };
+
+ const updateCost = (modelName: string, key: string, rawValue: string) => {
+ setModelCosts((prev) => {
+ const modelEntry = { ...(prev[modelName] || {}) };
+ if (rawValue === '') {
+ delete modelEntry[key];
+ } else {
+ modelEntry[key] = Number(rawValue);
+ }
+ return { ...prev, [modelName]: modelEntry };
+ });
+ };
+
+ const removeCost = (modelName: string, key: string) => {
+ setModelCosts((prev) => {
+ const modelEntry = { ...(prev[modelName] || {}) };
+ delete modelEntry[key];
+ return { ...prev, [modelName]: modelEntry };
+ });
+ };
+
+ const hasNamedModel = fields.some((_, index) => form.watch(`models.${index}.value`));
+
+ return (
+
+
+
+
+ {t('llm.form.models.title')}
+ {t('llm.form.models.description')}
+
+
+ {form.watch('provider_type') === 'ollama' && (
+
+
+
+
+ {t('llm.form.models.syncOllama')}
+
+ )}
+ {actions}
+
+
+
+
+
+ {fields.map((field, index) => (
+
+ (
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+ )}
+ />
+
+
+ ))}
+
+
+ {form.formState.errors.models?.message && (
+
{String(form.formState.errors.models.message)}
+ )}
+
+
+ {/* 模型成本配置 */}
+ {hasNamedModel && (
+
+
+
{t('llm.form.models.costsTitle')}
+ {t('llm.form.models.costsUnit')}
+
+
+
+ {fields.map((field, index) => {
+ const modelName = form.watch(`models.${index}.value`);
+ if (!modelName) return null;
+ const isExpanded = expandedModels[modelName] || false;
+ const costs = modelCosts[modelName] || {};
+ const customKeys = Object.keys(costs).filter((k) => !(k in PRESET_COST_DIMENSIONS));
+
+ return (
+
+
+
+ {isExpanded && (
+
+
+ {Object.entries(PRESET_COST_DIMENSIONS).map(([dimKey, dimConfig]) => (
+
+
+ updateCost(modelName, dimKey, e.target.value)}
+ className="font-mono h-9 bg-background"
+ />
+
+ ))}
+
+
+ {/* 自定义参数 */}
+ {customKeys.length > 0 && (
+
+
{t('llm.form.models.customParamsTitle')}
+ {customKeys.map((customKey) => (
+
+
+
+
{customKey}
+
+
+
+ updateCost(modelName, customKey, e.target.value)}
+ className="font-mono h-8"
+ />
+
+
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+
+ );
+}
diff --git a/backend/admin/src/app/admin/llm/schema.ts b/backend/admin/src/app/admin/llm/schema.ts
index ee226eab..dbbc57ae 100644
--- a/backend/admin/src/app/admin/llm/schema.ts
+++ b/backend/admin/src/app/admin/llm/schema.ts
@@ -52,19 +52,20 @@ export const PROVIDER_ICONS: Record = {
};
// Provider brand labels come from the vendor itself (proper nouns), not translated.
+// docsUrl 指向厂商官方 API Key / 开发者平台入口,供品牌卡片快捷跳转使用。
export const PROVIDER_OPTIONS = [
- { value: 'openai', label: 'OpenAI', icon: PROVIDER_ICONS.openai },
- { value: 'azure', label: 'Azure OpenAI', icon: PROVIDER_ICONS.azure },
- { value: 'dashscope', label: 'Dashscope (Qwen)', icon: PROVIDER_ICONS.dashscope },
- { value: 'anthropic', label: 'Anthropic (Claude)', icon: PROVIDER_ICONS.anthropic },
- { value: 'gemini', label: 'Google Gemini', icon: PROVIDER_ICONS.gemini },
- { value: 'deepseek', label: 'DeepSeek', icon: PROVIDER_ICONS.deepseek },
- { value: 'minimax', label: 'MiniMax', icon: PROVIDER_ICONS.minimax },
- { value: 'xai', label: 'xAI (Grok)', icon: PROVIDER_ICONS.xai },
- { value: 'kimi', label: 'Kimi (Moonshot)', icon: PROVIDER_ICONS.kimi },
- { value: 'ark', label: '火山方舟 (Ark)', icon: PROVIDER_ICONS.ark },
- { value: 'openrouter', label: 'OpenRouter', icon: PROVIDER_ICONS.openrouter },
- { value: 'ollama', label: 'Ollama (Local)', icon: PROVIDER_ICONS.ollama },
+ { value: 'openai', label: 'OpenAI', icon: PROVIDER_ICONS.openai, docsUrl: 'https://platform.openai.com/api-keys' },
+ { value: 'azure', label: 'Azure OpenAI', icon: PROVIDER_ICONS.azure, docsUrl: 'https://oai.azure.com/portal' },
+ { value: 'dashscope', label: 'Dashscope (Qwen)', icon: PROVIDER_ICONS.dashscope, docsUrl: 'https://bailian.console.aliyun.com/?apiKey=1' },
+ { value: 'anthropic', label: 'Anthropic (Claude)', icon: PROVIDER_ICONS.anthropic, docsUrl: 'https://platform.claude.com/' },
+ { value: 'gemini', label: 'Google Gemini', icon: PROVIDER_ICONS.gemini, docsUrl: 'https://aistudio.google.com/apikey' },
+ { value: 'deepseek', label: 'DeepSeek', icon: PROVIDER_ICONS.deepseek, docsUrl: 'https://platform.deepseek.com/api_keys' },
+ { value: 'minimax', label: 'MiniMax', icon: PROVIDER_ICONS.minimax, docsUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key' },
+ { value: 'xai', label: 'xAI (Grok)', icon: PROVIDER_ICONS.xai, docsUrl: 'https://console.x.ai/' },
+ { value: 'kimi', label: 'Kimi (Moonshot)', icon: PROVIDER_ICONS.kimi, docsUrl: 'https://platform.kimi.ai/' },
+ { value: 'ark', label: '火山方舟 (Ark)', icon: PROVIDER_ICONS.ark, docsUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey' },
+ { value: 'openrouter', label: 'OpenRouter', icon: PROVIDER_ICONS.openrouter, docsUrl: 'https://openrouter.ai/settings/keys' },
+ { value: 'ollama', label: 'Ollama (Local)', icon: PROVIDER_ICONS.ollama, docsUrl: 'https://ollama.com/search' },
];
// 本地部署且无鉴权的供应商(如 Ollama)允许 api_key 为空
diff --git a/backend/admin/src/components/admin/AdminLayout.tsx b/backend/admin/src/components/admin/AdminLayout.tsx
index e4ee55db..e48225f0 100644
--- a/backend/admin/src/components/admin/AdminLayout.tsx
+++ b/backend/admin/src/components/admin/AdminLayout.tsx
@@ -16,7 +16,6 @@ import {
Shield,
ChevronLeft,
ChevronRight,
- ChevronDown,
LogOut,
MoreHorizontal,
FileCode2,
@@ -27,11 +26,11 @@ import {
UserCircle,
Languages,
Check,
- Settings,
Mail,
} from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
+import { Separator } from '@/components/ui/separator';
import {
DropdownMenu,
DropdownMenuContent,
@@ -52,7 +51,6 @@ const LANGUAGES = [
const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [collapsed, setCollapsed] = useState(false);
- const [openGroups, setOpenGroups] = useState>({});
const pathname = usePathname();
const { logout, user } = useAuth();
const { t, i18n } = useTranslation();
@@ -62,39 +60,70 @@ const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <>{children}>;
}
+ type NavLeaf = { title: string; href: string; icon: React.ElementType };
+ type NavSection = { key: string; title: string; items: NavLeaf[] };
- type NavLeaf = { kind: 'leaf'; title: string; href: string; icon: React.ElementType };
- type NavGroup = { kind: 'group'; key: string; title: string; icon: React.ElementType; children: NavLeaf[] };
- type NavItem = NavLeaf | NavGroup;
-
- const items: NavItem[] = [
- { kind: 'leaf', title: t('layout.sidebar.dashboard'), href: '/admin', icon: LayoutDashboard },
- { kind: 'leaf', title: t('layout.sidebar.llm'), href: '/admin/llm', icon: Bot },
- { kind: 'leaf', title: t('layout.sidebar.pricing', '计费定价'), href: '/admin/pricing', icon: Coins },
- { kind: 'leaf', title: t('layout.sidebar.agents'), href: '/admin/agents', icon: Zap },
- { kind: 'leaf', title: t('layout.sidebar.skills'), href: '/admin/skills', icon: Blocks },
- { kind: 'leaf', title: t('layout.sidebar.mcp'), href: '/admin/mcp', icon: ServerCog },
- { kind: 'leaf', title: t('layout.sidebar.tools'), href: '/admin/tools', icon: Wrench },
- { kind: 'leaf', title: t('layout.sidebar.videos'), href: '/admin/videos', icon: Film },
- { kind: 'leaf', title: t('layout.sidebar.virtualHumans'), href: '/admin/virtual-humans', icon: UserCircle },
- { kind: 'leaf', title: t('layout.sidebar.promptTemplates'), href: '/admin/prompt-templates', icon: FileCode2 },
- { kind: 'leaf', title: t('layout.sidebar.users'), href: '/admin/users', icon: Users },
- { kind: 'leaf', title: t('layout.sidebar.subscriptions'), href: '/admin/subscriptions', icon: CreditCard },
- { kind: 'leaf', title: t('layout.sidebar.admins'), href: '/admin/admins', icon: Shield },
+ const sections: NavSection[] = [
+ {
+ key: 'overview',
+ title: t('layout.sidebar.groups.overview', '概览'),
+ items: [
+ { title: t('layout.sidebar.dashboard'), href: '/admin', icon: LayoutDashboard },
+ ],
+ },
+ {
+ key: 'ai',
+ title: t('layout.sidebar.groups.ai', '模型与 AI'),
+ items: [
+ { title: t('layout.sidebar.llm'), href: '/admin/llm', icon: Bot },
+ { title: t('layout.sidebar.promptTemplates'), href: '/admin/prompt-templates', icon: FileCode2 },
+ { title: t('layout.sidebar.mcp'), href: '/admin/mcp', icon: ServerCog },
+ { title: t('layout.sidebar.tools'), href: '/admin/tools', icon: Wrench },
+ ],
+ },
+ {
+ key: 'agents',
+ title: t('layout.sidebar.groups.agents', '智能体'),
+ items: [
+ { title: t('layout.sidebar.agents'), href: '/admin/agents', icon: Zap },
+ { title: t('layout.sidebar.skills'), href: '/admin/skills', icon: Blocks },
+ ],
+ },
+ {
+ key: 'content',
+ title: t('layout.sidebar.groups.content', '内容资产'),
+ items: [
+ { title: t('layout.sidebar.videos'), href: '/admin/videos', icon: Film },
+ { title: t('layout.sidebar.virtualHumans'), href: '/admin/virtual-humans', icon: UserCircle },
+ ],
+ },
+ {
+ key: 'business',
+ title: t('layout.sidebar.groups.business', '商业运营'),
+ items: [
+ { title: t('layout.sidebar.pricing', '计费定价'), href: '/admin/pricing', icon: Coins },
+ { title: t('layout.sidebar.subscriptions'), href: '/admin/subscriptions', icon: CreditCard },
+ ],
+ },
+ {
+ key: 'access',
+ title: t('layout.sidebar.groups.access', '用户与权限'),
+ items: [
+ { title: t('layout.sidebar.users'), href: '/admin/users', icon: Users },
+ { title: t('layout.sidebar.admins'), href: '/admin/admins', icon: Shield },
+ ],
+ },
{
- kind: 'group',
key: 'system',
- title: t('layout.sidebar.system'),
- icon: Settings,
- children: [
- { kind: 'leaf', title: t('layout.sidebar.emailProviders'), href: '/admin/system/email-providers', icon: Mail },
+ title: t('layout.sidebar.groups.system', '系统设置'),
+ items: [
+ { title: t('layout.sidebar.emailProviders'), href: '/admin/system/email-providers', icon: Mail },
],
},
];
- // 跳转或子路由命中时自动展开对应分组
+ // 跳转或子路由命中时高亮对应菜单项
const isLeafActive = (href: string) => href === '/admin' ? pathname === href : pathname.startsWith(href);
- const isGroupActive = (group: NavGroup) => group.children.some((c) => isLeafActive(c.href));
// 某些子页面需要全屏/无内边距布局(如:创建/编辑智能体页面有自己的滚动和分栏机制)
const isFullScreenPage = pathname?.match(/^\/admin\/agents\/[^/]+$/);
@@ -117,7 +146,7 @@ const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {