diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index c187f9e9..494c87bf 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -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", diff --git a/dashboard/package.json b/dashboard/package.json index c2338c25..62a0e11e 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -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": { diff --git a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx index db458bdb..64aeb091 100644 --- a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx +++ b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx @@ -53,7 +53,7 @@ function AdvancedSettingsButton({ return ( + + + + +
+ + + + + Prompt 文件 + {promptFiles.length} + +
+ + setQuery(event.target.value)} + placeholder="搜索文件" + className="pl-8" + /> +
+
+ + +
+ {loadingCatalog ? ( +
+ + 加载中 +
+ ) : filteredFiles.length > 0 ? ( + filteredFiles.map((file) => ( + + )) + ) : ( +
没有可编辑的 Prompt 文件
+ )} +
+
+
+ + + +
+ {filename || '未选择文件'} +

+ {language} + {selectedFile ? ` · ${formatFileSize(selectedFile.size)}` : ''} + {hasUnsavedChanges ? ' · 有未保存修改' : ''} +

+
+
+ + {loadingFile ? ( +
+ + 读取中 +
+ ) : ( + + )} +
+
+
+ + ) +} diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 86555e85..5a098015 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -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 ( +
+ + {label} +
+ ) +} + function IndexPageContent() { const { t } = useTranslation() const [dashboardData, setDashboardData] = useState(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(null) + const [featureStatus, setFeatureStatus] = useState({ + 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 } & Record + const botConfig = (botPayload.config ?? botPayload) as Record + const memorixConfig = (botConfig.a_memorix ?? {}) as Record + const memorixPlugin = (memorixConfig.plugin ?? {}) as Record + + const modelPayload = modelConfigResult.success + ? (modelConfigResult.data as { config?: Record } & Record) + : {} + const modelConfig = (modelPayload.config ?? modelPayload) as Record + const taskConfig = (modelConfig.model_task_config ?? {}) as Record + const vlmTask = (taskConfig.vlm ?? {}) as Record + 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() { -
-
- {botStatus?.running ? ( - <> -
- - - {t('home.botStatus.running')} +
+
+
+ {botStatus?.running ? ( + <> +
+ + + {t('home.botStatus.running')} + + + ) : ( + <> +
+ + + {t('home.botStatus.stopped')} + + + )} +
+ {botStatus && ( +
+ + v{botStatus.version} - - ) : ( - <> -
- - - {t('home.botStatus.stopped')} - - + | + {t('home.botStatus.uptime', { time: formatTime(botStatus.uptime) })} +
)}
- {botStatus && ( -
- v{botStatus.version} - | - {t('home.botStatus.uptime', { time: formatTime(botStatus.uptime) })} -
- )} +
+ + +
diff --git a/dashboard/src/routes/mcp-settings.tsx b/dashboard/src/routes/mcp-settings.tsx new file mode 100644 index 00000000..86b1526e --- /dev/null +++ b/dashboard/src/routes/mcp-settings.tsx @@ -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 + +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 ( + + + + ) +} + +function MCPSettingsPageContent() { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [mcpConfig, setMcpConfig] = useState({}) + const [mcpSchema, setMcpSchema] = useState(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 } & Record + const fullConfig = (configPayload.config ?? configPayload) as Record + 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 => { + 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 ( + +
+
+
+

MCP 设置

+

+ 管理 MCP 客户端能力与服务器连接配置 +

+
+
+ + +
+
+ + + + + MCP 设置保存后需要重启麦麦才会生效。这里与主程序配置中的 MCP 栏目使用同一份配置。 + + + + {loading && ( +
+ 加载中... +
+ )} + + {!loading && formSchema && ( + { + const [, ...restPath] = fieldPath.split('.') + const nextConfig = restPath.length === 0 + ? (value as ConfigSectionData) + : updateNestedValue(mcpConfig, restPath, value) + + setMcpConfig(nextConfig) + setHasUnsavedChanges(true) + }} + hooks={fieldHooks} + /> + )} + + {!loading && !formSchema && ( + + + 当前配置 schema 中没有找到 MCP 设置。 + + )} + + +
+
+ ) +} diff --git a/dashboard/src/routes/monitor/maisaka-monitor.tsx b/dashboard/src/routes/monitor/maisaka-monitor.tsx index 84acfd24..acffb0bc 100644 --- a/dashboard/src/routes/monitor/maisaka-monitor.tsx +++ b/dashboard/src/routes/monitor/maisaka-monitor.tsx @@ -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 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 ( -
+

等待 MaiSaka 会话…

@@ -92,35 +104,42 @@ function SessionSidebar({ } return ( -
+
{sortedSessions.map((session) => ( ))}
@@ -183,7 +202,8 @@ function TimingGateCard({ data }: { data: TimingGateResultEvent }) {
- Timing Gate + 反应 + react {config.label} @@ -198,6 +218,29 @@ function TimingGateCard({ data }: { data: TimingGateResultEvent }) { ) } +function ToolCallBadges({ toolCalls }: { toolCalls: MaisakaToolCall[] }) { + if (toolCalls.length <= 0) { + return null + } + + return ( +
+ {toolCalls.map((tc: MaisakaToolCall, idx: number) => ( + + + {tc.name} + + ))} +
+ ) +} + +function openPromptHtml(uri: string) { + const normalized = uri.trim() + if (!normalized) return + window.open(normalized, '_blank', 'noopener,noreferrer') +} + function PlannerResponseCard({ data }: { data: PlannerResponseEvent }) { return (
@@ -215,21 +258,120 @@ function PlannerResponseCard({ data }: { data: PlannerResponseEvent }) { {data.content && ( )} - {data.tool_calls.length > 0 && ( -
- {data.tool_calls.map((tc: MaisakaToolCall, idx: number) => ( - - - {tc.name} - - ))} -
- )} +
) } +function PlannerFinalizedCard({ data }: { data: PlannerFinalizedEvent }) { + const planner = data.planner + const promptHtmlUri = planner?.prompt_html_uri?.trim() ?? '' + + return ( + + +
+ + 主循环 planner + {promptHtmlUri && ( + + )} + + {formatMs(planner?.duration_ms ?? 0)} + + {data.request && ( + + 上下文 {data.request.selected_history_count} 条 / 可用工具 {data.request.tool_count} + + )} + {planner && (planner.prompt_tokens > 0 || planner.completion_tokens > 0) && ( + + {planner.prompt_tokens}+{planner.completion_tokens} tokens + + )} +
+ + {planner?.content ? ( + + ) : ( +

planner 本轮没有文本内容

+ )} + +
+
+ ) +} + +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 ( + + +
+ + Planner 工具调用 + + {displayTools.length} 个 + +
+
+ {displayTools.map((tool, idx) => ( +
+
+ {tool.tool_name || 'unknown'} + {tool.success + ? + : + } + {tool.duration_ms > 0 && ( + {formatMs(tool.duration_ms)} + )} +
+ {Object.keys(tool.tool_args ?? {}).length > 0 && ( +
+                  {JSON.stringify(tool.tool_args, null, 2)}
+                
+ )} + {tool.summary && ( +

{tool.summary}

+ )} +
+ ))} +
+
+
+ ) +} + function ToolExecutionCard({ data }: { data: ToolExecutionEvent }) { return (
@@ -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 case 'cycle.start': + if (!showCycleMarkers) return null return case 'timing_gate.result': return case 'planner.response': return + case 'planner.finalized': + return ( +
+ + +
+ ) case 'tool.execution': return case 'cycle.end': @@ -430,6 +586,22 @@ export function MaisakaMonitor() { const scrollRef = useRef(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 (
{/* 会话侧边栏 */} - - - - + + + + {!sidebarCollapsed && } 聊天流 {connected && ( - + )} + @@ -474,6 +669,7 @@ export function MaisakaMonitor() { sessions={sessions} selectedSession={selectedSession} onSelect={setSelectedSession} + collapsed={sidebarCollapsed} /> @@ -497,6 +693,16 @@ export function MaisakaMonitor() {
+
- {/* 统计卡片 */} -
- - - 已安装插件 - - - -
{plugins.length}
-

- {loading ? '正在加载...' : '个插件'} -

-
-
- - - - 已启用 - - - -
{enabledCount}
-

运行中的插件

-
-
- - - - 已禁用 - - - -
{disabledCount}
-

未激活的插件

-
-
-
+ {/* 统计信息 */} + + +
+ + + 已安装 {installedCount} 个插件 + + 已启用 {enabledCount} + 已禁用 {disabledCount} +
+
+ + + 加载成功 {loadSuccessCount} 个 + + + + 加载失败 {loadFailedCount} 个 + +
+
+
{/* 搜索框 */}
@@ -962,16 +965,23 @@ function PluginConfigPageContent() {
) : (
- {uniqueFilteredPlugins.map(plugin => ( + {uniqueFilteredPlugins.map(plugin => { + const statusMeta = getPluginStatusMeta(plugin) + return (
setSelectedPlugin(plugin)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedPlugin(plugin) } }} >
+
@@ -996,7 +1006,8 @@ function PluginConfigPageContent() {
- ))} + ) + })}
)} diff --git a/dashboard/src/routes/resource/knowledge-base.tsx b/dashboard/src/routes/resource/knowledge-base.tsx index 85ac50fc..58033ae5 100644 --- a/dashboard/src/routes/resource/knowledge-base.tsx +++ b/dashboard/src/routes/resource/knowledge-base.tsx @@ -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(null) - const [visualConfig, setVisualConfig] = useState>({}) - const [rawConfig, setRawConfig] = useState('') - const [rawConfigExists, setRawConfigExists] = useState(true) - const [rawConfigUsingDefault, setRawConfigUsingDefault] = useState(false) const [runtimeConfig, setRuntimeConfig] = useState(null) const [selfCheckReport, setSelfCheckReport] = useState | null>(null) const [importSettings, setImportSettings] = useState({}) @@ -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() {

长期记忆控制台

- 在这里完成配置、自检、导入资料和检索调优——一站式管理记忆库 + 在这里完成自检、导入资料和检索调优——一站式管理记忆库

-
- @@ -1813,14 +1731,29 @@ export function KnowledgeBasePage() {
+
+ +
{/* 运行时状态条 —— 紧凑、常驻、一眼看完 */} {runtimeBadges.length > 0 ? (
-
-
+
+
运行时状态
+ - - -
- - - - - 当前配置文件:{configPath} - {schema?._note ? `;${schema._note}` : ''} - - - {!rawConfigExists || rawConfigUsingDefault ? ( - - - 检测到配置文件尚未保存。当前展示的是默认模板内容,点击“保存”后会自动创建配置文件: - {' '} - {configPath} - - - - ) : null} - - {rawMode ? ( - - ) : schema ? ( - - ) : ( -
- 当前未能加载配置 schema,请先刷新页面或检查后端日志 -
- )} -
- - -
{getImportStepLabel(String(task.current_step ?? 'running'))} - {Number(task.progress ?? 0).toFixed(1)}% + {formatProgressPercent(task.progress)}
@@ -966,7 +967,7 @@ export function ImportTab(props: ImportTabProps) {
完成进度 - {Number(task.progress ?? 0).toFixed(1)}% + {formatProgressPercent(task.progress)}
@@ -1155,11 +1156,11 @@ export function ImportTab(props: ImportTabProps) {
{getImportStepLabel(String(file.current_step ?? ''))} - {Number(file.progress ?? 0).toFixed(1)}% + {formatProgressPercent(file.progress)}
- {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)}
{file.error ? (
{file.error}
@@ -1230,7 +1231,7 @@ export function ImportTab(props: ImportTabProps) { {chunk.index} {getImportStatusLabel(String(chunk.status ?? ''))} {getImportStepLabel(String(chunk.step ?? ''))} - {Number(chunk.progress ?? 0).toFixed(1)}% + {formatProgressPercent(chunk.progress)}
{String(chunk.error ?? '').trim() ? ( diff --git a/dashboard/src/routes/resource/knowledge-base/utils.ts b/dashboard/src/routes/resource/knowledge-base/utils.ts index 397e0164..d282f7ba 100644 --- a/dashboard/src/routes/resource/knowledge-base/utils.ts +++ b/dashboard/src/routes/resource/knowledge-base/utils.ts @@ -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 { diff --git a/dashboard/src/routes/resource/knowledge-graph/index.tsx b/dashboard/src/routes/resource/knowledge-graph/index.tsx index c9413494..019f74a6 100644 --- a/dashboard/src/routes/resource/knowledge-graph/index.tsx +++ b/dashboard/src/routes/resource/knowledge-graph/index.tsx @@ -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 (
-
+
-
-

长期记忆图谱

-

- 基于 A_Memorix 的实体关系图与证据视图 -

-
+ {!embedded && ( +
+

长期记忆图谱

+

+ 基于 A_Memorix 的实体关系图与证据视图 +

+
+ )}
@@ -791,7 +805,7 @@ export function KnowledgeGraphPage() { 刷新图谱 - @@ -873,7 +887,7 @@ export function KnowledgeGraphPage() {

先在长期记忆控制台里完成导入或记忆生成,再回来查看关系网络。

- diff --git a/dashboard/src/types/config-schema.ts b/dashboard/src/types/config-schema.ts index c5d1856f..891045a1 100644 --- a/dashboard/src/types/config-schema.ts +++ b/dashboard/src/types/config-schema.ts @@ -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 } diff --git a/pytests/config_test/test_config_manager_startup_upgrade.py b/pytests/config_test/test_config_manager_startup_upgrade.py index 96d1f08e..f0c92205 100644 --- a/pytests/config_test/test_config_manager_startup_upgrade.py +++ b/pytests/config_test/test_config_manager_startup_upgrade.py @@ -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] diff --git a/requirements.txt b/requirements.txt index 9d64d4d4..33c87f97 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/chat/image_system/image_manager.py b/src/chat/image_system/image_manager.py index b2e424ca..6bfc77d6 100644 --- a/src/chat/image_system/image_manager.py +++ b/src/chat/image_system/image_manager.py @@ -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 diff --git a/src/chat/message_receive/message.py b/src/chat/message_receive/message.py index 0bebcd3d..9b9c280b 100644 --- a/src/chat/message_receive/message.py +++ b/src/chat/message_receive/message.py @@ -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 diff --git a/src/chat/replyer/maisaka_generator_base.py b/src/chat/replyer/maisaka_generator_base.py index dc055c4e..68784550 100644 --- a/src/chat/replyer/maisaka_generator_base.py +++ b/src/chat/replyer/maisaka_generator_base.py @@ -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): diff --git a/src/common/data_models/message_component_data_model.py b/src/common/data_models/message_component_data_model.py index d668fbd1..9d319dd2 100644 --- a/src/common/data_models/message_component_data_model.py +++ b/src/common/data_models/message_component_data_model.py @@ -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: diff --git a/src/config/config.py b/src/config/config.py index 135604b2..19b1b63d 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -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: diff --git a/src/config/default_model_config.py b/src/config/default_model_config.py index 5a7dd6d8..a4db612c 100644 --- a/src/config/default_model_config.py +++ b/src/config/default_model_config.py @@ -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]] = [ diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 2f22e453..d4b4ea4d 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -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, }, ) """ diff --git a/src/emoji_system/emoji_manager.py b/src/emoji_system/emoji_manager.py index f3d6d879..536669f1 100644 --- a/src/emoji_system/emoji_manager.py +++ b/src/emoji_system/emoji_manager.py @@ -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() diff --git a/src/maisaka/chat_history_visual_refresher.py b/src/maisaka/chat_history_visual_refresher.py index c3bc9098..9e0911f4 100644 --- a/src/maisaka/chat_history_visual_refresher.py +++ b/src/maisaka/chat_history_visual_refresher.py @@ -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: diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index f32f48c7..1cd508c7 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -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 diff --git a/src/maisaka/context_messages.py b/src/maisaka/context_messages.py index 11dd15fe..92edfac6 100644 --- a/src/maisaka/context_messages.py +++ b/src/maisaka/context_messages.py @@ -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 "[表情包]" diff --git a/src/maisaka/display/prompt_cli_renderer.py b/src/maisaka/display/prompt_cli_renderer.py index a770261e..3b9e677e 100644 --- a/src/maisaka/display/prompt_cli_renderer.py +++ b/src/maisaka/display/prompt_cli_renderer.py @@ -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 diff --git a/src/maisaka/message_adapter.py b/src/maisaka/message_adapter.py index 1b9e8991..32a519d6 100644 --- a/src/maisaka/message_adapter.py +++ b/src/maisaka/message_adapter.py @@ -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): diff --git a/src/maisaka/monitor_events.py b/src/maisaka/monitor_events.py index d637c19a..31f0b15a 100644 --- a/src/maisaka/monitor_events.py +++ b/src/maisaka/monitor_events.py @@ -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": { diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py index 037f6618..e2282326 100644 --- a/src/maisaka/reasoning_engine.py +++ b/src/maisaka/reasoning_engine.py @@ -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, diff --git a/src/plugin_runtime/host/supervisor.py b/src/plugin_runtime/host/supervisor.py index 3e56944d..ff2b9c09 100644 --- a/src/plugin_runtime/host/supervisor.py +++ b/src/plugin_runtime/host/supervisor.py @@ -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 启动时应拒绝加载的插件列表。 diff --git a/src/plugin_runtime/integration.py b/src/plugin_runtime/integration.py index 548136ae..8ea86924 100644 --- a/src/plugin_runtime/integration.py +++ b/src/plugin_runtime/integration.py @@ -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 可用的外部插件版本映射。""" diff --git a/src/webui/routers/config.py b/src/webui/routers/config.py index 8e609754..9aadc207 100644 --- a/src/webui/routers/config.py +++ b/src/webui/routers/config.py @@ -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(): """获取麦麦主程序配置架构""" diff --git a/src/webui/routers/plugin/management.py b/src/webui/routers/plugin/management.py index 2c509215..ac38274f 100644 --- a/src/webui/routers/plugin/management.py +++ b/src/webui/routers/plugin/management.py @@ -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: diff --git a/src/webui/routers/plugin/support.py b/src/webui/routers/plugin/support.py index 39269c75..6af06ece 100644 --- a/src/webui/routers/plugin/support.py +++ b/src/webui/routers/plugin/support.py @@ -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)))