658 lines
22 KiB
TypeScript
658 lines
22 KiB
TypeScript
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.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>
|
||
<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>
|
||
)
|
||
}
|