fix:优化图片识别,优化webui配置和排版,优化聊天流监控,新增mcp显示,新增prompt修改面板,优化插件状态显示,优化长期记忆控制台,

This commit is contained in:
SengokuCola
2026-05-04 16:25:31 +08:00
parent c5cd47adc2
commit 120acb835f
51 changed files with 1764 additions and 493 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "maibot-dashboard",
"version": "1.0.2",
"version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "maibot-dashboard",
"version": "1.0.2",
"version": "1.0.3",
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.4",

View File

@@ -1,7 +1,7 @@
{
"name": "maibot-dashboard",
"private": true,
"version": "1.0.2",
"version": "1.0.3",
"type": "module",
"main": "./out/main/index.js",
"scripts": {

View File

@@ -53,7 +53,7 @@ function AdvancedSettingsButton({
return (
<Button
type="button"
variant={active ? 'secondary' : 'outline'}
variant={active ? 'default' : 'outline'}
size="sm"
onClick={onClick}
>
@@ -207,10 +207,8 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
<>
{fields.map((field, index) => (
<React.Fragment key={field.name}>
{index > 0 && field.type !== 'boolean' && fields[index - 1]?.type !== 'boolean' && (
<Separator className="my-1" />
)}
<div>{renderField(field)}</div>
{index > 0 && <Separator className="my-2 bg-border/50" />}
<div className="py-1">{renderField(field)}</div>
</React.Fragment>
))}
</>
@@ -219,7 +217,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
return (
<div className="space-y-6">
{topLevelFields.length > 0 && (
<div className="space-y-1">
<div>
{advancedVisible === undefined && advancedFields.length > 0 && (
<div className="flex justify-end pb-2">
<AdvancedSettingsButton
@@ -302,33 +300,33 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
}
return (
<div
key={key}
className="relative space-y-4 rounded-lg border-l-2 border-muted-foreground/20 pl-4 pt-1 pb-1"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={nestedSchema.uiIcon} />
<h4 className="text-sm font-semibold">{sectionTitle}</h4>
<Card key={key} className="border-border/70 bg-muted/20 shadow-none">
<CardHeader className="px-4 py-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={nestedSchema.uiIcon} />
<CardTitle className="text-sm">{sectionTitle}</CardTitle>
</div>
{sectionDescription && (
<CardDescription className="text-xs">
{sectionDescription}
</CardDescription>
)}
</div>
{sectionDescription && (
<p className="text-xs text-muted-foreground">
{sectionDescription}
</p>
)}
</div>
</div>
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
/>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
/>
</CardContent>
</Card>
)
})}
</div>

View File

@@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import type { FieldSchema } from "@/types/config-schema"
export interface DynamicFieldProps {
@@ -93,6 +94,28 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
return <IconComponent className="h-4 w-4" />
}
const renderFieldHeader = () => (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Label
className={cn(
"inline-flex min-h-7 items-center gap-1.5 rounded-md border px-2 py-1 text-sm font-medium shadow-sm",
schema.advanced
? "border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-500/60 dark:bg-amber-500/15 dark:text-amber-100"
: "bg-muted/60 text-foreground",
)}
>
{renderIcon()}
<span className="break-all">{schema.label}</span>
{schema.required && <span className="text-destructive">*</span>}
</Label>
{schema.description && (
<span className="text-[13px] leading-6 text-muted-foreground whitespace-pre-line">
{schema.description}
</span>
)}
</div>
)
/**
* 根据 x-widget 或 type 选择并渲染对应的输入组件
*/
@@ -175,16 +198,9 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
const renderSwitch = () => {
const checked = Boolean(value)
return (
<div className="flex items-center justify-between rounded-lg border p-3 sm:p-4">
<div className="space-y-0.5 pr-4">
<Label className="text-sm font-medium flex items-center gap-2">
{renderIcon()}
{schema.label}
{schema.required && <span className="text-destructive">*</span>}
</Label>
{schema.description && (
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
)}
<div className="flex items-center justify-between gap-4 py-2">
<div className="pr-4">
{renderFieldHeader()}
</div>
<Switch
checked={checked}
@@ -305,27 +321,35 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
const isBoolean =
schema['x-widget'] === 'switch' ||
(!schema['x-widget'] && schema.type === 'boolean')
const supportsInlineRight =
schema['x-layout'] === 'inline-right' &&
['input', 'number', 'password', 'select', undefined].includes(schema['x-widget']) &&
['string', 'number', 'integer', 'select'].includes(schema.type)
// Switch/Boolean 字段自带完整布局,直接返回
if (isBoolean) {
return renderInputComponent()
}
if (supportsInlineRight) {
return (
<div
className="flex flex-col gap-2 py-2 sm:flex-row sm:items-center sm:justify-between"
style={{ '--field-input-width': schema['x-input-width'] ?? '12rem' } as React.CSSProperties}
>
<div className="min-w-0 flex-1">
{renderFieldHeader()}
</div>
<div className="w-full shrink-0 sm:w-[var(--field-input-width)]">
{renderInputComponent()}
</div>
</div>
)
}
return (
<div className="space-y-2">
<div className="space-y-0.5">
{/* Label with icon */}
<Label className="text-sm font-medium flex items-center gap-2">
{renderIcon()}
{schema.label}
{schema.required && <span className="text-destructive">*</span>}
</Label>
{/* Description */}
{schema.description && (
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
)}
</div>
{renderFieldHeader()}
{/* Input component */}
{renderInputComponent()}

View File

@@ -1,4 +1,4 @@
import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react'
import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, ScrollText, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react'
import type { MenuSection } from './types'
@@ -15,6 +15,7 @@ export const menuSections: MenuSection[] = [
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' },
{ icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', searchDescription: 'search.items.modelProviderDesc', tourId: 'sidebar-model-provider' },
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' },
{ icon: ScrollText, label: 'sidebar.menu.promptManagement', path: '/config/prompts' },
],
},
{
@@ -24,7 +25,6 @@ export const menuSections: MenuSection[] = [
{ 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: Network, label: 'sidebar.menu.knowledgeGraph', path: '/resource/knowledge-graph' },
{ icon: Database, label: 'sidebar.menu.knowledgeBase', path: '/resource/knowledge-base' },
],
},
@@ -33,6 +33,7 @@ export const menuSections: MenuSection[] = [
items: [
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' },
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
{ 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' },
],

View File

@@ -27,6 +27,7 @@
"botMainConfig": "Bot Main Config",
"aiModelProvider": "AI Model Providers",
"modelManagement": "Model Management",
"promptManagement": "Prompt Management",
"adapterConfig": "Adapter Config",
"emojiManagement": "Emoji Management",
"expressionManagement": "Expression Management",
@@ -37,6 +38,7 @@
"pluginMarket": "Plugin Market",
"configTemplate": "Config Templates",
"pluginConfig": "Plugin Config",
"mcpSettings": "MCP Settings",
"logViewer": "Log Viewer",
"maisakaMonitor": "MaiSaka Chat Monitor",
"localChat": "Local Chat",

View File

@@ -27,6 +27,7 @@
"botMainConfig": "ボットメイン設定",
"aiModelProvider": "AIモデルプロバイダー",
"modelManagement": "モデル管理",
"promptManagement": "Prompt 管理",
"adapterConfig": "アダプター設定",
"emojiManagement": "絵文字管理",
"expressionManagement": "表現管理",
@@ -37,6 +38,7 @@
"pluginMarket": "プラグインマーケット",
"configTemplate": "設定テンプレート",
"pluginConfig": "プラグイン設定",
"mcpSettings": "MCP 設定",
"logViewer": "ログビューア",
"maisakaMonitor": "MaiSaka チャット監視",
"localChat": "ローカルチャット",

View File

@@ -27,6 +27,7 @@
"botMainConfig": "봇 메인 설정",
"aiModelProvider": "AI 모델 공급자",
"modelManagement": "모델 관리",
"promptManagement": "Prompt 관리",
"adapterConfig": "어댑터 설정",
"emojiManagement": "이모티콘 관리",
"expressionManagement": "표현 관리",
@@ -37,6 +38,7 @@
"pluginMarket": "플러그인 마켓",
"configTemplate": "설정 템플릿",
"pluginConfig": "플러그인 설정",
"mcpSettings": "MCP 설정",
"logViewer": "로그 뷰어",
"maisakaMonitor": "MaiSaka 채팅 모니터",
"localChat": "로컬 채팅",

View File

@@ -27,6 +27,7 @@
"botMainConfig": "麦麦主程序配置",
"aiModelProvider": "AI模型厂商配置",
"modelManagement": "模型管理与分配",
"promptManagement": "Prompt 管理",
"adapterConfig": "麦麦适配器配置",
"emojiManagement": "表情包管理",
"expressionManagement": "表达方式管理",
@@ -37,6 +38,7 @@
"pluginMarket": "插件市场",
"configTemplate": "配置模板市场",
"pluginConfig": "插件配置",
"mcpSettings": "MCP 设置",
"logViewer": "日志查看器",
"maisakaMonitor": "MaiSaka 聊天流监控",
"localChat": "本地聊天室",

View File

@@ -95,6 +95,60 @@ export interface ToolExecutionEvent {
timestamp: number
}
export interface MaisakaRequestBlock {
messages: MaisakaMessage[]
selected_history_count: number
tool_count: number
}
export interface MaisakaPlannerBlock {
content: string | null
tool_calls: MaisakaToolCall[]
prompt_tokens: number
completion_tokens: number
total_tokens: number
duration_ms: number
prompt_html_uri?: string
}
export interface MaisakaTimingGateBlock {
request: MaisakaRequestBlock | null
result: {
action: 'continue' | 'wait' | 'no_reply' | null
content: string | null
tool_calls: MaisakaToolCall[]
tool_results: unknown[]
prompt_tokens: number
completion_tokens: number
total_tokens: number
duration_ms: number
}
}
export interface MaisakaFinalizedToolResult {
tool_call_id: string
tool_name: string
tool_args: Record<string, unknown>
success: boolean
duration_ms: number
summary: string
detail?: unknown
}
export interface PlannerFinalizedEvent {
session_id: string
cycle_id: number
timestamp: number
timing_gate: MaisakaTimingGateBlock | null
request: MaisakaRequestBlock | null
planner: MaisakaPlannerBlock | null
tools: MaisakaFinalizedToolResult[]
final_state: {
time_records: Record<string, number>
agent_state: string
}
}
export interface CycleEndEvent {
session_id: string
cycle_id: number
@@ -132,6 +186,7 @@ export type MaisakaMonitorEvent =
| { type: 'timing_gate.result'; data: TimingGateResultEvent }
| { type: 'planner.request'; data: PlannerRequestEvent }
| { type: 'planner.response'; data: PlannerResponseEvent }
| { type: 'planner.finalized'; data: PlannerFinalizedEvent }
| { type: 'tool.execution'; data: ToolExecutionEvent }
| { type: 'cycle.end'; data: CycleEndEvent }
| { type: 'replier.request'; data: ReplierRequestEvent }

View File

@@ -44,6 +44,10 @@ export interface InstalledPlugin {
[key: string]: unknown // 允许其他字段
}
path: string
enabled?: boolean
disabled?: boolean
loaded?: boolean
load_status?: 'success' | 'failed' | 'inactive' | 'disabled' | 'unknown'
}
/**
* 旧版本插件格式(直接包含 version 字段)

View File

@@ -0,0 +1,49 @@
import { parseResponse } from '@/lib/api-helpers'
import { fetchWithAuth } from '@/lib/fetch-with-auth'
import type { ApiResponse } from '@/types/api'
const API_BASE = '/api/webui/config/prompts'
export interface PromptFileInfo {
name: string
size: number
modified_at: number
}
export interface PromptCatalog {
success: boolean
languages: string[]
files: Record<string, PromptFileInfo[]>
}
export interface PromptFileContent {
success: boolean
language: string
filename: string
content: string
}
export async function getPromptCatalog(): Promise<ApiResponse<PromptCatalog>> {
const response = await fetchWithAuth(API_BASE)
return parseResponse<PromptCatalog>(response)
}
export async function getPromptFile(
language: string,
filename: string
): Promise<ApiResponse<PromptFileContent>> {
const response = await fetchWithAuth(`${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`)
return parseResponse<PromptFileContent>(response)
}
export async function updatePromptFile(
language: string,
filename: string,
content: string
): Promise<ApiResponse<PromptFileContent>> {
const response = await fetchWithAuth(`${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`, {
method: 'PUT',
body: JSON.stringify({ content }),
})
return parseResponse<PromptFileContent>(response)
}

View File

@@ -5,7 +5,7 @@
* 修改此处的版本号后,所有展示版本的地方都会自动更新
*/
export const APP_VERSION = '1.0.2'
export const APP_VERSION = '1.0.3'
export const APP_NAME = 'MaiBot Dashboard'
export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}`

View File

@@ -86,6 +86,12 @@ const modelConfigRoute = createRoute({
})
// 配置路由 - 麦麦适配器配置(已停用,引导跳转到插件配置;旧实现保留在 ./routes/config/adapter
const promptManagementRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/prompts',
component: lazyRouteComponent(() => import('./routes/config/prompts'), 'PromptManagementPage'),
})
const adapterConfigRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/adapter',
@@ -206,6 +212,12 @@ const pluginMirrorsRoute = createRoute({
})
// 设置页路由
const mcpSettingsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/mcp-settings',
component: lazyRouteComponent(() => import('./routes/mcp-settings'), 'MCPSettingsPage'),
})
const settingsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/settings',
@@ -262,6 +274,7 @@ const routeTree = rootRoute.addChildren([
botConfigRoute,
modelProviderConfigRoute,
modelConfigRoute,
promptManagementRoute,
adapterConfigRoute,
emojiManagementRoute,
expressionManagementRoute,
@@ -274,6 +287,7 @@ const routeTree = rootRoute.addChildren([
modelPresetsRoute,
pluginConfigRoute,
pluginMirrorsRoute,
mcpSettingsRoute,
logsRoute,
plannerMonitorRoute,
chatRoute,

View File

@@ -57,7 +57,7 @@ const TAB_ORDER = [
'webui',
'maisaka',
'plugin_runtime',
'debug',
'log',
]
// ==================== Tab 分组类型与构建 ====================
@@ -157,6 +157,7 @@ function BotConfigPageContent() {
const [emojiConfig, setEmojiConfig] = useState<ConfigSectionData | null>(null)
const [memoryConfig, setMemoryConfig] = useState<ConfigSectionData | null>(null)
const [relationshipConfig, setRelationshipConfig] = useState<ConfigSectionData | null>(null)
const [visualConfig, setVisualConfig] = useState<ConfigSectionData | null>(null)
const [voiceConfig, setVoiceConfig] = useState<ConfigSectionData | null>(null)
const [messageReceiveConfig, setMessageReceiveConfig] = useState<ConfigSectionData | null>(null)
const [lpmmConfig, setLpmmConfig] = useState<ConfigSectionData | null>(null)
@@ -173,6 +174,7 @@ function BotConfigPageContent() {
const [maisakaConfig, setMaisakaConfig] = useState<ConfigSectionData | null>(null)
const [mcpConfig, setMcpConfig] = useState<ConfigSectionData | null>(null)
const [pluginRuntimeConfig, setPluginRuntimeConfig] = useState<ConfigSectionData | null>(null)
const [aMemorixConfig, setAMemorixConfig] = useState<ConfigSectionData | null>(null)
// Schema 状态(用于动态 tab 分组)
const [configSchema, setConfigSchema] = useState<ConfigSchema | null>(null)
@@ -254,6 +256,7 @@ function BotConfigPageContent() {
setEmojiConfig((config.emoji ?? {}) as ConfigSectionData)
setMemoryConfig((config.memory ?? {}) as ConfigSectionData)
setRelationshipConfig((config.relationship ?? {}) as ConfigSectionData)
setVisualConfig((config.visual ?? {}) as ConfigSectionData)
setVoiceConfig((config.voice ?? {}) as ConfigSectionData)
setMessageReceiveConfig((config.message_receive ?? {}) as ConfigSectionData)
setLpmmConfig((config.lpmm_knowledge ?? {}) as ConfigSectionData)
@@ -270,6 +273,7 @@ function BotConfigPageContent() {
setMaisakaConfig((config.maisaka ?? {}) as ConfigSectionData)
setMcpConfig((config.mcp ?? {}) as ConfigSectionData)
setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData)
setAMemorixConfig((config.a_memorix ?? {}) as ConfigSectionData)
}, [])
/**
@@ -286,6 +290,7 @@ function BotConfigPageContent() {
emoji: emojiConfig,
memory: memoryConfig,
relationship: relationshipConfig,
visual: visualConfig,
voice: voiceConfig,
message_receive: messageReceiveConfig,
lpmm_knowledge: lpmmConfig,
@@ -302,6 +307,7 @@ function BotConfigPageContent() {
maisaka: maisakaConfig,
mcp: mcpConfig,
plugin_runtime: pluginRuntimeConfig,
a_memorix: aMemorixConfig,
}
}, [
botConfig,
@@ -311,6 +317,7 @@ function BotConfigPageContent() {
emojiConfig,
memoryConfig,
relationshipConfig,
visualConfig,
voiceConfig,
messageReceiveConfig,
lpmmConfig,
@@ -327,6 +334,7 @@ function BotConfigPageContent() {
maisakaConfig,
mcpConfig,
pluginRuntimeConfig,
aMemorixConfig,
])
// 加载源代码
@@ -443,6 +451,7 @@ function BotConfigPageContent() {
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(relationshipConfig, 'relationship', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(lpmmConfig, 'lpmm_knowledge', initialLoadRef.current, triggerAutoSave)
@@ -459,6 +468,7 @@ function BotConfigPageContent() {
useConfigAutoSave(maisakaConfig, 'maisaka', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerAutoSave)
// 保存源代码
const saveSourceCode = async () => {
@@ -658,6 +668,7 @@ function BotConfigPageContent() {
emoji: emojiConfig,
memory: memoryConfig,
relationship: relationshipConfig,
visual: visualConfig,
voice: voiceConfig,
message_receive: messageReceiveConfig,
lpmm_knowledge: lpmmConfig,
@@ -674,6 +685,7 @@ function BotConfigPageContent() {
maisaka: maisakaConfig,
mcp: mcpConfig,
plugin_runtime: pluginRuntimeConfig,
a_memorix: aMemorixConfig,
}),
[
botConfig,
@@ -683,6 +695,7 @@ function BotConfigPageContent() {
emojiConfig,
memoryConfig,
relationshipConfig,
visualConfig,
voiceConfig,
messageReceiveConfig,
lpmmConfig,
@@ -699,6 +712,7 @@ function BotConfigPageContent() {
maisakaConfig,
mcpConfig,
pluginRuntimeConfig,
aMemorixConfig,
]
)
@@ -711,6 +725,7 @@ function BotConfigPageContent() {
emoji: setEmojiConfig,
memory: setMemoryConfig,
relationship: setRelationshipConfig,
visual: setVisualConfig,
voice: setVoiceConfig,
message_receive: setMessageReceiveConfig,
lpmm_knowledge: setLpmmConfig,
@@ -727,6 +742,7 @@ function BotConfigPageContent() {
maisaka: setMaisakaConfig,
mcp: setMcpConfig,
plugin_runtime: setPluginRuntimeConfig,
a_memorix: setAMemorixConfig,
}
sectionSetterMap[sectionName]?.(value)

View File

@@ -590,16 +590,16 @@ export const ExpressionSection = React.memo(function ExpressionSection({
id="expression_auto_check_interval"
type="number"
min="60"
value={config.expression_auto_check_interval ?? 3600}
value={config.expression_auto_check_interval ?? 900}
onChange={(e) =>
onChange({
...config,
expression_auto_check_interval: parseInt(e.target.value) || 3600,
expression_auto_check_interval: parseInt(e.target.value) || 900,
})
}
/>
<p className="text-xs text-muted-foreground">
36001
90015
</p>
</div>
@@ -613,16 +613,16 @@ export const ExpressionSection = React.memo(function ExpressionSection({
type="number"
min="1"
max="100"
value={config.expression_auto_check_count ?? 10}
value={config.expression_auto_check_count ?? 5}
onChange={(e) =>
onChange({
...config,
expression_auto_check_count: parseInt(e.target.value) || 10,
expression_auto_check_count: parseInt(e.target.value) || 5,
})
}
/>
<p className="text-xs text-muted-foreground">
10
5
</p>
</div>

View File

@@ -262,6 +262,7 @@ export type ConfigSectionName =
| 'emoji'
| 'memory'
| 'relationship'
| 'visual'
| 'tool'
| 'voice'
| 'message_receive'
@@ -281,3 +282,4 @@ export type ConfigSectionName =
| 'maisaka'
| 'mcp'
| 'plugin_runtime'
| 'a_memorix'

View File

@@ -949,7 +949,7 @@ function ModelConfigPageContent() {
{taskConfigSchema?.fields.some((field) => field.advanced) && (
<Button
type="button"
variant={advancedTaskSettingsVisible ? 'secondary' : 'outline'}
variant={advancedTaskSettingsVisible ? 'default' : 'outline'}
size="sm"
onClick={() => setAdvancedTaskSettingsVisible((current) => !current)}
>
@@ -975,6 +975,7 @@ function ModelConfigPageContent() {
taskConfig={taskConfig[field.name] ?? { model_list: [] }}
modelNames={modelNames}
onChange={(f, value) => updateTaskConfig(field.name, f, value)}
advanced={field.advanced}
{...(index === 0 ? { dataTour: 'task-model-select' } : {})}
/>
)

View File

@@ -13,6 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import type { TaskConfig } from '../types'
interface TaskConfigCardProps {
@@ -23,6 +24,7 @@ interface TaskConfigCardProps {
onChange: (field: keyof TaskConfig, value: string[] | number | string) => void
hideTemperature?: boolean
hideMaxTokens?: boolean
advanced?: boolean
dataTour?: string
}
@@ -34,6 +36,7 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
onChange,
hideTemperature = false,
hideMaxTokens = false,
advanced = false,
dataTour,
}: TaskConfigCardProps) {
const handleModelChange = (values: string[]) => {
@@ -41,7 +44,12 @@ export const TaskConfigCard = React.memo(function TaskConfigCard({
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<div
className={cn(
"rounded-lg border bg-card p-4 sm:p-6 space-y-4",
advanced && "border-amber-300 bg-amber-50/40 dark:border-amber-500/50 dark:bg-amber-500/10",
)}
>
<div>
<h4 className="font-semibold text-base sm:text-lg">{title}</h4>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{description}</p>

View File

@@ -0,0 +1,272 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { FileText, Loader2, RefreshCw, Save, Search } from 'lucide-react'
import { CodeEditor } from '@/components/CodeEditor'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { useToast } from '@/hooks/use-toast'
import {
getPromptCatalog,
getPromptFile,
updatePromptFile,
type PromptCatalog,
type PromptFileInfo,
} from '@/lib/prompt-api'
import { cn } from '@/lib/utils'
function formatFileSize(size: number) {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
export function PromptManagementPage() {
const { toast } = useToast()
const [catalog, setCatalog] = useState<PromptCatalog | null>(null)
const [language, setLanguage] = useState('zh-CN')
const [filename, setFilename] = useState('')
const [content, setContent] = useState('')
const [savedContent, setSavedContent] = useState('')
const [loadingCatalog, setLoadingCatalog] = useState(true)
const [loadingFile, setLoadingFile] = useState(false)
const [saving, setSaving] = useState(false)
const [query, setQuery] = useState('')
const hasUnsavedChanges = content !== savedContent
const promptFiles = useMemo<PromptFileInfo[]>(() => {
if (!catalog || !language) return []
return catalog.files[language] ?? []
}, [catalog, language])
const filteredFiles = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) return promptFiles
return promptFiles.filter((file) => file.name.toLowerCase().includes(normalizedQuery))
}, [promptFiles, query])
const selectedFile = promptFiles.find((file) => file.name === filename)
const loadCatalog = useCallback(async () => {
try {
setLoadingCatalog(true)
const result = await getPromptCatalog()
if (!result.success) {
toast({ title: '加载 Prompt 目录失败', description: result.error, variant: 'destructive' })
return
}
setCatalog(result.data)
const nextLanguage = language && result.data.languages.includes(language)
? language
: result.data.languages.includes('zh-CN')
? 'zh-CN'
: result.data.languages[0] ?? ''
setLanguage(nextLanguage)
const nextFiles = nextLanguage ? result.data.files[nextLanguage] ?? [] : []
setFilename((current) => nextFiles.some((file) => file.name === current) ? current : nextFiles[0]?.name ?? '')
} catch (error) {
toast({
title: '加载 Prompt 目录失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setLoadingCatalog(false)
}
}, [language, toast])
useEffect(() => {
void loadCatalog()
}, [loadCatalog])
useEffect(() => {
if (!language || !filename) {
setContent('')
setSavedContent('')
return
}
let cancelled = false
const loadFile = async () => {
try {
setLoadingFile(true)
const result = await getPromptFile(language, filename)
if (cancelled) return
if (!result.success) {
toast({ title: '读取 Prompt 失败', description: result.error, variant: 'destructive' })
return
}
setContent(result.data.content)
setSavedContent(result.data.content)
} catch (error) {
if (!cancelled) {
toast({
title: '读取 Prompt 失败',
description: (error as Error).message,
variant: 'destructive',
})
}
} finally {
if (!cancelled) {
setLoadingFile(false)
}
}
}
void loadFile()
return () => {
cancelled = true
}
}, [filename, language, toast])
const handleLanguageChange = (nextLanguage: string) => {
setLanguage(nextLanguage)
setQuery('')
const nextFiles = catalog?.files[nextLanguage] ?? []
setFilename(nextFiles[0]?.name ?? '')
}
const handleSave = async () => {
if (!language || !filename) return
try {
setSaving(true)
const result = await updatePromptFile(language, filename, content)
if (!result.success) {
toast({ title: '保存 Prompt 失败', description: result.error, variant: 'destructive' })
return
}
setContent(result.data.content)
setSavedContent(result.data.content)
toast({ title: 'Prompt 已保存', description: `${language}/${filename}` })
void loadCatalog()
} catch (error) {
toast({
title: '保存 Prompt 失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
return (
<div className="flex h-[calc(100vh-140px)] flex-col gap-4 p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold sm:text-2xl md:text-3xl">Prompt </h1>
<p className="mt-1 text-sm text-muted-foreground"> prompts </p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={language} onValueChange={handleLanguageChange} disabled={loadingCatalog}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="选择语言" />
</SelectTrigger>
<SelectContent>
{(catalog?.languages ?? []).map((item) => (
<SelectItem key={item} value={item}>{item}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={() => void loadCatalog()} disabled={loadingCatalog}>
<RefreshCw className={cn('mr-2 h-4 w-4', loadingCatalog && 'animate-spin')} />
</Button>
<Button size="sm" onClick={handleSave} disabled={!hasUnsavedChanges || saving || loadingFile || !filename}>
<Save className="mr-2 h-4 w-4" />
{saving ? '保存中' : hasUnsavedChanges ? '保存' : '已保存'}
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
<Card className="min-h-0 overflow-hidden">
<CardHeader className="space-y-3 pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<FileText className="h-4 w-4" />
Prompt
<Badge variant="secondary" className="ml-auto">{promptFiles.length}</Badge>
</CardTitle>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索文件"
className="pl-8"
/>
</div>
</CardHeader>
<Separator />
<ScrollArea className="h-full">
<div className="space-y-1 p-2">
{loadingCatalog ? (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : filteredFiles.length > 0 ? (
filteredFiles.map((file) => (
<button
key={file.name}
type="button"
onClick={() => setFilename(file.name)}
className={cn(
'w-full rounded-md px-3 py-2 text-left text-sm transition-colors',
'hover:bg-accent hover:text-accent-foreground',
filename === file.name ? 'bg-accent text-accent-foreground' : 'text-muted-foreground',
)}
>
<div className="truncate font-medium" title={file.name}>{file.name}</div>
<div className="mt-0.5 text-xs text-muted-foreground">{formatFileSize(file.size)}</div>
</button>
))
) : (
<div className="p-6 text-center text-sm text-muted-foreground"> Prompt </div>
)}
</div>
</ScrollArea>
</Card>
<Card className="min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0 pb-3">
<div className="min-w-0">
<CardTitle className="truncate text-sm">{filename || '未选择文件'}</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{language}
{selectedFile ? ` · ${formatFileSize(selectedFile.size)}` : ''}
{hasUnsavedChanges ? ' · 有未保存修改' : ''}
</p>
</div>
</CardHeader>
<CardContent className="min-h-0 p-0">
{loadingFile ? (
<div className="flex h-[calc(100vh-290px)] items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<CodeEditor
value={content}
onChange={setContent}
language="text"
height="calc(100vh - 290px)"
minHeight="520px"
placeholder="选择一个 Prompt 文件后开始编辑"
/>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -54,6 +54,7 @@ import { Link } from '@tanstack/react-router'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { RestartOverlay } from '@/components/restart-overlay'
import { ExpressionReviewer } from '@/components/expression-reviewer'
import { getBotConfig, getModelConfig } from '@/lib/config-api'
import { getReviewStats } from '@/lib/expression-api'
import { ZoomableChart } from '@/components/ui/zoomable-chart'
@@ -119,6 +120,11 @@ interface DashboardData {
recent_activity: RecentActivity[]
}
interface FeatureStatus {
memoryEnabled: boolean
visualEnabled: boolean
}
// 为饼图生成更丰富的颜色方案 (HSL色相均匀分布)
const generatePieColors = (count: number): string[] => {
const colors: string[] = []
@@ -131,6 +137,19 @@ const generatePieColors = (count: number): string[] => {
}
// 内部实现组件
function FeatureStatusLight({ enabled, label }: { enabled: boolean; label: string }) {
return (
<div className="inline-flex items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-xs text-muted-foreground">
<span
className={`h-2.5 w-2.5 rounded-full ${
enabled ? 'bg-green-500 shadow-[0_0_0_3px_rgba(34,197,94,0.18)]' : 'bg-muted-foreground/30'
}`}
/>
<span>{label}</span>
</div>
)
}
function IndexPageContent() {
const { t } = useTranslation()
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null)
@@ -141,6 +160,10 @@ function IndexPageContent() {
const [hitokoto, setHitokoto] = useState<{ hitokoto: string; from: string } | null>(null)
const [hitokotoLoading, setHitokotoLoading] = useState(true)
const [botStatus, setBotStatus] = useState<BotStatus | null>(null)
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>({
memoryEnabled: false,
visualEnabled: false,
})
const [isReviewerOpen, setIsReviewerOpen] = useState(false)
const [uncheckedCount, setUncheckedCount] = useState(0)
const { triggerRestart, isRestarting } = useRestart()
@@ -221,6 +244,44 @@ function IndexPageContent() {
}, [])
// 重启机器人
const fetchFeatureStatus = useCallback(async () => {
try {
const [botConfigResult, modelConfigResult] = await Promise.all([
getBotConfig(),
getModelConfig(),
])
if (!isMountedRef.current || !botConfigResult.success) return
const botPayload = botConfigResult.data as { config?: Record<string, unknown> } & Record<string, unknown>
const botConfig = (botPayload.config ?? botPayload) as Record<string, unknown>
const memorixConfig = (botConfig.a_memorix ?? {}) as Record<string, unknown>
const memorixPlugin = (memorixConfig.plugin ?? {}) as Record<string, unknown>
const modelPayload = modelConfigResult.success
? (modelConfigResult.data as { config?: Record<string, unknown> } & Record<string, unknown>)
: {}
const modelConfig = (modelPayload.config ?? modelPayload) as Record<string, unknown>
const taskConfig = (modelConfig.model_task_config ?? {}) as Record<string, unknown>
const vlmTask = (taskConfig.vlm ?? {}) as Record<string, unknown>
const vlmModelList = Array.isArray(vlmTask.model_list) ? vlmTask.model_list : []
const hasVlmModel = vlmModelList.some((modelName) => String(modelName ?? '').trim().length > 0)
setFeatureStatus({
memoryEnabled: memorixPlugin.enabled === true,
visualEnabled: hasVlmModel,
})
} catch (error) {
console.error('获取功能启用状态失败:', error)
if (isMountedRef.current) {
setFeatureStatus({
memoryEnabled: false,
visualEnabled: false,
})
}
}
}, [])
const handleRestart = async () => {
await triggerRestart()
}
@@ -280,8 +341,9 @@ function IndexPageContent() {
fetchDashboardData()
fetchHitokoto()
fetchBotStatus()
fetchFeatureStatus()
fetchReviewStats()
}, [fetchDashboardData, fetchHitokoto, fetchBotStatus, fetchReviewStats])
}, [fetchDashboardData, fetchHitokoto, fetchBotStatus, fetchFeatureStatus, fetchReviewStats])
// 自动刷新
useEffect(() => {
@@ -297,6 +359,7 @@ function IndexPageContent() {
if (isMountedRef.current) {
fetchDashboardData()
fetchBotStatus()
fetchFeatureStatus()
}
}, 30000) // 30秒刷新一次
@@ -306,7 +369,7 @@ function IndexPageContent() {
refreshIntervalRef.current = null
}
}
}, [autoRefresh, fetchDashboardData, fetchBotStatus])
}, [autoRefresh, fetchDashboardData, fetchBotStatus, fetchFeatureStatus])
if (loading || !dashboardData) {
return (
@@ -485,33 +548,41 @@ function IndexPageContent() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{botStatus?.running ? (
<>
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
<Badge variant="outline" className="text-green-600 border-green-300 bg-green-50">
<CheckCircle2 className="h-3 w-3 mr-1" />
{t('home.botStatus.running')}
<div className="space-y-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{botStatus?.running ? (
<>
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
<Badge variant="outline" className="text-green-600 border-green-300 bg-green-50">
<CheckCircle2 className="h-3 w-3 mr-1" />
{t('home.botStatus.running')}
</Badge>
</>
) : (
<>
<div className="h-3 w-3 rounded-full bg-red-500" />
<Badge variant="outline" className="text-red-600 border-red-300 bg-red-50">
<AlertCircle className="h-3 w-3 mr-1" />
{t('home.botStatus.stopped')}
</Badge>
</>
)}
</div>
{botStatus && (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="border border-primary/20 bg-primary/10 px-2 py-0.5 font-semibold text-primary">
v{botStatus.version}
</Badge>
</>
) : (
<>
<div className="h-3 w-3 rounded-full bg-red-500" />
<Badge variant="outline" className="text-red-600 border-red-300 bg-red-50">
<AlertCircle className="h-3 w-3 mr-1" />
{t('home.botStatus.stopped')}
</Badge>
</>
<span className="mx-2">|</span>
<span>{t('home.botStatus.uptime', { time: formatTime(botStatus.uptime) })}</span>
</div>
)}
</div>
{botStatus && (
<div className="text-xs text-muted-foreground">
<span>v{botStatus.version}</span>
<span className="mx-2">|</span>
<span>{t('home.botStatus.uptime', { time: formatTime(botStatus.uptime) })}</span>
</div>
)}
<div className="flex flex-wrap gap-2">
<FeatureStatusLight enabled={featureStatus.visualEnabled} label="启用视觉" />
<FeatureStatusLight enabled={featureStatus.memoryEnabled} label="启用记忆" />
</div>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,251 @@
import { useCallback, useEffect, useState } from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
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 { Info, Power, Save } from 'lucide-react'
import { MCPRootItemsHook, MCPServersHook } from './config/bot/hooks'
type ConfigSectionData = Record<string, unknown>
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),
}
}
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],
['mcp.servers', MCPServersHook],
] 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,
},
}
: null
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 && 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>
)
}

View File

@@ -15,6 +15,7 @@ import {
CircleDot,
Clock,
Eraser,
ExternalLink,
Gauge,
MessageSquare,
PauseCircle,
@@ -36,6 +37,7 @@ import type {
CycleStartEvent,
MaisakaToolCall,
MessageIngestedEvent,
PlannerFinalizedEvent,
PlannerResponseEvent,
ReplierResponseEvent,
TimingGateResultEvent,
@@ -73,18 +75,28 @@ function SessionSidebar({
sessions,
selectedSession,
onSelect,
collapsed,
}: {
sessions: Map<string, SessionInfo>
selectedSession: string | null
onSelect: (id: string) => void
collapsed: boolean
}) {
const sortedSessions = Array.from(sessions.values()).sort(
(a, b) => b.lastActivity - a.lastActivity,
)
const getSessionInitial = (session: SessionInfo) => {
const name = session.sessionName.trim()
if (name) return name.slice(0, 1)
return session.isGroupChat ? '群' : '私'
}
if (sortedSessions.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2 p-4">
<div className={cn(
'flex flex-col items-center justify-center h-full text-muted-foreground gap-2',
collapsed ? 'p-2' : 'p-4',
)}>
<Bot className="h-8 w-8 opacity-40" />
<p className="text-sm text-center"> MaiSaka </p>
</div>
@@ -92,35 +104,42 @@ function SessionSidebar({
}
return (
<div className="flex flex-col gap-1 p-2">
<div className={cn('flex flex-col gap-1', collapsed ? 'items-center p-2' : 'p-2')}>
{sortedSessions.map((session) => (
<button
key={session.sessionId}
onClick={() => onSelect(session.sessionId)}
title={session.sessionName}
className={cn(
'flex flex-col items-start gap-0.5 rounded-lg px-3 py-2 text-left text-sm transition-colors',
'rounded-lg text-left text-sm transition-colors',
'hover:bg-accent/50',
collapsed
? 'flex h-10 w-10 items-center justify-center p-0'
: 'flex w-full flex-col items-start gap-0.5 px-2.5 py-2',
selectedSession === session.sessionId && 'bg-accent text-accent-foreground',
)}
>
<div className="flex w-full items-center justify-between">
<div className="flex min-w-0 items-center gap-1.5">
{session.isGroupChat !== undefined && (
<div className={cn('flex w-full items-center', collapsed ? 'justify-center' : 'justify-between gap-2')}>
<div className={cn('flex min-w-0 items-center gap-2', !collapsed && 'flex-1')}>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-xs font-semibold text-primary">
{getSessionInitial(session)}
</span>
{false && session.isGroupChat !== undefined && (
<Badge variant="outline" className="h-4 shrink-0 px-1 text-[10px]">
{session.isGroupChat ? '群' : '私'}
</Badge>
)}
<span className="truncate font-medium" title={session.sessionName}>
{!collapsed && <span className="min-w-0 flex-1 truncate font-medium" title={session.sessionName}>
{session.sessionName}
</span>
</span>}
</div>
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[10px]">
{!collapsed && <Badge variant="secondary" className="h-4 shrink-0 px-1 text-[10px]">
{session.eventCount}
</Badge>
</Badge>}
</div>
<span className="text-xs text-muted-foreground">
{!collapsed && <span className="text-xs text-muted-foreground">
{formatRelativeTime(session.lastActivity)}
</span>
</span>}
</button>
))}
</div>
@@ -183,7 +202,8 @@ function TimingGateCard({ data }: { data: TimingGateResultEvent }) {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-sm font-medium">Timing Gate</span>
<span className="text-sm font-medium"></span>
<Badge variant="outline" className="text-[10px]">react</Badge>
<Badge variant={config.variant} className="text-[10px] gap-0.5">
<Icon className="h-2.5 w-2.5" />
{config.label}
@@ -198,6 +218,29 @@ function TimingGateCard({ data }: { data: TimingGateResultEvent }) {
)
}
function ToolCallBadges({ toolCalls }: { toolCalls: MaisakaToolCall[] }) {
if (toolCalls.length <= 0) {
return null
}
return (
<div className="mt-2 flex flex-wrap gap-1.5">
{toolCalls.map((tc: MaisakaToolCall, idx: number) => (
<Badge key={`${tc.id || tc.name}-${idx}`} variant="secondary" className="text-[10px] gap-1">
<Wrench className="h-2.5 w-2.5" />
{tc.name}
</Badge>
))}
</div>
)
}
function openPromptHtml(uri: string) {
const normalized = uri.trim()
if (!normalized) return
window.open(normalized, '_blank', 'noopener,noreferrer')
}
function PlannerResponseCard({ data }: { data: PlannerResponseEvent }) {
return (
<div className="flex items-start gap-3">
@@ -215,21 +258,120 @@ function PlannerResponseCard({ data }: { data: PlannerResponseEvent }) {
{data.content && (
<CollapsibleText text={data.content} maxLines={6} />
)}
{data.tool_calls.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{data.tool_calls.map((tc: MaisakaToolCall, idx: number) => (
<Badge key={idx} variant="secondary" className="text-[10px] gap-1">
<Wrench className="h-2.5 w-2.5" />
{tc.name}
</Badge>
))}
</div>
)}
<ToolCallBadges toolCalls={data.tool_calls} />
</div>
</div>
)
}
function PlannerFinalizedCard({ data }: { data: PlannerFinalizedEvent }) {
const planner = data.planner
const promptHtmlUri = planner?.prompt_html_uri?.trim() ?? ''
return (
<Card className="border-l-4 border-l-emerald-500/60">
<CardHeader className="py-3 px-4 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<Brain className="h-4 w-4 text-emerald-500" />
<CardTitle className="text-sm font-medium"> planner</CardTitle>
{promptHtmlUri && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => openPromptHtml(promptHtmlUri)}
title="打开 planner HTML 记录"
>
<ExternalLink className="mr-1 h-3 w-3" />
HTML
</Button>
)}
<Badge variant="outline" className="text-xs font-normal ml-auto">
{formatMs(planner?.duration_ms ?? 0)}
</Badge>
{data.request && (
<Badge variant="secondary" className="text-[10px]">
{data.request.selected_history_count} / {data.request.tool_count}
</Badge>
)}
{planner && (planner.prompt_tokens > 0 || planner.completion_tokens > 0) && (
<Badge variant="outline" className="text-[10px]">
{planner.prompt_tokens}+{planner.completion_tokens} tokens
</Badge>
)}
</div>
{planner?.content ? (
<CollapsibleText text={planner.content} maxLines={6} className="text-foreground/90" />
) : (
<p className="text-sm text-muted-foreground">planner </p>
)}
</CardHeader>
</Card>
)
}
function PlannerToolCallsBlock({ data }: { data: PlannerFinalizedEvent }) {
const toolCalls = data.planner?.tool_calls ?? []
const tools = data.tools ?? []
const displayTools = tools.length > 0
? tools
: toolCalls.map((toolCall) => ({
tool_call_id: toolCall.id,
tool_name: toolCall.name,
tool_args: toolCall.arguments ?? {},
success: true,
duration_ms: 0,
summary: '',
}))
if (displayTools.length <= 0) {
return null
}
return (
<Card className="border-l-4 border-l-teal-500/60">
<CardHeader className="py-3 px-4 space-y-2">
<div className="flex items-center gap-2">
<Wrench className="h-4 w-4 text-teal-500" />
<CardTitle className="text-sm font-medium">Planner </CardTitle>
<Badge variant="secondary" className="ml-auto text-[10px]">
{displayTools.length}
</Badge>
</div>
<div className="space-y-2">
{displayTools.map((tool, idx) => (
<div
key={`${tool.tool_call_id || tool.tool_name}-${idx}`}
className="rounded-md border bg-muted/40 px-2.5 py-2 text-xs"
>
<div className="flex items-center gap-2">
<span className="font-mono font-medium">{tool.tool_name || 'unknown'}</span>
{tool.success
? <CheckCircle2 className="h-3.5 w-3.5 text-teal-500" />
: <XCircle className="h-3.5 w-3.5 text-red-500" />
}
{tool.duration_ms > 0 && (
<span className="text-muted-foreground">{formatMs(tool.duration_ms)}</span>
)}
</div>
{Object.keys(tool.tool_args ?? {}).length > 0 && (
<pre className="mt-1 whitespace-pre-wrap break-all rounded bg-background/70 px-2 py-1 text-[11px] text-muted-foreground">
{JSON.stringify(tool.tool_args, null, 2)}
</pre>
)}
{tool.summary && (
<p className="mt-1 text-muted-foreground whitespace-pre-wrap break-words">{tool.summary}</p>
)}
</div>
))}
</div>
</CardHeader>
</Card>
)
}
function ToolExecutionCard({ data }: { data: ToolExecutionEvent }) {
return (
<div className="flex items-start gap-3">
@@ -394,16 +536,30 @@ function ReplierResponseCard({ data }: { data: ReplierResponseEvent }) {
// ─── 时间线入口渲染器 ──────────────────────────────────────────
function TimelineEventRenderer({ entry }: { entry: TimelineEntry }) {
function TimelineEventRenderer({
entry,
showCycleMarkers,
}: {
entry: TimelineEntry
showCycleMarkers: boolean
}) {
switch (entry.type) {
case 'message.ingested':
return <MessageIngestedCard data={entry.data as MessageIngestedEvent} />
case 'cycle.start':
if (!showCycleMarkers) return null
return <CycleStartCard data={entry.data as CycleStartEvent} />
case 'timing_gate.result':
return <TimingGateCard data={entry.data as TimingGateResultEvent} />
case 'planner.response':
return <PlannerResponseCard data={entry.data as PlannerResponseEvent} />
case 'planner.finalized':
return (
<div className="space-y-2">
<PlannerFinalizedCard data={entry.data as PlannerFinalizedEvent} />
<PlannerToolCallsBlock data={entry.data as PlannerFinalizedEvent} />
</div>
)
case 'tool.execution':
return <ToolExecutionCard data={entry.data as ToolExecutionEvent} />
case 'cycle.end':
@@ -430,6 +586,22 @@ export function MaisakaMonitor() {
const scrollRef = useRef<HTMLDivElement>(null)
const [autoScroll, setAutoScroll] = useState(true)
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem('maisaka-monitor-sidebar-collapsed')
return saved !== 'false'
})
const [showCycleMarkers, setShowCycleMarkers] = useState(() => {
const saved = localStorage.getItem('maisaka-monitor-show-cycle-markers')
return saved === 'true'
})
useEffect(() => {
localStorage.setItem('maisaka-monitor-sidebar-collapsed', String(sidebarCollapsed))
}, [sidebarCollapsed])
useEffect(() => {
localStorage.setItem('maisaka-monitor-show-cycle-markers', String(showCycleMarkers))
}, [showCycleMarkers])
// 自动滚动到底部
useEffect(() => {
@@ -452,20 +624,43 @@ export function MaisakaMonitor() {
const stats = {
messages: timeline.filter((e) => e.type === 'message.ingested').length,
cycles: timeline.filter((e) => e.type === 'cycle.start').length,
toolCalls: timeline.filter((e) => e.type === 'tool.execution').length,
toolCalls: timeline.reduce((count, entry) => {
if (entry.type === 'tool.execution') {
return count + 1
}
if (entry.type === 'planner.finalized') {
return count + ((entry.data as PlannerFinalizedEvent).tools?.length ?? 0)
}
return count
}, 0),
}
return (
<div className="flex h-[calc(100vh-180px)] gap-4">
{/* 会话侧边栏 */}
<Card className="w-60 shrink-0 flex flex-col">
<CardHeader className="py-3 px-4 space-y-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Activity className="h-4 w-4" />
<Card className={cn(
'shrink-0 flex flex-col transition-[width] duration-200',
sidebarCollapsed ? 'w-16' : 'w-52',
)}>
<CardHeader className={cn('py-3 space-y-0', sidebarCollapsed ? 'px-2' : 'px-3')}>
<CardTitle className={cn(
'text-sm font-medium flex items-center gap-2',
sidebarCollapsed && 'justify-center text-[0px]',
)}>
{!sidebarCollapsed && <Activity className="h-4 w-4" />}
{connected && (
<span className="ml-auto flex h-2 w-2 rounded-full bg-emerald-500" />
<span className={cn('flex h-2 w-2 rounded-full bg-emerald-500', !sidebarCollapsed && 'ml-auto')} />
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => setSidebarCollapsed((value) => !value)}
title={sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'}
>
{sidebarCollapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</Button>
</CardTitle>
</CardHeader>
<Separator />
@@ -474,6 +669,7 @@ export function MaisakaMonitor() {
sessions={sessions}
selectedSession={selectedSession}
onSelect={setSelectedSession}
collapsed={sidebarCollapsed}
/>
</ScrollArea>
</Card>
@@ -497,6 +693,16 @@ export function MaisakaMonitor() {
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
variant={showCycleMarkers ? 'secondary' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setShowCycleMarkers((value) => !value)}
title={showCycleMarkers ? '隐藏推理循环标记' : '显示推理循环标记'}
>
<CircleDot className={cn('h-3.5 w-3.5 mr-1', showCycleMarkers && 'text-primary')} />
</Button>
<Button
variant="ghost"
size="sm"
@@ -536,7 +742,7 @@ export function MaisakaMonitor() {
</div>
) : (
timeline.map((entry) => {
const rendered = <TimelineEventRenderer entry={entry} />
const rendered = <TimelineEventRenderer entry={entry} showCycleMarkers={showCycleMarkers} />
if (!rendered) return null
return (
<div

View File

@@ -851,8 +851,25 @@ function PluginConfigPageContent() {
)
// 统计数据
const enabledCount = plugins.length // 暂时假设都启用
const disabledCount = 0
const isPluginDisabled = (plugin: InstalledPlugin) => plugin.disabled === true || plugin.enabled === false
const isPluginLoadSuccess = (plugin: InstalledPlugin) => !isPluginDisabled(plugin) && (
plugin.load_status === 'success' || plugin.loaded === true
)
const isPluginLoadFailed = (plugin: InstalledPlugin) => !isPluginDisabled(plugin) && !isPluginLoadSuccess(plugin)
const installedCount = plugins.length
const disabledCount = plugins.filter(isPluginDisabled).length
const enabledCount = installedCount - disabledCount
const loadSuccessCount = plugins.filter(isPluginLoadSuccess).length
const loadFailedCount = plugins.filter(isPluginLoadFailed).length
const getPluginStatusMeta = (plugin: InstalledPlugin) => {
if (isPluginDisabled(plugin)) {
return { dotClassName: 'bg-muted-foreground/45', label: '已禁用' }
}
if (isPluginLoadSuccess(plugin)) {
return { dotClassName: 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.16)]', label: '加载成功' }
}
return { dotClassName: 'bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.16)]', label: '加载失败' }
}
// 如果选中了插件,显示配置编辑器
if (selectedPlugin) {
@@ -888,43 +905,29 @@ function PluginConfigPageContent() {
</Button>
</div>
{/* 统计卡片 */}
<div className="grid gap-4 grid-cols-1 xs:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{plugins.length}</div>
<p className="text-xs text-muted-foreground mt-1">
{loading ? '正在加载...' : '个插件'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{enabledCount}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<AlertCircle className="h-4 w-4 text-orange-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{disabledCount}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
</div>
{/* 统计信息 */}
<Card>
<CardContent className="space-y-3 p-4">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<span className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" />
<strong>{installedCount}</strong>
</span>
<span> <strong className="text-emerald-600">{enabledCount}</strong> </span>
<span> <strong className="text-muted-foreground">{disabledCount}</strong> </span>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-t pt-3 text-sm">
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<strong className="text-emerald-600">{loadSuccessCount}</strong>
</span>
<span className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600" />
<strong className="text-red-600">{loadFailedCount}</strong>
</span>
</div>
</CardContent>
</Card>
{/* 搜索框 */}
<div className="relative">
@@ -962,16 +965,23 @@ function PluginConfigPageContent() {
</div>
) : (
<div className="space-y-2">
{uniqueFilteredPlugins.map(plugin => (
{uniqueFilteredPlugins.map(plugin => {
const statusMeta = getPluginStatusMeta(plugin)
return (
<div
key={plugin.id}
className="flex items-center justify-between p-4 rounded-lg border hover:bg-muted/50 cursor-pointer transition-colors"
className={`flex items-center justify-between p-4 rounded-lg border hover:bg-muted/50 cursor-pointer transition-colors ${isPluginDisabled(plugin) ? 'opacity-70' : ''}`}
role="button"
tabIndex={0}
onClick={() => setSelectedPlugin(plugin)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedPlugin(plugin) } }}
>
<div className="flex items-center gap-3 min-w-0">
<span
className={`h-2.5 w-2.5 rounded-full flex-shrink-0 ${statusMeta.dotClassName}`}
title={statusMeta.label}
aria-label={statusMeta.label}
/>
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Package className="h-5 w-5 text-primary" />
</div>
@@ -996,7 +1006,8 @@ function PluginConfigPageContent() {
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</div>
))}
)
})}
</div>
)}
</CardContent>

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import {
Database,
@@ -7,7 +6,6 @@ import {
Loader2,
RefreshCw,
RotateCcw,
Save,
SlidersHorizontal,
Sparkles,
Upload,
@@ -19,7 +17,6 @@ import {
import { CodeEditor } from '@/components/CodeEditor'
import { MemoryDeleteDialog } from '@/components/memory/MemoryDeleteDialog'
import { MemoryConfigEditor } from '@/components/memory/MemoryConfigEditor'
import { MemoryMiniTabs } from '@/components/memory/MemoryMiniTabs'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
@@ -56,9 +53,6 @@ import {
createMemoryPasteImport,
createMemoryTuningTask,
createMemoryUploadImport,
getMemoryConfig,
getMemoryConfigRaw,
getMemoryConfigSchema,
getMemoryDeleteOperation,
getMemoryDeleteOperations,
getMemoryImportTasks,
@@ -78,9 +72,6 @@ import {
resolveMemoryImportPath,
retryMemoryImportTask,
restoreMemoryDelete,
updateMemoryConfig,
updateMemoryConfigRaw,
type MemoryConfigSchemaPayload,
type MemoryDeleteExecutePayload,
type MemoryDeleteOperationPayload,
type MemoryFeedbackActionLogPayload,
@@ -113,25 +104,18 @@ import { DeleteTab } from './knowledge-base/tabs/DeleteTab'
import { FeedbackTab } from './knowledge-base/tabs/FeedbackTab'
import { ImportTab } from './knowledge-base/tabs/ImportTab'
import { TuningTab } from './knowledge-base/tabs/TuningTab'
import { KnowledgeGraphPage } from './knowledge-graph'
export function KnowledgeBasePage() {
const navigate = useNavigate()
const { toast } = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [refreshingCheck, setRefreshingCheck] = useState(false)
const [creatingImport, setCreatingImport] = useState(false)
const [creatingTuning, setCreatingTuning] = useState(false)
const [rawMode, setRawMode] = useState(false)
const [activeTab, setActiveTab] = useState<
'overview' | 'config' | 'import' | 'tuning' | 'delete' | 'feedback'
'overview' | 'graph' | 'import' | 'tuning' | 'delete' | 'feedback'
>('overview')
const [schemaPayload, setSchemaPayload] = useState<MemoryConfigSchemaPayload | null>(null)
const [visualConfig, setVisualConfig] = useState<Record<string, unknown>>({})
const [rawConfig, setRawConfig] = useState('')
const [rawConfigExists, setRawConfigExists] = useState(true)
const [rawConfigUsingDefault, setRawConfigUsingDefault] = useState(false)
const [runtimeConfig, setRuntimeConfig] = useState<MemoryRuntimeConfigPayload | null>(null)
const [selfCheckReport, setSelfCheckReport] = useState<Record<string, unknown> | null>(null)
const [importSettings, setImportSettings] = useState<MemoryImportSettings>({})
@@ -259,9 +243,6 @@ export function KnowledgeBasePage() {
try {
setLoading(true)
const [
schema,
configPayload,
rawPayload,
runtimePayload,
importSettingsPayload,
pathAliasPayload,
@@ -272,9 +253,6 @@ export function KnowledgeBasePage() {
deleteOperationPayload,
feedbackCorrectionPayload,
] = await Promise.all([
getMemoryConfigSchema(),
getMemoryConfig(),
getMemoryConfigRaw(),
getMemoryRuntimeConfig(),
getMemoryImportSettings(),
getMemoryImportPathAliases(),
@@ -286,11 +264,6 @@ export function KnowledgeBasePage() {
getMemoryFeedbackCorrections({ limit: FEEDBACK_CORRECTION_FETCH_LIMIT }),
])
setSchemaPayload(schema)
setVisualConfig(configPayload.config ?? {})
setRawConfig(rawPayload.config ?? '')
setRawConfigExists(rawPayload.exists ?? true)
setRawConfigUsingDefault(rawPayload.using_default ?? false)
setRuntimeConfig(runtimePayload)
setImportSettings(importSettingsPayload.settings ?? {})
setImportPathAliases(pathAliasPayload.path_aliases ?? {})
@@ -331,9 +304,6 @@ export function KnowledgeBasePage() {
void loadPage()
}, [loadPage])
const configPath = schemaPayload?.path ?? 'config/a_memorix.toml'
const schema = schemaPayload?.schema
const runtimeBadges = useMemo(() => {
if (!runtimeConfig) {
return []
@@ -1613,58 +1583,6 @@ export function KnowledgeBasePage() {
}
}, [selectedOperationItemPage, selectedOperationItemPageCount])
const saveVisualConfig = useCallback(async () => {
try {
setSaving(true)
await updateMemoryConfig(visualConfig)
const [nextConfig, nextRaw, nextRuntime] = await Promise.all([
getMemoryConfig(),
getMemoryConfigRaw(),
getMemoryRuntimeConfig(),
])
setVisualConfig(nextConfig.config)
setRawConfig(nextRaw.config)
setRawConfigExists(nextRaw.exists ?? true)
setRawConfigUsingDefault(nextRaw.using_default ?? false)
setRuntimeConfig(nextRuntime)
toast({ title: '配置已保存', description: '长期记忆配置已经应用到运行时' })
} catch (error) {
toast({
title: '保存配置失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setSaving(false)
}
}, [toast, visualConfig])
const saveRaw = useCallback(async () => {
try {
setSaving(true)
await updateMemoryConfigRaw(rawConfig)
const [nextConfig, nextRaw, nextRuntime] = await Promise.all([
getMemoryConfig(),
getMemoryConfigRaw(),
getMemoryRuntimeConfig(),
])
setVisualConfig(nextConfig.config)
setRawConfig(nextRaw.config ?? '')
setRawConfigExists(nextRaw.exists ?? true)
setRawConfigUsingDefault(nextRaw.using_default ?? false)
setRuntimeConfig(nextRuntime)
toast({ title: '原始 TOML 已保存', description: '长期记忆配置已经重新加载' })
} catch (error) {
toast({
title: '保存原始配置失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setSaving(false)
}
}, [rawConfig, toast])
const refreshSelfCheck = useCallback(async () => {
try {
setRefreshingCheck(true)
@@ -1794,12 +1712,12 @@ export function KnowledgeBasePage() {
</div>
<h1 className="mt-1 text-2xl font-bold leading-tight"></h1>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/resource/knowledge-graph' })}>
<div className="hidden">
<Button variant="outline" size="sm" onClick={() => setActiveTab('graph')}>
<Database className="mr-2 h-4 w-4" />
</Button>
@@ -1813,14 +1731,29 @@ export function KnowledgeBasePage() {
<div className="flex-1 overflow-auto">
<div className="mx-auto flex w-full max-w-[1800px] flex-col gap-6 px-6 py-6">
<div className="hidden">
<Button variant="outline" size="sm" onClick={() => void loadPage()}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 运行时状态条 —— 紧凑、常驻、一眼看完 */}
{runtimeBadges.length > 0 ? (
<div className="rounded-2xl border border-border/60 bg-card/60 p-4 shadow-sm backdrop-blur">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<div className="mb-3 flex items-center gap-2">
<div className="mr-auto flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<Gauge className="h-3.5 w-3.5" />
</div>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => void loadPage()}
>
<RefreshCw className="mr-1.5 h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -1904,7 +1837,7 @@ export function KnowledgeBasePage() {
</button>
<button
type="button"
onClick={() => navigate({ to: '/resource/knowledge-graph' })}
onClick={() => setActiveTab('graph')}
className="group flex items-start gap-3 rounded-xl border border-border/70 bg-background/80 p-3.5 text-left transition hover:border-primary/50 hover:bg-background hover:shadow-md"
>
<div className="flex-none rounded-lg bg-violet-500/10 p-2 text-violet-500 transition-transform group-hover:scale-105">
@@ -1929,8 +1862,8 @@ export function KnowledgeBasePage() {
<div className="sticky top-0 z-10 -mx-6 border-b border-border/40 bg-background/85 px-6 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/70">
<MemoryMiniTabs
items={[
{ value: 'overview', label: '概览', description: '运行状态与配置摘要' },
{ value: 'config', label: '配置', description: '可视化或 TOML 编辑配置' },
{ value: 'overview', label: '概览', description: '运行状态与运行时摘要' },
{ value: 'graph', label: '图谱', description: '实体关系图与证据视图' },
{ value: 'import', label: '导入', description: '创建并管理导入任务' },
{ value: 'tuning', label: '调优', description: '检索策略调优' },
{ value: 'delete', label: '删除', description: '批量删除与历史回溯' },
@@ -1940,6 +1873,10 @@ export function KnowledgeBasePage() {
/>
</div>
<TabsContent value="graph" className="h-[calc(100vh-220px)] min-h-[720px] overflow-hidden rounded-2xl border border-border/60 bg-background shadow-sm">
<KnowledgeGraphPage embedded onOpenConsole={() => setActiveTab('overview')} />
</TabsContent>
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<Card>
@@ -1959,7 +1896,7 @@ export function KnowledgeBasePage() {
<CardContent className="space-y-3">
<Alert>
<AlertDescription>
<code>{configPath}</code>
/
</AlertDescription>
</Alert>
<CodeEditor
@@ -2001,72 +1938,6 @@ export function KnowledgeBasePage() {
</div>
</TabsContent>
<TabsContent value="config" className="space-y-4">
<Card>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4" />
</CardTitle>
<CardDescription>
TOML
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button variant={rawMode ? 'outline' : 'default'} onClick={() => setRawMode(false)}>
</Button>
<Button variant={rawMode ? 'default' : 'outline'} onClick={() => setRawMode(true)}>
TOML
</Button>
<Button onClick={() => void (rawMode ? saveRaw() : saveVisualConfig())} disabled={saving}>
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertDescription>
<code>{configPath}</code>
{schema?._note ? `${schema._note}` : ''}
</AlertDescription>
</Alert>
{!rawConfigExists || rawConfigUsingDefault ? (
<Alert>
<AlertDescription>
{' '}
<code>{configPath}</code>
</AlertDescription>
</Alert>
) : null}
{rawMode ? (
<CodeEditor
value={rawConfig}
onChange={setRawConfig}
language="toml"
height="620px"
/>
) : schema ? (
<MemoryConfigEditor
schema={schema}
config={visualConfig}
onChange={setVisualConfig}
disabled={saving}
/>
) : (
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">
schema
</div>
)}
</CardContent>
</Card>
</TabsContent>
<ImportTab
importCreateMode={importCreateMode}
setImportCreateMode={setImportCreateMode}

View File

@@ -31,6 +31,7 @@ import type {
import { IMPORT_CHUNK_PAGE_SIZE, IMPORT_KIND_OPTIONS, RUNNING_IMPORT_STATUS } from '../constants'
import {
formatImportTime,
formatProgressPercent,
getImportStatusLabel,
getImportStatusVariant,
getImportStepLabel,
@@ -871,7 +872,7 @@ export function ImportTab(props: ImportTabProps) {
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{getImportStepLabel(String(task.current_step ?? 'running'))}</span>
<span>{Number(task.progress ?? 0).toFixed(1)}%</span>
<span>{formatProgressPercent(task.progress)}</span>
</div>
<Progress value={normalizeProgress(task.progress)} className="mt-2 h-1.5" />
</button>
@@ -966,7 +967,7 @@ export function ImportTab(props: ImportTabProps) {
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span></span>
<span>{Number(task.progress ?? 0).toFixed(1)}%</span>
<span>{formatProgressPercent(task.progress)}</span>
</div>
<Progress value={normalizeProgress(task.progress)} className="mt-2 h-1.5" />
</button>
@@ -1155,11 +1156,11 @@ export function ImportTab(props: ImportTabProps) {
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{getImportStepLabel(String(file.current_step ?? ''))}</span>
<span>{Number(file.progress ?? 0).toFixed(1)}%</span>
<span>{formatProgressPercent(file.progress)}</span>
</div>
<Progress value={normalizeProgress(file.progress)} className="mt-2 h-1.5" />
<div className="mt-2 text-xs text-muted-foreground">
{Number(file.progress ?? 0).toFixed(1)}% · {Number(file.done_chunks ?? 0)} / {Number(file.total_chunks ?? 0)}
{formatProgressPercent(file.progress)} · {Number(file.done_chunks ?? 0)} / {Number(file.total_chunks ?? 0)}
</div>
{file.error ? (
<div className="mt-2 truncate text-xs text-destructive">{file.error}</div>
@@ -1230,7 +1231,7 @@ export function ImportTab(props: ImportTabProps) {
<TableCell>{chunk.index}</TableCell>
<TableCell>{getImportStatusLabel(String(chunk.status ?? ''))}</TableCell>
<TableCell>{getImportStepLabel(String(chunk.step ?? ''))}</TableCell>
<TableCell>{Number(chunk.progress ?? 0).toFixed(1)}%</TableCell>
<TableCell>{formatProgressPercent(chunk.progress)}</TableCell>
<TableCell className="max-w-[360px]">
<div className="space-y-2">
{String(chunk.error ?? '').trim() ? (

View File

@@ -20,13 +20,18 @@ export function normalizeProgress(value: number | string | null | undefined): nu
if (!Number.isFinite(numeric)) {
return 0
}
if (numeric < 0) {
const percent = numeric > 0 && numeric <= 1 ? numeric * 100 : numeric
if (percent < 0) {
return 0
}
if (numeric > 100) {
if (percent > 100) {
return 100
}
return numeric
return percent
}
export function formatProgressPercent(value: number | string | null | undefined): string {
return `${normalizeProgress(value).toFixed(1)}%`
}
export function parseOptionalPositiveInt(input: string): number | undefined {

View File

@@ -206,7 +206,12 @@ function buildParagraphFromMetadata(
}
}
export function KnowledgeGraphPage() {
interface KnowledgeGraphPageProps {
embedded?: boolean
onOpenConsole?: () => void
}
export function KnowledgeGraphPage({ embedded = false, onOpenConsole }: KnowledgeGraphPageProps = {}) {
const navigate = useNavigate()
const { toast } = useToast()
const [loading, setLoading] = useState(false)
@@ -731,17 +736,26 @@ export function KnowledgeGraphPage() {
const activeGraph = viewMode === 'entity' ? graphData : evidenceGraph
const canShowEvidence = Boolean(selectedNodeData || selectedEdgeData || nodeDetail || edgeDetail)
const openConsole = useCallback(() => {
if (onOpenConsole) {
onOpenConsole()
return
}
void navigate({ to: '/resource/knowledge-base' })
}, [navigate, onOpenConsole])
return (
<div className="flex h-full flex-col">
<div className="flex-none border-b bg-card/60 px-6 py-4 backdrop-blur">
<div className={embedded ? 'flex-none border-b bg-card/60 px-4 py-4 backdrop-blur' : 'flex-none border-b bg-card/60 px-6 py-4 backdrop-blur'}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
A_Memorix
</p>
</div>
{!embedded && (
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
A_Memorix
</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="gap-1">
@@ -791,7 +805,7 @@ export function KnowledgeGraphPage() {
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button variant="outline" onClick={() => navigate({ to: '/resource/knowledge-base' })}>
<Button variant="outline" onClick={openConsole} className={embedded ? 'hidden' : undefined}>
<SlidersHorizontal className="mr-2 h-4 w-4" />
</Button>
@@ -873,7 +887,7 @@ export function KnowledgeGraphPage() {
<p className="mt-2 text-sm text-muted-foreground">
</p>
<Button className="mt-4" onClick={() => navigate({ to: '/resource/knowledge-base' })}>
<Button className="mt-4" onClick={openConsole}>
</Button>
</>

View File

@@ -38,6 +38,8 @@ export interface FieldSchema {
properties?: ConfigSchema
'x-widget'?: XWidgetType
'x-icon'?: string
'x-layout'?: 'inline-right'
'x-input-width'?: string
advanced?: boolean
step?: number
}