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
}

View File

@@ -1,33 +1,22 @@
from typing import Any
import pytest
from src.config import config as config_module
from src.config.config import Config, ConfigManager, ModelConfig
class _StartupUpgradeExit(Exception):
pass
def test_initialize_upgrades_bot_and_model_config_before_exit(monkeypatch):
def test_initialize_upgrades_bot_and_model_config_without_exit(monkeypatch):
manager = ConfigManager()
loaded_config_classes: list[type[Any]] = []
exit_codes: list[int | None] = []
warnings: list[Any] = []
def fake_load_config_from_file(config_class, config_path, new_ver, override_repr=False):
loaded_config_classes.append(config_class)
return object(), True
def fake_exit(code: int | None = None):
exit_codes.append(code)
raise _StartupUpgradeExit
monkeypatch.setattr(config_module, "load_config_from_file", fake_load_config_from_file)
monkeypatch.setattr(config_module.sys, "exit", fake_exit)
monkeypatch.setattr(ConfigManager, "_warn_if_vlm_not_configured", lambda self, model_config: warnings.append(model_config))
with pytest.raises(_StartupUpgradeExit):
manager.initialize()
manager.initialize()
assert loaded_config_classes == [Config, ModelConfig]
assert exit_codes == [0]
assert warnings == [manager.model_config]

View File

@@ -9,13 +9,16 @@ httpx
jieba>=0.42.1
json-repair>=0.47.6
maim-message>=0.6.2
maibot-dashboard>=1.0.2.dev2026050359
maibot-plugin-sdk>=2.4.0
matplotlib>=3.10.5
mcp
msgpack>=1.1.2
numpy>=2.2.6
openai>=1.95.0
pandas>=2.3.1
pillow>=11.3.0
playwright>=1.54.0
pyarrow>=20.0.0
pydantic>=2.11.7
pypinyin>=0.54.0

View File

@@ -13,6 +13,7 @@ from src.common.logger import get_logger
from src.common.database.database import get_db_session
from src.common.database.database_model import Images, ImageType
from src.common.data_models.image_data_model import MaiImage
from src.config.config import config_manager
from src.prompt.prompt_manager import prompt_manager
from src.services.llm_service import LLMServiceClient
@@ -30,6 +31,17 @@ def _ensure_image_dir_exists() -> None:
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
def _is_vlm_task_configured() -> bool:
"""判断是否配置了可用于图片识别的视觉模型任务。"""
try:
vlm_models = config_manager.get_model_config().model_task_config.vlm.model_list
return any(str(model_name).strip() for model_name in vlm_models)
except Exception as exc:
logger.warning(f"读取 VLM 模型配置失败,跳过图片识别: {exc}")
return False
vlm = LLMServiceClient(task_name="vlm", request_type="image")
@@ -111,6 +123,9 @@ class ImageManager:
except Exception as e:
logger.error(f"保存图片文件时发生错误: {e}")
return ""
if not _is_vlm_task_configured():
logger.info("未配置 VLM 模型,跳过图片识别")
return ""
if not wait_for_build:
self._schedule_description_build(hash_str, image_bytes)
return ""
@@ -129,6 +144,10 @@ class ImageManager:
image_hash: 图片哈希值。
image_bytes: 图片字节数据。
"""
if not _is_vlm_task_configured():
logger.info("未配置 VLM 模型,跳过图片后台识别任务")
return
if image_hash in self._pending_description_tasks:
return
@@ -303,6 +322,9 @@ class ImageManager:
await mai_image.calculate_hash_format()
if mai_image.vlm_processed and mai_image.description:
return mai_image
if not _is_vlm_task_configured():
logger.info(f"未配置 VLM 模型,跳过图片识别: {mai_image.file_hash}")
return mai_image
desc = await self._generate_image_description(image_bytes, mai_image.image_format)
mai_image.description = desc

View File

@@ -245,7 +245,7 @@ class SessionMessage(MaiMessage):
except Exception:
desc = None # 失败置空
content = f"[图片:{desc}]" if desc else "[图片]"
content = f"[图片:{desc}]" if desc else ""
component.content = content
component.binary_data = b"" # 处理完就丢掉二进制数据,节省内存
return content

View File

@@ -174,7 +174,7 @@ class BaseMaisakaReplyGenerator:
continue
if isinstance(component, ImageComponent):
rendered_parts.append(component.content.strip() or "[图片]")
rendered_parts.append(component.content.strip() or "[图片,识别中.....]")
continue
if isinstance(component, EmojiComponent):

View File

@@ -348,7 +348,7 @@ class MessageSequence:
if isinstance(item, TextComponent):
return {"type": "text", "data": item.text}
elif isinstance(item, ImageComponent):
return {"type": "image", "data": self._ensure_binary_component_content(item, "[图片]"), "hash": item.binary_hash}
return {"type": "image", "data": item.content.strip(), "hash": item.binary_hash}
elif isinstance(item, EmojiComponent):
return {"type": "emoji", "data": self._ensure_binary_component_content(item, "[表情包]"), "hash": item.binary_hash}
elif isinstance(item, VoiceComponent):
@@ -387,10 +387,8 @@ class MessageSequence:
"""确保二进制组件在序列化时带有稳定的文本占位。"""
normalized_content = item.content.strip()
if normalized_content:
item.content = normalized_content
return item.content
item.content = fallback_text
return item.content
return normalized_content
return fallback_text
@classmethod
def _dict_2_item(cls, item: Dict[str, Any]) -> StandardMessageComponents:

View File

@@ -5,7 +5,6 @@ from typing import Any, Callable, Mapping, Sequence, TypeVar, cast
import asyncio
import copy
import inspect
import sys
import tomlkit
@@ -57,8 +56,8 @@ BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute()
MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute()
LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0"
CONFIG_VERSION: str = "8.10.1"
MMC_VERSION: str = "1.0.0-pre.10"
CONFIG_VERSION: str = "8.10.6"
MODEL_CONFIG_VERSION: str = "1.14.8"
logger = get_logger("config")
@@ -250,7 +249,7 @@ class ConfigManager:
True,
)
if global_updated or model_updated:
sys.exit(0) # 配置已自动升级,退出一次让用户确认新配置后再启动
logger.info("配置已自动升级,将继续使用更新后的配置启动")
self._warn_if_vlm_not_configured(self.model_config)
logger.info(t("config.loaded"))
@@ -263,13 +262,13 @@ class ConfigManager:
def load_global_config(self) -> Config:
config, updated = load_config_from_file(Config, self.bot_config_path, CONFIG_VERSION)
if updated:
sys.exit(0) # 先直接退出
logger.info("bot_config.toml 已自动升级,将继续使用更新后的配置")
return config
def load_model_config(self) -> ModelConfig:
config, updated = load_config_from_file(ModelConfig, self.model_config_path, MODEL_CONFIG_VERSION, True)
if updated:
sys.exit(0) # 先直接退出
logger.info("model_config.toml 已自动升级,将继续使用更新后的配置")
return config
def get_global_config(self) -> Config:

View File

@@ -32,13 +32,6 @@ DEFAULT_TASK_CONFIG_TEMPLATES: dict[str, dict[str, Any]] = {
"slow_threshold": 120.0,
"selection_strategy": "random",
},
"learner": {
"model_list": [],
"max_tokens": 4096,
"temperature": 0.5,
"slow_threshold": 15.0,
"selection_strategy": "random",
},
"planner": {
"model_list": ["deepseek-v4-flash"],
"max_tokens": 8000,
@@ -46,13 +39,6 @@ DEFAULT_TASK_CONFIG_TEMPLATES: dict[str, dict[str, Any]] = {
"slow_threshold": 12.0,
"selection_strategy": "random",
},
"voice": {
"model_list": [""],
"max_tokens": 1024,
"temperature": 0.3,
"slow_threshold": 12.0,
"selection_strategy": "random",
},
}
DEFAULT_MODEL_TEMPLATES: list[dict[str, Any]] = [

View File

@@ -27,6 +27,8 @@ class BotConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "wifi",
"x-layout": "inline-right",
"x-input-width": "12rem",
},
)
"""平台"""
@@ -36,6 +38,8 @@ class BotConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "user",
"x-layout": "inline-right",
"x-input-width": "12rem",
},
)
"""QQ账号"""
@@ -211,6 +215,7 @@ class ChatConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "at-sign",
"advanced": True,
},
)
"""是否允许 replyer 使用 at[msg_id] 标记来发送真正的 at 消息"""
@@ -220,6 +225,7 @@ class ChatConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "quote",
"advanced": True,
},
)
"""是否启用回复时附带引用回复"""
@@ -243,11 +249,12 @@ class ChatConfig(ConfigBase):
"""私聊上下文长度"""
planner_interrupt_max_consecutive_count: int = Field(
default=2,
default=0,
ge=0,
json_schema_extra={
"x-widget": "input",
"x-icon": "pause-circle",
"advanced": True,
},
)
"""Planner 连续被新消息打断的最大次数0 表示不启用打断"""
@@ -453,6 +460,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "messages-square",
"advanced": True,
},
)
"""自动写回聊天摘要的消息窗口阈值"""
@@ -464,6 +472,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "rows-3",
"advanced": True,
},
)
"""自动写回聊天摘要时,从聊天流中回看的消息条数"""
@@ -1127,19 +1136,21 @@ class ExpressionConfig(ConfigBase):
"""是否启用自动表达优化"""
expression_auto_check_interval: int = Field(
default=600,
default=900,
json_schema_extra={
"x-widget": "input",
"x-icon": "clock",
"advanced": True,
},
)
"""表达方式自动检查的间隔时间(秒)"""
expression_auto_check_count: int = Field(
default=20,
default=5,
json_schema_extra={
"x-widget": "input",
"x-icon": "hash",
"advanced": True,
},
)
"""每次自动检查时随机选取的表达方式数量"""
@@ -1149,6 +1160,7 @@ class ExpressionConfig(ConfigBase):
json_schema_extra={
"x-widget": "custom",
"x-icon": "file-text",
"advanced": True,
},
)
"""表达方式自动检查的额外自定义评估标准"""
@@ -1190,6 +1202,7 @@ class EmojiConfig(ConfigBase):
json_schema_extra={
"x-widget": "input",
"x-icon": "grid",
"advanced": True,
},
)
"""一次从多少个表情包中选择发送,最大为 64"""
@@ -1208,6 +1221,7 @@ class EmojiConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "refresh-cw",
"advanced": True,
},
)
"""达到最大注册数量时替换旧表情包,关闭则达到最大数量时不会继续收集表情包"""
@@ -1445,6 +1459,7 @@ class ResponseSplitterConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "smile",
"advanced": True,
},
)
"""是否启用颜文字保护"""
@@ -1454,6 +1469,7 @@ class ResponseSplitterConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "maximize",
"advanced": True,
},
)
"""是否在句子数量超出回复允许的最大句子数时一次性返回全部内容"""
@@ -1462,7 +1478,7 @@ class ResponseSplitterConfig(ConfigBase):
class LogConfig(ConfigBase):
"""日志配置类"""
__ui_label__ = "日志"
__ui_label__ = "调试与日志"
__ui_icon__ = "file-text"
date_style: str = Field(
@@ -1590,6 +1606,7 @@ class LogConfig(ConfigBase):
json_schema_extra={
"x-widget": "custom",
"x-icon": "volume-x",
"advanced": True,
},
)
"""完全屏蔽日志的第三方库列表"""
@@ -1599,6 +1616,7 @@ class LogConfig(ConfigBase):
json_schema_extra={
"x-widget": "custom",
"x-icon": "sliders-horizontal",
"advanced": True,
},
)
"""特定第三方库的日志级别"""
@@ -1622,6 +1640,7 @@ class TelemetryConfig(ConfigBase):
class DebugConfig(ConfigBase):
"""调试配置类"""
__ui_parent__ = "log"
__ui_label__ = "其他"
__ui_icon__ = "more-horizontal"
@@ -2116,6 +2135,7 @@ class DatabaseConfig(ConfigBase):
json_schema_extra={
"x-widget": "switch",
"x-icon": "save",
"advanced": True,
},
)
"""

View File

@@ -215,6 +215,17 @@ def _is_available_emoji_record(record: Images) -> bool:
return record_path.exists() and record_path.is_file()
def _is_vlm_task_configured() -> bool:
"""判断是否配置了可用于表情包识别和审核的视觉模型任务。"""
try:
vlm_models = config_manager.get_model_config().model_task_config.vlm.model_list
return any(str(model_name).strip() for model_name in vlm_models)
except Exception as exc:
logger.warning(f"读取 VLM 模型配置失败,跳过表情包识别和审核: {exc}")
return False
# TODO: 修改这个vlm为获取的vlm client暂时使用这个VLM方法
emoji_manager_vlm = LLMServiceClient(task_name="vlm", request_type="emoji.see")
emoji_manager_emotion_judge_llm = LLMServiceClient(
@@ -316,6 +327,10 @@ class EmojiManager:
# 如果提供了字节数据但数据库中没有找到,尝试构建
if not emoji_bytes:
return None
if not _is_vlm_task_configured():
await self.ensure_emoji_saved(emoji_bytes, emoji_hash=emoji_hash)
logger.info("未配置 VLM 模型,跳过表情包识别、打标签和审核")
return None
if not wait_for_build:
await self.ensure_emoji_saved(emoji_bytes, emoji_hash=emoji_hash)
self._schedule_description_build(emoji_hash, emoji_bytes)
@@ -386,6 +401,10 @@ class EmojiManager:
emoji_hash: 表情包哈希值。
emoji_bytes: 表情包字节数据。
"""
if not _is_vlm_task_configured():
logger.info("未配置 VLM 模型,跳过表情包后台识别任务")
return
if emoji_hash in self._pending_description_tasks:
return
@@ -826,6 +845,12 @@ class EmojiManager:
Returns:
return (Tuple[bool, MaiEmoji]): 返回是否成功构建描述,及表情包对象
"""
if not _is_vlm_task_configured():
logger.info(
f"[构建描述] 未配置 VLM 模型,跳过表情包识别、打标签和审核: {target_emoji.file_name}"
)
return False, target_emoji
if not target_emoji.file_hash or not target_emoji.image_format:
# Should not happen, but just in case
await target_emoji.calculate_hash_format()

View File

@@ -91,7 +91,7 @@ def _should_refresh_image_component(component: ImageComponent) -> bool:
"""判断图片组件当前是否仍处于待补全文本的占位状态。"""
normalized_content = component.content.strip()
return not normalized_content or normalized_content == "[图片]"
return not normalized_content or normalized_content == "[图片,识别中.....]"
def _should_refresh_emoji_component(component: EmojiComponent) -> bool:

View File

@@ -63,6 +63,7 @@ class ChatResponse:
completion_tokens: int
total_tokens: int
prompt_section: Optional[RenderableType] = None
prompt_html_uri: Optional[str] = None
logger = get_logger("maisaka_chat_loop")
@@ -585,8 +586,9 @@ class MaisakaChatLoopService:
all_tools = [item for item in raw_tool_definitions if isinstance(item, dict)]
prompt_section: RenderableType | None = None
prompt_html_uri: str | None = None
if global_config.debug.show_maisaka_thinking:
prompt_section = PromptCLIVisualizer.build_prompt_section(
prompt_section_result = PromptCLIVisualizer.build_prompt_section_result(
built_messages,
category="planner" if request_kind != "timing_gate" else "timing_gate",
chat_id=self._session_id,
@@ -595,6 +597,9 @@ class MaisakaChatLoopService:
folded=global_config.debug.fold_maisaka_thinking,
tool_definitions=list(all_tools),
)
prompt_section = prompt_section_result.panel
if prompt_section_result.preview_access is not None:
prompt_html_uri = prompt_section_result.preview_access.viewer_web_uri
llm_chat = self._get_llm_chat_client(request_kind)
generation_result = await llm_chat.generate_response_with_messages(
@@ -660,6 +665,7 @@ class MaisakaChatLoopService:
completion_tokens=completion_tokens,
total_tokens=total_tokens,
prompt_section=prompt_section,
prompt_html_uri=prompt_html_uri,
)
@staticmethod

View File

@@ -83,7 +83,7 @@ def _append_image_component(
builder.add_text_content(normalized_content)
return True
builder.add_text_content("[图片]")
builder.add_text_content("[图片,识别中.....]")
return True
@@ -147,7 +147,7 @@ def _render_component_for_prompt(component: StandardMessageComponents) -> str:
return (component.text or "").strip()
if isinstance(component, ImageComponent):
return component.content.strip() if component.content else "[图片]"
return component.content.strip() if component.content else "[图片,识别中.....]"
if isinstance(component, EmojiComponent):
return component.content.strip() if component.content else "[表情包]"

View File

@@ -7,6 +7,7 @@ from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Literal
from urllib.parse import quote
import hashlib
import html
@@ -32,6 +33,36 @@ from .prompt_preview_logger import PromptPreviewLogger
DATA_IMAGE_DIR = REPO_ROOT / "data" / "images"
def _build_prompt_preview_web_uri(file_path: Path) -> str:
"""构建 WebUI 可访问的 Prompt 预览地址。"""
try:
relative_path = file_path.resolve().relative_to(PromptPreviewLogger._BASE_DIR.resolve())
except ValueError:
return build_file_uri(file_path)
return f"/api/webui/config/maisaka-prompt-preview?path={quote(relative_path.as_posix(), safe='')}"
@dataclass(frozen=True)
class PromptPreviewAccess:
"""Prompt 预览文件的展示入口和可直接打开的路径。"""
body: RenderableType
viewer_path: Path
viewer_uri: str
viewer_web_uri: str
dump_path: Path
dump_uri: str
@dataclass(frozen=True)
class PromptSectionResult:
"""Prompt 面板及其可选 HTML 预览入口。"""
panel: Panel
preview_access: PromptPreviewAccess | None = None
class PromptImageDisplayMode(str, Enum):
"""图片在终端中的展示模式。"""
@@ -470,6 +501,77 @@ class PromptCLIVisualizer:
),
)
@classmethod
def build_prompt_preview_access(
cls,
messages: list[Any],
*,
category: str,
chat_id: str,
request_kind: str,
selection_reason: str,
tool_definitions: list[dict[str, Any]] | None = None,
) -> PromptPreviewAccess:
"""保存 Prompt 预览文件,并返回 CLI 展示入口与浏览器可打开的 URI。"""
viewer_messages: list[dict[str, Any]] = []
for message in messages:
if isinstance(message, dict):
viewer_messages.append(dict(message))
continue
normalized_message = {
"content": getattr(message, "content", None),
"role": getattr(getattr(message, "role", "unknown"), "value", getattr(message, "role", "unknown")),
}
tool_call_id = getattr(message, "tool_call_id", None)
if tool_call_id:
normalized_message["tool_call_id"] = tool_call_id
tool_calls = getattr(message, "tool_calls", None)
if tool_calls:
normalized_message["tool_calls"] = [
cls.format_tool_call_for_display(tool_call) for tool_call in tool_calls
]
viewer_messages.append(normalized_message)
prompt_dump_text = cls._build_prompt_dump_text(messages)
tool_definition_dump_text = cls._build_tool_definition_dump_text(tool_definitions)
if tool_definition_dump_text:
prompt_dump_text = f"{prompt_dump_text}\n\n{'=' * 80}\n\n{tool_definition_dump_text}"
viewer_html_text = cls._build_prompt_viewer_html(
viewer_messages,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
saved_paths = PromptPreviewLogger.save_preview_files(
chat_id,
category,
{
".html": viewer_html_text,
".txt": prompt_dump_text,
},
)
viewer_html_path = saved_paths[".html"]
prompt_dump_path = saved_paths[".txt"]
body = cls._build_preview_access_body(
viewer_label="html预览",
viewer_path=viewer_html_path,
viewer_link_text="在浏览器打开 Prompt",
dump_label="原始文本",
dump_path=prompt_dump_path,
dump_link_text="点击打开 Prompt 文本",
)
return PromptPreviewAccess(
body=body,
viewer_path=viewer_html_path,
viewer_uri=build_file_uri(viewer_html_path),
viewer_web_uri=_build_prompt_preview_web_uri(viewer_html_path),
dump_path=prompt_dump_path,
dump_uri=build_file_uri(prompt_dump_path),
)
@classmethod
def _build_html_role_class(cls, role: str) -> str:
return {
@@ -804,56 +906,14 @@ class PromptCLIVisualizer:
) -> RenderableType:
"""构建用于查看完整 prompt 的折叠入口内容。"""
viewer_messages: list[dict[str, Any]] = []
for message in messages:
if isinstance(message, dict):
viewer_messages.append(dict(message))
continue
normalized_message = {
"content": getattr(message, "content", None),
"role": getattr(getattr(message, "role", "unknown"), "value", getattr(message, "role", "unknown")),
}
tool_call_id = getattr(message, "tool_call_id", None)
if tool_call_id:
normalized_message["tool_call_id"] = tool_call_id
tool_calls = getattr(message, "tool_calls", None)
if tool_calls:
normalized_message["tool_calls"] = [
cls.format_tool_call_for_display(tool_call) for tool_call in tool_calls
]
viewer_messages.append(normalized_message)
prompt_dump_text = cls._build_prompt_dump_text(messages)
tool_definition_dump_text = cls._build_tool_definition_dump_text(tool_definitions)
if tool_definition_dump_text:
prompt_dump_text = f"{prompt_dump_text}\n\n{'=' * 80}\n\n{tool_definition_dump_text}"
viewer_html_text = cls._build_prompt_viewer_html(
viewer_messages,
return cls.build_prompt_preview_access(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
saved_paths = PromptPreviewLogger.save_preview_files(
chat_id,
category,
{
".html": viewer_html_text,
".txt": prompt_dump_text,
},
)
viewer_html_path = saved_paths[".html"]
prompt_dump_path = saved_paths[".txt"]
body = cls._build_preview_access_body(
viewer_label="html预览",
viewer_path=viewer_html_path,
viewer_link_text="在浏览器打开 Prompt",
dump_label="原始文本",
dump_path=prompt_dump_path,
dump_link_text="点击打开 Prompt 文本",
)
return body
).body
@classmethod
def build_prompt_section(
@@ -870,26 +930,56 @@ class PromptCLIVisualizer:
) -> Panel:
"""构建用于嵌入结果面板中的 Prompt 区块。"""
return cls.build_prompt_section_result(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
image_display_mode=image_display_mode,
folded=folded,
tool_definitions=tool_definitions,
).panel
@classmethod
def build_prompt_section_result(
cls,
messages: list[Any],
*,
category: str,
chat_id: str,
request_kind: str,
selection_reason: str,
image_display_mode: Literal["legacy", "path_link"] = "path_link",
folded: bool,
tool_definitions: list[dict[str, Any]] | None = None,
) -> PromptSectionResult:
"""构建 Prompt 面板,并在折叠模式下返回对应的 HTML 预览入口。"""
panel_title, panel_border_style = cls.get_request_panel_style(request_kind)
preview_access = cls.build_prompt_preview_access(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
if folded:
prompt_renderable = cls.build_prompt_access_panel(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
prompt_renderable = preview_access.body
else:
ordered_panels = cls.build_prompt_panels(messages)
prompt_renderable = Group(*ordered_panels)
return Panel(
prompt_renderable,
title=panel_title,
subtitle=selection_reason,
border_style=panel_border_style,
padding=(0, 1),
return PromptSectionResult(
panel=Panel(
prompt_renderable,
title=panel_title,
subtitle=selection_reason,
border_style=panel_border_style,
padding=(0, 1),
),
preview_access=preview_access,
)
@classmethod

View File

@@ -95,7 +95,7 @@ def build_visible_text_from_sequence(message_sequence: MessageSequence) -> str:
continue
if isinstance(component, ImageComponent):
append_visible_part(component.content.strip() or "[图片]")
append_visible_part(component.content.strip() or "[图片,识别中.....]")
continue
if isinstance(component, AtComponent):

View File

@@ -4,8 +4,9 @@
"""
from datetime import datetime
import time
from typing import Any, Dict, List, Optional
import json
import time
from src.common.logger import get_logger
@@ -57,7 +58,7 @@ def _extract_text_content(content: Any) -> Optional[str]:
if block_type == "text":
text_parts.append(str(block.get("text", "")))
elif block_type == "image_url":
text_parts.append("[图片]")
text_parts.append("[图片,识别中.....]")
else:
text_parts.append(f"[{block_type}]")
elif isinstance(block, str):
@@ -66,43 +67,65 @@ def _extract_text_content(content: Any) -> Optional[str]:
return str(content)
def _normalize_tool_call_arguments(arguments: Any) -> tuple[Any, Optional[str]]:
"""标准化工具调用参数,兼容 JSON 字符串和对象。"""
if isinstance(arguments, str):
raw_arguments = arguments
try:
parsed_arguments = json.loads(arguments) if arguments.strip() else {}
except json.JSONDecodeError:
return {}, raw_arguments
return _normalize_payload_value(parsed_arguments), raw_arguments
return _normalize_payload_value(arguments or {}), None
def _serialize_single_tool_call(tool_call: Any) -> Dict[str, Any]:
"""将不同来源的 tool_call 标准化为前端可直接展示的结构。"""
if isinstance(tool_call, dict):
function_info = tool_call.get("function")
if isinstance(function_info, dict):
raw_arguments = function_info.get("arguments", tool_call.get("arguments", tool_call.get("args", {})))
name = function_info.get("name", tool_call.get("name", tool_call.get("func_name", "unknown")))
else:
raw_arguments = tool_call.get("arguments", tool_call.get("args", {}))
name = tool_call.get("name", tool_call.get("func_name", "unknown"))
arguments, arguments_raw = _normalize_tool_call_arguments(raw_arguments)
serialized: Dict[str, Any] = {
"id": str(tool_call.get("id", tool_call.get("call_id", ""))),
"name": str(name or "unknown"),
"arguments": arguments,
}
if arguments_raw is not None:
serialized["arguments_raw"] = arguments_raw
return serialized
raw_arguments = getattr(tool_call, "args", None)
if raw_arguments is None:
raw_arguments = getattr(tool_call, "arguments", None)
arguments, arguments_raw = _normalize_tool_call_arguments(raw_arguments)
serialized = {
"id": str(getattr(tool_call, "id", None) or getattr(tool_call, "call_id", "")),
"name": str(getattr(tool_call, "func_name", None) or getattr(tool_call, "name", "unknown")),
"arguments": arguments,
}
if arguments_raw is not None:
serialized["arguments_raw"] = arguments_raw
return serialized
def _serialize_tool_calls_from_objects(tool_calls: List[Any]) -> List[Dict[str, Any]]:
"""将工具调用对象列表序列化为字典列表。"""
result: List[Dict[str, Any]] = []
for tool_call in tool_calls:
serialized: Dict[str, Any] = {
"id": getattr(tool_call, "id", None) or getattr(tool_call, "call_id", ""),
"name": getattr(tool_call, "func_name", None) or getattr(tool_call, "name", "unknown"),
}
args = getattr(tool_call, "args", None) or getattr(tool_call, "arguments", None)
if isinstance(args, dict):
serialized["arguments"] = _normalize_payload_value(args)
elif isinstance(args, str):
serialized["arguments_raw"] = args
result.append(serialized)
return result
return [_serialize_single_tool_call(tool_call) for tool_call in tool_calls]
def _serialize_tool_calls_from_dicts(tool_calls: List[Any]) -> List[Dict[str, Any]]:
"""将工具调用字典列表标准化为可传输格式。"""
result: List[Dict[str, Any]] = []
for tool_call in tool_calls:
if isinstance(tool_call, dict):
result.append({
"id": str(tool_call.get("id", "")),
"name": str(tool_call.get("name", tool_call.get("func_name", "unknown"))),
"arguments": _normalize_payload_value(tool_call.get("arguments", tool_call.get("args", {}))),
})
continue
result.append({
"id": str(getattr(tool_call, "id", getattr(tool_call, "call_id", ""))),
"name": str(getattr(tool_call, "func_name", getattr(tool_call, "name", "unknown"))),
"arguments": _normalize_payload_value(getattr(tool_call, "args", getattr(tool_call, "arguments", {}))),
})
return result
return [_serialize_single_tool_call(tool_call) for tool_call in tool_calls]
def _serialize_message(message: Any) -> Dict[str, Any]:
@@ -214,6 +237,7 @@ def _serialize_planner_block(
completion_tokens: Optional[int],
total_tokens: Optional[int],
duration_ms: Optional[float],
prompt_html_uri: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""标准化 planner 结果区块。"""
@@ -224,6 +248,7 @@ def _serialize_planner_block(
and completion_tokens is None
and total_tokens is None
and duration_ms is None
and prompt_html_uri is None
):
return None
@@ -234,6 +259,7 @@ def _serialize_planner_block(
"completion_tokens": int(completion_tokens or 0),
"total_tokens": int(total_tokens or 0),
"duration_ms": float(duration_ms or 0.0),
"prompt_html_uri": str(prompt_html_uri or ""),
}
@@ -429,6 +455,7 @@ async def emit_planner_finalized(
planner_completion_tokens: Optional[int],
planner_total_tokens: Optional[int],
planner_duration_ms: Optional[float],
planner_prompt_html_uri: Optional[str],
tools: Optional[List[Dict[str, Any]]],
time_records: Dict[str, float],
agent_state: str,
@@ -464,6 +491,7 @@ async def emit_planner_finalized(
planner_completion_tokens,
planner_total_tokens,
planner_duration_ms,
planner_prompt_html_uri,
),
"tools": _serialize_tool_results(list(tools or [])),
"final_state": {

View File

@@ -709,6 +709,7 @@ class MaisakaReasoningEngine:
),
planner_total_tokens=response.total_tokens if response is not None else None,
planner_duration_ms=planner_duration_ms if response is not None else None,
planner_prompt_html_uri=response.prompt_html_uri if response is not None else None,
tools=tool_monitor_results,
time_records=dict(completed_cycle.time_records),
agent_state=self._runtime._agent_state,

View File

@@ -216,6 +216,20 @@ class PluginRunnerSupervisor:
"""
return {plugin_id: registration.plugin_version for plugin_id, registration in self._registered_plugins.items()}
def get_plugin_load_statuses(self) -> Dict[str, str]:
"""返回 Runner 最近一次上报的插件加载状态。"""
statuses: Dict[str, str] = {}
for plugin_id in self._runner_ready_payloads.loaded_plugins:
statuses[plugin_id] = "success"
for plugin_id in self._runner_ready_payloads.failed_plugins:
statuses[plugin_id] = "failed"
for plugin_id in self._runner_ready_payloads.inactive_plugins:
statuses.setdefault(plugin_id, "inactive")
for plugin_id in self._registered_plugins:
statuses[plugin_id] = "success"
return statuses
def set_blocked_plugin_reasons(self, blocked_plugin_reasons: Dict[str, str]) -> None:
"""设置当前 Runner 启动时应拒绝加载的插件列表。

View File

@@ -657,6 +657,14 @@ class PluginRuntimeManager(
plugin_id: supervisor for supervisor in self.supervisors for plugin_id in supervisor.get_loaded_plugin_ids()
}
def get_plugin_load_statuses(self) -> Dict[str, str]:
"""汇总所有 Supervisor 上报的插件加载状态。"""
statuses: Dict[str, str] = {}
for supervisor in self.supervisors:
statuses.update(supervisor.get_plugin_load_statuses())
return statuses
def _build_external_available_plugins_for_supervisor(self, target_supervisor: "PluginSupervisor") -> Dict[str, str]:
"""收集某个 Supervisor 可用的外部插件版本映射。"""

View File

@@ -8,7 +8,9 @@ from pathlib import Path
from typing import Annotated, Any, Dict, List, Tuple
import tomlkit
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from src.common.logger import get_logger
from src.config.config import CONFIG_DIR, PROJECT_ROOT, Config, ModelConfig
@@ -47,9 +49,76 @@ ConfigBody = Annotated[Dict[str, Any], Body()]
SectionBody = Annotated[Any, Body()]
RawContentBody = Annotated[str, Body(embed=True)]
PathBody = Annotated[Dict[str, str], Body()]
PromptContentBody = Annotated[str, Body(embed=True)]
router = APIRouter(prefix="/config", tags=["config"], dependencies=[Depends(require_auth)])
PROMPTS_DIR = PROJECT_ROOT / "prompts"
MAISAKA_PROMPT_PREVIEW_DIR = (PROJECT_ROOT / "logs" / "maisaka_prompt").resolve()
class PromptFileInfo(BaseModel):
"""Prompt 文件信息。"""
name: str = Field(..., description="Prompt 文件名")
size: int = Field(..., description="文件大小")
modified_at: float = Field(..., description="最后修改时间戳")
class PromptCatalogResponse(BaseModel):
"""Prompt 目录响应。"""
success: bool = True
languages: List[str]
files: Dict[str, List[PromptFileInfo]]
class PromptFileResponse(BaseModel):
"""Prompt 文件内容响应。"""
success: bool = True
language: str
filename: str
content: str
def _safe_prompt_path(language: str, filename: str) -> Path:
"""校验并解析 prompts 下的文件路径。"""
normalized_language = language.strip()
normalized_filename = filename.strip()
if not normalized_language or any(part in normalized_language for part in ("..", "/", "\\")):
raise HTTPException(status_code=400, detail="无效的 Prompt 语言目录")
if not normalized_filename.endswith(".prompt") or any(part in normalized_filename for part in ("..", "/", "\\")):
raise HTTPException(status_code=400, detail="无效的 Prompt 文件名")
prompt_path = (PROMPTS_DIR / normalized_language / normalized_filename).resolve()
prompts_root = PROMPTS_DIR.resolve()
try:
prompt_path.relative_to(prompts_root)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Prompt 路径越界") from exc
return prompt_path
def _safe_maisaka_prompt_preview_path(relative_path: str) -> Path:
"""校验并解析 MaiSaka Prompt HTML 预览路径。"""
normalized_path = relative_path.strip().replace("\\", "/")
if not normalized_path or normalized_path.startswith("/") or ".." in Path(normalized_path).parts:
raise HTTPException(status_code=400, detail="无效的 Prompt 预览路径")
preview_path = (MAISAKA_PROMPT_PREVIEW_DIR / normalized_path).resolve()
try:
preview_path.relative_to(MAISAKA_PROMPT_PREVIEW_DIR)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Prompt 预览路径越界") from exc
if preview_path.suffix.lower() != ".html":
raise HTTPException(status_code=400, detail="只允许打开 HTML Prompt 预览")
return preview_path
def _toml_to_plain_dict(obj: Any) -> Any:
"""递归转换 tomlkit 文档/Table 为纯 Python 字典,避免 from_dict 触发 tomlkit __setitem__"""
@@ -63,6 +132,87 @@ def _toml_to_plain_dict(obj: Any) -> Any:
# ===== 架构获取接口 =====
@router.get("/prompts", response_model=PromptCatalogResponse)
async def list_prompt_files():
"""列出 prompts 目录下的语言和 Prompt 文件。"""
try:
if not PROMPTS_DIR.exists():
return PromptCatalogResponse(languages=[], files={})
languages: List[str] = []
files: Dict[str, List[PromptFileInfo]] = {}
for language_dir in sorted(PROMPTS_DIR.iterdir(), key=lambda item: item.name):
if not language_dir.is_dir():
continue
language = language_dir.name
prompt_files: List[PromptFileInfo] = []
for prompt_file in sorted(language_dir.glob("*.prompt"), key=lambda item: item.name):
stat = prompt_file.stat()
prompt_files.append(
PromptFileInfo(
name=prompt_file.name,
size=stat.st_size,
modified_at=stat.st_mtime,
)
)
languages.append(language)
files[language] = prompt_files
return PromptCatalogResponse(languages=languages, files=files)
except HTTPException:
raise
except Exception as e:
logger.error(f"列出 Prompt 文件失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"列出 Prompt 文件失败: {str(e)}") from e
@router.get("/prompts/{language}/{filename}", response_model=PromptFileResponse)
async def get_prompt_file(language: str, filename: str):
"""读取指定语言下的 Prompt 文件内容。"""
prompt_path = _safe_prompt_path(language, filename)
if not prompt_path.exists() or not prompt_path.is_file():
raise HTTPException(status_code=404, detail="Prompt 文件不存在")
try:
content = prompt_path.read_text(encoding="utf-8")
return PromptFileResponse(language=language, filename=filename, content=content)
except Exception as e:
logger.error(f"读取 Prompt 文件失败: {prompt_path} {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"读取 Prompt 文件失败: {str(e)}") from e
@router.put("/prompts/{language}/{filename}", response_model=PromptFileResponse)
async def update_prompt_file(language: str, filename: str, content: PromptContentBody):
"""更新指定语言下的 Prompt 文件内容。"""
prompt_path = _safe_prompt_path(language, filename)
if not prompt_path.parent.exists() or not prompt_path.parent.is_dir():
raise HTTPException(status_code=404, detail="Prompt 语言目录不存在")
if not prompt_path.exists() or not prompt_path.is_file():
raise HTTPException(status_code=404, detail="Prompt 文件不存在")
try:
prompt_path.write_text(content, encoding="utf-8", newline="\n")
return PromptFileResponse(language=language, filename=filename, content=content)
except Exception as e:
logger.error(f"保存 Prompt 文件失败: {prompt_path} {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"保存 Prompt 文件失败: {str(e)}") from e
@router.get("/maisaka-prompt-preview", response_class=FileResponse)
async def get_maisaka_prompt_preview(path: str = Query(..., description="logs/maisaka_prompt 下的相对 HTML 路径")):
"""打开 MaiSaka 监控中生成的 Prompt HTML 预览。"""
preview_path = _safe_maisaka_prompt_preview_path(path)
if not preview_path.exists() or not preview_path.is_file():
raise HTTPException(status_code=404, detail="Prompt 预览文件不存在")
return FileResponse(preview_path, media_type="text/html")
@router.get("/schema/bot")
async def get_bot_config_schema():
"""获取麦麦主程序配置架构"""

View File

@@ -1,8 +1,9 @@
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Cookie, HTTPException
import json
import tomlkit
from src.common.logger import get_logger
from src.webui.services.git_mirror_service import get_git_mirror_service
@@ -12,6 +13,7 @@ from .schemas import InstallPluginRequest, UninstallPluginRequest, UpdatePluginR
from .support import (
find_plugin_path_by_id,
get_plugin_candidate_paths,
get_plugin_config_path,
iter_plugin_directories,
load_manifest_json,
parse_repository_url,
@@ -64,6 +66,39 @@ def _infer_plugin_id(folder_name: str, manifest: Dict[str, Any], manifest_path:
return plugin_id
def _coerce_enabled_value(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() not in {"false", "0", "no", "off", "disabled"}
return bool(value)
def _read_plugin_enabled(plugin_id: str, plugin_path: Path) -> bool:
try:
config_path = get_plugin_config_path(plugin_id, plugin_path)
if not config_path.exists():
return True
with open(config_path, "r", encoding="utf-8") as file_obj:
config = tomlkit.load(file_obj).unwrap()
except Exception as exc:
logger.warning(f"读取插件 {plugin_id} 启用状态失败,将按启用处理: {exc}")
return True
plugin_config = config.get("plugin") if isinstance(config, dict) else None
if not isinstance(plugin_config, dict):
return True
return _coerce_enabled_value(plugin_config.get("enabled", True))
def _get_runtime_plugin_load_statuses() -> Dict[str, str]:
try:
from src.plugin_runtime.integration import get_plugin_runtime_manager
return get_plugin_runtime_manager().get_plugin_load_statuses()
except Exception as exc:
logger.warning(f"获取插件运行时加载状态失败: {exc}")
return {}
@router.post("/install")
async def install_plugin(request: InstallPluginRequest, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]:
require_plugin_token(maibot_session)
@@ -401,6 +436,7 @@ async def get_installed_plugins(maibot_session: Optional[str] = Cookie(None)) ->
try:
installed_plugins: List[Dict[str, Any]] = []
runtime_statuses = _get_runtime_plugin_load_statuses()
for plugin_path in iter_plugin_directories():
folder_name = plugin_path.name
if folder_name.startswith(".") or folder_name.startswith("__"):
@@ -420,7 +456,19 @@ async def get_installed_plugins(maibot_session: Optional[str] = Cookie(None)) ->
logger.warning(f"插件文件夹 {folder_name} 的 _manifest.json 格式无效,跳过")
continue
plugin_id = _infer_plugin_id(folder_name, manifest, manifest_path)
installed_plugins.append({"id": plugin_id, "manifest": manifest, "path": str(plugin_path.absolute())})
enabled = _read_plugin_enabled(plugin_id, plugin_path)
load_status = runtime_statuses.get(plugin_id, "unknown")
installed_plugins.append(
{
"id": plugin_id,
"manifest": manifest,
"path": str(plugin_path.absolute()),
"enabled": enabled,
"disabled": not enabled,
"loaded": load_status == "success",
"load_status": "disabled" if not enabled else load_status,
}
)
except json.JSONDecodeError as e:
logger.warning(f"插件 {folder_name} 的 _manifest.json 解析失败: {e}")
except Exception as e:

View File

@@ -106,7 +106,7 @@ def validate_plugin_id(plugin_id: str) -> str:
def parse_version(version_str: str) -> Tuple[int, int, int]:
base_version = re.split(r"[-.](?:snapshot|dev|alpha|beta|rc)", version_str, flags=re.IGNORECASE)[0]
base_version = re.split(r"[-.](?:snapshot|dev|pre|alpha|beta|rc)", version_str, flags=re.IGNORECASE)[0]
parts = base_version.split(".")
if len(parts) < 3:
parts.extend(["0"] * (3 - len(parts)))