feat:优化配置项分类,maisaka聊天流监控展示

This commit is contained in:
SengokuCola
2026-05-04 17:28:14 +08:00
parent 120acb835f
commit ccb1d60e06
12 changed files with 561 additions and 46 deletions

View File

@@ -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.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>
@@ -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}

View File

@@ -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>