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

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

View File

@@ -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' },
],
},

View File

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

View File

@@ -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 チャット監視",

View File

@@ -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 채팅 모니터",

View File

@@ -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 聊天流监控",

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>

View File

@@ -52,6 +52,7 @@ export interface ConfigSchema {
uiParent?: string
uiLabel?: string
uiIcon?: string
uiMergeChildren?: string[]
}
export interface ConfigSchemaResponse {

View File

@@ -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]):

View File

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

View File

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