fix:优化图片识别,优化webui配置和排版,优化聊天流监控,新增mcp显示,新增prompt修改面板,优化插件状态显示,优化长期记忆控制台,
This commit is contained in:
4
dashboard/package-lock.json
generated
4
dashboard/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ローカルチャット",
|
||||
|
||||
@@ -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": "로컬 채팅",
|
||||
|
||||
@@ -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": "本地聊天室",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 字段)
|
||||
|
||||
49
dashboard/src/lib/prompt-api.ts
Normal file
49
dashboard/src/lib/prompt-api.ts
Normal 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)
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
表达方式自动检查的间隔时间(单位:秒),默认值:3600秒(1小时)
|
||||
表达方式自动检查的间隔时间(单位:秒),默认值:900秒(15分钟)
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' } : {})}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
272
dashboard/src/routes/config/prompts.tsx
Normal file
272
dashboard/src/routes/config/prompts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
251
dashboard/src/routes/mcp-settings.tsx
Normal file
251
dashboard/src/routes/mcp-settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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() ? (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]] = [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "[表情包]"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 启动时应拒绝加载的插件列表。
|
||||
|
||||
|
||||
@@ -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 可用的外部插件版本映射。"""
|
||||
|
||||
|
||||
@@ -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():
|
||||
"""获取麦麦主程序配置架构"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user