feat:合并memory配置,优化webui交互和展示

This commit is contained in:
SengokuCola
2026-05-06 18:13:14 +08:00
parent 3bdc2a9f70
commit ad5b5889e2
28 changed files with 921 additions and 726 deletions

View File

@@ -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' },
],

View File

@@ -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) {

View File

@@ -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'])
)

View File

@@ -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: {

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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>
)
})

View File

@@ -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'

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>}

View File

@@ -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('超时')) {

View File

@@ -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,

View File

@@ -3020,80 +3020,80 @@ class SDKMemoryKernel:
@staticmethod
def _feedback_cfg_enabled() -> bool:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return bool(getattr(memory_cfg, "feedback_correction_enabled", False))
@staticmethod
def _feedback_cfg_window_hours() -> float:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return max(0.1, float(getattr(memory_cfg, "feedback_correction_window_hours", 12.0) or 12.0))
@staticmethod
def _feedback_cfg_check_interval_seconds() -> float:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
minutes = max(1, int(getattr(memory_cfg, "feedback_correction_check_interval_minutes", 30) or 30))
return float(minutes) * 60.0
@staticmethod
def _feedback_cfg_batch_size() -> int:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return max(1, int(getattr(memory_cfg, "feedback_correction_batch_size", 20) or 20))
@staticmethod
def _feedback_cfg_auto_apply_threshold() -> float:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
value = float(getattr(memory_cfg, "feedback_correction_auto_apply_threshold", 0.85) or 0.85)
return min(1.0, max(0.0, value))
@staticmethod
def _feedback_cfg_max_messages() -> int:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return max(1, int(getattr(memory_cfg, "feedback_correction_max_feedback_messages", 30) or 30))
@staticmethod
def _feedback_cfg_prefilter_enabled() -> bool:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return bool(getattr(memory_cfg, "feedback_correction_prefilter_enabled", True))
@staticmethod
def _feedback_cfg_paragraph_mark_enabled() -> bool:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return bool(getattr(memory_cfg, "feedback_correction_paragraph_mark_enabled", True))
@staticmethod
def _feedback_cfg_paragraph_hard_filter_enabled() -> bool:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return bool(getattr(memory_cfg, "feedback_correction_paragraph_hard_filter_enabled", True))
@staticmethod
def _feedback_cfg_profile_refresh_enabled() -> bool:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return bool(getattr(memory_cfg, "feedback_correction_profile_refresh_enabled", True))
@staticmethod
def _feedback_cfg_profile_force_refresh_on_read() -> bool:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return bool(getattr(memory_cfg, "feedback_correction_profile_force_refresh_on_read", True))
@staticmethod
def _feedback_cfg_episode_rebuild_enabled() -> bool:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return bool(getattr(memory_cfg, "feedback_correction_episode_rebuild_enabled", True))
@staticmethod
def _feedback_cfg_episode_query_block_enabled() -> bool:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return bool(getattr(memory_cfg, "feedback_correction_episode_query_block_enabled", True))
@staticmethod
def _feedback_cfg_reconcile_interval_seconds() -> float:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
minutes = max(1, int(getattr(memory_cfg, "feedback_correction_reconcile_interval_minutes", 5) or 5))
return float(minutes) * 60.0
@staticmethod
def _feedback_cfg_reconcile_batch_size() -> int:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
return max(1, int(getattr(memory_cfg, "feedback_correction_reconcile_batch_size", 20) or 20))
@classmethod

View File

@@ -529,7 +529,7 @@ class EpisodeService:
"paragraph_count": 0,
}
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
paragraphs = self.metadata_store.get_live_paragraphs_by_source(
token,
exclude_stale=bool(getattr(memory_cfg, "feedback_correction_paragraph_hard_filter_enabled", True)),

View File

@@ -349,7 +349,7 @@ class PersonProfileService:
self,
evidence: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
memory_cfg = getattr(global_config, "memory", None)
memory_cfg = global_config.a_memorix.integration
if not bool(getattr(memory_cfg, "feedback_correction_paragraph_hard_filter_enabled", True)):
return evidence
paragraph_hashes = [

View File

@@ -27,7 +27,6 @@ from .official_configs import (
LogConfig,
MaimMessageConfig,
MCPConfig,
MemoryConfig,
MessageReceiveConfig,
PersonalityConfig,
PluginRuntimeConfig,
@@ -57,7 +56,7 @@ MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute(
LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0-pre.11"
CONFIG_VERSION: str = "8.10.8"
CONFIG_VERSION: str = "8.10.9"
MODEL_CONFIG_VERSION: str = "1.15.3"
logger = get_logger("config")
@@ -84,9 +83,6 @@ class Config(ConfigBase):
expression: ExpressionConfig = Field(default_factory=ExpressionConfig)
"""表达配置类"""
memory: MemoryConfig = Field(default_factory=MemoryConfig)
"""记忆配置类"""
a_memorix: AMemorixConfig = Field(default_factory=AMemorixConfig)
"""A_Memorix 长期记忆子系统配置"""

View File

@@ -392,6 +392,23 @@ def try_migrate_legacy_bot_config_dict(data: dict[str, Any]) -> MigrationResult:
migrated_any = True
reasons.append("visual.visual_style_removed")
memory = _as_dict(data.pop("memory", None))
if memory is not None:
a_memorix = _as_dict(data.get("a_memorix"))
if a_memorix is None:
a_memorix = {}
data["a_memorix"] = a_memorix
integration = _as_dict(a_memorix.get("integration"))
if integration is None:
integration = {}
a_memorix["integration"] = integration
for key, value in memory.items():
integration.setdefault(key, value)
migrated_any = True
reasons.append("memory->a_memorix.integration")
keyword_reaction = _as_dict(data.get("keyword_reaction"))
if keyword_reaction is not None:
if _drop_empty_keyword_rules(keyword_reaction, "keyword_rules"):

View File

@@ -467,8 +467,8 @@ class TargetItem(ConfigBase):
"""聊天流类型group群聊或private私聊"""
class MemoryConfig(ConfigBase):
"""记忆配置"""
class AMemorixIntegrationConfig(ConfigBase):
"""A_Memorix 与 Maisaka 集成配置"""
__ui_parent__ = "a_memorix"
@@ -1038,6 +1038,9 @@ class AMemorixConfig(ConfigBase):
__ui_label__ = "长期记忆"
__ui_icon__ = "brain"
integration: AMemorixIntegrationConfig = Field(default_factory=AMemorixIntegrationConfig)
"""Maisaka 集成配置"""
plugin: AMemorixPluginConfig = Field(default_factory=AMemorixPluginConfig)
"""子系统状态"""

View File

@@ -157,15 +157,6 @@ class MainSystem:
logger.info(t("startup.schedule_cancelled"))
raise
# async def forget_memory_task(self):
# """记忆遗忘任务"""
# while True:
# await asyncio.sleep(global_config.memory.forget_memory_interval)
# logger.info("[记忆遗忘] 开始遗忘记忆...")
# await self.hippocampus_manager.forget_memory(percentage=global_config.memory.memory_forget_percentage) # type: ignore
# logger.info("[记忆遗忘] 记忆遗忘完成")
async def main() -> None:
"""主函数"""
system = MainSystem()

View File

@@ -66,7 +66,7 @@ class BuiltinToolEntry:
def _get_query_memory_tool_spec() -> ToolSpec:
"""根据配置生成 query_memory 工具声明。"""
return get_query_memory_tool_spec(enabled=bool(global_config.memory.enable_memory_query_tool))
return get_query_memory_tool_spec(enabled=bool(global_config.a_memorix.integration.enable_memory_query_tool))
BUILTIN_TOOL_ENTRIES: List[BuiltinToolEntry] = [

View File

@@ -161,7 +161,7 @@ async def handle_tool(
f"不支持的检索模式:{mode}。可选值search/time/hybrid/episode/aggregate。",
)
default_limit = max(1, global_config.memory.memory_query_default_limit)
default_limit = max(1, global_config.a_memorix.integration.memory_query_default_limit)
try:
limit = int(invocation.arguments.get("limit", default_limit) or default_limit)
except (TypeError, ValueError):

View File

@@ -51,7 +51,7 @@ class PersonFactWritebackService:
logger.warning("关闭人物事实写回 worker 失败: %s", exc)
async def enqueue(self, message: Any) -> None:
if not bool(getattr(global_config.memory, "person_fact_writeback_enabled", True)):
if not bool(global_config.a_memorix.integration.person_fact_writeback_enabled):
return
if self._stopping:
return
@@ -251,7 +251,7 @@ class ChatSummaryWritebackService:
logger.warning("关闭聊天摘要写回 worker 失败: %s", exc)
async def enqueue(self, message: Any) -> None:
if not bool(getattr(global_config.memory, "chat_summary_writeback_enabled", True)):
if not bool(global_config.a_memorix.integration.chat_summary_writeback_enabled):
return
if self._stopping:
return
@@ -434,11 +434,11 @@ class ChatSummaryWritebackService:
@staticmethod
def _message_threshold() -> int:
return max(1, int(getattr(global_config.memory, "chat_summary_writeback_message_threshold", 12) or 12))
return max(1, int(global_config.a_memorix.integration.chat_summary_writeback_message_threshold))
@staticmethod
def _context_length() -> int:
return max(1, int(getattr(global_config.memory, "chat_summary_writeback_context_length", 50) or 50))
return max(1, int(global_config.a_memorix.integration.chat_summary_writeback_context_length))
class MemoryAutomationService:

View File

@@ -32,7 +32,6 @@ from src.config.official_configs import (
ExpressionConfig,
KeywordReactionConfig,
MaimMessageConfig,
MemoryConfig,
MessageReceiveConfig,
PersonalityConfig,
ResponsePostProcessConfig,
@@ -333,7 +332,6 @@ async def get_config_section_schema(section_name: str):
- response_splitter: ResponseSplitterConfig
- telemetry: TelemetryConfig
- maim_message: MaimMessageConfig
- memory: MemoryConfig
- debug: DebugConfig
- voice: VoiceConfig
- jargon: JargonConfig
@@ -354,7 +352,6 @@ async def get_config_section_schema(section_name: str):
"response_splitter": ResponseSplitterConfig,
"telemetry": TelemetryConfig,
"maim_message": MaimMessageConfig,
"memory": MemoryConfig,
"a_memorix": AMemorixConfig,
"debug": DebugConfig,
"voice": VoiceConfig,