feat:合并memory配置,优化webui交互和展示
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, ScrollText, Server, Settings, Sliders, Smile } from 'lucide-react'
|
||||
import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, ScrollText, Settings, Sliders, Smile } from 'lucide-react'
|
||||
|
||||
import type { MenuSection } from './types'
|
||||
|
||||
@@ -14,7 +14,6 @@ export const menuSections: MenuSection[] = [
|
||||
title: 'sidebar.groups.botConfig',
|
||||
items: [
|
||||
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' },
|
||||
{ icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', searchDescription: 'search.items.modelProviderDesc', tourId: 'sidebar-model-provider' },
|
||||
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' },
|
||||
{ icon: ScrollText, label: 'sidebar.menu.promptManagement', path: '/config/prompts' },
|
||||
],
|
||||
|
||||
@@ -58,8 +58,8 @@ function unwrapConfigSchema(payload: unknown): ConfigSchema | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function getModelConfigPath(fieldPath: string) {
|
||||
return fieldPath.startsWith('api_providers') ? '/config/modelProvider' : '/config/model'
|
||||
function getModelConfigPath(_fieldPath: string) {
|
||||
return '/config/model'
|
||||
}
|
||||
|
||||
function buildFieldSearchText(field: FieldSchema, fieldPath: string, sectionTitle: string, language?: string) {
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
import type { Step, Placement } from 'react-joyride'
|
||||
import type { Placement, Step } from 'react-joyride'
|
||||
|
||||
export const MODEL_ASSIGNMENT_TOUR_ID = 'model-assignment-tour'
|
||||
|
||||
// Tour 步骤定义
|
||||
export const modelAssignmentTourSteps: Step[] = [
|
||||
// Step 1: 全屏介绍
|
||||
{
|
||||
target: 'body',
|
||||
content: '本引导旨在帮助你配置模型提供商和对应的模型,并为麦麦的各个组件分配合适的模型。',
|
||||
content: '本引导会帮你在同一个页面完成模型厂商、模型列表和功能分配配置。',
|
||||
placement: 'center' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 2: 侧边栏 - 模型提供商按钮(点击下一步会自动导航)
|
||||
{
|
||||
target: '[data-tour="sidebar-model-provider"]',
|
||||
content: '第一步,你需要配置模型提供商。模型提供商决定了你要使用谁家的模型,无论是单一厂商(如 DeepSeek),还是模型平台(如 Siliconflow),都可以在这里进行配置。点击"下一步"进入配置页面。',
|
||||
placement: 'right' as Placement,
|
||||
target: '[data-tour="providers-tab-trigger"]',
|
||||
content: '第一步,进入"模型厂商设置"。这里用于配置要连接的模型服务厂商或模型平台。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 3: 添加提供商按钮
|
||||
{
|
||||
target: '[data-tour="add-provider-button"]',
|
||||
content: '点击"添加提供商"按钮,开始配置你的模型提供商。',
|
||||
content: '点击"添加提供商"按钮,开始配置模型厂商的连接信息。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
@@ -35,70 +33,63 @@ export const modelAssignmentTourSteps: Step[] = [
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 4: 添加提供商弹窗
|
||||
{
|
||||
target: '[data-tour="provider-dialog"]',
|
||||
content: '在这里,你可以选择你想要配置的模型提供商,填写相关信息后保存即可。',
|
||||
content: '在这里可以选择厂商模板,填写 API Key、URL 和连接参数,保存后即可供模型引用。',
|
||||
placement: 'left' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 5: 名称输入框
|
||||
{
|
||||
target: '[data-tour="provider-name-input"]',
|
||||
content: '这里的名称是你为这个模型提供商起的一个名字,方便你在后续使用时识别它。',
|
||||
content: '这里的名称用于在后续模型配置中识别这个厂商。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 6: API 密钥输入框
|
||||
{
|
||||
target: '[data-tour="provider-apikey-input"]',
|
||||
content: '这里需要填写你从模型提供商那里获取的 API 密钥,用于验证和调用模型服务。对于不同的提供商,获取 API 密钥的方式可能有所不同,请参考对应提供商的文档。',
|
||||
content: '这里填写从模型厂商获取的 API Key,用于验证并调用模型服务。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 7: URL 输入框
|
||||
{
|
||||
target: '[data-tour="provider-url-input"]',
|
||||
content: '这里需要填写模型提供商的 API 访问地址,确保填写正确以便系统能够连接到模型服务。对于不同的提供商,API 地址可能有所不同,请参考对应提供商的文档。',
|
||||
content: '这里填写模型厂商的 API 访问地址。不同厂商或平台的地址可能不同。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 8: 模板选择下拉框
|
||||
{
|
||||
target: '[data-tour="provider-template-select"]',
|
||||
content: '当然,如果你不知道如何填写这些信息,很多模型提供商在这里都提供了预设的模板供你选择,选择对应的模板后,相关信息会自动填充。',
|
||||
content: '如果不确定如何填写,可以从预设模板中选择常用厂商,相关信息会自动填充。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 9: 保存按钮
|
||||
{
|
||||
target: '[data-tour="provider-save-button"]',
|
||||
content: '填写完所有信息后,点击保存按钮,模型提供商就配置完成了。',
|
||||
content: '填写完成后点击保存,模型厂商就配置好了。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 10: 取消按钮
|
||||
{
|
||||
target: '[data-tour="provider-cancel-button"]',
|
||||
content: '因为这次咱们什么都没有填写,所以点击取消按钮退出吧。',
|
||||
content: '这次只是演示流程,点击取消关闭厂商配置窗口。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
@@ -106,20 +97,19 @@ export const modelAssignmentTourSteps: Step[] = [
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 11: 侧边栏 - 模型管理与分配按钮(点击下一步会自动导航)
|
||||
{
|
||||
target: '[data-tour="sidebar-model-management"]',
|
||||
content: '配置好模型提供商后,接下来我们需要为麦麦添加模型并分配功能。点击"下一步"进入模型管理页面。',
|
||||
placement: 'right' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 12: 添加模型按钮
|
||||
{
|
||||
target: '[data-tour="add-model-button"]',
|
||||
content: '在为麦麦的组件分配模型之前,首先需要添加你想要分配的模型,点击"添加模型"按钮开始添加。',
|
||||
target: '[data-tour="models-tab-trigger"]',
|
||||
content: '厂商配置完成后,切换到"添加模型",把具体要使用的模型加入列表。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="add-model-button"]',
|
||||
content: '点击"添加模型"按钮,开始添加一个可分配给功能的模型。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
@@ -127,60 +117,54 @@ export const modelAssignmentTourSteps: Step[] = [
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 13: 添加模型弹窗
|
||||
{
|
||||
target: '[data-tour="model-dialog"]',
|
||||
content: '在这里,你可以选择你之前配置好的模型提供商,然后选择对应的模型来添加。',
|
||||
content: '在这里选择刚才配置好的厂商,并填写模型名称、标识符、价格和能力参数。',
|
||||
placement: 'left' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 14: 模型名称输入框
|
||||
{
|
||||
target: '[data-tour="model-name-input"]',
|
||||
content: '这里的模型名称是你为这个模型起的一个名字,方便你在后续使用时识别它。',
|
||||
content: '模型名称用于在任务分配时识别这个模型。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 15: API 提供商下拉框
|
||||
{
|
||||
target: '[data-tour="model-provider-select"]',
|
||||
content: '在这里选择你之前配置好的模型提供商,这样系统才能知道你要添加哪个提供商的模型。',
|
||||
content: '这里选择模型所属的厂商,系统会根据厂商配置获取或调用对应模型。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 16: 模型标识符输入框
|
||||
{
|
||||
target: '[data-tour="model-identifier-input"]',
|
||||
content: '这里需要填写你想要添加的模型的标识符,不同的模型提供商可能有不同的标识符格式,请参考对应提供商的文档。',
|
||||
content: '这里填写模型标识符。不同厂商的模型标识符格式可能不同,请参考对应厂商文档。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 17: 保存按钮
|
||||
{
|
||||
target: '[data-tour="model-save-button"]',
|
||||
content: '填写完所有信息后,点击保存按钮,模型就添加完成了。',
|
||||
content: '填写完成后点击保存,模型就会加入可用模型列表。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 18: 取消按钮
|
||||
{
|
||||
target: '[data-tour="model-cancel-button"]',
|
||||
content: '当然,因为这次咱们什么都没有填写,所以直接点击取消按钮退出吧,等你准备好了再来添加模型。',
|
||||
content: '这次只是演示流程,点击取消关闭模型配置窗口。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
@@ -188,10 +172,9 @@ export const modelAssignmentTourSteps: Step[] = [
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 19: 为模型分配功能标签页
|
||||
{
|
||||
target: '[data-tour="tasks-tab-trigger"]',
|
||||
content: '最后一步,添加好模型后,切换到"为模型分配功能"标签页,为麦麦的各个组件分配合适的模型。',
|
||||
content: '最后切换到"为模型分配功能",为麦麦的各个组件选择合适的模型。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
@@ -199,10 +182,9 @@ export const modelAssignmentTourSteps: Step[] = [
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 20: 组件模型卡片的模型选择
|
||||
{
|
||||
target: '[data-tour="task-model-select"]',
|
||||
content: '在这里,你可以为每个组件选择一个或多个合适的模型,选择完成后配置会自动保存。恭喜你完成了模型配置的学习!',
|
||||
content: '在这里可以为每个组件选择一个或多个模型,选择完成后配置会自动保存。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
@@ -212,33 +194,9 @@ export const modelAssignmentTourSteps: Step[] = [
|
||||
]
|
||||
|
||||
// 需要用户点击才能继续的步骤索引(0-based)
|
||||
// Step 2 (index 2): 点击添加提供商按钮
|
||||
// Step 9 (index 9): 点击取消按钮关闭提供商弹窗
|
||||
// Step 11 (index 11): 点击添加模型按钮
|
||||
// Step 17 (index 17): 点击取消按钮关闭模型弹窗
|
||||
// Step 18 (index 18): 点击标签页切换
|
||||
export const CLICK_TO_CONTINUE_STEPS = new Set([2, 9, 11, 17, 18])
|
||||
export const CLICK_TO_CONTINUE_STEPS = new Set([1, 2, 9, 10, 11, 17, 18])
|
||||
|
||||
// 步骤与路由的映射
|
||||
export const STEP_ROUTE_MAP: Record<number, string> = {
|
||||
0: '/config/model', // 起始页面
|
||||
1: '/config/model', // 侧边栏可见
|
||||
2: '/config/modelProvider', // 需要在模型提供商页面
|
||||
3: '/config/modelProvider',
|
||||
4: '/config/modelProvider',
|
||||
5: '/config/modelProvider',
|
||||
6: '/config/modelProvider',
|
||||
7: '/config/modelProvider',
|
||||
8: '/config/modelProvider',
|
||||
9: '/config/modelProvider',
|
||||
10: '/config/modelProvider',
|
||||
11: '/config/model', // 需要在模型管理页面
|
||||
12: '/config/model',
|
||||
13: '/config/model',
|
||||
14: '/config/model',
|
||||
15: '/config/model',
|
||||
16: '/config/model',
|
||||
17: '/config/model',
|
||||
18: '/config/model',
|
||||
19: '/config/model',
|
||||
}
|
||||
// 合并后所有步骤都在模型管理与分配页面内完成
|
||||
export const STEP_ROUTE_MAP: Record<number, string> = Object.fromEntries(
|
||||
modelAssignmentTourSteps.map((_, index) => [index, '/config/model'])
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -10,7 +10,7 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
"grid place-content-center peer h-4 w-4 shrink-0 cursor-pointer rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -114,7 +114,7 @@ const CommandItem = React.forwardRef<
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-9 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-2 pl-2 pr-8 text-sm outline-none bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-gray-800 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex w-full cursor-pointer select-none items-center rounded-sm py-2 pl-2 pr-8 text-sm outline-none bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-gray-800 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
"inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -170,7 +170,6 @@ function BotConfigPageContent() {
|
||||
const [chatConfig, setChatConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [expressionConfig, setExpressionConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [emojiConfig, setEmojiConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [memoryConfig, setMemoryConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [visualConfig, setVisualConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [voiceConfig, setVoiceConfig] = useState<ConfigSectionData | null>(null)
|
||||
const [messageReceiveConfig, setMessageReceiveConfig] = useState<ConfigSectionData | null>(null)
|
||||
@@ -259,14 +258,14 @@ function BotConfigPageContent() {
|
||||
* 抽取自 loadConfig 和 handleModeChange 中的重复逻辑
|
||||
*/
|
||||
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
|
||||
configRef.current = config
|
||||
const { memory: _legacyMemory, ...configWithoutLegacyMemory } = config
|
||||
configRef.current = configWithoutLegacyMemory
|
||||
|
||||
setBotConfig((config.bot ?? {}) as ConfigSectionData)
|
||||
setPersonalityConfig((config.personality ?? {}) as ConfigSectionData)
|
||||
setChatConfig((config.chat ?? {}) as ConfigSectionData)
|
||||
setExpressionConfig((config.expression ?? {}) as ConfigSectionData)
|
||||
setEmojiConfig((config.emoji ?? {}) as ConfigSectionData)
|
||||
setMemoryConfig((config.memory ?? {}) as ConfigSectionData)
|
||||
setVisualConfig((config.visual ?? {}) as ConfigSectionData)
|
||||
setVoiceConfig((config.voice ?? {}) as ConfigSectionData)
|
||||
setMessageReceiveConfig((config.message_receive ?? {}) as ConfigSectionData)
|
||||
@@ -297,7 +296,6 @@ function BotConfigPageContent() {
|
||||
chat: chatConfig,
|
||||
expression: expressionConfig,
|
||||
emoji: emojiConfig,
|
||||
memory: memoryConfig,
|
||||
visual: visualConfig,
|
||||
voice: voiceConfig,
|
||||
message_receive: messageReceiveConfig,
|
||||
@@ -321,7 +319,6 @@ function BotConfigPageContent() {
|
||||
chatConfig,
|
||||
expressionConfig,
|
||||
emojiConfig,
|
||||
memoryConfig,
|
||||
visualConfig,
|
||||
voiceConfig,
|
||||
messageReceiveConfig,
|
||||
@@ -455,7 +452,6 @@ function BotConfigPageContent() {
|
||||
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerAutoSave)
|
||||
@@ -683,7 +679,6 @@ function BotConfigPageContent() {
|
||||
chat: chatConfig,
|
||||
expression: expressionConfig,
|
||||
emoji: emojiConfig,
|
||||
memory: memoryConfig,
|
||||
visual: visualConfig,
|
||||
voice: voiceConfig,
|
||||
message_receive: messageReceiveConfig,
|
||||
@@ -707,7 +702,6 @@ function BotConfigPageContent() {
|
||||
chatConfig,
|
||||
expressionConfig,
|
||||
emojiConfig,
|
||||
memoryConfig,
|
||||
visualConfig,
|
||||
voiceConfig,
|
||||
messageReceiveConfig,
|
||||
@@ -734,7 +728,6 @@ function BotConfigPageContent() {
|
||||
chat: setChatConfig,
|
||||
expression: setExpressionConfig,
|
||||
emoji: setEmojiConfig,
|
||||
memory: setMemoryConfig,
|
||||
visual: setVisualConfig,
|
||||
voice: setVoiceConfig,
|
||||
message_receive: setMessageReceiveConfig,
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { EmojiConfig, MemoryConfig, ToolConfig, VoiceConfig } from '../types'
|
||||
|
||||
interface FeaturesSectionProps {
|
||||
emojiConfig: EmojiConfig
|
||||
memoryConfig: MemoryConfig
|
||||
toolConfig: ToolConfig
|
||||
voiceConfig: VoiceConfig
|
||||
onEmojiChange: (config: EmojiConfig) => void
|
||||
onMemoryChange: (config: MemoryConfig) => void
|
||||
onToolChange: (config: ToolConfig) => void
|
||||
onVoiceChange: (config: VoiceConfig) => void
|
||||
}
|
||||
|
||||
export const FeaturesSection = React.memo(function FeaturesSection({
|
||||
emojiConfig,
|
||||
memoryConfig,
|
||||
toolConfig,
|
||||
voiceConfig,
|
||||
onEmojiChange,
|
||||
onMemoryChange,
|
||||
onToolChange,
|
||||
onVoiceChange,
|
||||
}: FeaturesSectionProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 工具设置 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">工具设置</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enable_tool"
|
||||
checked={toolConfig.enable_tool}
|
||||
onCheckedChange={(checked) => onToolChange({ ...toolConfig, enable_tool: checked })}
|
||||
/>
|
||||
<Label htmlFor="enable_tool" className="cursor-pointer">
|
||||
启用工具系统
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
允许麦麦使用各种工具来增强功能
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch
|
||||
id="enable_asr"
|
||||
checked={voiceConfig.enable_asr}
|
||||
onCheckedChange={(checked) => onVoiceChange({ ...voiceConfig, enable_asr: checked })}
|
||||
/>
|
||||
<Label htmlFor="enable_asr" className="cursor-pointer">
|
||||
启用语音识别
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
启用后麦麦可以识别语音消息,需要配置语音识别模型
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 记忆设置 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">记忆设置</h3>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="max_agent_iterations">记忆思考深度</Label>
|
||||
<Input
|
||||
id="max_agent_iterations"
|
||||
type="number"
|
||||
min="1"
|
||||
value={memoryConfig.max_agent_iterations}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, max_agent_iterations: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">最低为 1(不深入思考)</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent_timeout_seconds">最长回忆时间(秒)</Label>
|
||||
<Input
|
||||
id="agent_timeout_seconds"
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.1"
|
||||
value={memoryConfig.agent_timeout_seconds ?? 120}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, agent_timeout_seconds: parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">记忆检索的超时时间,避免过长的等待</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enable_jargon_detection"
|
||||
checked={memoryConfig.enable_jargon_detection ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
onMemoryChange({ ...memoryConfig, enable_jargon_detection: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="enable_jargon_detection" className="cursor-pointer">
|
||||
启用黑话识别
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
记忆检索过程中是否启用黑话识别
|
||||
</p>
|
||||
|
||||
{/* 聊天历史总结配置 */}
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h4 className="text-sm font-semibold mb-3">聊天历史总结配置</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chat_history_topic_check_message_threshold">话题检查消息数阈值</Label>
|
||||
<Input
|
||||
id="chat_history_topic_check_message_threshold"
|
||||
type="number"
|
||||
min="1"
|
||||
value={memoryConfig.chat_history_topic_check_message_threshold ?? 80}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, chat_history_topic_check_message_threshold: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当累积消息数达到此值时触发话题检查
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chat_history_topic_check_time_hours">话题检查时间阈值(小时)</Label>
|
||||
<Input
|
||||
id="chat_history_topic_check_time_hours"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
value={memoryConfig.chat_history_topic_check_time_hours ?? 8.0}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, chat_history_topic_check_time_hours: parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当距离上次检查超过此时间且消息数达到最小阈值时触发话题检查
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chat_history_topic_check_min_messages">时间触发最小消息数</Label>
|
||||
<Input
|
||||
id="chat_history_topic_check_min_messages"
|
||||
type="number"
|
||||
min="1"
|
||||
value={memoryConfig.chat_history_topic_check_min_messages ?? 20}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, chat_history_topic_check_min_messages: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
时间触发模式下的最小消息数阈值
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chat_history_finalize_no_update_checks">打包存储连续无更新次数</Label>
|
||||
<Input
|
||||
id="chat_history_finalize_no_update_checks"
|
||||
type="number"
|
||||
min="1"
|
||||
value={memoryConfig.chat_history_finalize_no_update_checks ?? 3}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, chat_history_finalize_no_update_checks: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当话题连续N次检查无新增内容时触发打包存储
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chat_history_finalize_message_count">打包存储消息条数阈值</Label>
|
||||
<Input
|
||||
id="chat_history_finalize_message_count"
|
||||
type="number"
|
||||
min="1"
|
||||
value={memoryConfig.chat_history_finalize_message_count ?? 5}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, chat_history_finalize_message_count: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当话题的消息条数超过此值时触发打包存储
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表情包设置 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">表情包设置</h3>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="emoji_chance">表情包激活概率</Label>
|
||||
<Input
|
||||
id="emoji_chance"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={emojiConfig.emoji_chance}
|
||||
onChange={(e) =>
|
||||
onEmojiChange({ ...emojiConfig, emoji_chance: parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">范围 0-1,越大越容易发送表情包</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="max_reg_num">最大注册数量</Label>
|
||||
<Input
|
||||
id="max_reg_num"
|
||||
type="number"
|
||||
min="1"
|
||||
value={emojiConfig.max_reg_num}
|
||||
onChange={(e) =>
|
||||
onEmojiChange({ ...emojiConfig, max_reg_num: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">麦麦最多可以注册的表情包数量</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="check_interval">检查间隔(分钟)</Label>
|
||||
<Input
|
||||
id="check_interval"
|
||||
type="number"
|
||||
min="1"
|
||||
value={emojiConfig.check_interval}
|
||||
onChange={(e) =>
|
||||
onEmojiChange({ ...emojiConfig, check_interval: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
检查表情包(注册、破损、删除)的时间间隔
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="do_replace"
|
||||
checked={emojiConfig.do_replace}
|
||||
onCheckedChange={(checked) =>
|
||||
onEmojiChange({ ...emojiConfig, do_replace: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="do_replace" className="cursor-pointer">
|
||||
达到最大数量时替换表情包
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="steal_emoji"
|
||||
checked={emojiConfig.steal_emoji}
|
||||
onCheckedChange={(checked) =>
|
||||
onEmojiChange({ ...emojiConfig, steal_emoji: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="steal_emoji" className="cursor-pointer">
|
||||
偷取表情包
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
允许麦麦将看到的表情包据为己有
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="content_filtration"
|
||||
checked={emojiConfig.content_filtration}
|
||||
onCheckedChange={(checked) =>
|
||||
onEmojiChange({ ...emojiConfig, content_filtration: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="content_filtration" className="cursor-pointer">
|
||||
启用表情包过滤
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -10,7 +10,6 @@ export { LogSection } from './LogSection'
|
||||
export { DebugSection } from './DebugSection'
|
||||
export { MaimMessageSection } from './MaimMessageSection'
|
||||
export { TelemetrySection } from './TelemetrySection'
|
||||
export { FeaturesSection } from './FeaturesSection'
|
||||
export { ExpressionSection } from './ExpressionSection'
|
||||
export { ProcessingSection } from './ProcessingSection'
|
||||
export { default as MessageReceiveSection } from './MessageReceiveSection'
|
||||
|
||||
@@ -80,21 +80,6 @@ export interface EmojiConfig {
|
||||
content_filtration: boolean
|
||||
}
|
||||
|
||||
export interface MemoryConfig {
|
||||
max_agent_iterations: number
|
||||
agent_timeout_seconds: number
|
||||
enable_jargon_detection: boolean
|
||||
chat_history_topic_check_message_threshold: number
|
||||
chat_history_topic_check_time_hours: number
|
||||
chat_history_topic_check_min_messages: number
|
||||
chat_history_finalize_no_update_checks: number
|
||||
chat_history_finalize_message_count: number
|
||||
}
|
||||
|
||||
export interface ToolConfig {
|
||||
enable_tool: boolean
|
||||
}
|
||||
|
||||
// MoodConfig 已在后端移除
|
||||
|
||||
export interface VoiceConfig {
|
||||
@@ -226,8 +211,6 @@ export interface AllBotConfigs {
|
||||
chatConfig: ChatConfig | null
|
||||
expressionConfig: ExpressionConfig | null
|
||||
emojiConfig: EmojiConfig | null
|
||||
memoryConfig: MemoryConfig | null
|
||||
toolConfig: ToolConfig | null
|
||||
voiceConfig: VoiceConfig | null
|
||||
messageReceiveConfig: MessageReceiveConfig | null
|
||||
dreamConfig: DreamConfig | null
|
||||
|
||||
@@ -48,8 +48,9 @@ import {
|
||||
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 } from 'lucide-react'
|
||||
import { getModelConfig, getModelConfigSchema, updateModelConfig } from '@/lib/config-api'
|
||||
import { Plus, Trash2, Save, Search, Info, Power, Check, ChevronsUpDown, RefreshCw, Loader2, GraduationCap, Share2, AlertTriangle, Settings, Zap } from 'lucide-react'
|
||||
import { getModelConfig, getModelConfigSchema, testProviderConnection, updateModelConfig, updateModelConfigSection } from '@/lib/config-api'
|
||||
import type { TestConnectionResult } from '@/lib/config-api'
|
||||
import { resolveFieldLabel } from '@/lib/config-label'
|
||||
import type { ConfigSchema } from '@/types/config-schema'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
@@ -59,6 +60,12 @@ 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 { TaskConfigCard, Pagination, ModelTable, ModelCardList } from './model/components'
|
||||
import { useModelTour, useModelFetcher, useModelAutoSave } from './model/hooks'
|
||||
import { ProviderForm } from './modelProvider/ProviderForm'
|
||||
import { ProviderList } from './modelProvider/ProviderList'
|
||||
import type { APIProvider, DeleteConfirmState } from './modelProvider/types'
|
||||
import { cleanProviderData } from './modelProvider/utils'
|
||||
|
||||
// 导入模块化的类型定义和组件
|
||||
import type { ModelInfo, ProviderConfig, ModelTaskConfig, TaskConfig } from './model/types'
|
||||
@@ -70,8 +77,6 @@ function unwrapModelConfig(data: unknown): Record<string, unknown> {
|
||||
}
|
||||
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() {
|
||||
@@ -88,6 +93,7 @@ function ModelConfigPageContent() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([])
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providerConfigs, setProviderConfigs] = useState<ProviderConfig[]>([])
|
||||
const [apiProviders, setApiProviders] = useState<APIProvider[]>([])
|
||||
const [modelNames, setModelNames] = useState<string[]>([])
|
||||
const [taskConfig, setTaskConfig] = useState<ModelTaskConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -100,13 +106,31 @@ function ModelConfigPageContent() {
|
||||
const [extraParamsDialogOpen, setExtraParamsDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [deletingIndex, setDeletingIndex] = useState<number | null>(null)
|
||||
const [providerDialogOpen, setProviderDialogOpen] = useState(false)
|
||||
const [editingProvider, setEditingProvider] = useState<APIProvider | null>(null)
|
||||
const [editingProviderIndex, setEditingProviderIndex] = useState<number | null>(null)
|
||||
const [providerDeleteDialogOpen, setProviderDeleteDialogOpen] = useState(false)
|
||||
const [deletingProviderIndex, setDeletingProviderIndex] = useState<number | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedModels, setSelectedModels] = useState<Set<number>>(new Set())
|
||||
const [selectedProviders, setSelectedProviders] = useState<Set<number>>(new Set())
|
||||
const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false)
|
||||
const [providerBatchDeleteDialogOpen, setProviderBatchDeleteDialogOpen] = useState(false)
|
||||
const [testingProviders, setTestingProviders] = useState<Set<string>>(new Set())
|
||||
const [testResults, setTestResults] = useState<Map<string, TestConnectionResult>>(new Map())
|
||||
const [deleteConfirmState, setDeleteConfirmState] = useState<DeleteConfirmState>({
|
||||
isOpen: false,
|
||||
providersToDelete: [],
|
||||
affectedModels: [],
|
||||
pendingProviders: [],
|
||||
context: 'auto',
|
||||
oldProviders: [],
|
||||
})
|
||||
const [taskConfigSchema, setTaskConfigSchema] = useState<ConfigSchema | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [jumpToPage, setJumpToPage] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('providers')
|
||||
|
||||
const [advancedModelSettingsVisible, setAdvancedModelSettingsVisible] = useState(false)
|
||||
const [advancedTaskSettingsVisible, setAdvancedTaskSettingsVisible] = useState(false)
|
||||
@@ -119,6 +143,8 @@ function ModelConfigPageContent() {
|
||||
|
||||
// 模型 Combobox 状态
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false)
|
||||
const providerAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const providersSnapshotRef = useRef<string | null>(null)
|
||||
|
||||
// 嵌入模型警告相关状态
|
||||
const [embeddingWarningOpen, setEmbeddingWarningOpen] = useState(false)
|
||||
@@ -199,6 +225,8 @@ function ModelConfigPageContent() {
|
||||
const providerList = (config.api_providers as ProviderConfig[]) || []
|
||||
setProviders(providerList.map((p) => p.name))
|
||||
setProviderConfigs(providerList)
|
||||
setApiProviders(providerList.map((provider) => cleanProviderData(provider as APIProvider)))
|
||||
providersSnapshotRef.current = JSON.stringify(providerList.map((provider) => cleanProviderData(provider as APIProvider)))
|
||||
|
||||
const taskConf = (config.model_task_config as ModelTaskConfig) || null
|
||||
setTaskConfig(taskConf)
|
||||
@@ -267,6 +295,167 @@ function ModelConfigPageContent() {
|
||||
localStorage.setItem('model-assignment-tour-entry-dismissed', 'true')
|
||||
setTourEntryVisible(false)
|
||||
}
|
||||
|
||||
const syncProviderState = useCallback((nextProviders: APIProvider[]) => {
|
||||
const cleanedProviders = nextProviders.map(cleanProviderData)
|
||||
setApiProviders(cleanedProviders)
|
||||
setProviders(cleanedProviders.map((provider) => provider.name))
|
||||
setProviderConfigs(cleanedProviders.map((provider) => ({
|
||||
name: provider.name,
|
||||
base_url: provider.base_url,
|
||||
api_key: provider.api_key,
|
||||
client_type: provider.client_type,
|
||||
max_retry: provider.max_retry ?? 2,
|
||||
timeout: provider.timeout ?? 30,
|
||||
retry_interval: provider.retry_interval ?? 10,
|
||||
})))
|
||||
}, [])
|
||||
|
||||
const removeModelsForProviders = useCallback((
|
||||
sourceModels: ModelInfo[],
|
||||
sourceTaskConfig: ModelTaskConfig | null,
|
||||
removedModels: unknown[],
|
||||
) => {
|
||||
const removedModelNames = new Set(
|
||||
removedModels
|
||||
.map((model) => (typeof model === 'object' && model !== null && 'name' in model ? String((model as Record<string, unknown>).name) : ''))
|
||||
.filter(Boolean)
|
||||
)
|
||||
if (removedModelNames.size === 0) {
|
||||
return { models: sourceModels, taskConfig: sourceTaskConfig }
|
||||
}
|
||||
|
||||
const nextModels = sourceModels.filter((model) => !removedModelNames.has(model.name))
|
||||
if (!sourceTaskConfig) {
|
||||
return { models: nextModels, taskConfig: sourceTaskConfig }
|
||||
}
|
||||
|
||||
const nextTaskConfig: ModelTaskConfig = {}
|
||||
for (const [taskName, task] of Object.entries(sourceTaskConfig)) {
|
||||
nextTaskConfig[taskName] = {
|
||||
...task,
|
||||
model_list: (task?.model_list || []).filter((modelName) => !removedModelNames.has(modelName)),
|
||||
}
|
||||
}
|
||||
return { models: nextModels, taskConfig: nextTaskConfig }
|
||||
}, [])
|
||||
|
||||
const checkDeleteProviderImpact = useCallback(async (
|
||||
nextProviders: APIProvider[],
|
||||
context: 'auto' | 'manual' | 'restart' = 'auto'
|
||||
) => {
|
||||
const oldProviderNames = new Set(apiProviders.map((provider) => provider.name))
|
||||
const nextProviderNames = new Set(nextProviders.map((provider) => provider.name))
|
||||
const deletedProviders = Array.from(oldProviderNames).filter((name) => !nextProviderNames.has(name))
|
||||
|
||||
if (deletedProviders.length === 0) {
|
||||
return { shouldProceed: true }
|
||||
}
|
||||
|
||||
const affectedModels = models.filter((model) => deletedProviders.includes(model.api_provider))
|
||||
if (affectedModels.length === 0) {
|
||||
return { shouldProceed: true }
|
||||
}
|
||||
|
||||
setDeleteConfirmState({
|
||||
isOpen: true,
|
||||
providersToDelete: deletedProviders,
|
||||
affectedModels,
|
||||
pendingProviders: nextProviders,
|
||||
context,
|
||||
oldProviders: [...apiProviders],
|
||||
})
|
||||
return { shouldProceed: false }
|
||||
}, [apiProviders, models])
|
||||
|
||||
const saveProviders = useCallback(async (
|
||||
nextProviders: APIProvider[],
|
||||
context: 'auto' | 'manual' | 'restart' = 'auto',
|
||||
affectedModels: unknown[] = []
|
||||
) => {
|
||||
const cleanedProviders = nextProviders.map(cleanProviderData)
|
||||
const { models: nextModels, taskConfig: nextTaskConfig } = removeModelsForProviders(models, taskConfig, affectedModels)
|
||||
|
||||
if (context === 'auto' && affectedModels.length === 0) {
|
||||
const result = await updateModelConfigSection('api_providers', cleanedProviders)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '保存提供商失败')
|
||||
}
|
||||
} else {
|
||||
const resultGet = await getModelConfig()
|
||||
if (!resultGet.success) {
|
||||
throw new Error(resultGet.error || '加载模型配置失败')
|
||||
}
|
||||
const config = unwrapModelConfig(resultGet.data)
|
||||
config.api_providers = cleanedProviders
|
||||
config.models = nextModels.map(cleanModelForSave)
|
||||
config.model_task_config = nextTaskConfig
|
||||
const resultUpdate = await updateModelConfig(config)
|
||||
if (!resultUpdate.success) {
|
||||
throw new Error(resultUpdate.error || '保存模型配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
syncProviderState(cleanedProviders)
|
||||
setModels(nextModels)
|
||||
setModelNames(nextModels.map((model) => model.name))
|
||||
setTaskConfig(nextTaskConfig)
|
||||
checkTaskConfigIssues(nextTaskConfig, nextModels)
|
||||
providersSnapshotRef.current = JSON.stringify(cleanedProviders)
|
||||
setHasUnsavedChanges(false)
|
||||
|
||||
if (context === 'restart') {
|
||||
await handleRestart()
|
||||
}
|
||||
}, [checkTaskConfigIssues, models, removeModelsForProviders, syncProviderState, taskConfig])
|
||||
|
||||
const autoSaveProviders = useCallback(async (nextProviders: APIProvider[]) => {
|
||||
if (initialLoadRef.current) return
|
||||
const { shouldProceed } = await checkDeleteProviderImpact(nextProviders, 'auto')
|
||||
if (!shouldProceed) {
|
||||
setHasUnsavedChanges(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setAutoSaving(true)
|
||||
await saveProviders(nextProviders, 'auto')
|
||||
} catch (error) {
|
||||
console.error('自动保存提供商失败:', error)
|
||||
toast({
|
||||
title: '自动保存失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setHasUnsavedChanges(true)
|
||||
} finally {
|
||||
setAutoSaving(false)
|
||||
}
|
||||
}, [checkDeleteProviderImpact, initialLoadRef, saveProviders, toast])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoadRef.current) return
|
||||
const snapshot = JSON.stringify(apiProviders.map(cleanProviderData))
|
||||
if (providersSnapshotRef.current === null) {
|
||||
providersSnapshotRef.current = snapshot
|
||||
return
|
||||
}
|
||||
if (snapshot === providersSnapshotRef.current) return
|
||||
|
||||
setHasUnsavedChanges(true)
|
||||
if (providerAutoSaveTimerRef.current) {
|
||||
clearTimeout(providerAutoSaveTimerRef.current)
|
||||
}
|
||||
providerAutoSaveTimerRef.current = setTimeout(() => {
|
||||
autoSaveProviders(apiProviders)
|
||||
}, 2000)
|
||||
|
||||
return () => {
|
||||
if (providerAutoSaveTimerRef.current) {
|
||||
clearTimeout(providerAutoSaveTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [apiProviders, autoSaveProviders, initialLoadRef])
|
||||
|
||||
// 一键删除所有无效模型引用
|
||||
const handleRemoveInvalidRefs = useCallback(() => {
|
||||
@@ -322,6 +511,9 @@ function ModelConfigPageContent() {
|
||||
try {
|
||||
setSaving(true)
|
||||
clearAutoSaveTimers()
|
||||
if (providerAutoSaveTimerRef.current) {
|
||||
clearTimeout(providerAutoSaveTimerRef.current)
|
||||
}
|
||||
const resultGet = await getModelConfig()
|
||||
if (!resultGet.success) {
|
||||
toast({
|
||||
@@ -334,6 +526,7 @@ function ModelConfigPageContent() {
|
||||
}
|
||||
const config = unwrapModelConfig(resultGet.data)
|
||||
// 清理每个模型中的 null 值
|
||||
config.api_providers = apiProviders.map(cleanProviderData)
|
||||
config.models = models.map(cleanModelForSave)
|
||||
config.model_task_config = taskConfig
|
||||
const resultUpdate = await updateModelConfig(config)
|
||||
@@ -347,6 +540,7 @@ function ModelConfigPageContent() {
|
||||
return
|
||||
}
|
||||
resetSnapshots(config.models as ModelInfo[], taskConfig)
|
||||
providersSnapshotRef.current = JSON.stringify(config.api_providers)
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
@@ -371,6 +565,9 @@ function ModelConfigPageContent() {
|
||||
|
||||
// 先取消自动保存定时器
|
||||
clearAutoSaveTimers()
|
||||
if (providerAutoSaveTimerRef.current) {
|
||||
clearTimeout(providerAutoSaveTimerRef.current)
|
||||
}
|
||||
|
||||
const resultGet = await getModelConfig()
|
||||
if (!resultGet.success) {
|
||||
@@ -384,6 +581,7 @@ function ModelConfigPageContent() {
|
||||
}
|
||||
const config = unwrapModelConfig(resultGet.data)
|
||||
// 清理每个模型中的 null 值
|
||||
config.api_providers = apiProviders.map(cleanProviderData)
|
||||
config.models = models.map(cleanModelForSave)
|
||||
config.model_task_config = taskConfig
|
||||
const resultUpdate = await updateModelConfig(config)
|
||||
@@ -397,6 +595,7 @@ function ModelConfigPageContent() {
|
||||
return
|
||||
}
|
||||
resetSnapshots(config.models as ModelInfo[], taskConfig)
|
||||
providersSnapshotRef.current = JSON.stringify(config.api_providers)
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
@@ -441,12 +640,49 @@ function ModelConfigPageContent() {
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const openProviderDialog = (provider: APIProvider | null, index: number | null) => {
|
||||
setEditingProvider(provider || {
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
client_type: 'openai',
|
||||
max_retry: 2,
|
||||
timeout: 30,
|
||||
retry_interval: 10,
|
||||
})
|
||||
setEditingProviderIndex(index)
|
||||
setProviderDialogOpen(true)
|
||||
}
|
||||
|
||||
// Tour 引导 (使用 hook 封装的逻辑)
|
||||
const { startTour: handleStartTour, isRunning: tourIsRunning } = useModelTour({
|
||||
onOpenEditDialog: () => openEditDialog(null, null),
|
||||
onCloseEditDialog: () => setEditDialogOpen(false),
|
||||
onOpenProviderDialog: () => openProviderDialog(null, null),
|
||||
onCloseProviderDialog: () => setProviderDialogOpen(false),
|
||||
onOpenProvidersTab: () => setActiveTab('providers'),
|
||||
onOpenModelsTab: () => setActiveTab('models'),
|
||||
onOpenTasksTab: () => setActiveTab('tasks'),
|
||||
})
|
||||
|
||||
const handleSaveProviderEdit = (provider: APIProvider, index: number | null) => {
|
||||
const providerToSave = cleanProviderData(provider)
|
||||
if (index !== null) {
|
||||
const nextProviders = [...apiProviders]
|
||||
nextProviders[index] = providerToSave
|
||||
syncProviderState(nextProviders)
|
||||
} else {
|
||||
syncProviderState([...apiProviders, providerToSave])
|
||||
}
|
||||
setProviderDialogOpen(false)
|
||||
setEditingProvider(null)
|
||||
setEditingProviderIndex(null)
|
||||
toast({
|
||||
title: index !== null ? '提供商已更新' : '提供商已添加',
|
||||
description: '配置将在 2 秒后自动保存,或点击右上角"保存配置"按钮立即保存',
|
||||
})
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
const handleSaveEdit = () => {
|
||||
if (!editingModel) return
|
||||
@@ -581,6 +817,168 @@ function ModelConfigPageContent() {
|
||||
setDeletingIndex(null)
|
||||
}
|
||||
|
||||
const openProviderDeleteDialog = (index: number) => {
|
||||
setDeletingProviderIndex(index)
|
||||
setProviderDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmProviderDelete = async () => {
|
||||
if (deletingProviderIndex !== null) {
|
||||
const nextProviders = apiProviders.filter((_, index) => index !== deletingProviderIndex)
|
||||
const { shouldProceed } = await checkDeleteProviderImpact(nextProviders, 'manual')
|
||||
if (shouldProceed) {
|
||||
syncProviderState(nextProviders)
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: '提供商已从列表中移除',
|
||||
})
|
||||
}
|
||||
}
|
||||
setProviderDeleteDialogOpen(false)
|
||||
setDeletingProviderIndex(null)
|
||||
}
|
||||
|
||||
const toggleProviderSelection = (index: number) => {
|
||||
const nextSelected = new Set(selectedProviders)
|
||||
if (nextSelected.has(index)) {
|
||||
nextSelected.delete(index)
|
||||
} else {
|
||||
nextSelected.add(index)
|
||||
}
|
||||
setSelectedProviders(nextSelected)
|
||||
}
|
||||
|
||||
const toggleSelectAllProviders = () => {
|
||||
if (selectedProviders.size === apiProviders.length) {
|
||||
setSelectedProviders(new Set())
|
||||
} else {
|
||||
setSelectedProviders(new Set(apiProviders.map((_, index) => index)))
|
||||
}
|
||||
}
|
||||
|
||||
const openProviderBatchDeleteDialog = () => {
|
||||
if (selectedProviders.size === 0) {
|
||||
toast({
|
||||
title: '提示',
|
||||
description: '请先选择要删除的提供商',
|
||||
variant: 'default',
|
||||
})
|
||||
return
|
||||
}
|
||||
setProviderBatchDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmProviderBatchDelete = async () => {
|
||||
const nextProviders = apiProviders.filter((_, index) => !selectedProviders.has(index))
|
||||
const { shouldProceed } = await checkDeleteProviderImpact(nextProviders, 'manual')
|
||||
if (shouldProceed) {
|
||||
const deletedCount = selectedProviders.size
|
||||
syncProviderState(nextProviders)
|
||||
setSelectedProviders(new Set())
|
||||
toast({
|
||||
title: '批量删除成功',
|
||||
description: `已删除 ${deletedCount} 个提供商`,
|
||||
})
|
||||
}
|
||||
setProviderBatchDeleteDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleConfirmDeleteProviderImpact = async () => {
|
||||
try {
|
||||
const savingFlag = deleteConfirmState.context === 'auto' ? setAutoSaving : setSaving
|
||||
savingFlag(true)
|
||||
await saveProviders(
|
||||
deleteConfirmState.pendingProviders,
|
||||
deleteConfirmState.context,
|
||||
deleteConfirmState.affectedModels
|
||||
)
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: `已删除 ${deleteConfirmState.providersToDelete.length} 个提供商和 ${deleteConfirmState.affectedModels.length} 个关联模型`,
|
||||
})
|
||||
setDeleteConfirmState({
|
||||
isOpen: false,
|
||||
providersToDelete: [],
|
||||
affectedModels: [],
|
||||
pendingProviders: [],
|
||||
context: 'auto',
|
||||
oldProviders: [],
|
||||
})
|
||||
setSelectedProviders(new Set())
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '删除失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setAutoSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDeleteProviderImpact = () => {
|
||||
if (deleteConfirmState.oldProviders.length > 0) {
|
||||
syncProviderState(deleteConfirmState.oldProviders)
|
||||
}
|
||||
setDeleteConfirmState({
|
||||
isOpen: false,
|
||||
providersToDelete: [],
|
||||
affectedModels: [],
|
||||
pendingProviders: [],
|
||||
context: 'auto',
|
||||
oldProviders: [],
|
||||
})
|
||||
setHasUnsavedChanges(false)
|
||||
}
|
||||
|
||||
const handleTestProviderConnection = async (providerName: string) => {
|
||||
setTestingProviders((prev) => new Set(prev).add(providerName))
|
||||
try {
|
||||
const result = await testProviderConnection(providerName)
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '测试失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const testResult = result.data
|
||||
setTestResults((prev) => new Map(prev).set(providerName, testResult))
|
||||
if (testResult.network_ok && testResult.api_key_valid !== false) {
|
||||
toast({
|
||||
title: testResult.api_key_valid === true ? '连接正常' : '网络连接正常',
|
||||
description: `${providerName} 可以访问 (${testResult.latency_ms}ms)`,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: testResult.network_ok ? '连接正常但 Key 无效' : '连接失败',
|
||||
description: testResult.error || `${providerName} API Key 无效或无法连接`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '测试失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setTestingProviders((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(providerName)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestAllProviderConnections = async () => {
|
||||
for (const provider of apiProviders) {
|
||||
await handleTestProviderConnection(provider.name)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换单个模型选择
|
||||
const toggleModelSelection = (index: number) => {
|
||||
const newSelected = new Set(selectedModels)
|
||||
@@ -902,11 +1300,59 @@ function ModelConfigPageContent() {
|
||||
)}
|
||||
|
||||
{/* 标签页 */}
|
||||
<Tabs defaultValue="models" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="models" className="w-full">添加模型</TabsTrigger>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="providers" className="w-full" data-tour="providers-tab-trigger">模型厂商设置</TabsTrigger>
|
||||
<TabsTrigger value="models" className="w-full" data-tour="models-tab-trigger">添加模型</TabsTrigger>
|
||||
<TabsTrigger value="tasks" className="w-full" data-tour="tasks-tab-trigger">为模型分配功能</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* 模型厂商设置标签页 */}
|
||||
<TabsContent value="providers" className="space-y-4 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">
|
||||
管理 AI 模型厂商的 API 配置
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{selectedProviders.size > 0 && (
|
||||
<Button
|
||||
onClick={openProviderBatchDeleteDialog}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
|
||||
批量删除 ({selectedProviders.size})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleTestAllProviderConnections}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={apiProviders.length === 0 || testingProviders.size > 0}
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
{testingProviders.size > 0 ? `测试中 (${testingProviders.size})` : '测试全部'}
|
||||
</Button>
|
||||
<Button onClick={() => openProviderDialog(null, null)} size="sm" variant="outline" className="w-full sm:w-auto" data-tour="add-provider-button">
|
||||
<Plus className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
|
||||
添加提供商
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProviderList
|
||||
providers={apiProviders}
|
||||
testingProviders={testingProviders}
|
||||
testResults={testResults}
|
||||
selectedProviders={selectedProviders}
|
||||
onEdit={openProviderDialog}
|
||||
onDelete={openProviderDeleteDialog}
|
||||
onTest={handleTestProviderConnection}
|
||||
onToggleSelect={toggleProviderSelection}
|
||||
onToggleSelectAll={toggleSelectAllProviders}
|
||||
/>
|
||||
</TabsContent>
|
||||
{/* 模型配置标签页 */}
|
||||
<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">
|
||||
@@ -1030,6 +1476,104 @@ function ModelConfigPageContent() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ProviderForm
|
||||
open={providerDialogOpen}
|
||||
onOpenChange={setProviderDialogOpen}
|
||||
editingProvider={editingProvider}
|
||||
editingIndex={editingProviderIndex}
|
||||
providers={apiProviders}
|
||||
onSave={handleSaveProviderEdit}
|
||||
tourState={{ isRunning: tourIsRunning }}
|
||||
/>
|
||||
|
||||
{/* 删除提供商确认对话框 */}
|
||||
<AlertDialog open={providerDeleteDialogOpen} onOpenChange={setProviderDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除提供商</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除提供商"{deletingProviderIndex !== null ? apiProviders[deletingProviderIndex]?.name : ''}"吗?
|
||||
如果该提供商下存在模型,确认时会提示一并处理关联模型。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmProviderDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 批量删除提供商确认对话框 */}
|
||||
<AlertDialog open={providerBatchDeleteDialogOpen} onOpenChange={setProviderBatchDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认批量删除提供商</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除选中的 {selectedProviders.size} 个提供商吗?
|
||||
如果这些提供商下存在模型,确认时会提示一并处理关联模型。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmProviderBatchDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
批量删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 删除提供商影响确认对话框 */}
|
||||
<AlertDialog open={deleteConfirmState.isOpen}>
|
||||
<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>
|
||||
将删除 {deleteConfirmState.providersToDelete.length} 个提供商,并移除
|
||||
{' '}{deleteConfirmState.affectedModels.length} 个使用这些提供商的模型。
|
||||
</p>
|
||||
{deleteConfirmState.affectedModels.length > 0 && (
|
||||
<div className="rounded-md bg-muted p-3 text-muted-foreground">
|
||||
{deleteConfirmState.affectedModels.slice(0, 8).map((model) => (
|
||||
<div key={(model as ModelInfo).name}>
|
||||
{(model as ModelInfo).name} ({(model as ModelInfo).api_provider})
|
||||
</div>
|
||||
))}
|
||||
{deleteConfirmState.affectedModels.length > 8 && (
|
||||
<div>还有 {deleteConfirmState.affectedModels.length - 8} 个模型...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="font-medium text-foreground">
|
||||
关联模型会从模型列表和任务分配中移除,此操作无法撤销。
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancelDeleteProviderImpact}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDeleteProviderImpact}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 编辑模型对话框 */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogClose}>
|
||||
<DialogContent
|
||||
@@ -1083,11 +1627,7 @@ function ModelConfigPageContent() {
|
||||
</div>
|
||||
{formErrors.name ? (
|
||||
<p className="text-xs text-destructive sm:pl-28">{formErrors.name}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground sm:pl-28">
|
||||
用于在任务配置中引用此模型
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2" data-tour="model-provider-select">
|
||||
@@ -1153,99 +1693,89 @@ function ModelConfigPageContent() {
|
||||
)}
|
||||
</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 className="flex flex-col gap-2 sm:flex-row">
|
||||
{/* 模型标识符 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 sm:w-[46%]"
|
||||
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>
|
||||
</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>
|
||||
) : (
|
||||
) : (
|
||||
'未找到匹配的模型'
|
||||
)}
|
||||
</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>
|
||||
</CommandList>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<Input
|
||||
id="model_identifier"
|
||||
value={editingModel?.model_identifier || ''}
|
||||
@@ -1257,10 +1787,10 @@ function ModelConfigPageContent() {
|
||||
setFormErrors((prev) => ({ ...prev, model_identifier: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="Qwen/Qwen3-30B-A3B-Instruct-2507"
|
||||
className={formErrors.model_identifier ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
placeholder={matchedTemplate?.modelFetcher ? '手动输入模型标识符' : 'Qwen/Qwen3-30B-A3B-Instruct-2507'}
|
||||
className={`${matchedTemplate?.modelFetcher ? 'sm:flex-1' : 'w-full'} ${formErrors.model_identifier ? 'border-destructive focus-visible:ring-destructive' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 表单验证错误提示 */}
|
||||
{formErrors.model_identifier && (
|
||||
@@ -1277,27 +1807,10 @@ function ModelConfigPageContent() {
|
||||
</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'
|
||||
? '请手动输入模型标识符,或前往"模型厂商设置"检查 API Key'
|
||||
: matchedTemplate?.modelFetcher
|
||||
? `已识别为 ${matchedTemplate.display_name},支持自动获取模型列表`
|
||||
: 'API 提供商提供的模型 ID'}
|
||||
@@ -1305,6 +1818,21 @@ function ModelConfigPageContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="model_visual"
|
||||
checked={editingModel?.visual || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, visual: checked } : null
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="model_visual" className="cursor-pointer">
|
||||
启用视觉
|
||||
</Label>
|
||||
</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>
|
||||
@@ -1388,6 +1916,24 @@ function ModelConfigPageContent() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="force_stream_mode" className="cursor-pointer">强制流式输出模式</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
用于必须通过流式响应返回内容的模型
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="force_stream_mode"
|
||||
checked={editingModel?.force_stream_mode || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, force_stream_mode: checked } : null
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1555,36 +2101,6 @@ function ModelConfigPageContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="model_visual"
|
||||
checked={editingModel?.visual || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setEditingModel((prev) =>
|
||||
prev ? { ...prev, visual: checked } : null
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="model_visual" className="cursor-pointer">
|
||||
启用视觉
|
||||
</Label>
|
||||
</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>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import type { ModelInfo } from '../types'
|
||||
|
||||
@@ -49,17 +48,15 @@ export const ModelCardList = React.memo(function ModelCardList({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-base">{model.name}</h3>
|
||||
<Badge
|
||||
variant={used ? "default" : "secondary"}
|
||||
className={used ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{used ? '已使用' : '未使用'}
|
||||
</Badge>
|
||||
{model.visual && (
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600">
|
||||
视觉
|
||||
</Badge>
|
||||
)}
|
||||
<span
|
||||
className={`block h-3 w-3 shrink-0 rounded-full border ${
|
||||
used
|
||||
? 'border-green-500 bg-green-500 shadow-[0_0_0_3px_rgba(34,197,94,0.18)]'
|
||||
: 'border-green-700/40 bg-green-950/20'
|
||||
}`}
|
||||
title={used ? '已使用' : '未使用'}
|
||||
aria-label={used ? '已使用' : '未使用'}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground break-all" title={model.model_identifier}>
|
||||
{model.model_identifier}
|
||||
@@ -90,8 +87,18 @@ export const ModelCardList = React.memo(function ModelCardList({
|
||||
<p className="font-medium">{model.api_provider}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">模型温度</span>
|
||||
<p className="font-medium">{model.temperature != null ? model.temperature : <span className="text-muted-foreground">默认</span>}</p>
|
||||
<span className="text-muted-foreground text-xs">视觉</span>
|
||||
<p className="flex h-5 items-center">
|
||||
<span
|
||||
className={`block h-3 w-3 rounded-full border ${
|
||||
model.visual
|
||||
? 'border-green-500 bg-green-500 shadow-[0_0_0_3px_rgba(34,197,94,0.18)]'
|
||||
: 'border-green-700/40 bg-green-950/20'
|
||||
}`}
|
||||
title={model.visual ? '已启用视觉' : '未启用视觉'}
|
||||
aria-label={model.visual ? '已启用视觉' : '未启用视觉'}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">输入价格</span>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Table,
|
||||
@@ -63,11 +62,11 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-24">使用状态</TableHead>
|
||||
<TableHead className="w-14 text-center">使用</TableHead>
|
||||
<TableHead>模型名称</TableHead>
|
||||
<TableHead>模型标识符</TableHead>
|
||||
<TableHead>提供商</TableHead>
|
||||
<TableHead className="text-center">视觉</TableHead>
|
||||
<TableHead className="w-14 text-center">视觉</TableHead>
|
||||
<TableHead className="text-center">温度</TableHead>
|
||||
<TableHead className="text-right">输入价格</TableHead>
|
||||
<TableHead className="text-right">输出价格</TableHead>
|
||||
@@ -93,13 +92,16 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
onCheckedChange={() => onToggleSelection(actualIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={used ? "default" : "secondary"}
|
||||
className={used ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{used ? '已使用' : '未使用'}
|
||||
</Badge>
|
||||
<TableCell className="text-center">
|
||||
<span
|
||||
className={`mx-auto block h-3 w-3 rounded-full border ${
|
||||
used
|
||||
? 'border-green-500 bg-green-500 shadow-[0_0_0_3px_rgba(34,197,94,0.18)]'
|
||||
: 'border-green-700/40 bg-green-950/20'
|
||||
}`}
|
||||
title={used ? '已使用' : '未使用'}
|
||||
aria-label={used ? '已使用' : '未使用'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{model.name}</TableCell>
|
||||
<TableCell className="max-w-xs truncate" title={model.model_identifier}>
|
||||
@@ -107,13 +109,15 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
</TableCell>
|
||||
<TableCell>{model.api_provider}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{model.visual ? (
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600">
|
||||
启用
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
<span
|
||||
className={`mx-auto block h-3 w-3 rounded-full border ${
|
||||
model.visual
|
||||
? 'border-green-500 bg-green-500 shadow-[0_0_0_3px_rgba(34,197,94,0.18)]'
|
||||
: 'border-green-700/40 bg-green-950/20'
|
||||
}`}
|
||||
title={model.visual ? '已启用视觉' : '未启用视觉'}
|
||||
aria-label={model.visual ? '已启用视觉' : '未启用视觉'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{model.temperature != null ? model.temperature : <span className="text-muted-foreground">-</span>}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function useModelFetcher(options: UseModelFetcherOptions): UseModelFetche
|
||||
if (!config?.base_url) {
|
||||
setAvailableModels([])
|
||||
setMatchedTemplate(null)
|
||||
setModelFetchError('提供商配置不完整,请先在"模型提供商配置"中配置')
|
||||
setModelFetchError('提供商配置不完整,请先在"模型厂商设置"中配置')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function useModelFetcher(options: UseModelFetcherOptions): UseModelFetche
|
||||
if (!config.api_key) {
|
||||
setAvailableModels([])
|
||||
setMatchedTemplate(null)
|
||||
setModelFetchError('该提供商未配置 API Key,请先在"模型提供商配置"中填写')
|
||||
setModelFetchError('该提供商未配置 API Key,请先在"模型厂商设置"中填写')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function useModelFetcher(options: UseModelFetcherOptions): UseModelFetche
|
||||
const errorMessage = (error as Error).message || '获取模型列表失败'
|
||||
// 根据错误类型提供更友好的提示
|
||||
if (errorMessage.includes('无效') || errorMessage.includes('过期') || errorMessage.includes('API Key')) {
|
||||
setModelFetchError('API Key 无效或已过期,请检查"模型提供商配置"中的密钥')
|
||||
setModelFetchError('API Key 无效或已过期,请检查"模型厂商设置"中的密钥')
|
||||
} else if (errorMessage.includes('权限')) {
|
||||
setModelFetchError('没有权限获取模型列表,请检查 API Key 权限')
|
||||
} else if (errorMessage.includes('timeout') || errorMessage.includes('超时')) {
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
/**
|
||||
* Model 配置页面 Tour 引导 Hook
|
||||
* 模型配置页面 Tour 引导 Hook
|
||||
*/
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
import { useTour } from '@/components/tour'
|
||||
import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour'
|
||||
import { MODEL_ASSIGNMENT_TOUR_ID, STEP_ROUTE_MAP, modelAssignmentTourSteps } from '@/components/tour/tours/model-assignment-tour'
|
||||
|
||||
interface UseModelTourOptions {
|
||||
/** 打开模型编辑对话框回调 */
|
||||
onOpenEditDialog?: () => void
|
||||
/** 关闭编辑对话框回调 */
|
||||
/** 关闭模型编辑对话框回调 */
|
||||
onCloseEditDialog?: () => void
|
||||
/** 打开提供商编辑对话框回调 */
|
||||
onOpenProviderDialog?: () => void
|
||||
/** 关闭提供商编辑对话框回调 */
|
||||
onCloseProviderDialog?: () => void
|
||||
/** 切换到模型厂商设置标签页 */
|
||||
onOpenProvidersTab?: () => void
|
||||
/** 切换到添加模型标签页 */
|
||||
onOpenModelsTab?: () => void
|
||||
/** 切换到模型分配标签页 */
|
||||
onOpenTasksTab?: () => void
|
||||
}
|
||||
|
||||
interface UseModelTourReturn {
|
||||
@@ -22,15 +33,18 @@ interface UseModelTourReturn {
|
||||
stepIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Model 配置页面 Tour 引导 Hook
|
||||
*/
|
||||
export function useModelTour(options: UseModelTourOptions = {}): UseModelTourReturn {
|
||||
const { onOpenEditDialog, onCloseEditDialog } = options
|
||||
const {
|
||||
onOpenEditDialog,
|
||||
onCloseEditDialog,
|
||||
onOpenProviderDialog,
|
||||
onCloseProviderDialog,
|
||||
onOpenProvidersTab,
|
||||
onOpenModelsTab,
|
||||
onOpenTasksTab,
|
||||
} = options
|
||||
const navigate = useNavigate()
|
||||
const { registerTour, startTour: startTourFn, state: tourState, goToStep } = useTour()
|
||||
|
||||
// 用于追踪前一个步骤
|
||||
const prevTourStepRef = useRef(tourState.stepIndex)
|
||||
|
||||
const didClickTourTarget = useCallback((event: MouseEvent, selector: string) => {
|
||||
@@ -53,100 +67,121 @@ export function useModelTour(options: UseModelTourOptions = {}): UseModelTourRet
|
||||
)
|
||||
}, [])
|
||||
|
||||
// 注册 Tour
|
||||
useEffect(() => {
|
||||
registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps)
|
||||
}, [registerTour])
|
||||
|
||||
// 监听 Tour 步骤变化,处理页面导航
|
||||
useEffect(() => {
|
||||
if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) {
|
||||
const targetRoute = STEP_ROUTE_MAP[tourState.stepIndex]
|
||||
if (targetRoute && !window.location.pathname.endsWith(targetRoute.replace('/config/', ''))) {
|
||||
navigate({ to: targetRoute })
|
||||
}
|
||||
if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetRoute = STEP_ROUTE_MAP[tourState.stepIndex]
|
||||
if (targetRoute && window.location.pathname !== targetRoute) {
|
||||
navigate({ to: targetRoute })
|
||||
}
|
||||
}, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, navigate])
|
||||
|
||||
// 监听 Tour 步骤变化,当从弹窗内步骤回退到弹窗外步骤时,自动关闭弹窗
|
||||
// 模型弹窗步骤: 12-17 (index 12-17),弹窗外步骤: 10-11 (index 10-11)
|
||||
useEffect(() => {
|
||||
if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) {
|
||||
const prevStep = prevTourStepRef.current
|
||||
const currentStep = tourState.stepIndex
|
||||
|
||||
// 如果从弹窗内步骤 (12-17) 回退到弹窗外步骤 (<=11),关闭弹窗
|
||||
if (prevStep >= 12 && prevStep <= 17 && currentStep < 12) {
|
||||
onCloseEditDialog?.()
|
||||
}
|
||||
|
||||
prevTourStepRef.current = currentStep
|
||||
if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) {
|
||||
return
|
||||
}
|
||||
}, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, onCloseEditDialog])
|
||||
|
||||
// 处理 Tour 中需要用户点击才能继续的步骤
|
||||
const prevStep = prevTourStepRef.current
|
||||
const currentStep = tourState.stepIndex
|
||||
|
||||
if (currentStep <= 2) {
|
||||
onOpenProvidersTab?.()
|
||||
}
|
||||
|
||||
if (prevStep >= 3 && prevStep <= 9 && currentStep < 3) {
|
||||
onCloseProviderDialog?.()
|
||||
}
|
||||
|
||||
if (prevStep <= 2 && currentStep >= 3 && currentStep <= 9) {
|
||||
onOpenProviderDialog?.()
|
||||
}
|
||||
|
||||
if (currentStep === 10 || currentStep === 11) {
|
||||
onCloseProviderDialog?.()
|
||||
onOpenModelsTab?.()
|
||||
}
|
||||
|
||||
if (prevStep >= 12 && prevStep <= 17 && currentStep < 12) {
|
||||
onCloseEditDialog?.()
|
||||
}
|
||||
|
||||
if (prevStep <= 11 && currentStep >= 12 && currentStep <= 17) {
|
||||
onOpenEditDialog?.()
|
||||
}
|
||||
|
||||
if (currentStep === 19) {
|
||||
onOpenTasksTab?.()
|
||||
}
|
||||
|
||||
prevTourStepRef.current = currentStep
|
||||
}, [
|
||||
tourState.stepIndex,
|
||||
tourState.activeTourId,
|
||||
tourState.isRunning,
|
||||
onOpenEditDialog,
|
||||
onCloseEditDialog,
|
||||
onOpenProviderDialog,
|
||||
onCloseProviderDialog,
|
||||
onOpenProvidersTab,
|
||||
onOpenModelsTab,
|
||||
onOpenTasksTab,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return
|
||||
|
||||
const handleTourClick = (e: MouseEvent) => {
|
||||
const handleTourClick = (event: MouseEvent) => {
|
||||
const currentStep = tourState.stepIndex
|
||||
|
||||
// Step 3 (index 2): 点击添加提供商按钮
|
||||
if (currentStep === 2 && didClickTourTarget(e, '[data-tour="add-provider-button"]')) {
|
||||
if (currentStep === 1 && didClickTourTarget(event, '[data-tour="providers-tab-trigger"]')) {
|
||||
onOpenProvidersTab?.()
|
||||
setTimeout(() => goToStep(2), 300)
|
||||
} else if (currentStep === 2 && didClickTourTarget(event, '[data-tour="add-provider-button"]')) {
|
||||
onOpenProviderDialog?.()
|
||||
setTimeout(() => goToStep(3), 300)
|
||||
}
|
||||
// Step 10 (index 9): 点击取消按钮(关闭提供商弹窗)
|
||||
else if (currentStep === 9 && didClickTourTarget(e, '[data-tour="provider-cancel-button"]')) {
|
||||
} else if (currentStep === 9 && didClickTourTarget(event, '[data-tour="provider-cancel-button"]')) {
|
||||
onCloseProviderDialog?.()
|
||||
setTimeout(() => goToStep(10), 300)
|
||||
}
|
||||
// Step 12 (index 11): 点击添加模型按钮
|
||||
else if (currentStep === 11 && didClickTourTarget(e, '[data-tour="add-model-button"]')) {
|
||||
} else if (currentStep === 10 && didClickTourTarget(event, '[data-tour="models-tab-trigger"]')) {
|
||||
onOpenModelsTab?.()
|
||||
setTimeout(() => goToStep(11), 300)
|
||||
} else if (currentStep === 11 && didClickTourTarget(event, '[data-tour="add-model-button"]')) {
|
||||
onOpenEditDialog?.()
|
||||
setTimeout(() => goToStep(12), 300)
|
||||
}
|
||||
// Step 18 (index 17): 点击取消按钮(关闭模型弹窗)
|
||||
else if (currentStep === 17 && didClickTourTarget(e, '[data-tour="model-cancel-button"]')) {
|
||||
} else if (currentStep === 17 && didClickTourTarget(event, '[data-tour="model-cancel-button"]')) {
|
||||
onCloseEditDialog?.()
|
||||
setTimeout(() => goToStep(18), 300)
|
||||
}
|
||||
// Step 19 (index 18): 点击为模型分配功能标签页
|
||||
else if (currentStep === 18 && didClickTourTarget(e, '[data-tour="tasks-tab-trigger"]')) {
|
||||
} else if (currentStep === 18 && didClickTourTarget(event, '[data-tour="tasks-tab-trigger"]')) {
|
||||
onOpenTasksTab?.()
|
||||
setTimeout(() => goToStep(19), 300)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleTourClick, true)
|
||||
return () => document.removeEventListener('click', handleTourClick, true)
|
||||
}, [tourState, goToStep, onOpenEditDialog, didClickTourTarget])
|
||||
}, [
|
||||
tourState,
|
||||
goToStep,
|
||||
onOpenEditDialog,
|
||||
onCloseEditDialog,
|
||||
onOpenProviderDialog,
|
||||
onCloseProviderDialog,
|
||||
onOpenProvidersTab,
|
||||
onOpenModelsTab,
|
||||
onOpenTasksTab,
|
||||
didClickTourTarget,
|
||||
])
|
||||
|
||||
// Step 12 的 spotlight 点击在部分浏览器/布局下会被 Joyride 遮罩截获。
|
||||
// 这里直接给目标按钮补一个原生监听,确保点中按钮时能打开模型弹窗。
|
||||
useEffect(() => {
|
||||
if (
|
||||
tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID ||
|
||||
!tourState.isRunning ||
|
||||
tourState.stepIndex !== 11
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const addModelButton = document.querySelector('[data-tour="add-model-button"]')
|
||||
if (!addModelButton) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleAddModelButtonClick = () => {
|
||||
onOpenEditDialog?.()
|
||||
setTimeout(() => goToStep(12), 300)
|
||||
}
|
||||
|
||||
addModelButton.addEventListener('click', handleAddModelButtonClick, true)
|
||||
return () => addModelButton.removeEventListener('click', handleAddModelButtonClick, true)
|
||||
}, [tourState.activeTourId, tourState.isRunning, tourState.stepIndex, goToStep, onOpenEditDialog])
|
||||
|
||||
// 开始引导
|
||||
const handleStartTour = useCallback(() => {
|
||||
onOpenProvidersTab?.()
|
||||
startTourFn(MODEL_ASSIGNMENT_TOUR_ID)
|
||||
}, [startTourFn])
|
||||
}, [startTourFn, onOpenProvidersTab])
|
||||
|
||||
return {
|
||||
startTour: handleStartTour,
|
||||
|
||||
Reference in New Issue
Block a user