diff --git a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx index 64aeb091..71d1de92 100644 --- a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx +++ b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx @@ -34,6 +34,16 @@ function hasTopLevelAdvancedFields(schema: ConfigSchema) { return schema.fields.some((field) => field.advanced && !schema.nested?.[field.name]) } +function resolveSectionTitle(schema: ConfigSchema) { + return schema.uiLabel || schema.classDoc || schema.className +} + +function resolveSectionDescription(schema: ConfigSchema, sectionTitle: string) { + return schema.classDoc && schema.classDoc !== sectionTitle + ? schema.classDoc + : undefined +} + function SectionIcon({ iconName }: { iconName?: string }) { if (!iconName) return null const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as @@ -66,23 +76,33 @@ function DynamicConfigSection({ basePath, hooks, level, + mergedChildren = [], nestedSchema, onChange, sectionDescription, + sectionKey, sectionTitle, values, }: { basePath: string hooks: FieldHookRegistry level: number + mergedChildren?: Array<{ + key: string + schema: ConfigSchema + values: Record + }> nestedSchema: ConfigSchema onChange: (field: string, value: unknown) => void sectionDescription?: string + sectionKey: string sectionTitle: string values: Record }) { const [advancedVisible, setAdvancedVisible] = React.useState(false) - const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema) + const hasAdvanced = + hasTopLevelAdvancedFields(nestedSchema) || + mergedChildren.some((child) => hasTopLevelAdvancedFields(child.schema)) return ( @@ -109,12 +129,43 @@ function DynamicConfigSection({ onChange(`${sectionKey}.${field}`, value)} basePath={basePath} hooks={hooks} level={level} advancedVisible={hasAdvanced ? advancedVisible : undefined} /> + {mergedChildren.map((child) => { + const childTitle = resolveSectionTitle(child.schema) + const childDescription = resolveSectionDescription(child.schema, childTitle) + const parentPath = basePath.includes('.') + ? basePath.replace(/\.[^.]+$/, '') + : '' + const childPath = buildFieldPath(parentPath, child.key) + + return ( +
+
+
+ +

{childTitle}

+
+ {childDescription && ( +

{childDescription}

+ )} +
+ onChange(`${child.key}.${field}`, value)} + basePath={childPath} + hooks={hooks} + level={level} + advancedVisible={hasAdvanced ? advancedVisible : undefined} + /> +
+ ) + })}
) @@ -146,6 +197,17 @@ export const DynamicConfigForm: React.FC = ({ () => new Map(schema.fields.map((field) => [field.name, field])), [schema.fields], ) + const mergedChildKeys = React.useMemo(() => { + const keys = new Set() + for (const nestedSchema of Object.values(schema.nested ?? {})) { + for (const childKey of nestedSchema.uiMergeChildren ?? []) { + if (schema.nested?.[childKey]) { + keys.add(childKey) + } + } + } + return keys + }, [schema.nested]) const renderField = (field: FieldSchema) => { const fieldPath = buildFieldPath(basePath, field.name) @@ -231,7 +293,9 @@ export const DynamicConfigForm: React.FC = ({ )} {schema.nested && - Object.entries(schema.nested).map(([key, nestedSchema]) => { + Object.entries(schema.nested) + .filter(([key]) => !mergedChildKeys.has(key)) + .map(([key, nestedSchema]) => { const nestedField = fieldMap.get(key) const nestedFieldPath = buildFieldPath(basePath, key) @@ -276,23 +340,43 @@ export const DynamicConfigForm: React.FC = ({ ) } - const sectionTitle = - nestedSchema.uiLabel || nestedSchema.classDoc || nestedSchema.className - const sectionDescription = - nestedSchema.classDoc && nestedSchema.classDoc !== sectionTitle - ? nestedSchema.classDoc - : undefined + const sectionTitle = resolveSectionTitle(nestedSchema) + const sectionDescription = resolveSectionDescription(nestedSchema, sectionTitle) + const mergedChildren = (nestedSchema.uiMergeChildren ?? []) + .map((childKey) => { + const childSchema = schema.nested?.[childKey] + if (!childSchema) { + return null + } + + return { + key: childKey, + schema: childSchema, + values: (values[childKey] as Record) || {}, + } + }) + .filter( + ( + child, + ): child is { + key: string + schema: ConfigSchema + values: Record + } => Boolean(child), + ) if (level === 0) { return ( ) || {}} - onChange={(field, value) => onChange(`${key}.${field}`, value)} + onChange={onChange} basePath={nestedFieldPath} hooks={hooks} level={level + 1} + sectionKey={key} sectionTitle={sectionTitle} sectionDescription={sectionDescription} /> diff --git a/dashboard/src/components/layout/constants.ts b/dashboard/src/components/layout/constants.ts index f1ad948e..d743a2ad 100644 --- a/dashboard/src/components/layout/constants.ts +++ b/dashboard/src/components/layout/constants.ts @@ -1,4 +1,4 @@ -import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, ScrollText, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react' +import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, ScrollText, Server, Settings, Sliders, Smile } from 'lucide-react' import type { MenuSection } from './types' @@ -7,6 +7,7 @@ export const menuSections: MenuSection[] = [ title: 'sidebar.groups.overview', items: [ { icon: Home, label: 'sidebar.menu.home', path: '/', searchDescription: 'search.items.homeDesc' }, + { icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' }, ], }, { @@ -24,23 +25,21 @@ export const menuSections: MenuSection[] = [ { icon: Smile, label: 'sidebar.menu.emojiManagement', path: '/resource/emoji', searchDescription: 'search.items.emojiDesc' }, { icon: MessageSquare, label: 'sidebar.menu.expressionManagement', path: '/resource/expression', searchDescription: 'search.items.expressionDesc' }, { icon: Hash, label: 'sidebar.menu.slangManagement', path: '/resource/jargon', searchDescription: 'search.items.jargonDesc' }, - { icon: UserCircle, label: 'sidebar.menu.personInfo', path: '/resource/person', searchDescription: 'search.items.personDesc' }, { icon: Database, label: 'sidebar.menu.knowledgeBase', path: '/resource/knowledge-base' }, ], }, { title: 'sidebar.groups.extensionsMonitor', items: [ - { icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' }, { icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' }, + { icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' }, { icon: Network, label: 'sidebar.menu.mcpSettings', path: '/mcp-settings' }, - { icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' }, - { icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' }, ], }, { title: 'sidebar.groups.system', items: [ + { icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' }, { icon: Settings, label: 'sidebar.menu.settings', path: '/settings', searchDescription: 'search.items.settingsDesc' }, ], }, diff --git a/dashboard/src/i18n/locales/en.json b/dashboard/src/i18n/locales/en.json index 01b6570f..a4e16283 100644 --- a/dashboard/src/i18n/locales/en.json +++ b/dashboard/src/i18n/locales/en.json @@ -19,7 +19,7 @@ "overview": "Overview", "botConfig": "Bot Configuration", "botResources": "Bot Resources", - "extensionsMonitor": "Extensions & Monitor", + "extensionsMonitor": "Plugins & Extensions", "system": "System" }, "menu": { @@ -34,10 +34,10 @@ "slangManagement": "Slang Management", "personInfo": "Person Info", "knowledgeGraph": "Long-Term Memory Graph", - "knowledgeBase": "Long-Term Memory Console", + "knowledgeBase": "Long-Term Memory", "pluginMarket": "Plugin Market", "configTemplate": "Config Templates", - "pluginConfig": "Plugin Config", + "pluginConfig": "Plugin Management", "mcpSettings": "MCP Settings", "logViewer": "Log Viewer", "maisakaMonitor": "MaiSaka Chat Monitor", diff --git a/dashboard/src/i18n/locales/ja.json b/dashboard/src/i18n/locales/ja.json index fa1c92b3..d318efc9 100644 --- a/dashboard/src/i18n/locales/ja.json +++ b/dashboard/src/i18n/locales/ja.json @@ -19,7 +19,7 @@ "overview": "概要", "botConfig": "ボット設定", "botResources": "ボットリソース", - "extensionsMonitor": "拡張機能 & 監視", + "extensionsMonitor": "プラグインと拡張", "system": "システム" }, "menu": { @@ -34,10 +34,10 @@ "slangManagement": "スラング管理", "personInfo": "人物情報", "knowledgeGraph": "長期記憶グラフ", - "knowledgeBase": "長期記憶コンソール", + "knowledgeBase": "長期記憶", "pluginMarket": "プラグインマーケット", "configTemplate": "設定テンプレート", - "pluginConfig": "プラグイン設定", + "pluginConfig": "プラグイン管理", "mcpSettings": "MCP 設定", "logViewer": "ログビューア", "maisakaMonitor": "MaiSaka チャット監視", diff --git a/dashboard/src/i18n/locales/ko.json b/dashboard/src/i18n/locales/ko.json index 7e880079..3a4d9bb9 100644 --- a/dashboard/src/i18n/locales/ko.json +++ b/dashboard/src/i18n/locales/ko.json @@ -19,7 +19,7 @@ "overview": "개요", "botConfig": "봇 설정", "botResources": "봇 리소스", - "extensionsMonitor": "확장 기능 & 모니터", + "extensionsMonitor": "플러그인 및 확장", "system": "시스템" }, "menu": { @@ -34,10 +34,10 @@ "slangManagement": "슬랭 관리", "personInfo": "인물 정보", "knowledgeGraph": "장기 기억 그래프", - "knowledgeBase": "장기 기억 콘솔", + "knowledgeBase": "장기 기억", "pluginMarket": "플러그인 마켓", "configTemplate": "설정 템플릿", - "pluginConfig": "플러그인 설정", + "pluginConfig": "플러그인 관리", "mcpSettings": "MCP 설정", "logViewer": "로그 뷰어", "maisakaMonitor": "MaiSaka 채팅 모니터", diff --git a/dashboard/src/i18n/locales/zh.json b/dashboard/src/i18n/locales/zh.json index e9090fef..d1efaefd 100644 --- a/dashboard/src/i18n/locales/zh.json +++ b/dashboard/src/i18n/locales/zh.json @@ -19,7 +19,7 @@ "overview": "概览", "botConfig": "麦麦配置编辑", "botResources": "麦麦资源管理", - "extensionsMonitor": "扩展与监控", + "extensionsMonitor": "插件与扩展", "system": "系统" }, "menu": { @@ -34,10 +34,10 @@ "slangManagement": "黑话管理", "personInfo": "人物信息管理", "knowledgeGraph": "长期记忆图谱", - "knowledgeBase": "长期记忆控制台", + "knowledgeBase": "长期记忆", "pluginMarket": "插件市场", "configTemplate": "配置模板市场", - "pluginConfig": "插件配置", + "pluginConfig": "插件管理", "mcpSettings": "MCP 设置", "logViewer": "日志查看器", "maisakaMonitor": "MaiSaka 聊天流监控", diff --git a/dashboard/src/routes/mcp-settings.tsx b/dashboard/src/routes/mcp-settings.tsx index 86b1526e..9945030e 100644 --- a/dashboard/src/routes/mcp-settings.tsx +++ b/dashboard/src/routes/mcp-settings.tsx @@ -1,8 +1,27 @@ import { useCallback, useEffect, useState } from 'react' import { Alert, AlertDescription } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { KeyValueEditor } from '@/components/ui/key-value-editor' import { ScrollArea } from '@/components/ui/scroll-area' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' import { DynamicConfigForm } from '@/components/dynamic-form' import { RestartOverlay } from '@/components/restart-overlay' import { useToast } from '@/hooks/use-toast' @@ -10,11 +29,107 @@ import { getBotConfig, getBotConfigSchema, updateBotConfigSection } from '@/lib/ import { fieldHooks } from '@/lib/field-hooks' import { RestartProvider, useRestart } from '@/lib/restart-context' import type { ConfigSchema } from '@/types/config-schema' -import { Info, Power, Save } from 'lucide-react' +import { Copy, Info, Plus, Power, Save, Server, Trash2 } from 'lucide-react' -import { MCPRootItemsHook, MCPServersHook } from './config/bot/hooks' +import { MCPRootItemsHook } from './config/bot/hooks' type ConfigSectionData = Record +type MCPTransport = 'stdio' | 'streamable_http' + +interface MCPAuthorization { + mode: 'none' | 'bearer' + bearer_token: string +} + +interface MCPServerConfig { + name: string + enabled: boolean + transport: MCPTransport + command: string + args: string[] + env: Record + url: string + headers: Record + http_timeout_seconds: number + read_timeout_seconds: number + authorization: MCPAuthorization +} + +const DEFAULT_MCP_SERVER: MCPServerConfig = { + name: '', + enabled: true, + transport: 'stdio', + command: '', + args: [], + env: {}, + url: '', + headers: {}, + http_timeout_seconds: 30, + read_timeout_seconds: 300, + authorization: { + mode: 'none', + bearer_token: '', + }, +} + +function asStringMap(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + return Object.fromEntries( + Object.entries(value as Record).map(([key, itemValue]) => [ + key, + String(itemValue ?? ''), + ]), + ) +} + +function normalizeMCPServer(value: unknown, index: number): MCPServerConfig { + const source = + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} + const auth = + source.authorization && + typeof source.authorization === 'object' && + !Array.isArray(source.authorization) + ? (source.authorization as Record) + : {} + const transport = source.transport === 'streamable_http' ? 'streamable_http' : 'stdio' + + return { + ...DEFAULT_MCP_SERVER, + name: typeof source.name === 'string' ? source.name : `mcp-server-${index + 1}`, + enabled: typeof source.enabled === 'boolean' ? source.enabled : DEFAULT_MCP_SERVER.enabled, + transport, + command: typeof source.command === 'string' ? source.command : '', + args: Array.isArray(source.args) ? source.args.map((item) => String(item ?? '')) : [], + env: asStringMap(source.env), + url: typeof source.url === 'string' ? source.url : '', + headers: asStringMap(source.headers), + http_timeout_seconds: + typeof source.http_timeout_seconds === 'number' + ? source.http_timeout_seconds + : DEFAULT_MCP_SERVER.http_timeout_seconds, + read_timeout_seconds: + typeof source.read_timeout_seconds === 'number' + ? source.read_timeout_seconds + : DEFAULT_MCP_SERVER.read_timeout_seconds, + authorization: { + mode: auth.mode === 'bearer' ? 'bearer' : 'none', + bearer_token: typeof auth.bearer_token === 'string' ? auth.bearer_token : '', + }, + } +} + +function normalizeMCPServers(value: unknown): MCPServerConfig[] { + if (!Array.isArray(value)) { + return [] + } + + return value.map((item, index) => normalizeMCPServer(item, index)) +} function updateNestedValue( target: ConfigSectionData | null | undefined, @@ -41,6 +156,276 @@ function updateNestedValue( } } +function MCPServersBlockEditor({ + servers, + onChange, +}: { + servers: MCPServerConfig[] + onChange: (servers: MCPServerConfig[]) => void +}) { + const updateServer = (index: number, patch: Partial) => { + onChange(servers.map((server, serverIndex) => ( + serverIndex === index ? { ...server, ...patch } : server + ))) + } + + const updateAuthorization = (index: number, patch: Partial) => { + const server = servers[index] + if (!server) { + return + } + updateServer(index, { + authorization: { + ...server.authorization, + ...patch, + }, + }) + } + + const addServer = () => { + onChange([ + ...servers, + { + ...DEFAULT_MCP_SERVER, + name: `mcp-server-${servers.length + 1}`, + }, + ]) + } + + const duplicateServer = (index: number) => { + const server = servers[index] + if (!server) { + return + } + const nextServer = { + ...server, + name: `${server.name || 'mcp-server'}-copy`, + args: [...server.args], + env: { ...server.env }, + headers: { ...server.headers }, + authorization: { ...server.authorization }, + } + onChange([ + ...servers.slice(0, index + 1), + nextServer, + ...servers.slice(index + 1), + ]) + } + + const removeServer = (index: number) => { + onChange(servers.filter((_, serverIndex) => serverIndex !== index)) + } + + return ( + + +
+
+
+ + MCP 服务 + + {servers.length} 个 + +
+ + 这里会写入 mcp.servers。stdio 用命令启动本地服务,streamable_http 连接远程 MCP 端点。 + +
+ +
+
+ + {servers.length === 0 ? ( +
+ 尚未配置 MCP 服务。添加一个服务后,MaiSaka 可以调用它暴露的工具。 +
+ ) : ( + servers.map((server, index) => ( + + +
+
+ updateServer(index, { enabled })} + /> +
+ updateServer(index, { name: event.target.value })} + placeholder="服务名称,必须唯一" + className="h-8 font-medium" + /> +
+ + {server.enabled ? '启用' : '禁用'} + +
+
+ + +
+
+
+ +
+
+ + +
+ {server.transport === 'stdio' ? ( +
+ + updateServer(index, { command: event.target.value })} + placeholder="例如 uvx、npx、python" + /> +
+ ) : ( +
+ + updateServer(index, { url: event.target.value })} + placeholder="https://example.com/mcp" + /> +
+ )} +
+ + {server.transport === 'stdio' ? ( +
+
+ +