feat:优化配置项分类,maisaka聊天流监控展示
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user