merge: 同步上游 dev 并增强人物画像查询

This commit is contained in:
DawnARC
2026-05-04 22:43:03 +08:00
103 changed files with 11456 additions and 4389 deletions

View File

@@ -29,7 +29,6 @@ import { Code2, Info, Layout, Power, Save } from 'lucide-react'
import type { ConfigSchema } from '@/types/config-schema'
import {
ChatTalkValueRulesHook,
ExperimentalChatPromptsHook,
ExpressionGroupsHook,
ExpressionLearningListHook,
KeywordRulesHook,
@@ -53,11 +52,9 @@ const TAB_ORDER = [
'expression',
'emoji',
'response_post_process',
'lpmm_knowledge',
'webui',
'maisaka',
'plugin_runtime',
'debug',
'log',
]
// ==================== Tab 分组类型与构建 ====================
@@ -156,23 +153,21 @@ function BotConfigPageContent() {
const [expressionConfig, setExpressionConfig] = useState<ConfigSectionData | null>(null)
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)
const [keywordReactionConfig, setKeywordReactionConfig] = useState<ConfigSectionData | null>(null)
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ConfigSectionData | null>(null)
const [chineseTypoConfig, setChineseTypoConfig] = useState<ConfigSectionData | null>(null)
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ConfigSectionData | null>(null)
const [debugConfig, setDebugConfig] = useState<ConfigSectionData | null>(null)
const [experimentalConfig, setExperimentalConfig] = useState<ConfigSectionData | null>(null)
const [maimMessageConfig, setMaimMessageConfig] = useState<ConfigSectionData | null>(null)
const [telemetryConfig, setTelemetryConfig] = useState<ConfigSectionData | null>(null)
const [webuiConfig, setWebuiConfig] = useState<ConfigSectionData | null>(null)
const [databaseConfig, setDatabaseConfig] = useState<ConfigSectionData | null>(null)
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)
@@ -253,23 +248,21 @@ function BotConfigPageContent() {
setExpressionConfig((config.expression ?? {}) as ConfigSectionData)
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)
setKeywordReactionConfig((config.keyword_reaction ?? {}) as ConfigSectionData)
setResponsePostProcessConfig((config.response_post_process ?? {}) as ConfigSectionData)
setChineseTypoConfig((config.chinese_typo ?? {}) as ConfigSectionData)
setResponseSplitterConfig((config.response_splitter ?? {}) as ConfigSectionData)
setDebugConfig((config.debug ?? {}) as ConfigSectionData)
setExperimentalConfig((config.experimental ?? {}) as ConfigSectionData)
setMaimMessageConfig((config.maim_message ?? {}) as ConfigSectionData)
setTelemetryConfig((config.telemetry ?? {}) as ConfigSectionData)
setWebuiConfig((config.webui ?? {}) as ConfigSectionData)
setDatabaseConfig((config.database ?? {}) as ConfigSectionData)
setMaisakaConfig((config.maisaka ?? {}) as ConfigSectionData)
setMcpConfig((config.mcp ?? {}) as ConfigSectionData)
setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData)
setAMemorixConfig((config.a_memorix ?? {}) as ConfigSectionData)
}, [])
/**
@@ -285,23 +278,21 @@ function BotConfigPageContent() {
expression: expressionConfig,
emoji: emojiConfig,
memory: memoryConfig,
relationship: relationshipConfig,
visual: visualConfig,
voice: voiceConfig,
message_receive: messageReceiveConfig,
lpmm_knowledge: lpmmConfig,
keyword_reaction: keywordReactionConfig,
response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig,
debug: debugConfig,
experimental: experimentalConfig,
maim_message: maimMessageConfig,
telemetry: telemetryConfig,
webui: webuiConfig,
database: databaseConfig,
maisaka: maisakaConfig,
mcp: mcpConfig,
plugin_runtime: pluginRuntimeConfig,
a_memorix: aMemorixConfig,
}
}, [
botConfig,
@@ -310,23 +301,21 @@ function BotConfigPageContent() {
expressionConfig,
emojiConfig,
memoryConfig,
relationshipConfig,
visualConfig,
voiceConfig,
messageReceiveConfig,
lpmmConfig,
keywordReactionConfig,
responsePostProcessConfig,
chineseTypoConfig,
responseSplitterConfig,
debugConfig,
experimentalConfig,
maimMessageConfig,
telemetryConfig,
webuiConfig,
databaseConfig,
maisakaConfig,
mcpConfig,
pluginRuntimeConfig,
aMemorixConfig,
])
// 加载源代码
@@ -406,7 +395,6 @@ function BotConfigPageContent() {
useEffect(() => {
const hookEntries = [
['chat.talk_value_rules', ChatTalkValueRulesHook],
['experimental.chat_prompts', ExperimentalChatPromptsHook],
['expression.expression_groups', ExpressionGroupsHook],
['expression.learning_list', ExpressionLearningListHook],
['keyword_reaction.keyword_rules', KeywordRulesHook],
@@ -442,23 +430,21 @@ function BotConfigPageContent() {
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
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)
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(experimentalConfig, 'experimental', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerAutoSave)
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 () => {
@@ -657,23 +643,21 @@ function BotConfigPageContent() {
expression: expressionConfig,
emoji: emojiConfig,
memory: memoryConfig,
relationship: relationshipConfig,
visual: visualConfig,
voice: voiceConfig,
message_receive: messageReceiveConfig,
lpmm_knowledge: lpmmConfig,
keyword_reaction: keywordReactionConfig,
response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig,
debug: debugConfig,
experimental: experimentalConfig,
maim_message: maimMessageConfig,
telemetry: telemetryConfig,
webui: webuiConfig,
database: databaseConfig,
maisaka: maisakaConfig,
mcp: mcpConfig,
plugin_runtime: pluginRuntimeConfig,
a_memorix: aMemorixConfig,
}),
[
botConfig,
@@ -682,23 +666,21 @@ function BotConfigPageContent() {
expressionConfig,
emojiConfig,
memoryConfig,
relationshipConfig,
visualConfig,
voiceConfig,
messageReceiveConfig,
lpmmConfig,
keywordReactionConfig,
responsePostProcessConfig,
chineseTypoConfig,
responseSplitterConfig,
debugConfig,
experimentalConfig,
maimMessageConfig,
telemetryConfig,
webuiConfig,
databaseConfig,
maisakaConfig,
mcpConfig,
pluginRuntimeConfig,
aMemorixConfig,
]
)
@@ -710,23 +692,21 @@ function BotConfigPageContent() {
expression: setExpressionConfig,
emoji: setEmojiConfig,
memory: setMemoryConfig,
relationship: setRelationshipConfig,
visual: setVisualConfig,
voice: setVoiceConfig,
message_receive: setMessageReceiveConfig,
lpmm_knowledge: setLpmmConfig,
keyword_reaction: setKeywordReactionConfig,
response_post_process: setResponsePostProcessConfig,
chinese_typo: setChineseTypoConfig,
response_splitter: setResponseSplitterConfig,
debug: setDebugConfig,
experimental: setExperimentalConfig,
maim_message: setMaimMessageConfig,
telemetry: setTelemetryConfig,
webui: setWebuiConfig,
database: setDatabaseConfig,
maisaka: setMaisakaConfig,
mcp: setMcpConfig,
plugin_runtime: setPluginRuntimeConfig,
a_memorix: setAMemorixConfig,
}
sectionSetterMap[sectionName]?.(value)

View File

@@ -101,12 +101,6 @@ export const ExpressionGroupsHook = createJsonFieldHook({
placeholder: '[\n {\n "expression_groups": [\n {\n "platform": "qq",\n "item_id": "123456",\n "rule_type": "group"\n }\n ]\n }\n]',
})
export const ExperimentalChatPromptsHook = createJsonFieldHook({
emptyValue: [],
helperText: '实验配置中的定向 Prompt 列表使用 JSON 编辑。每一项应包含 platform、item_id、rule_type、prompt。',
placeholder: '[\n {\n "platform": "qq",\n "item_id": "123456",\n "rule_type": "group",\n "prompt": "这里填写额外提示词"\n }\n]',
})
export const MCPRootItemsHook = createJsonFieldHook({
emptyValue: [],
helperText: 'MCP Roots 条目为对象数组,使用 JSON 编辑。',

View File

@@ -12,7 +12,6 @@ export type {
} from './useAutoSave'
export {
ChatTalkValueRulesHook,
ExperimentalChatPromptsHook,
ExpressionGroupsHook,
ExpressionLearningListHook,
KeywordRulesHook,

View File

@@ -1,311 +0,0 @@
import React from 'react'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Plus, Trash2, AlertTriangle, Eye, Code2 } from 'lucide-react'
import type { ExperimentalConfig } from '../types'
interface ChatPromptData {
platform: string
id: string
type: 'group' | 'private'
prompt: string
}
interface ExperimentalSectionProps {
config: ExperimentalConfig
onChange: (config: ExperimentalConfig) => void
}
export const ExperimentalSection = React.memo(function ExperimentalSection({ config, onChange }: ExperimentalSectionProps) {
// 解析 chat_prompt 字符串为结构化数据
const parseChatPrompt = (promptStr: string): ChatPromptData => {
const parts = promptStr.split(':')
if (parts.length >= 4) {
const platform = parts[0]
const id = parts[1]
const type = parts[2] as 'group' | 'private'
const prompt = parts.slice(3).join(':') // 处理 prompt 中可能包含的冒号
return { platform, id, type, prompt }
}
return { platform: 'qq', id: '', type: 'group', prompt: '' }
}
// 将结构化数据转换为字符串
const stringifyChatPrompt = (data: ChatPromptData): string => {
return `${data.platform}:${data.id}:${data.type}:${data.prompt}`
}
const addChatPrompt = () => {
onChange({ ...config, chat_prompts: [...config.chat_prompts, 'qq::group:'] })
}
const removeChatPrompt = (index: number) => {
onChange({
...config,
chat_prompts: config.chat_prompts.filter((_, i) => i !== index),
})
}
const updateChatPrompt = (index: number, data: Partial<ChatPromptData>) => {
const currentData = parseChatPrompt(config.chat_prompts[index])
const newData = { ...currentData, ...data }
const newPrompts = [...config.chat_prompts]
newPrompts[index] = stringifyChatPrompt(newData)
onChange({ ...config, chat_prompts: newPrompts })
}
// 预览组件
const ChatPromptPreview = ({ promptStr }: { promptStr: string }) => {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-1" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 sm:w-96">
<div className="space-y-2">
<h4 className="font-medium text-sm"></h4>
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
"{promptStr}"
</div>
<p className="text-xs text-muted-foreground">
bot_config.toml
</p>
</div>
</PopoverContent>
</Popover>
)
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div className="flex items-start gap-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<h4 className="font-medium text-orange-500"></h4>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid gap-6">
<div className="flex items-center space-x-2">
<Switch
id="lpmm_memory"
checked={config.lpmm_memory ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, lpmm_memory: checked })
}
/>
<Label htmlFor="lpmm_memory" className="cursor-pointer">
LPMM
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-4">
chat_history_summarizer
</p>
<div className="grid gap-2">
<Label htmlFor="private_plan_style"></Label>
<Textarea
id="private_plan_style"
value={config.private_plan_style}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, private_plan_style: e.target.value })}
placeholder="私聊的说话规则和行为风格(不推荐修改)"
rows={4}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="grid gap-4">
<div className="flex items-center justify-between">
<div>
<Label> Prompt </Label>
<p className="text-xs text-muted-foreground mt-1">
prompt
</p>
</div>
<Button onClick={addChatPrompt} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-4">
{config.chat_prompts.map((promptStr, index) => {
const data = parseChatPrompt(promptStr)
return (
<div key={index} className="rounded-lg border p-4 space-y-4 bg-card">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
Prompt {index + 1}
</span>
<div className="flex items-center gap-2">
<ChatPromptPreview promptStr={promptStr} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
prompt
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeChatPrompt(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="grid gap-4">
{/* 平台选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={data.platform}
onValueChange={(value) => updateChatPrompt(index, { platform: value })}
>
<SelectTrigger>
<SelectValue placeholder="选择平台" />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
<SelectItem value="webui">WebUI</SelectItem>
</SelectContent>
</Select>
</div>
{/* ID 输入 */}
<div className="grid gap-2">
<Label className="text-xs font-medium">
{data.type === 'group' ? '群号' : '用户ID'}
</Label>
<Input
value={data.id}
onChange={(e) => updateChatPrompt(index, { id: e.target.value })}
placeholder={data.type === 'group' ? '输入群号' : '输入用户ID'}
className="font-mono"
/>
</div>
{/* 类型选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={data.type}
onValueChange={(value: 'group' | 'private') => updateChatPrompt(index, { type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="group"> (group)</SelectItem>
<SelectItem value="private"> (private)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Prompt 内容 */}
<div className="grid gap-2">
<Label className="text-xs font-medium">Prompt </Label>
<Textarea
value={data.prompt}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => updateChatPrompt(index, { prompt: e.target.value })}
placeholder="输入额外的 prompt 内容,例如:这是一个摄影群,你精通摄影知识"
rows={3}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 原始格式显示 */}
<div className="rounded-md bg-muted/50 p-3">
<div className="flex items-center gap-2 mb-2">
<Code2 className="h-3 w-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground"></span>
</div>
<code className="text-xs font-mono text-muted-foreground break-all">
{promptStr || '(未配置)'}
</code>
</div>
</div>
</div>
)
})}
{config.chat_prompts.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm"> prompt </p>
<p className="text-xs mt-1">"添加配置"</p>
</div>
)}
</div>
{/* 使用说明 */}
<div className="text-xs text-muted-foreground space-y-2 p-4 rounded-lg bg-muted/30 border">
<p className="font-medium text-foreground">💡 使</p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li></li>
<li>QQWebUI</li>
<li></li>
<li>Prompt </li>
</ul>
<p className="font-medium text-foreground mt-3">📝 </p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li><code className="text-xs bg-muted px-1 py-0.5 rounded"></code></li>
<li><code className="text-xs bg-muted px-1 py-0.5 rounded"></code></li>
<li><code className="text-xs bg-muted px-1 py-0.5 rounded"></code></li>
</ul>
</div>
</div>
</div>
</div>
</div>
)
})

View File

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

View File

@@ -8,7 +8,6 @@ export { DreamSection } from './DreamSection'
export { LPMMSection } from './LPMMSection'
export { LogSection } from './LogSection'
export { DebugSection } from './DebugSection'
export { ExperimentalSection } from './ExperimentalSection'
export { MaimMessageSection } from './MaimMessageSection'
export { TelemetrySection } from './TelemetrySection'
export { FeaturesSection } from './FeaturesSection'

View File

@@ -189,12 +189,6 @@ export interface DebugConfig {
show_lpmm_paragraph: boolean
}
export interface ExperimentalConfig {
private_plan_style: string
chat_prompts: string[]
lpmm_memory: boolean
}
export interface MaimMessageConfig {
auth_token: string[]
enable_api_server: boolean
@@ -239,14 +233,12 @@ export interface AllBotConfigs {
voiceConfig: VoiceConfig | null
messageReceiveConfig: MessageReceiveConfig | null
dreamConfig: DreamConfig | null
lpmmConfig: LPMMKnowledgeConfig | null
keywordReactionConfig: KeywordReactionConfig | null
responsePostProcessConfig: ResponsePostProcessConfig | null
chineseTypoConfig: ChineseTypoConfig | null
responseSplitterConfig: ResponseSplitterConfig | null
logConfig: LogConfig | null
debugConfig: DebugConfig | null
experimentalConfig: ExperimentalConfig | null
maimMessageConfig: MaimMessageConfig | null
telemetryConfig: TelemetryConfig | null
}
@@ -261,23 +253,21 @@ export type ConfigSectionName =
| 'expression'
| 'emoji'
| 'memory'
| 'relationship'
| 'visual'
| 'tool'
| 'voice'
| 'message_receive'
| 'dream'
| 'lpmm_knowledge'
| 'keyword_reaction'
| 'response_post_process'
| 'chinese_typo'
| 'response_splitter'
| 'log'
| 'debug'
| 'experimental'
| 'maim_message'
| 'telemetry'
| 'webui'
| 'database'
| 'maisaka'
| 'mcp'
| 'plugin_runtime'
| 'a_memorix'

View File

@@ -106,6 +106,7 @@ function ModelConfigPageContent() {
const [jumpToPage, setJumpToPage] = useState('')
const [advancedTemperatureMode, setAdvancedTemperatureMode] = useState(false)
const [advancedTaskSettingsVisible, setAdvancedTaskSettingsVisible] = useState(false)
// 模型 Combobox 状态
const [modelComboboxOpen, setModelComboboxOpen] = useState(false)
@@ -155,7 +156,9 @@ function ModelConfigPageContent() {
// 检查是否有模型
if (!task.model_list || task.model_list.length === 0) {
emptyTaskList.push(key)
if (key !== 'learner') {
emptyTaskList.push(key)
}
continue
}
@@ -939,14 +942,26 @@ function ModelConfigPageContent() {
{/* 模型任务配置标签页 */}
<TabsContent value="tasks" className="space-y-6 mt-0">
<p className="text-sm text-muted-foreground">
使
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
使
</p>
{taskConfigSchema?.fields.some((field) => field.advanced) && (
<Button
type="button"
variant={advancedTaskSettingsVisible ? 'default' : 'outline'}
size="sm"
onClick={() => setAdvancedTaskSettingsVisible((current) => !current)}
>
</Button>
)}
</div>
{taskConfig && taskConfigSchema && (
<div className="grid gap-4 sm:gap-6">
{taskConfigSchema.fields
.filter(f => f.type === 'object')
.filter(f => f.type === 'object' && (advancedTaskSettingsVisible || !f.advanced))
.map((field, index) => {
const desc = field.description || field.name
const commaIdx = desc.search(/[,]/)
@@ -960,6 +975,7 @@ function ModelConfigPageContent() {
taskConfig={taskConfig[field.name] ?? { model_list: [] }}
modelNames={modelNames}
onChange={(f, value) => updateTaskConfig(field.name, f, value)}
advanced={field.advanced}
{...(index === 0 ? { dataTour: 'task-model-select' } : {})}
/>
)

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ import {
} from 'recharts'
import {
Activity,
BarChart3,
TrendingUp,
DollarSign,
Clock,
@@ -45,6 +46,7 @@ import {
AlertCircle,
ClipboardList,
ClipboardCheck,
ExternalLink,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -52,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'
@@ -117,6 +120,11 @@ interface DashboardData {
recent_activity: RecentActivity[]
}
interface FeatureStatus {
memoryEnabled: boolean
visualEnabled: boolean
}
// 为饼图生成更丰富的颜色方案 (HSL色相均匀分布)
const generatePieColors = (count: number): string[] => {
const colors: string[] = []
@@ -129,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)
@@ -139,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()
@@ -219,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()
}
@@ -278,8 +341,9 @@ function IndexPageContent() {
fetchDashboardData()
fetchHitokoto()
fetchBotStatus()
fetchFeatureStatus()
fetchReviewStats()
}, [fetchDashboardData, fetchHitokoto, fetchBotStatus, fetchReviewStats])
}, [fetchDashboardData, fetchHitokoto, fetchBotStatus, fetchFeatureStatus, fetchReviewStats])
// 自动刷新
useEffect(() => {
@@ -295,6 +359,7 @@ function IndexPageContent() {
if (isMountedRef.current) {
fetchDashboardData()
fetchBotStatus()
fetchFeatureStatus()
}
}, 30000) // 30秒刷新一次
@@ -304,7 +369,7 @@ function IndexPageContent() {
refreshIntervalRef.current = null
}
}
}, [autoRefresh, fetchDashboardData, fetchBotStatus])
}, [autoRefresh, fetchDashboardData, fetchBotStatus, fetchFeatureStatus])
if (loading || !dashboardData) {
return (
@@ -483,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>
@@ -566,6 +639,13 @@ function IndexPageContent() {
{t('home.quickActions.systemSettings')}
</Link>
</Button>
<Button variant="outline" size="sm" asChild className="gap-2">
<a href="/maibot_statistics.html" target="_blank" rel="noopener noreferrer">
<BarChart3 className="h-4 w-4" />
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,657 @@
import { useCallback, useEffect, useState } from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { KeyValueEditor } from '@/components/ui/key-value-editor'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { DynamicConfigForm } from '@/components/dynamic-form'
import { RestartOverlay } from '@/components/restart-overlay'
import { useToast } from '@/hooks/use-toast'
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 { Copy, Info, Plus, Power, Save, Server, Trash2 } from 'lucide-react'
import { MCPRootItemsHook } from './config/bot/hooks'
type ConfigSectionData = Record<string, unknown>
type MCPTransport = 'stdio' | 'streamable_http'
interface MCPAuthorization {
mode: 'none' | 'bearer'
bearer_token: string
}
interface MCPServerConfig {
name: string
enabled: boolean
transport: MCPTransport
command: string
args: string[]
env: Record<string, string>
url: string
headers: Record<string, string>
http_timeout_seconds: number
read_timeout_seconds: number
authorization: MCPAuthorization
}
const DEFAULT_MCP_SERVER: MCPServerConfig = {
name: '',
enabled: true,
transport: 'stdio',
command: '',
args: [],
env: {},
url: '',
headers: {},
http_timeout_seconds: 30,
read_timeout_seconds: 300,
authorization: {
mode: 'none',
bearer_token: '',
},
}
function asStringMap(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, itemValue]) => [
key,
String(itemValue ?? ''),
]),
)
}
function normalizeMCPServer(value: unknown, index: number): MCPServerConfig {
const source =
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
const auth =
source.authorization &&
typeof source.authorization === 'object' &&
!Array.isArray(source.authorization)
? (source.authorization as Record<string, unknown>)
: {}
const transport = source.transport === 'streamable_http' ? 'streamable_http' : 'stdio'
return {
...DEFAULT_MCP_SERVER,
name: typeof source.name === 'string' ? source.name : `mcp-server-${index + 1}`,
enabled: typeof source.enabled === 'boolean' ? source.enabled : DEFAULT_MCP_SERVER.enabled,
transport,
command: typeof source.command === 'string' ? source.command : '',
args: Array.isArray(source.args) ? source.args.map((item) => String(item ?? '')) : [],
env: asStringMap(source.env),
url: typeof source.url === 'string' ? source.url : '',
headers: asStringMap(source.headers),
http_timeout_seconds:
typeof source.http_timeout_seconds === 'number'
? source.http_timeout_seconds
: DEFAULT_MCP_SERVER.http_timeout_seconds,
read_timeout_seconds:
typeof source.read_timeout_seconds === 'number'
? source.read_timeout_seconds
: DEFAULT_MCP_SERVER.read_timeout_seconds,
authorization: {
mode: auth.mode === 'bearer' ? 'bearer' : 'none',
bearer_token: typeof auth.bearer_token === 'string' ? auth.bearer_token : '',
},
}
}
function normalizeMCPServers(value: unknown): MCPServerConfig[] {
if (!Array.isArray(value)) {
return []
}
return value.map((item, index) => normalizeMCPServer(item, index))
}
function updateNestedValue(
target: ConfigSectionData | null | undefined,
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),
}
}
function MCPServersBlockEditor({
servers,
onChange,
}: {
servers: MCPServerConfig[]
onChange: (servers: MCPServerConfig[]) => void
}) {
const updateServer = (index: number, patch: Partial<MCPServerConfig>) => {
onChange(servers.map((server, serverIndex) => (
serverIndex === index ? { ...server, ...patch } : server
)))
}
const updateAuthorization = (index: number, patch: Partial<MCPAuthorization>) => {
const server = servers[index]
if (!server) {
return
}
updateServer(index, {
authorization: {
...server.authorization,
...patch,
},
})
}
const addServer = () => {
onChange([
...servers,
{
...DEFAULT_MCP_SERVER,
name: `mcp-server-${servers.length + 1}`,
},
])
}
const duplicateServer = (index: number) => {
const server = servers[index]
if (!server) {
return
}
const nextServer = {
...server,
name: `${server.name || 'mcp-server'}-copy`,
args: [...server.args],
env: { ...server.env },
headers: { ...server.headers },
authorization: { ...server.authorization },
}
onChange([
...servers.slice(0, index + 1),
nextServer,
...servers.slice(index + 1),
])
}
const removeServer = (index: number) => {
onChange(servers.filter((_, serverIndex) => serverIndex !== index))
}
return (
<Card>
<CardHeader className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">MCP </CardTitle>
<Badge variant="secondary" className="text-xs">
{servers.length}
</Badge>
</div>
<CardDescription>
mcp.serversstdio streamable_http MCP
</CardDescription>
</div>
<Button type="button" size="sm" onClick={addServer}>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{servers.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground">
MCP MaiSaka
</div>
) : (
servers.map((server, index) => (
<Card key={`${server.name}-${index}`} className="border-border/70 bg-muted/20 shadow-none">
<CardHeader className="space-y-3 px-4 py-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Switch
checked={server.enabled}
onCheckedChange={(enabled) => updateServer(index, { enabled })}
/>
<div className="min-w-0 flex-1">
<Input
value={server.name}
onChange={(event) => updateServer(index, { name: event.target.value })}
placeholder="服务名称,必须唯一"
className="h-8 font-medium"
/>
</div>
<Badge variant={server.enabled ? 'default' : 'secondary'} className="shrink-0 text-[10px]">
{server.enabled ? '启用' : '禁用'}
</Badge>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => duplicateServer(index)}
title="复制服务"
>
<Copy className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => removeServer(index)}
title="删除服务"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4 pt-0">
<div className="grid gap-3 md:grid-cols-[12rem_1fr]">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground"></label>
<Select
value={server.transport}
onValueChange={(transport) => updateServer(index, { transport: transport as MCPTransport })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">stdio</SelectItem>
<SelectItem value="streamable_http">streamable_http</SelectItem>
</SelectContent>
</Select>
</div>
{server.transport === 'stdio' ? (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground"></label>
<Input
value={server.command}
onChange={(event) => updateServer(index, { command: event.target.value })}
placeholder="例如 uvx、npx、python"
/>
</div>
) : (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground"> URL</label>
<Input
value={server.url}
onChange={(event) => updateServer(index, { url: event.target.value })}
placeholder="https://example.com/mcp"
/>
</div>
)}
</div>
{server.transport === 'stdio' ? (
<div className="grid gap-3 lg:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground"></label>
<Textarea
value={server.args.join('\n')}
onChange={(event) => updateServer(index, {
args: event.target.value
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0),
})}
rows={4}
placeholder="每行一个参数"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground"></label>
<KeyValueEditor
value={server.env}
onChange={(env) => updateServer(index, { env: asStringMap(env) })}
/>
</div>
</div>
) : (
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground"></label>
<Select
value={server.authorization.mode}
onValueChange={(mode) => updateAuthorization(index, { mode: mode as MCPAuthorization['mode'] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">none</SelectItem>
<SelectItem value="bearer">bearer</SelectItem>
</SelectContent>
</Select>
</div>
{server.authorization.mode === 'bearer' && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Bearer Token</label>
<Input
type="password"
value={server.authorization.bearer_token}
onChange={(event) => updateAuthorization(index, { bearer_token: event.target.value })}
placeholder="HTTP Bearer Token"
/>
</div>
)}
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground"> Headers</label>
<KeyValueEditor
value={server.headers}
onChange={(headers) => updateServer(index, { headers: asStringMap(headers) })}
/>
</div>
</div>
)}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">HTTP </label>
<Input
type="number"
min={0.1}
step={0.1}
value={server.http_timeout_seconds}
onChange={(event) => updateServer(index, {
http_timeout_seconds: Number.parseFloat(event.target.value) || 0.1,
})}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground"></label>
<Input
type="number"
min={0.1}
step={0.1}
value={server.read_timeout_seconds}
onChange={(event) => updateServer(index, {
read_timeout_seconds: Number.parseFloat(event.target.value) || 0.1,
})}
/>
</div>
</div>
</CardContent>
</Card>
))
)}
</CardContent>
</Card>
)
}
export function MCPSettingsPage() {
return (
<RestartProvider>
<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],
] 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,
fields: mcpSchema.fields.filter((field) => field.name !== 'servers'),
nested: mcpSchema.nested
? Object.fromEntries(
Object.entries(mcpSchema.nested).filter(([key]) => key !== 'servers'),
)
: undefined,
},
},
}
: null
const mcpServers = normalizeMCPServers(mcpConfig.servers)
return (
<ScrollArea className="h-full">
<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 && (
<MCPServersBlockEditor
servers={mcpServers}
onChange={(servers) => {
setMcpConfig((currentConfig) => ({
...currentConfig,
servers,
}))
setHasUnsavedChanges(true)
}}
/>
)}
{!loading && formSchema && (
<DynamicConfigForm
schema={formSchema}
values={{ mcp: mcpConfig }}
onChange={(fieldPath, value) => {
const [, ...restPath] = fieldPath.split('.')
const nextConfig = restPath.length === 0
? (value as ConfigSectionData)
: updateNestedValue(mcpConfig, restPath, value)
setMcpConfig(nextConfig)
setHasUnsavedChanges(true)
}}
hooks={fieldHooks}
/>
)}
{!loading && !formSchema && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription> schema MCP </AlertDescription>
</Alert>
)}
<RestartOverlay />
</div>
</ScrollArea>
)
}

View File

@@ -15,9 +15,11 @@ import {
CircleDot,
Clock,
Eraser,
ExternalLink,
Gauge,
MessageSquare,
PauseCircle,
Radio,
Timer,
Wrench,
XCircle,
@@ -36,6 +38,7 @@ import type {
CycleStartEvent,
MaisakaToolCall,
MessageIngestedEvent,
PlannerFinalizedEvent,
PlannerResponseEvent,
ReplierResponseEvent,
TimingGateResultEvent,
@@ -51,6 +54,10 @@ function formatMs(ms: number): string {
return `${(ms / 1000).toFixed(2)}s`
}
function buildCycleKey(sessionId: string, cycleId: number) {
return `${sessionId}:${cycleId}`
}
function formatTimestamp(ts: number): string {
return new Date(ts * 1000).toLocaleTimeString('zh-CN', {
hour: '2-digit',
@@ -73,18 +80,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,28 +109,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">
<span className="font-medium truncate max-w-35">
{session.sessionName}
</span>
<Badge variant="secondary" className="text-[10px] h-4 px-1">
<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>
)}
{!collapsed && <span className="min-w-0 flex-1 truncate font-medium" title={session.sessionName}>
{session.sessionName}
</span>}
</div>
{!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>
@@ -176,7 +207,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}
@@ -191,6 +223,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">
@@ -208,21 +263,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">
@@ -387,16 +541,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':
@@ -418,11 +586,29 @@ export function MaisakaMonitor() {
selectedSession,
setSelectedSession,
connected,
backgroundCollection,
setBackgroundCollectionEnabled,
clearTimeline,
} = useMaisakaMonitor()
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(() => {
@@ -445,20 +631,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 />
@@ -467,6 +676,7 @@ export function MaisakaMonitor() {
sessions={sessions}
selectedSession={selectedSession}
onSelect={setSelectedSession}
collapsed={sidebarCollapsed}
/>
</ScrollArea>
</Card>
@@ -490,6 +700,26 @@ export function MaisakaMonitor() {
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
variant={backgroundCollection ? 'secondary' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setBackgroundCollectionEnabled(!backgroundCollection)}
title={backgroundCollection ? '关闭离开页面后的持续获取' : '开启离开页面后的持续获取'}
>
<Radio className={cn('h-3.5 w-3.5 mr-1', backgroundCollection && 'text-primary')} />
</Button>
<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"
@@ -528,21 +758,39 @@ export function MaisakaMonitor() {
</p>
</div>
) : (
timeline.map((entry) => {
const rendered = <TimelineEventRenderer entry={entry} />
if (!rendered) return null
return (
<div
key={entry.id}
className="animate-in fade-in-0 slide-in-from-bottom-2 duration-300"
>
{rendered}
{entry.type === 'cycle.end' && (
<Separator className="mt-3" />
)}
</div>
)
})
(() => {
const continuedTimingGateCycles = new Set<string>()
return timeline.map((entry) => {
if (entry.type === 'timing_gate.result') {
const data = entry.data as TimingGateResultEvent
if (data.action === 'continue') {
continuedTimingGateCycles.add(buildCycleKey(data.session_id, data.cycle_id))
}
}
if (entry.type === 'planner.response' || entry.type === 'planner.finalized') {
const data = entry.data as PlannerResponseEvent | PlannerFinalizedEvent
if (!continuedTimingGateCycles.has(buildCycleKey(data.session_id, data.cycle_id))) {
return null
}
}
const rendered = <TimelineEventRenderer entry={entry} showCycleMarkers={showCycleMarkers} />
if (!rendered) return null
return (
<div
key={entry.id}
className="animate-in fade-in-0 slide-in-from-bottom-2 duration-300"
>
{rendered}
{entry.type === 'cycle.end' && (
<Separator className="mt-3" />
)}
</div>
)
})
})()
)}
</div>
</ScrollArea>

View File

@@ -3,7 +3,7 @@
*
* 管理 WebSocket 订阅与事件流的状态。
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import type { MaisakaMonitorEvent } from '@/lib/maisaka-monitor-client'
import { maisakaMonitorClient } from '@/lib/maisaka-monitor-client'
@@ -26,105 +26,266 @@ export interface TimelineEntry {
export interface SessionInfo {
sessionId: string
sessionName: string
isGroupChat?: boolean
groupId?: string | null
userId?: string | null
platform?: string
lastActivity: number
eventCount: number
}
/** 最大保留的时间线条目数 */
const MAX_TIMELINE_ENTRIES = 500
const BACKGROUND_COLLECTION_STORAGE_KEY = 'maisaka-monitor-background-collection'
function resolveSessionDisplayName({
fallbackName,
groupId,
isGroupChat,
sessionId,
userId,
}: {
fallbackName?: string
groupId?: string | null
isGroupChat?: boolean
sessionId: string
userId?: string | null
}) {
const targetId = isGroupChat ? groupId : userId
const normalizedName = fallbackName?.trim()
if (targetId && normalizedName?.endsWith(`(${targetId})`)) {
return normalizedName
}
if (normalizedName && targetId && normalizedName !== targetId && normalizedName !== sessionId) {
return `${normalizedName}(${targetId})`
}
if (isGroupChat && groupId) {
return groupId
}
if (!isGroupChat && userId) {
return userId
}
return fallbackName || sessionId.slice(0, 8)
}
let entryCounter = 0
let cachedTimeline: TimelineEntry[] = []
let cachedSessions: Map<string, SessionInfo> = new Map()
let cachedSelectedSession: string | null = null
let cachedConnected = false
let backgroundCollectionEnabled = false
let backgroundCollectionPreferenceLoaded = false
let activeConsumerCount = 0
let monitorSubscriptionStarted = false
let monitorSubscriptionPromise: Promise<void> | null = null
let monitorUnsubscribe: (() => Promise<void>) | null = null
const storeListeners = new Set<() => void>()
export function useMaisakaMonitor() {
const [timeline, setTimeline] = useState<TimelineEntry[]>([])
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map())
const [selectedSession, setSelectedSession] = useState<string | null>(null)
const [connected, setConnected] = useState(false)
const unsubRef = useRef<(() => Promise<void>) | null>(null)
function notifyStoreListeners() {
storeListeners.forEach((listener) => listener())
}
const handleEvent = useCallback((event: MaisakaMonitorEvent) => {
const sessionId = (event.data as unknown as Record<string, unknown>).session_id as string
const timestamp = (event.data as unknown as Record<string, unknown>).timestamp as number
function loadBackgroundCollectionPreference() {
if (backgroundCollectionPreferenceLoaded) {
return backgroundCollectionEnabled
}
const entry: TimelineEntry = {
id: `evt_${++entryCounter}_${Date.now()}`,
type: event.type,
data: event.data,
timestamp,
backgroundCollectionPreferenceLoaded = true
if (typeof window !== 'undefined') {
backgroundCollectionEnabled = window.localStorage.getItem(BACKGROUND_COLLECTION_STORAGE_KEY) === 'true'
}
return backgroundCollectionEnabled
}
function shouldKeepMonitorActive() {
return activeConsumerCount > 0 || backgroundCollectionEnabled
}
function appendTimelineEntry(entry: TimelineEntry) {
const next = [...cachedTimeline, entry]
cachedTimeline = next.length > MAX_TIMELINE_ENTRIES
? next.slice(next.length - MAX_TIMELINE_ENTRIES)
: next
}
function updateSessionInfo(event: MaisakaMonitorEvent, sessionId: string, timestamp: number) {
const dataRecord = event.data as unknown as Record<string, unknown>
const isGroupChat = typeof dataRecord.is_group_chat === 'boolean'
? dataRecord.is_group_chat
: undefined
const groupId = typeof dataRecord.group_id === 'string' ? dataRecord.group_id : null
const userId = typeof dataRecord.user_id === 'string' ? dataRecord.user_id : null
const platform = typeof dataRecord.platform === 'string' ? dataRecord.platform : undefined
const sessionName = typeof dataRecord.session_name === 'string'
? dataRecord.session_name
: undefined
const next = new Map(cachedSessions)
const existing = next.get(sessionId)
if (event.type === 'session.start' || !existing) {
next.set(sessionId, {
sessionId,
}
setTimeline((prev) => {
const next = [...prev, entry]
return next.length > MAX_TIMELINE_ENTRIES
? next.slice(next.length - MAX_TIMELINE_ENTRIES)
: next
sessionName: resolveSessionDisplayName({
fallbackName: sessionName,
groupId,
isGroupChat,
sessionId,
userId,
}),
isGroupChat,
groupId,
userId,
platform,
lastActivity: timestamp,
eventCount: (existing?.eventCount ?? 0) + 1,
})
} else {
next.set(sessionId, {
...existing,
sessionName: resolveSessionDisplayName({
fallbackName: sessionName ?? existing.sessionName,
groupId: groupId ?? existing.groupId,
isGroupChat: isGroupChat ?? existing.isGroupChat,
sessionId,
userId: userId ?? existing.userId,
}),
isGroupChat: isGroupChat ?? existing.isGroupChat,
groupId: groupId ?? existing.groupId,
userId: userId ?? existing.userId,
platform: platform ?? existing.platform,
lastActivity: timestamp,
eventCount: existing.eventCount + 1,
})
}
// 更新会话信息
if (event.type === 'session.start') {
const d = event.data
setSessions((prev) => {
const next = new Map(prev)
next.set(sessionId, {
sessionId,
sessionName: d.session_name,
lastActivity: timestamp,
eventCount: (prev.get(sessionId)?.eventCount ?? 0) + 1,
})
return next
})
} else {
setSessions((prev) => {
const existing = prev.get(sessionId)
if (!existing) {
const next = new Map(prev)
next.set(sessionId, {
sessionId,
sessionName: sessionId.slice(0, 8),
lastActivity: timestamp,
eventCount: 1,
})
return next
}
const next = new Map(prev)
next.set(sessionId, {
...existing,
lastActivity: timestamp,
eventCount: existing.eventCount + 1,
})
return next
})
}
cachedSessions = next
}
// 自动选中第一个会话
setSelectedSession((current) => current ?? sessionId)
}, [])
function handleMonitorEvent(event: MaisakaMonitorEvent) {
const dataRecord = event.data as unknown as Record<string, unknown>
const sessionId = dataRecord.session_id as string
const timestamp = dataRecord.timestamp as number
useEffect(() => {
let cancelled = false
if (!sessionId || typeof timestamp !== 'number') {
return
}
maisakaMonitorClient.subscribe(handleEvent).then((unsub) => {
if (cancelled) {
appendTimelineEntry({
id: `evt_${++entryCounter}_${Date.now()}`,
type: event.type,
data: event.data,
timestamp,
sessionId,
})
updateSessionInfo(event, sessionId, timestamp)
if (cachedSelectedSession === null) {
cachedSelectedSession = sessionId
}
notifyStoreListeners()
}
function ensureMonitorSubscription() {
if (monitorSubscriptionStarted || monitorSubscriptionPromise !== null) {
return
}
monitorSubscriptionPromise = maisakaMonitorClient
.subscribe(handleMonitorEvent)
.then((unsub) => {
monitorUnsubscribe = unsub
if (!shouldKeepMonitorActive()) {
monitorUnsubscribe = null
void unsub()
cachedConnected = false
notifyStoreListeners()
return
}
unsubRef.current = unsub
setConnected(true)
monitorSubscriptionStarted = true
cachedConnected = true
notifyStoreListeners()
})
.catch((error) => {
console.error('MaiSaka 监控订阅失败:', error)
cachedConnected = false
notifyStoreListeners()
})
.finally(() => {
monitorSubscriptionPromise = null
})
}
return () => {
cancelled = true
if (unsubRef.current) {
void unsubRef.current()
unsubRef.current = null
}
setConnected(false)
function stopMonitorSubscriptionIfIdle() {
if (shouldKeepMonitorActive()) {
return
}
if (monitorUnsubscribe) {
const unsub = monitorUnsubscribe
monitorUnsubscribe = null
monitorSubscriptionStarted = false
cachedConnected = false
notifyStoreListeners()
void unsub()
}
}
export function useMaisakaMonitor() {
const [timeline, setTimeline] = useState<TimelineEntry[]>(cachedTimeline)
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map(cachedSessions))
const [selectedSession, setSelectedSessionState] = useState<string | null>(cachedSelectedSession)
const [connected, setConnected] = useState(cachedConnected)
const [backgroundCollection, setBackgroundCollection] = useState(loadBackgroundCollectionPreference)
useEffect(() => {
activeConsumerCount += 1
ensureMonitorSubscription()
const syncFromStore = () => {
setTimeline(cachedTimeline)
setSessions(new Map(cachedSessions))
setSelectedSessionState(cachedSelectedSession)
setConnected(cachedConnected)
setBackgroundCollection(backgroundCollectionEnabled)
}
}, [handleEvent])
storeListeners.add(syncFromStore)
syncFromStore()
return () => {
storeListeners.delete(syncFromStore)
activeConsumerCount = Math.max(0, activeConsumerCount - 1)
stopMonitorSubscriptionIfIdle()
}
}, [])
const clearTimeline = useCallback(() => {
cachedTimeline = []
setTimeline([])
notifyStoreListeners()
}, [])
const setSelectedSession = useCallback((sessionId: string | null) => {
cachedSelectedSession = sessionId
setSelectedSessionState(sessionId)
notifyStoreListeners()
}, [])
const setBackgroundCollectionEnabled = useCallback((enabled: boolean) => {
backgroundCollectionEnabled = enabled
backgroundCollectionPreferenceLoaded = true
if (typeof window !== 'undefined') {
window.localStorage.setItem(BACKGROUND_COLLECTION_STORAGE_KEY, String(enabled))
}
if (enabled) {
ensureMonitorSubscription()
} else {
stopMonitorSubscriptionIfIdle()
}
notifyStoreListeners()
}, [])
/** 当前选中会话的时间线 */
@@ -139,6 +300,8 @@ export function useMaisakaMonitor() {
selectedSession,
setSelectedSession,
connected,
backgroundCollection,
setBackgroundCollectionEnabled,
clearTimeline,
}
}

View File

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

View File

@@ -110,10 +110,20 @@ export function PluginDetailPage() {
throw new Error('未找到该插件')
}
const rawManifest = foundPlugin.manifest || {}
const repositoryUrl = rawManifest.repository_url || rawManifest.urls?.repository
const homepageUrl = rawManifest.homepage_url || rawManifest.urls?.homepage
// 转换为 PluginInfo 格式
const pluginInfo: PluginInfo = {
id: foundPlugin.id,
manifest: foundPlugin.manifest,
manifest: {
...rawManifest,
homepage_url: homepageUrl,
repository_url: repositoryUrl,
default_locale: rawManifest.default_locale || rawManifest.i18n?.default_locale || 'zh-CN',
locales_path: rawManifest.locales_path || rawManifest.i18n?.locales_path,
},
downloads: 0,
rating: 0,
review_count: 0,
@@ -270,7 +280,8 @@ export function PluginDetailPage() {
try {
setOperating(true)
const installResult = await installPlugin(plugin.id, plugin.manifest.repository_url || '', 'main')
const repositoryUrl = plugin.manifest.repository_url || plugin.manifest.urls?.repository || ''
const installResult = await installPlugin(plugin.id, repositoryUrl, 'main')
if (!installResult.success) {
toast({
@@ -367,7 +378,8 @@ export function PluginDetailPage() {
try {
setOperating(true)
const updateResult = await updatePlugin(plugin.id, plugin.manifest.repository_url || '', 'main')
const repositoryUrl = plugin.manifest.repository_url || plugin.manifest.urls?.repository || ''
const updateResult = await updatePlugin(plugin.id, repositoryUrl, 'main')
if (!updateResult.success) {
toast({

View File

@@ -214,6 +214,7 @@ function PluginsPageContent() {
for (const installedPlugin of installed) {
const existsInMarket = mergedData.some(p => p.id === installedPlugin.id)
if (!existsInMarket && installedPlugin.manifest) {
const urls = installedPlugin.manifest.urls as PluginInfo['manifest']['urls'] | undefined
// 添加本地插件到列表
mergedData.push({
id: installedPlugin.id,
@@ -225,8 +226,9 @@ function PluginsPageContent() {
author: installedPlugin.manifest.author,
license: installedPlugin.manifest.license || 'Unknown',
host_application: installedPlugin.manifest.host_application,
homepage_url: installedPlugin.manifest.homepage_url,
repository_url: installedPlugin.manifest.repository_url,
homepage_url: installedPlugin.manifest.homepage_url || urls?.homepage,
repository_url: installedPlugin.manifest.repository_url || urls?.repository,
urls,
keywords: installedPlugin.manifest.keywords || [],
categories: installedPlugin.manifest.categories || [],
default_locale: (installedPlugin.manifest.default_locale as string) || 'zh-CN',
@@ -430,7 +432,7 @@ function PluginsPageContent() {
const installResult = await installPlugin(
installingPlugin.id,
installingPlugin.manifest.repository_url || '',
installingPlugin.manifest.repository_url || installingPlugin.manifest.urls?.repository || '',
branch
)
@@ -574,7 +576,7 @@ function PluginsPageContent() {
try {
const updateResult = await updatePlugin(
plugin.id,
plugin.manifest.repository_url || '',
plugin.manifest.repository_url || plugin.manifest.urls?.repository || '',
'main'
)

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
// 设置向导各步骤表单组件
import { ExternalLink, Eye, EyeOff, X } from 'lucide-react'
import { Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -15,16 +14,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import type {
ApiProviderSetupConfig,
BotBasicConfig,
EmojiConfig,
OtherBasicConfig,
ModelSetupConfig,
PersonalityConfig,
SiliconFlowConfig,
} from './types'
// ====== 步骤1Bot基础配置 ======
@@ -156,22 +153,6 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
}
}
const handleAddAlias = (alias: string) => {
if (alias.trim() && !config.alias_names.includes(alias.trim())) {
onChange({
...config,
alias_names: [...config.alias_names, alias.trim()],
})
}
}
const handleRemoveAlias = (index: number) => {
onChange({
...config,
alias_names: config.alias_names.filter((_, aliasIndex) => aliasIndex !== index),
})
}
return (
<div className="space-y-6">
<div className="space-y-3">
@@ -254,53 +235,6 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
{t('setupPage.forms.botBasic.nickname.description')}
</p>
</div>
<div className="space-y-3">
<Label>{t('setupPage.forms.botBasic.alias.label')}</Label>
<div className="mb-2 flex flex-wrap gap-2">
{config.alias_names.map((alias, index) => (
<Badge key={index} variant="secondary" className="gap-1">
{alias}
<button
type="button"
onClick={() => handleRemoveAlias(index)}
className="hover:text-destructive ml-1"
aria-label={t('setupPage.forms.botBasic.alias.remove', { alias })}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
id="alias_input"
placeholder={t('setupPage.forms.botBasic.alias.placeholder')}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleAddAlias((e.target as HTMLInputElement).value)
;(e.target as HTMLInputElement).value = ''
}
}}
/>
<Button
type="button"
variant="outline"
onClick={() => {
const input = document.getElementById('alias_input') as HTMLInputElement
if (input) {
handleAddAlias(input.value)
input.value = ''
}
}}
>
{t('setupPage.forms.botBasic.alias.add')}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.botBasic.alias.description')}
</p>
</div>
</div>
)
}
@@ -313,7 +247,6 @@ interface PersonalityFormProps {
export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
const { t } = useTranslation()
const multipleReplyStyleText = config.multiple_reply_style.join('\n')
return (
<div className="space-y-6">
@@ -344,276 +277,61 @@ export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
{t('setupPage.forms.personality.replyStyle.description')}
</p>
</div>
<div className="space-y-3">
<Label htmlFor="multiple_reply_style">
{t('setupPage.forms.personality.multipleReplyStyle.label')}
</Label>
<Textarea
id="multiple_reply_style"
placeholder={t('setupPage.forms.personality.multipleReplyStyle.placeholder')}
value={multipleReplyStyleText}
onChange={(e) =>
onChange({
...config,
multiple_reply_style: e.target.value
.split('\n')
.map((style) => style.trim())
.filter(Boolean),
})
}
rows={5}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.personality.multipleReplyStyle.description')}
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="multiple_probability">
{t('setupPage.forms.personality.multipleProbability.label')}
</Label>
<span className="text-muted-foreground text-sm">
{(config.multiple_probability * 100).toFixed(0)}%
</span>
</div>
<Input
id="multiple_probability"
type="range"
min="0"
max="1"
step="0.1"
value={config.multiple_probability}
onChange={(e) => onChange({ ...config, multiple_probability: Number(e.target.value) })}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.personality.multipleProbability.description')}
</p>
</div>
</div>
)
}
// ====== 步骤3表情包配置 ======
interface EmojiFormProps {
config: EmojiConfig
onChange: (config: EmojiConfig) => void
// ====== 步骤3API 提供商配置 ======
interface ApiProviderSetupFormProps {
config: ApiProviderSetupConfig
onChange: (config: ApiProviderSetupConfig) => void
}
export function EmojiForm({ config, onChange }: EmojiFormProps) {
const { t } = useTranslation()
return (
<div className="space-y-6">
<div className="space-y-3">
<Label htmlFor="emoji_send_num">{t('setupPage.forms.emoji.emojiSendNum.label')}</Label>
<Input
id="emoji_send_num"
type="number"
min="1"
max="64"
value={config.emoji_send_num}
onChange={(e) => onChange({ ...config, emoji_send_num: Number(e.target.value) })}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.emoji.emojiSendNum.description')}
</p>
</div>
<div className="space-y-3">
<Label htmlFor="max_reg_num">{t('setupPage.forms.emoji.maxRegNum.label')}</Label>
<Input
id="max_reg_num"
type="number"
min="1"
max="200"
value={config.max_reg_num}
onChange={(e) => onChange({ ...config, max_reg_num: Number(e.target.value) })}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.emoji.maxRegNum.description')}
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="do_replace">{t('setupPage.forms.emoji.doReplace.label')}</Label>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.emoji.doReplace.description')}
</p>
</div>
<Switch
id="do_replace"
checked={config.do_replace}
onCheckedChange={(checked) => onChange({ ...config, do_replace: checked })}
/>
</div>
<div className="space-y-3">
<Label htmlFor="check_interval">{t('setupPage.forms.emoji.checkInterval.label')}</Label>
<Input
id="check_interval"
type="number"
min="1"
max="120"
value={config.check_interval}
onChange={(e) => onChange({ ...config, check_interval: Number(e.target.value) })}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.emoji.checkInterval.description')}
</p>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="steal_emoji">{t('setupPage.forms.emoji.stealEmoji.label')}</Label>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.emoji.stealEmoji.description')}
</p>
</div>
<Switch
id="steal_emoji"
checked={config.steal_emoji}
onCheckedChange={(checked) => onChange({ ...config, steal_emoji: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="content_filtration">
{t('setupPage.forms.emoji.contentFiltration.label')}
</Label>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.emoji.contentFiltration.description')}
</p>
</div>
<Switch
id="content_filtration"
checked={config.content_filtration}
onCheckedChange={(checked) => onChange({ ...config, content_filtration: checked })}
/>
</div>
{config.content_filtration && (
<div className="space-y-3">
<Label htmlFor="filtration_prompt">
{t('setupPage.forms.emoji.filtrationPrompt.label')}
</Label>
<Input
id="filtration_prompt"
placeholder={t('setupPage.forms.emoji.filtrationPrompt.placeholder')}
value={config.filtration_prompt}
onChange={(e) => onChange({ ...config, filtration_prompt: e.target.value })}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.emoji.filtrationPrompt.description')}
</p>
</div>
)}
</div>
)
}
// ====== 步骤4其他基础配置 ======
interface OtherBasicFormProps {
config: OtherBasicConfig
onChange: (config: OtherBasicConfig) => void
}
export function OtherBasicForm({ config, onChange }: OtherBasicFormProps) {
const { t } = useTranslation()
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="all_global">{t('setupPage.forms.other.allGlobal.label')}</Label>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.other.allGlobal.description')}
</p>
</div>
<Switch
id="all_global"
checked={config.all_global}
onCheckedChange={(checked) => onChange({ ...config, all_global: checked })}
/>
</div>
</div>
)
}
// ====== 步骤5硅基流动API配置 ======
interface SiliconFlowFormProps {
config: SiliconFlowConfig
onChange: (config: SiliconFlowConfig) => void
}
export function SiliconFlowForm({ config, onChange }: SiliconFlowFormProps) {
export function ApiProviderSetupForm({ config, onChange }: ApiProviderSetupFormProps) {
const { t } = useTranslation()
const [showApiKey, setShowApiKey] = useState(false)
const apiKeyToggleLabel = showApiKey
? t('setupPage.forms.siliconFlow.apiKey.hide')
: t('setupPage.forms.siliconFlow.apiKey.show')
const autoConfigItems = [
t('setupPage.forms.siliconFlow.autoConfig.items.deepseek'),
t('setupPage.forms.siliconFlow.autoConfig.items.qwen3'),
t('setupPage.forms.siliconFlow.autoConfig.items.qwen3Vl'),
t('setupPage.forms.siliconFlow.autoConfig.items.senseVoice'),
t('setupPage.forms.siliconFlow.autoConfig.items.bgeM3'),
t('setupPage.forms.siliconFlow.autoConfig.items.lpmm'),
]
? t('setupPage.forms.apiProvider.apiKey.hide')
: t('setupPage.forms.apiProvider.apiKey.show')
return (
<div className="space-y-6">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
<div className="flex items-start gap-3">
<div className="mt-0.5">
<svg
className="h-5 w-5 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="flex-1 text-sm">
<p className="mb-1 font-medium text-blue-900 dark:text-blue-100">
{t('setupPage.forms.siliconFlow.about.title')}
</p>
<p className="mb-2 text-blue-700 dark:text-blue-300">
{t('setupPage.forms.siliconFlow.about.description')}
</p>
<a
href="https://cloud.siliconflow.cn"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{t('setupPage.forms.siliconFlow.about.link')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
<div className="space-y-3">
<Label htmlFor="provider_name">{t('setupPage.forms.apiProvider.providerName.label')}</Label>
<Input
id="provider_name"
placeholder={t('setupPage.forms.apiProvider.providerName.placeholder')}
value={config.provider_name}
onChange={(e) => onChange({ ...config, provider_name: e.target.value })}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.apiProvider.providerName.description')}
</p>
</div>
<div className="space-y-3">
<Label htmlFor="siliconflow_api_key">{t('setupPage.forms.siliconFlow.apiKey.label')}</Label>
<Label htmlFor="base_url">{t('setupPage.forms.apiProvider.baseUrl.label')}</Label>
<Input
id="base_url"
placeholder="https://api.example.com/v1"
value={config.base_url}
onChange={(e) => onChange({ ...config, base_url: e.target.value })}
className="font-mono"
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.apiProvider.baseUrl.description')}
</p>
</div>
<div className="space-y-3">
<Label htmlFor="api_key">{t('setupPage.forms.apiProvider.apiKey.label')}</Label>
<div className="relative">
<Input
id="siliconflow_api_key"
id="api_key"
type={showApiKey ? 'text' : 'password'}
placeholder="sk-..."
value={config.api_key}
onChange={(e) => onChange({ api_key: e.target.value })}
onChange={(e) => onChange({ ...config, api_key: e.target.value })}
className="pr-10 font-mono"
/>
<Button
@@ -633,25 +351,103 @@ export function SiliconFlowForm({ config, onChange }: SiliconFlowFormProps) {
</Button>
</div>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.siliconFlow.apiKey.description')}
</p>
</div>
<div className="bg-muted/50 space-y-2 rounded-lg p-4 text-sm">
<p className="font-medium">{t('setupPage.forms.siliconFlow.autoConfig.title')}</p>
<ul className="text-muted-foreground ml-2 list-inside list-disc space-y-1">
{autoConfigItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm text-amber-900 dark:text-amber-100">
<span className="font-medium">{t('setupPage.forms.siliconFlow.hint.title')}</span>
{t('setupPage.forms.siliconFlow.hint.description')}
{t('setupPage.forms.apiProvider.apiKey.description')}
</p>
</div>
</div>
)
}
// ====== 步骤4基础模型配置 ======
interface ModelSetupFormProps {
config: ModelSetupConfig
onChange: (config: ModelSetupConfig) => void
}
export function ModelSetupForm({ config, onChange }: ModelSetupFormProps) {
const { t } = useTranslation()
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-3">
<Label htmlFor="planner_model_identifier">
{t('setupPage.forms.modelSetup.planner.identifier.label')}
</Label>
<Input
id="planner_model_identifier"
placeholder="gpt-4.1-mini"
value={config.planner_model_identifier}
onChange={(e) =>
onChange({
...config,
planner_model_identifier: e.target.value,
planner_model_name: e.target.value,
})
}
className="font-mono"
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.modelSetup.planner.identifier.description')}
</p>
</div>
<div className="flex items-center justify-between gap-4 rounded-md bg-muted/40 p-3">
<Label htmlFor="planner_visual" className="text-sm font-medium">
{t('setupPage.forms.modelSetup.planner.visual.label')}
</Label>
<Switch
id="planner_visual"
checked={config.planner_visual}
onCheckedChange={(checked) =>
onChange({ ...config, planner_visual: checked })
}
/>
</div>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-3">
<Label htmlFor="replyer_model_identifier">
{t('setupPage.forms.modelSetup.replyer.identifier.label')}
</Label>
<Input
id="replyer_model_identifier"
placeholder="gpt-4.1"
value={config.replyer_model_identifier}
onChange={(e) =>
onChange({
...config,
replyer_model_identifier: e.target.value,
replyer_model_name: e.target.value,
})
}
className="font-mono"
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.modelSetup.replyer.identifier.description')}
</p>
</div>
<div className="flex items-center justify-between gap-4 rounded-md bg-muted/40 p-3">
<Label htmlFor="replyer_visual" className="text-sm font-medium">
{t('setupPage.forms.modelSetup.replyer.visual.label')}
</Label>
<Switch
id="replyer_visual"
checked={config.replyer_visual}
onCheckedChange={(checked) =>
onChange({ ...config, replyer_visual: checked })
}
/>
</div>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 text-sm text-muted-foreground">
{t('setupPage.forms.modelSetup.saveHint')}
</div>
</div>
)
}

View File

@@ -4,13 +4,49 @@ import { parseResponse, throwIfError } from '@/lib/api-helpers'
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
import type {
ApiProviderSetupConfig,
BotBasicConfig,
EmojiConfig,
OtherBasicConfig,
ModelSetupConfig,
PersonalityConfig,
SiliconFlowConfig,
} from './types'
interface ModelInfo {
model_identifier: string
name: string
api_provider: string
price_in?: number
cache?: boolean
cache_price_in?: number
price_out?: number
force_stream_mode?: boolean
visual?: boolean
extra_params?: Record<string, unknown>
}
interface ApiProviderConfig {
name: string
base_url: string
api_key: string
client_type?: string
max_retry?: number
timeout?: number
retry_interval?: number
}
interface TaskConfig {
model_list?: string[]
max_tokens?: number
temperature?: number
slow_threshold?: number
selection_strategy?: string
}
interface ModelConfig {
models?: ModelInfo[]
api_providers?: ApiProviderConfig[]
model_task_config?: Record<string, TaskConfig>
}
// ===== 读取配置 =====
// 读取Bot基础配置
@@ -56,73 +92,57 @@ export async function loadPersonalityConfig(): Promise<PersonalityConfig> {
}
}
// 读取表情包配置
export async function loadEmojiConfig(): Promise<EmojiConfig> {
const response = await fetchWithAuth('/api/webui/config/bot', {
method: 'GET',
headers: getAuthHeaders(),
})
const result = await parseResponse<{ config: { emoji?: EmojiConfig } }>(
response
)
const data = throwIfError(result)
const emojiConfig = (data.config.emoji || {}) as Partial<EmojiConfig>
return {
emoji_send_num: emojiConfig.emoji_send_num ?? 25,
max_reg_num: emojiConfig.max_reg_num ?? 64,
do_replace: emojiConfig.do_replace ?? true,
check_interval: emojiConfig.check_interval ?? 10,
steal_emoji: emojiConfig.steal_emoji ?? true,
content_filtration: emojiConfig.content_filtration ?? false,
filtration_prompt: emojiConfig.filtration_prompt || '',
}
}
// 读取其他基础配置
export async function loadOtherBasicConfig(): Promise<OtherBasicConfig> {
const response = await fetchWithAuth('/api/webui/config/bot', {
method: 'GET',
headers: getAuthHeaders(),
})
const result = await parseResponse<{
config: {
expression?: { all_global_jargon?: boolean }
}
}>(response)
const data = throwIfError(result)
const config = data.config
const expressionConfig = config.expression || {}
return {
all_global: expressionConfig.all_global_jargon ?? true,
}
}
// 读取硅基流动API配置
export async function loadSiliconFlowConfig(): Promise<SiliconFlowConfig> {
async function loadModelConfig(): Promise<ModelConfig> {
const response = await fetchWithAuth('/api/webui/config/model', {
method: 'GET',
headers: getAuthHeaders(),
})
const result = await parseResponse<{
config: {
api_providers?: Array<{ name: string; api_key?: string }>
}
}>(response)
const result = await parseResponse<{ config: ModelConfig }>(response)
const data = throwIfError(result)
const modelConfig = data.config
return data.config || {}
}
// 获取SiliconFlow提供商的API Key
const apiProviders = modelConfig.api_providers || []
const siliconFlowProvider = apiProviders.find((p) => p.name === 'SiliconFlow')
// 读取 API 提供商配置
export async function loadApiProviderSetupConfig(): Promise<ApiProviderSetupConfig> {
const modelConfig = await loadModelConfig()
const models = modelConfig.models || []
const taskConfig = modelConfig.model_task_config || {}
const plannerName = taskConfig.planner?.model_list?.[0] || ''
const replyerName = taskConfig.replyer?.model_list?.[0] || ''
const plannerModel = models.find((model) => model.name === plannerName)
const replyerModel = models.find((model) => model.name === replyerName)
const providerName =
plannerModel?.api_provider ||
replyerModel?.api_provider ||
modelConfig.api_providers?.[0]?.name ||
''
const provider = modelConfig.api_providers?.find((item) => item.name === providerName)
return {
api_key: siliconFlowProvider?.api_key || '',
provider_name: providerName,
base_url: provider?.base_url || '',
api_key: '',
}
}
// 读取基础模型配置
export async function loadModelSetupConfig(): Promise<ModelSetupConfig> {
const modelConfig = await loadModelConfig()
const models = modelConfig.models || []
const taskConfig = modelConfig.model_task_config || {}
const plannerName = taskConfig.planner?.model_list?.[0] || ''
const replyerName = taskConfig.replyer?.model_list?.[0] || ''
const plannerModel = models.find((model) => model.name === plannerName)
const replyerModel = models.find((model) => model.name === replyerName)
return {
planner_model_name: plannerName,
planner_model_identifier: plannerModel?.model_identifier || plannerName,
planner_visual: Boolean(plannerModel?.visual),
replyer_model_name: replyerName,
replyer_model_identifier: replyerModel?.model_identifier || replyerName,
replyer_visual: Boolean(replyerModel?.visual),
}
}
@@ -143,19 +163,6 @@ export async function saveBotBasicConfig(config: BotBasicConfig) {
// 保存人格配置
export async function savePersonalityConfig(config: PersonalityConfig) {
const response = await fetchWithAuth('/api/webui/config/bot/section/personality', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(config),
}
)
const result = await parseResponse(response)
return throwIfError(result)
}
// 保存表情包配置
export async function saveEmojiConfig(config: EmojiConfig) {
const response = await fetchWithAuth('/api/webui/config/bot/section/emoji', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(config),
@@ -165,58 +172,62 @@ export async function saveEmojiConfig(config: EmojiConfig) {
return throwIfError(result)
}
// 保存其他基础配置(黑话)
export async function saveOtherBasicConfig(config: OtherBasicConfig) {
const response = await fetchWithAuth('/api/webui/config/bot/section/expression', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ all_global_jargon: config.all_global }),
})
const result = await parseResponse(response)
return throwIfError(result)
function createBasicModel(
modelName: string,
modelIdentifier: string,
providerName: string,
visual: boolean,
existing?: ModelInfo
): ModelInfo {
return {
price_in: 0,
cache: false,
cache_price_in: 0,
price_out: 0,
force_stream_mode: false,
extra_params: {},
...existing,
visual,
model_identifier: modelIdentifier,
name: modelName,
api_provider: providerName,
}
}
// 保存硅基流动API配置
export async function saveSiliconFlowConfig(config: SiliconFlowConfig) {
// 1. 读取现有配置
const response = await fetchWithAuth('/api/webui/config/model', {
method: 'GET',
headers: getAuthHeaders(),
})
function upsertModel(models: ModelInfo[], model: ModelInfo): ModelInfo[] {
const index = models.findIndex((item) => item.name === model.name)
if (index >= 0) {
return models.map((item, itemIndex) => (itemIndex === index ? model : item))
}
return [...models, model]
}
const result = await parseResponse<{
config: {
api_providers?: Array<Record<string, unknown>>
}
}>(response)
const currentModelConfig = throwIfError(result)
const modelConfig = currentModelConfig.config
// 保存 API 提供商配置
export async function saveApiProviderSetupConfig(config: ApiProviderSetupConfig) {
const modelConfig = await loadModelConfig()
const providerName = config.provider_name.trim()
// 2. 更新SiliconFlow提供商的API Key
const apiProviders = modelConfig.api_providers || []
const siliconFlowIndex = apiProviders.findIndex((p) => p.name === 'SiliconFlow')
if (siliconFlowIndex >= 0) {
// 更新现有提供商的API Key
apiProviders[siliconFlowIndex] = {
...apiProviders[siliconFlowIndex],
api_key: config.api_key,
}
} else {
// 如果不存在,创建新的SiliconFlow提供商
apiProviders.push({
name: 'SiliconFlow',
base_url: 'https://api.siliconflow.cn/v1',
api_key: config.api_key,
client_type: 'openai',
max_retry: 3,
timeout: 120,
retry_interval: 5,
})
const providerIndex = apiProviders.findIndex((provider) => provider.name === providerName)
const providerConfig: ApiProviderConfig = {
name: providerName,
base_url: config.base_url.trim(),
api_key: config.api_key.trim(),
client_type: 'openai',
max_retry: 3,
timeout: 120,
retry_interval: 5,
}
if (providerIndex >= 0) {
apiProviders[providerIndex] = {
...apiProviders[providerIndex],
...providerConfig,
}
} else {
apiProviders.push(providerConfig)
}
// 3. 保存更新后的配置
const updatedConfig = {
...modelConfig,
api_providers: apiProviders,
@@ -232,6 +243,77 @@ export async function saveSiliconFlowConfig(config: SiliconFlowConfig) {
return throwIfError(saveResult)
}
// 保存基础模型配置
export async function saveModelSetupConfig(
config: ModelSetupConfig,
providerName: string
) {
const modelConfig = await loadModelConfig()
const trimmedProviderName = providerName.trim()
const plannerModelIdentifier = config.planner_model_identifier.trim()
const plannerModelName = plannerModelIdentifier
const replyerModelIdentifier = config.replyer_model_identifier.trim()
const replyerModelName = replyerModelIdentifier
// 新增或更新 planner/replyer 模型,并仅同步 utils 到 planner。
let models = modelConfig.models || []
const existingPlannerModel = models.find((model) => model.name === plannerModelName)
const existingReplyerModel = models.find((model) => model.name === replyerModelName)
models = upsertModel(
models,
createBasicModel(
plannerModelName,
plannerModelIdentifier,
trimmedProviderName,
config.planner_visual,
existingPlannerModel
)
)
models = upsertModel(
models,
createBasicModel(
replyerModelName,
replyerModelIdentifier,
trimmedProviderName,
config.replyer_visual,
existingReplyerModel
)
)
const modelTaskConfig = modelConfig.model_task_config || {}
const updatedTaskConfig = {
...modelTaskConfig,
planner: {
...(modelTaskConfig.planner || {}),
model_list: [plannerModelName],
},
replyer: {
...(modelTaskConfig.replyer || {}),
model_list: [replyerModelName],
},
utils: {
...(modelTaskConfig.utils || {}),
model_list: [plannerModelName],
},
}
// vlm/voice/embedding 等其他任务配置保持原样。
const updatedConfig = {
...modelConfig,
models,
model_task_config: updatedTaskConfig,
}
const saveResponse = await fetchWithAuth('/api/webui/config/model', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(updatedConfig),
})
const saveResult = await parseResponse(saveResponse)
return throwIfError(saveResult)
}
// 标记设置完成
export async function completeSetup() {
const response = await fetchWithAuth('/api/webui/setup/complete', {

View File

@@ -1,13 +1,12 @@
import { useNavigate } from '@tanstack/react-router'
import {
ArrowRight,
Brain,
Bot,
CheckCircle2,
Globe,
Key,
Settings,
SkipForward,
Smile,
Sparkles,
User,
} from 'lucide-react'
@@ -38,31 +37,27 @@ import { cn } from '@/lib/utils'
import { APP_NAME } from '@/lib/version'
import { useToast } from '@/hooks/use-toast'
import type {
ApiProviderSetupConfig,
SetupStep,
BotBasicConfig,
ModelSetupConfig,
PersonalityConfig,
EmojiConfig,
OtherBasicConfig,
SiliconFlowConfig,
} from './types'
import {
ApiProviderSetupForm,
BotBasicForm,
ModelSetupForm,
PersonalityForm,
EmojiForm,
OtherBasicForm,
SiliconFlowForm,
} from './StepForms'
import {
loadBotBasicConfig,
loadPersonalityConfig,
loadEmojiConfig,
loadOtherBasicConfig,
loadSiliconFlowConfig,
loadApiProviderSetupConfig,
loadModelSetupConfig,
saveBotBasicConfig,
savePersonalityConfig,
saveEmojiConfig,
saveOtherBasicConfig,
saveSiliconFlowConfig,
saveApiProviderSetupConfig,
saveModelSetupConfig,
completeSetup,
} from './api'
import { RestartProvider, useRestart } from '@/lib/restart-context'
@@ -103,15 +98,6 @@ function SetupPageContent() {
],
multiple_probability: 0.2,
})
const createDefaultEmojiConfig = (): EmojiConfig => ({
emoji_send_num: 25,
max_reg_num: 64,
do_replace: true,
check_interval: 10,
steal_emoji: true,
content_filtration: false,
filtration_prompt: t('setupPage.defaults.emoji.filtrationPrompt'),
})
const [currentStep, setCurrentStep] = useState(0)
const [isCompleting, setIsCompleting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
@@ -131,17 +117,21 @@ function SetupPageContent() {
createDefaultPersonalityConfig()
)
// 步骤3表情包配置
const [emoji, setEmoji] = useState<EmojiConfig>(() => createDefaultEmojiConfig())
// 步骤4其他基础配置
const [otherBasic, setOtherBasic] = useState<OtherBasicConfig>({
all_global: true,
// 步骤3API 提供商配置
const [apiProviderSetup, setApiProviderSetup] = useState<ApiProviderSetupConfig>({
provider_name: '',
base_url: '',
api_key: '',
})
// 步骤5硅基流动API配置
const [siliconFlow, setSiliconFlow] = useState<SiliconFlowConfig>({
api_key: '',
// 步骤4基础模型配置
const [modelSetup, setModelSetup] = useState<ModelSetupConfig>({
planner_model_name: '',
planner_model_identifier: '',
planner_visual: false,
replyer_model_name: '',
replyer_model_identifier: '',
replyer_visual: false,
})
const steps: SetupStep[] = [
@@ -158,23 +148,17 @@ function SetupPageContent() {
icon: User,
},
{
id: 'emoji',
title: t('setupPage.steps.emoji.title'),
description: t('setupPage.steps.emoji.description'),
icon: Smile,
},
{
id: 'other',
title: t('setupPage.steps.other.title'),
description: t('setupPage.steps.other.description'),
icon: Settings,
},
{
id: 'siliconflow',
title: t('setupPage.steps.siliconFlow.title'),
description: t('setupPage.steps.siliconFlow.description'),
id: 'api-provider',
title: t('setupPage.steps.apiProvider.title'),
description: t('setupPage.steps.apiProvider.description'),
icon: Key,
},
{
id: 'model-setup',
title: t('setupPage.steps.modelSetup.title'),
description: t('setupPage.steps.modelSetup.description'),
icon: Brain,
},
]
const progress = ((currentStep + 1) / steps.length) * 100
@@ -186,19 +170,17 @@ function SetupPageContent() {
setIsLoading(true)
// 并行加载所有配置
const [bot, personality, emoji, other, silicon] = await Promise.all([
const [bot, personality, apiProvider, model] = await Promise.all([
loadBotBasicConfig(),
loadPersonalityConfig(),
loadEmojiConfig(),
loadOtherBasicConfig(),
loadSiliconFlowConfig(),
loadApiProviderSetupConfig(),
loadModelSetupConfig(),
])
setBotBasic(bot)
setPersonality(personality)
setEmoji(emoji)
setOtherBasic(other)
setSiliconFlow(silicon)
setApiProviderSetup(apiProvider)
setModelSetup(model)
} catch (error) {
toast({
title: t('setupPage.toast.loadFailedTitle'),
@@ -225,14 +207,11 @@ function SetupPageContent() {
case 1: // 人格配置
await savePersonalityConfig(personality)
break
case 2: // 表情包
await saveEmojiConfig(emoji)
case 2: // API 提供商
await saveApiProviderSetupConfig(apiProviderSetup)
break
case 3: // 其他设置
await saveOtherBasicConfig(otherBasic)
break
case 4: // 硅基流动API
await saveSiliconFlowConfig(siliconFlow)
case 3: // 基础模型
await saveModelSetupConfig(modelSetup, apiProviderSetup.provider_name)
break
}
@@ -272,6 +251,24 @@ function SetupPageContent() {
return null
}
function validateApiProviderSetup(config: ApiProviderSetupConfig): string | null {
if (!config.provider_name.trim()) return t('setupPage.validation.enterProviderName')
if (!config.base_url.trim()) return t('setupPage.validation.enterBaseUrl')
if (!config.api_key.trim()) return t('setupPage.validation.enterApiKey')
return null
}
function validateModelSetup(config: ModelSetupConfig): string | null {
if (!config.planner_model_identifier.trim()) {
return t('setupPage.validation.enterPlannerModelIdentifier')
}
if (!config.replyer_model_identifier.trim()) {
return t('setupPage.validation.enterReplyerModelIdentifier')
}
if (!apiProviderSetup.provider_name.trim()) return t('setupPage.validation.enterProviderName')
return null
}
const handleNext = async () => {
// Step 1 验证
if (currentStep === 0) {
@@ -285,6 +282,28 @@ function SetupPageContent() {
return
}
}
if (currentStep === 2) {
const error = validateApiProviderSetup(apiProviderSetup)
if (error) {
toast({
title: t('setupPage.toast.validationFailedTitle'),
description: error,
variant: 'destructive',
})
return
}
}
if (currentStep === 3) {
const error = validateModelSetup(modelSetup)
if (error) {
toast({
title: t('setupPage.toast.validationFailedTitle'),
description: error,
variant: 'destructive',
})
return
}
}
// 保存当前步骤
const saved = await saveCurrentStep()
@@ -306,7 +325,18 @@ function SetupPageContent() {
setIsCompleting(true)
try {
// 1. 保存最后一步的配置(硅基流动API Key)
const error = validateModelSetup(modelSetup)
if (error) {
toast({
title: t('setupPage.toast.validationFailedTitle'),
description: error,
variant: 'destructive',
})
setIsCompleting(false)
return
}
// 1. 保存最后一步的基础模型配置
const saved = await saveCurrentStep()
if (!saved) {
setIsCompleting(false)
@@ -357,11 +387,9 @@ function SetupPageContent() {
case 1:
return <PersonalityForm config={personality} onChange={setPersonality} />
case 2:
return <EmojiForm config={emoji} onChange={setEmoji} />
return <ApiProviderSetupForm config={apiProviderSetup} onChange={setApiProviderSetup} />
case 3:
return <OtherBasicForm config={otherBasic} onChange={setOtherBasic} />
case 4:
return <SiliconFlowForm config={siliconFlow} onChange={setSiliconFlow} />
return <ModelSetupForm config={modelSetup} onChange={setModelSetup} />
default:
return null
}

View File

@@ -24,23 +24,19 @@ export interface PersonalityConfig {
multiple_probability: number
}
// 步骤3表情包配置
export interface EmojiConfig {
emoji_send_num: number
max_reg_num: number
do_replace: boolean
check_interval: number
steal_emoji: boolean
content_filtration: boolean
filtration_prompt: string
}
// 步骤4其他基础配置
export interface OtherBasicConfig {
all_global: boolean // 全局黑话模式expression.all_global_jargon
}
// 步骤5硅基流动API配置
export interface SiliconFlowConfig {
// 步骤3API 提供商配置
export interface ApiProviderSetupConfig {
provider_name: string
base_url: string
api_key: string
}
// 步骤4基础模型配置
export interface ModelSetupConfig {
planner_model_name: string
planner_model_identifier: string
planner_visual: boolean
replyer_model_name: string
replyer_model_identifier: string
replyer_visual: boolean
}