Files
mai-bot/dashboard/src/routes/mcp-settings.tsx

658 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import { getBotConfig, getBotConfigSchema, updateBotConfigSection } from '@/lib/config-api'
import { fieldHooks } from '@/lib/field-hooks'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import type { ConfigSchema } from '@/types/config-schema'
import { Copy, Info, Plus, Power, Save, Server, Trash2 } from 'lucide-react'
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,
pathSegments: string[],
value: unknown
): ConfigSectionData {
const currentTarget = target && typeof target === 'object' && !Array.isArray(target) ? target : {}
const [currentPath, ...restPath] = pathSegments
if (!currentPath) {
return currentTarget
}
if (restPath.length === 0) {
return {
...currentTarget,
[currentPath]: value,
}
}
return {
...currentTarget,
[currentPath]: updateNestedValue(currentTarget[currentPath] as ConfigSectionData | undefined, restPath, value),
}
}
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.serversstdio 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>
<MCPSettingsPageContent />
</RestartProvider>
)
}
function MCPSettingsPageContent() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [mcpConfig, setMcpConfig] = useState<ConfigSectionData>({})
const [mcpSchema, setMcpSchema] = useState<ConfigSchema | null>(null)
const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart()
useEffect(() => {
const hookEntries = [
['mcp.client.roots.items', MCPRootItemsHook],
] as const
for (const [fieldPath, hookComponent] of hookEntries) {
fieldHooks.register(fieldPath, hookComponent, 'replace')
}
return () => {
for (const [fieldPath] of hookEntries) {
fieldHooks.unregister(fieldPath)
}
}
}, [])
const loadConfig = useCallback(async () => {
try {
setLoading(true)
const [configResult, schemaResult] = await Promise.all([getBotConfig(), getBotConfigSchema()])
if (!configResult.success) {
toast({
title: '加载失败',
description: configResult.error,
variant: 'destructive',
})
return
}
if (!schemaResult.success) {
toast({
title: '加载失败',
description: schemaResult.error,
variant: 'destructive',
})
return
}
const configPayload = configResult.data as { config?: Record<string, unknown> } & Record<string, unknown>
const fullConfig = (configPayload.config ?? configPayload) as Record<string, unknown>
const schemaPayload = schemaResult.data as { schema?: ConfigSchema } & ConfigSchema
const fullSchema = (schemaPayload.schema ?? schemaPayload) as ConfigSchema
setMcpConfig((fullConfig.mcp ?? {}) as ConfigSectionData)
setMcpSchema(fullSchema.nested?.mcp ?? null)
setHasUnsavedChanges(false)
} catch (error) {
console.error('加载 MCP 设置失败:', error)
toast({
title: '加载失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => {
void loadConfig()
}, [loadConfig])
const saveConfig = useCallback(async (): Promise<boolean> => {
try {
setSaving(true)
const result = await updateBotConfigSection('mcp', mcpConfig)
if (!result.success) {
toast({
title: '保存失败',
description: result.error,
variant: 'destructive',
})
return false
}
setHasUnsavedChanges(false)
toast({
title: '保存成功',
description: 'MCP 设置已保存,重启后生效。',
})
return true
} catch (error) {
console.error('保存 MCP 设置失败:', error)
toast({
title: '保存失败',
description: (error as Error).message,
variant: 'destructive',
})
return false
} finally {
setSaving(false)
}
}, [mcpConfig, toast])
const saveAndRestart = useCallback(async () => {
const saved = await saveConfig()
if (!saved) {
return
}
await triggerRestart({ delay: 500 })
}, [saveConfig, triggerRestart])
const formSchema: ConfigSchema | null = mcpSchema
? {
className: 'MCPSettings',
classDoc: 'MCP 设置',
fields: [],
nested: {
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">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold">MCP </h1>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
MCP
</p>
</div>
<div className="flex gap-2">
<Button
onClick={saveConfig}
disabled={loading || saving || !hasUnsavedChanges || isRestarting}
size="sm"
variant="outline"
className="w-24"
>
<Save className="h-4 w-4" strokeWidth={2} fill="none" />
<span className="ml-1 text-xs sm:text-sm">{saving ? '保存中' : hasUnsavedChanges ? '保存' : '已保存'}</span>
</Button>
<Button
onClick={saveAndRestart}
disabled={loading || saving || isRestarting}
size="sm"
className="w-28"
>
<Power className="h-4 w-4" />
<span className="ml-1 text-xs sm:text-sm">{isRestarting ? '重启中' : '保存重启'}</span>
</Button>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
MCP MCP 使
</AlertDescription>
</Alert>
{loading && (
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
...
</div>
)}
{!loading && (
<MCPServersBlockEditor
servers={mcpServers}
onChange={(servers) => {
setMcpConfig((currentConfig) => ({
...currentConfig,
servers,
}))
setHasUnsavedChanges(true)
}}
/>
)}
{!loading && formSchema && (
<DynamicConfigForm
schema={formSchema}
values={{ mcp: mcpConfig }}
onChange={(fieldPath, value) => {
const [, ...restPath] = fieldPath.split('.')
const nextConfig = restPath.length === 0
? (value as ConfigSectionData)
: updateNestedValue(mcpConfig, restPath, value)
setMcpConfig(nextConfig)
setHasUnsavedChanges(true)
}}
hooks={fieldHooks}
/>
)}
{!loading && !formSchema && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription> schema MCP </AlertDescription>
</Alert>
)}
<RestartOverlay />
</div>
</ScrollArea>
)
}