feat:优化配置项分类,maisaka聊天流监控展示
This commit is contained in:
@@ -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<string, unknown>
|
||||
}>
|
||||
nestedSchema: ConfigSchema
|
||||
onChange: (field: string, value: unknown) => void
|
||||
sectionDescription?: string
|
||||
sectionKey: string
|
||||
sectionTitle: string
|
||||
values: Record<string, unknown>
|
||||
}) {
|
||||
const [advancedVisible, setAdvancedVisible] = React.useState(false)
|
||||
const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema)
|
||||
const hasAdvanced =
|
||||
hasTopLevelAdvancedFields(nestedSchema) ||
|
||||
mergedChildren.some((child) => hasTopLevelAdvancedFields(child.schema))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -109,12 +129,43 @@ function DynamicConfigSection({
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
onChange={(field, value) => 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 (
|
||||
<div key={child.key} className="mt-5 border-t border-border/50 pt-4">
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionIcon iconName={child.schema.uiIcon} />
|
||||
<h3 className="text-sm font-medium">{childTitle}</h3>
|
||||
</div>
|
||||
{childDescription && (
|
||||
<p className="text-xs text-muted-foreground">{childDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
<DynamicConfigForm
|
||||
schema={child.schema}
|
||||
values={child.values}
|
||||
onChange={(field, value) => onChange(`${child.key}.${field}`, value)}
|
||||
basePath={childPath}
|
||||
hooks={hooks}
|
||||
level={level}
|
||||
advancedVisible={hasAdvanced ? advancedVisible : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -146,6 +197,17 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
() => new Map(schema.fields.map((field) => [field.name, field])),
|
||||
[schema.fields],
|
||||
)
|
||||
const mergedChildKeys = React.useMemo(() => {
|
||||
const keys = new Set<string>()
|
||||
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<DynamicConfigFormProps> = ({
|
||||
)}
|
||||
|
||||
{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<DynamicConfigFormProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
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<string, unknown>) || {},
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
child,
|
||||
): child is {
|
||||
key: string
|
||||
schema: ConfigSchema
|
||||
values: Record<string, unknown>
|
||||
} => Boolean(child),
|
||||
)
|
||||
|
||||
if (level === 0) {
|
||||
return (
|
||||
<DynamicConfigSection
|
||||
key={key}
|
||||
mergedChildren={mergedChildren}
|
||||
nestedSchema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
||||
onChange={onChange}
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
sectionKey={key}
|
||||
sectionTitle={sectionTitle}
|
||||
sectionDescription={sectionDescription}
|
||||
/>
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 チャット監視",
|
||||
|
||||
@@ -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 채팅 모니터",
|
||||
|
||||
@@ -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 聊天流监控",
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<string, string>
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
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<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).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<string, unknown>)
|
||||
: {}
|
||||
const auth =
|
||||
source.authorization &&
|
||||
typeof source.authorization === 'object' &&
|
||||
!Array.isArray(source.authorization)
|
||||
? (source.authorization as Record<string, unknown>)
|
||||
: {}
|
||||
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<MCPServerConfig>) => {
|
||||
onChange(servers.map((server, serverIndex) => (
|
||||
serverIndex === index ? { ...server, ...patch } : server
|
||||
)))
|
||||
}
|
||||
|
||||
const updateAuthorization = (index: number, patch: Partial<MCPAuthorization>) => {
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">MCP 服务</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{servers.length} 个
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
这里会写入 mcp.servers。stdio 用命令启动本地服务,streamable_http 连接远程 MCP 端点。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={addServer}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
添加服务
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{servers.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
尚未配置 MCP 服务。添加一个服务后,MaiSaka 可以调用它暴露的工具。
|
||||
</div>
|
||||
) : (
|
||||
servers.map((server, index) => (
|
||||
<Card key={`${server.name}-${index}`} className="border-border/70 bg-muted/20 shadow-none">
|
||||
<CardHeader className="space-y-3 px-4 py-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Switch
|
||||
checked={server.enabled}
|
||||
onCheckedChange={(enabled) => updateServer(index, { enabled })}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Input
|
||||
value={server.name}
|
||||
onChange={(event) => updateServer(index, { name: event.target.value })}
|
||||
placeholder="服务名称,必须唯一"
|
||||
className="h-8 font-medium"
|
||||
/>
|
||||
</div>
|
||||
<Badge variant={server.enabled ? 'default' : 'secondary'} className="shrink-0 text-[10px]">
|
||||
{server.enabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => duplicateServer(index)}
|
||||
title="复制服务"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => removeServer(index)}
|
||||
title="删除服务"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-4 pb-4 pt-0">
|
||||
<div className="grid gap-3 md:grid-cols-[12rem_1fr]">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">传输方式</label>
|
||||
<Select
|
||||
value={server.transport}
|
||||
onValueChange={(transport) => updateServer(index, { transport: transport as MCPTransport })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">stdio</SelectItem>
|
||||
<SelectItem value="streamable_http">streamable_http</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{server.transport === 'stdio' ? (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">启动命令</label>
|
||||
<Input
|
||||
value={server.command}
|
||||
onChange={(event) => updateServer(index, { command: event.target.value })}
|
||||
placeholder="例如 uvx、npx、python"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">服务 URL</label>
|
||||
<Input
|
||||
value={server.url}
|
||||
onChange={(event) => updateServer(index, { url: event.target.value })}
|
||||
placeholder="https://example.com/mcp"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{server.transport === 'stdio' ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">命令参数</label>
|
||||
<Textarea
|
||||
value={server.args.join('\n')}
|
||||
onChange={(event) => updateServer(index, {
|
||||
args: event.target.value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0),
|
||||
})}
|
||||
rows={4}
|
||||
placeholder="每行一个参数"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">环境变量</label>
|
||||
<KeyValueEditor
|
||||
value={server.env}
|
||||
onChange={(env) => updateServer(index, { env: asStringMap(env) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">认证模式</label>
|
||||
<Select
|
||||
value={server.authorization.mode}
|
||||
onValueChange={(mode) => updateAuthorization(index, { mode: mode as MCPAuthorization['mode'] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">none</SelectItem>
|
||||
<SelectItem value="bearer">bearer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{server.authorization.mode === 'bearer' && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Bearer Token</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={server.authorization.bearer_token}
|
||||
onChange={(event) => updateAuthorization(index, { bearer_token: event.target.value })}
|
||||
placeholder="HTTP Bearer Token"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">请求 Headers</label>
|
||||
<KeyValueEditor
|
||||
value={server.headers}
|
||||
onChange={(headers) => updateServer(index, { headers: asStringMap(headers) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">HTTP 请求超时(秒)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
value={server.http_timeout_seconds}
|
||||
onChange={(event) => updateServer(index, {
|
||||
http_timeout_seconds: Number.parseFloat(event.target.value) || 0.1,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">会话读取超时(秒)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
value={server.read_timeout_seconds}
|
||||
onChange={(event) => updateServer(index, {
|
||||
read_timeout_seconds: Number.parseFloat(event.target.value) || 0.1,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function MCPSettingsPage() {
|
||||
return (
|
||||
<RestartProvider>
|
||||
@@ -61,7 +446,6 @@ function MCPSettingsPageContent() {
|
||||
useEffect(() => {
|
||||
const hookEntries = [
|
||||
['mcp.client.roots.items', MCPRootItemsHook],
|
||||
['mcp.servers', MCPServersHook],
|
||||
] as const
|
||||
|
||||
for (const [fieldPath, hookComponent] of hookEntries) {
|
||||
@@ -169,10 +553,19 @@ function MCPSettingsPageContent() {
|
||||
classDoc: 'MCP 设置',
|
||||
fields: [],
|
||||
nested: {
|
||||
mcp: mcpSchema,
|
||||
mcp: {
|
||||
...mcpSchema,
|
||||
fields: mcpSchema.fields.filter((field) => field.name !== 'servers'),
|
||||
nested: mcpSchema.nested
|
||||
? Object.fromEntries(
|
||||
Object.entries(mcpSchema.nested).filter(([key]) => key !== 'servers'),
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
: null
|
||||
const mcpServers = normalizeMCPServers(mcpConfig.servers)
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
@@ -220,6 +613,19 @@ function MCPSettingsPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<MCPServersBlockEditor
|
||||
servers={mcpServers}
|
||||
onChange={(servers) => {
|
||||
setMcpConfig((currentConfig) => ({
|
||||
...currentConfig,
|
||||
servers,
|
||||
}))
|
||||
setHasUnsavedChanges(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && formSchema && (
|
||||
<DynamicConfigForm
|
||||
schema={formSchema}
|
||||
|
||||
@@ -53,6 +53,10 @@ function formatMs(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
function buildCycleKey(sessionId: string, cycleId: number) {
|
||||
return `${sessionId}:${cycleId}`
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
@@ -741,21 +745,37 @@ export function MaisakaMonitor() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
timeline.map((entry) => {
|
||||
const rendered = <TimelineEventRenderer entry={entry} showCycleMarkers={showCycleMarkers} />
|
||||
if (!rendered) return null
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="animate-in fade-in-0 slide-in-from-bottom-2 duration-300"
|
||||
>
|
||||
{rendered}
|
||||
{entry.type === 'cycle.end' && (
|
||||
<Separator className="mt-3" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
(() => {
|
||||
const displayedTimingGateCycles = new Set<string>()
|
||||
|
||||
return timeline.map((entry) => {
|
||||
if (entry.type === 'timing_gate.result') {
|
||||
const data = entry.data as TimingGateResultEvent
|
||||
displayedTimingGateCycles.add(buildCycleKey(data.session_id, data.cycle_id))
|
||||
}
|
||||
|
||||
if (entry.type === 'planner.response' || entry.type === 'planner.finalized') {
|
||||
const data = entry.data as PlannerResponseEvent | PlannerFinalizedEvent
|
||||
if (!displayedTimingGateCycles.has(buildCycleKey(data.session_id, data.cycle_id))) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const rendered = <TimelineEventRenderer entry={entry} showCycleMarkers={showCycleMarkers} />
|
||||
if (!rendered) return null
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="animate-in fade-in-0 slide-in-from-bottom-2 duration-300"
|
||||
>
|
||||
{rendered}
|
||||
{entry.type === 'cycle.end' && (
|
||||
<Separator className="mt-3" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface ConfigSchema {
|
||||
uiParent?: string
|
||||
uiLabel?: string
|
||||
uiIcon?: string
|
||||
uiMergeChildren?: string[]
|
||||
}
|
||||
|
||||
export interface ConfigSchemaResponse {
|
||||
|
||||
@@ -135,6 +135,7 @@ class ConfigBase(BaseModel, AttrDocBase):
|
||||
__ui_parent__: ClassVar[str] = "" # 父配置类在 Config 中的字段名,空表示独立 Tab
|
||||
__ui_label__: ClassVar[str] = "" # Tab 显示名称(仅做 Tab 主人时使用),空则使用 classDoc
|
||||
__ui_icon__: ClassVar[str] = "" # Tab 图标名称(Lucide 图标名)
|
||||
__ui_merge_children__: ClassVar[List[str]] = [] # 在 WebUI 中并入当前配置卡片展示的子配置字段名
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, attribute_data: AttributeData, data: dict[str, Any]):
|
||||
|
||||
@@ -1347,6 +1347,7 @@ class ResponsePostProcessConfig(ConfigBase):
|
||||
|
||||
__ui_label__ = "处理"
|
||||
__ui_icon__ = "settings"
|
||||
__ui_merge_children__ = ["chinese_typo", "response_splitter"]
|
||||
|
||||
enable_response_post_process: bool = Field(
|
||||
default=True,
|
||||
|
||||
@@ -39,12 +39,15 @@ class ConfigSchemaGenerator:
|
||||
ui_parent = getattr(config_class, "__ui_parent__", "")
|
||||
ui_label = getattr(config_class, "__ui_label__", "")
|
||||
ui_icon = getattr(config_class, "__ui_icon__", "")
|
||||
ui_merge_children = getattr(config_class, "__ui_merge_children__", [])
|
||||
if ui_parent:
|
||||
schema["uiParent"] = ui_parent
|
||||
if ui_label:
|
||||
schema["uiLabel"] = ui_label
|
||||
if ui_icon:
|
||||
schema["uiIcon"] = ui_icon
|
||||
if ui_merge_children:
|
||||
schema["uiMergeChildren"] = list(ui_merge_children)
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
Reference in New Issue
Block a user