Merge pull request #1625 from Mai-with-u/dev

Dev
This commit is contained in:
SengokuCola
2026-05-04 12:50:22 +08:00
committed by GitHub
51 changed files with 1965 additions and 973 deletions

View File

@@ -7,4 +7,17 @@ mongodb
napcat napcat
docs/ docs/
.github/ .github/
# test # test
.env
.venv/
.pytest_cache/
.ruff_cache/
.tmp_*/
node_modules/
dashboard/node_modules/
data/
logs/
temp/
tmp/
mai_knowledge/
depends-data/

View File

@@ -41,8 +41,6 @@
# 配置文件修改 # 配置文件修改
如果你需要改动配置文件不需要修改实际的bot_config.toml或者model_config.toml只需要修改配置文件模版并新增一个版本号即可也不必要为配置改动创建测试文件。 如果你需要改动配置文件不需要修改实际的bot_config.toml或者model_config.toml只需要修改配置文件模版并新增一个版本号即可也不必要为配置改动创建测试文件。
# 关于webui修改
不要修改dashboard下的内容因为这部分内容由另一个仓库build
# 关于 A_memorix 修改 # 关于 A_memorix 修改
如果修改涉及 `src/A_memorix`,请先阅读 `src/A_memorix/MODIFICATION_POLICY.md` 如果修改涉及 `src/A_memorix`,请先阅读 `src/A_memorix/MODIFICATION_POLICY.md`

View File

@@ -7,14 +7,13 @@ WORKDIR /MaiMBot
ENV MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1 ENV MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1
# Copy dependency list # Copy dependency metadata
COPY requirements.txt . COPY pyproject.toml uv.lock ./
RUN apt-get update && apt-get install -y git RUN apt-get update && apt-get install -y git
# Install runtime dependencies # Install runtime dependencies
RUN uv pip install --system --upgrade pip RUN uv sync --frozen --no-dev --system --no-install-project
RUN uv pip install --system -r requirements.txt
# Copy project source # Copy project source
COPY . . COPY . .

View File

@@ -166,8 +166,8 @@ MaiSaka 不仅仅是一个机器人,不仅仅是一个可以帮你完成任务
## 🙋 贡献和致谢 ## 🙋 贡献和致谢
<sub><sup>Contributing and Acknowledgments</sup></sub> <sub><sup>Contributing and Acknowledgments</sup></sub>
欢迎参与贡献!请先阅读 [贡献指南](docs-src/CONTRIBUTE.md)。 欢迎参与贡献!请先阅读 [贡献指南](docs/CONTRIBUTE.md)。
<sub><sup>Contributions are welcome. Please read the <a href="docs-src/CONTRIBUTE.md">Contribution Guide</a> first.</sup></sub> <sub><sup>Contributions are welcome. Please read the <a href="docs/CONTRIBUTE.md">Contribution Guide</a> first.</sup></sub>
### 🌟 贡献者 ### 🌟 贡献者
<sub><sup>Contributors</sup></sub> <sub><sup>Contributors</sup></sub>

View File

@@ -1,12 +1,12 @@
{ {
"name": "maibot-dashboard", "name": "maibot-dashboard",
"version": "1.0.0", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "maibot-dashboard", "name": "maibot-dashboard",
"version": "1.0.0", "version": "1.0.2",
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
@@ -62,6 +62,7 @@
"idb": "^8.0.3", "idb": "^8.0.3",
"katex": "^0.16.27", "katex": "^0.16.27",
"lucide-react": "^0.556.0", "lucide-react": "^0.556.0",
"motion": "^12.38.0",
"react": "^19.2.1", "react": "^19.2.1",
"react-day-picker": "^9.12.0", "react-day-picker": "^9.12.0",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
@@ -9794,6 +9795,33 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -13187,6 +13215,47 @@
"mkdirp": "bin/cmd.js" "mkdirp": "bin/cmd.js"
} }
}, },
"node_modules/motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
"integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.38.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT"
},
"node_modules/mrmime": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",

View File

@@ -1,6 +1,7 @@
import * as React from 'react' import * as React from 'react'
import * as LucideIcons from 'lucide-react' import * as LucideIcons from 'lucide-react'
import { Button } from '@/components/ui/button'
import { import {
Card, Card,
CardContent, CardContent,
@@ -9,8 +10,8 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks' import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
import { DynamicField } from './DynamicField' import { DynamicField } from './DynamicField'
@@ -20,53 +21,142 @@ export interface DynamicConfigFormProps {
onChange: (field: string, value: unknown) => void onChange: (field: string, value: unknown) => void
basePath?: string basePath?: string
hooks?: FieldHookRegistry hooks?: FieldHookRegistry
/** 嵌套层级0 = tab 内容层, 1 = section 内容层, 2+ = 更深嵌套 */ /** 嵌套层级0 = tab 内容层1 = section 内容层2+ = 更深嵌套 */
level?: number level?: number
advancedVisible?: boolean
}
function buildFieldPath(basePath: string, fieldName: string) {
return basePath ? `${basePath}.${fieldName}` : fieldName
}
function hasTopLevelAdvancedFields(schema: ConfigSchema) {
return schema.fields.some((field) => field.advanced && !schema.nested?.[field.name])
}
function SectionIcon({ iconName }: { iconName?: string }) {
if (!iconName) return null
const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as
| React.ComponentType<{ className?: string }>
| undefined
if (!IconComponent) return null
return <IconComponent className="h-5 w-5 text-muted-foreground" />
}
function AdvancedSettingsButton({
active,
onClick,
}: {
active: boolean
onClick: () => void
}) {
return (
<Button
type="button"
variant={active ? 'secondary' : 'outline'}
size="sm"
onClick={onClick}
>
</Button>
)
}
function DynamicConfigSection({
basePath,
hooks,
level,
nestedSchema,
onChange,
sectionDescription,
sectionTitle,
values,
}: {
basePath: string
hooks: FieldHookRegistry
level: number
nestedSchema: ConfigSchema
onChange: (field: string, value: unknown) => void
sectionDescription?: string
sectionTitle: string
values: Record<string, unknown>
}) {
const [advancedVisible, setAdvancedVisible] = React.useState(false)
const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema)
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={nestedSchema.uiIcon} />
<CardTitle className="text-lg">{sectionTitle}</CardTitle>
</div>
{sectionDescription && (
<CardDescription>{sectionDescription}</CardDescription>
)}
</div>
{hasAdvanced && (
<AdvancedSettingsButton
active={advancedVisible}
onClick={() => setAdvancedVisible((current) => !current)}
/>
)}
</div>
</CardHeader>
<CardContent>
<DynamicConfigForm
schema={nestedSchema}
values={values}
onChange={onChange}
basePath={basePath}
hooks={hooks}
level={level}
advancedVisible={hasAdvanced ? advancedVisible : undefined}
/>
</CardContent>
</Card>
)
} }
/** /**
* DynamicConfigForm - 动态配置表单组件 * DynamicConfigForm - 动态配置表单组件
* *
* 根据 ConfigSchema 渲染表单字段,支持: * 根据 ConfigSchema 渲染表单字段,支持:
* 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染 * 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染
* - replace 模式:完全替换默认渲染 * - replace 模式:完全替换默认渲染
* - wrapper 模式:包装默认渲染(通过 children 传递) * - wrapper 模式:包装默认渲染(通过 children 传递)
* 2. 嵌套 schema递归渲染 schema.nested 中的子配置,使用 Card 容器区分层级 * 2. 嵌套 schema递归渲染 schema.nested 中的子配置
* 3. 默认渲染:使用 DynamicField 组件 * 3. 高级设置:由栏目标题右侧按钮控制显示
*/ */
export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
schema, schema,
values, values,
onChange, onChange,
basePath = '', basePath = '',
hooks = fieldHooks, // 默认使用全局单例 hooks = fieldHooks,
level = 0, level = 0,
advancedVisible,
}) => { }) => {
const [localAdvancedVisible, setLocalAdvancedVisible] = React.useState(false)
const resolvedAdvancedVisible = advancedVisible ?? localAdvancedVisible
const fieldMap = React.useMemo( const fieldMap = React.useMemo(
() => new Map(schema.fields.map((field) => [field.name, field])), () => new Map(schema.fields.map((field) => [field.name, field])),
[schema.fields] [schema.fields],
) )
const buildFieldPath = (fieldName: string) => {
return basePath ? `${basePath}.${fieldName}` : fieldName
}
/**
* 渲染单个字段
* 检查是否有注册的 Hook根据 Hook 类型选择渲染方式
*/
const renderField = (field: FieldSchema) => { const renderField = (field: FieldSchema) => {
const fieldPath = buildFieldPath(field.name) const fieldPath = buildFieldPath(basePath, field.name)
// 检查是否有注册的 Hook
if (hooks.has(fieldPath)) { if (hooks.has(fieldPath)) {
const hookEntry = hooks.get(fieldPath) const hookEntry = hooks.get(fieldPath)
if (!hookEntry) return null // Type guard理论上不会发生 if (!hookEntry) return null
const HookComponent = hookEntry.component const HookComponent = hookEntry.component
if (hookEntry.type === 'replace') { if (hookEntry.type === 'replace') {
// replace 模式:完全替换默认渲染
return ( return (
<HookComponent <HookComponent
fieldPath={fieldPath} fieldPath={fieldPath}
@@ -75,27 +165,25 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
schema={field} schema={field}
/> />
) )
} else { }
// wrapper 模式:包装默认渲染
return ( return (
<HookComponent <HookComponent
fieldPath={fieldPath} fieldPath={fieldPath}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
schema={field}
>
<DynamicField
schema={field}
value={values[field.name]} value={values[field.name]}
onChange={(v) => onChange(field.name, v)} onChange={(v) => onChange(field.name, v)}
schema={field} fieldPath={fieldPath}
> />
<DynamicField </HookComponent>
schema={field} )
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
fieldPath={fieldPath}
/>
</HookComponent>
)
}
} }
// 无 Hook使用默认渲染
return ( return (
<DynamicField <DynamicField
schema={field} schema={field}
@@ -106,44 +194,49 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
) )
} }
/** 渲染 section 图标 */
const renderSectionIcon = (iconName?: string) => {
if (!iconName) return null
const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as
| React.ComponentType<{ className?: string }>
| undefined
if (!IconComponent) return null
return <IconComponent className="h-5 w-5 text-muted-foreground" />
}
// 过滤出不属于 nested 的顶层字段
const topLevelFields = schema.fields.filter( const topLevelFields = schema.fields.filter(
(field) => !schema.nested?.[field.name] (field) => !schema.nested?.[field.name],
)
const normalFields = topLevelFields.filter((field) => !field.advanced)
const advancedFields = topLevelFields.filter((field) => field.advanced)
const visibleFields = resolvedAdvancedVisible
? [...normalFields, ...advancedFields]
: normalFields
const renderFieldList = (fields: FieldSchema[]) => (
<>
{fields.map((field, index) => (
<React.Fragment key={field.name}>
{index > 0 && field.type !== 'boolean' && fields[index - 1]?.type !== 'boolean' && (
<Separator className="my-1" />
)}
<div>{renderField(field)}</div>
</React.Fragment>
))}
</>
) )
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 渲染顶层字段 */}
{topLevelFields.length > 0 && ( {topLevelFields.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
{topLevelFields.map((field, index) => ( {advancedVisible === undefined && advancedFields.length > 0 && (
<React.Fragment key={field.name}> <div className="flex justify-end pb-2">
{index > 0 && field.type !== 'boolean' && topLevelFields[index - 1]?.type !== 'boolean' && ( <AdvancedSettingsButton
<Separator className="my-1" /> active={localAdvancedVisible}
)} onClick={() => setLocalAdvancedVisible((current) => !current)}
<div>{renderField(field)}</div> />
</React.Fragment> </div>
))} )}
{renderFieldList(visibleFields)}
</div> </div>
)} )}
{/* 渲染嵌套 schema */}
{schema.nested && {schema.nested &&
Object.entries(schema.nested).map(([key, nestedSchema]) => { Object.entries(schema.nested).map(([key, nestedSchema]) => {
const nestedField = fieldMap.get(key) const nestedField = fieldMap.get(key)
const nestedFieldPath = buildFieldPath(key) const nestedFieldPath = buildFieldPath(basePath, key)
// Hook 系统处理
if (hooks.has(nestedFieldPath)) { if (hooks.has(nestedFieldPath)) {
const hookEntry = hooks.get(nestedFieldPath) const hookEntry = hooks.get(nestedFieldPath)
if (!hookEntry) return null if (!hookEntry) return null
@@ -192,49 +285,39 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
? nestedSchema.classDoc ? nestedSchema.classDoc
: undefined : undefined
// 一级嵌套:使用 Card 包裹,清晰的 section 边界
if (level === 0) { if (level === 0) {
return ( return (
<Card key={key}> <DynamicConfigSection
<CardHeader className="pb-4"> key={key}
<div className="flex items-center gap-2"> nestedSchema={nestedSchema}
{renderSectionIcon(nestedSchema.uiIcon)} values={(values[key] as Record<string, unknown>) || {}}
<CardTitle className="text-lg">{sectionTitle}</CardTitle> onChange={(field, value) => onChange(`${key}.${field}`, value)}
</div> basePath={nestedFieldPath}
{sectionDescription && ( hooks={hooks}
<CardDescription>{sectionDescription}</CardDescription> level={level + 1}
)} sectionTitle={sectionTitle}
</CardHeader> sectionDescription={sectionDescription}
<CardContent> />
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
/>
</CardContent>
</Card>
) )
} }
// 二级及更深嵌套:使用左侧指示条 + 轻量分组
return ( return (
<div <div
key={key} key={key}
className="relative space-y-4 rounded-lg border-l-2 border-muted-foreground/20 pl-4 pt-1 pb-1" className="relative space-y-4 rounded-lg border-l-2 border-muted-foreground/20 pl-4 pt-1 pb-1"
> >
<div className="space-y-1"> <div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-2"> <div className="space-y-1">
{renderSectionIcon(nestedSchema.uiIcon)} <div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{sectionTitle}</h4> <SectionIcon iconName={nestedSchema.uiIcon} />
<h4 className="text-sm font-semibold">{sectionTitle}</h4>
</div>
{sectionDescription && (
<p className="text-xs text-muted-foreground">
{sectionDescription}
</p>
)}
</div> </div>
{sectionDescription && (
<p className="text-xs text-muted-foreground">
{sectionDescription}
</p>
)}
</div> </div>
<DynamicConfigForm <DynamicConfigForm

View File

@@ -313,20 +313,22 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{/* Label with icon */} <div className="space-y-0.5">
<Label className="text-sm font-medium flex items-center gap-2"> {/* Label with icon */}
{renderIcon()} <Label className="text-sm font-medium flex items-center gap-2">
{schema.label} {renderIcon()}
{schema.required && <span className="text-destructive">*</span>} {schema.label}
</Label> {schema.required && <span className="text-destructive">*</span>}
</Label>
{/* Description */}
{schema.description && (
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
)}
</div>
{/* Input component */} {/* Input component */}
{renderInputComponent()} {renderInputComponent()}
{/* Description */}
{schema.description && (
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
)}
</div> </div>
) )
} }

View File

@@ -1,4 +1,4 @@
import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, LayoutGrid, MessageSquare, Network, Package, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react' import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react'
import type { MenuSection } from './types' import type { MenuSection } from './types'
@@ -15,7 +15,6 @@ export const menuSections: MenuSection[] = [
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' }, { icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' },
{ icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', searchDescription: 'search.items.modelProviderDesc', tourId: 'sidebar-model-provider' }, { icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', searchDescription: 'search.items.modelProviderDesc', tourId: 'sidebar-model-provider' },
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' }, { icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' },
{ icon: Sliders, label: 'sidebar.menu.adapterConfig', path: '/config/adapter' },
], ],
}, },
{ {
@@ -33,7 +32,6 @@ export const menuSections: MenuSection[] = [
title: 'sidebar.groups.extensionsMonitor', title: 'sidebar.groups.extensionsMonitor',
items: [ items: [
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' }, { icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' },
{ icon: LayoutGrid, label: 'sidebar.menu.configTemplate', path: '/config/pack-market' },
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' }, { icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' }, { icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' },
{ icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' }, { icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' },

View File

@@ -500,17 +500,13 @@
"title": "Personality", "title": "Personality",
"description": "Define the bot's personality and speaking style" "description": "Define the bot's personality and speaking style"
}, },
"emoji": { "apiProvider": {
"title": "Emoji",
"description": "Configure emoji-related settings"
},
"other": {
"title": "Other Settings",
"description": "Configure global slang and other basic options"
},
"siliconFlow": {
"title": "API Setup", "title": "API Setup",
"description": "Configure the SiliconFlow API key" "description": "Configure the API provider"
},
"modelSetup": {
"title": "Model Setup",
"description": "Configure planner and replyer models"
} }
}, },
"loading": { "loading": {
@@ -528,7 +524,12 @@
"selectPlatform": "Please select a platform", "selectPlatform": "Please select a platform",
"enterNickname": "Please enter a nickname", "enterNickname": "Please enter a nickname",
"enterQqAccount": "Please enter a QQ account", "enterQqAccount": "Please enter a QQ account",
"enterAccountId": "Please enter an account ID" "enterAccountId": "Please enter an account ID",
"enterProviderName": "Please enter an API provider name",
"enterBaseUrl": "Please enter the API base URL",
"enterApiKey": "Please enter the API key",
"enterPlannerModelIdentifier": "Please enter the planner model identifier",
"enterReplyerModelIdentifier": "Please enter the replyer model identifier"
}, },
"toast": { "toast": {
"loadFailedTitle": "Failed to load configuration", "loadFailedTitle": "Failed to load configuration",
@@ -667,33 +668,43 @@
"description": "Allow the bot to learn and use group-specific slang" "description": "Allow the bot to learn and use group-specific slang"
} }
}, },
"siliconFlow": { "apiProvider": {
"about": { "providerName": {
"title": "About SiliconFlow", "label": "API Provider Name *",
"description": "SiliconFlow provides broad model coverage, including DeepSeek V3, Qwen, vision models, speech recognition, and embedding models. A single API key unlocks all MaiBot features.", "placeholder": "For example OpenAI, DeepSeek, or self-hosted",
"link": "Get an API key from SiliconFlow" "description": "This name is written to model_config.toml and referenced by the models below"
},
"baseUrl": {
"label": "API Base URL *",
"description": "Enter an OpenAI-compatible endpoint, for example https://api.example.com/v1"
}, },
"apiKey": { "apiKey": {
"label": "SiliconFlow API Key *", "label": "API Key *",
"description": "Enter your SiliconFlow API key. Once provided, MaiBot will automatically configure all required models.", "description": "Enter the API key for this provider",
"show": "Show API key", "show": "Show API key",
"hide": "Hide API key" "hide": "Hide API key"
}, }
"autoConfig": { },
"title": "The following models will be configured automatically:", "modelSetup": {
"items": { "planner": {
"deepseek": "DeepSeek V3 - primary chat and tool model", "identifier": {
"qwen3": "Qwen3 30B - frequent small tasks and tool calls", "label": "planner Model Identifier *",
"qwen3Vl": "Qwen3 VL 30B - image recognition", "description": "The real model ID provided by the API service; the model name will be initialized from it"
"senseVoice": "SenseVoice - speech recognition", },
"bgeM3": "BGE-M3 - text embeddings", "visual": {
"lpmm": "Knowledge-base-related models (LPMM)" "label": "Enable vision"
} }
}, },
"hint": { "replyer": {
"title": "Tip: ", "identifier": {
"description": "After finishing the wizard, you can add more API providers and models in \"System Settings -> Model Config\"." "label": "replyer Model Identifier *",
} "description": "The real model ID provided by the API service; the model name will be initialized from it"
},
"visual": {
"label": "Enable vision"
}
},
"saveHint": "You can configure more detailed task assignment later."
} }
} }
}, },

View File

@@ -500,17 +500,13 @@
"title": "人格設定", "title": "人格設定",
"description": "ボットの性格や話し方を定義します" "description": "ボットの性格や話し方を定義します"
}, },
"emoji": { "apiProvider": {
"title": "絵文字パック",
"description": "絵文字パック関連の設定を行います"
},
"other": {
"title": "その他の設定",
"description": "グローバルスラングなどの基本オプションを設定します"
},
"siliconFlow": {
"title": "API設定", "title": "API設定",
"description": "SiliconFlow API キーを設定します" "description": "APIプロバイダーを設定します"
},
"modelSetup": {
"title": "モデル設定",
"description": "planner と replyer モデルを設定します"
} }
}, },
"loading": { "loading": {
@@ -528,7 +524,12 @@
"selectPlatform": "プラットフォームを選択してください", "selectPlatform": "プラットフォームを選択してください",
"enterNickname": "ニックネームを入力してください", "enterNickname": "ニックネームを入力してください",
"enterQqAccount": "QQ アカウントを入力してください", "enterQqAccount": "QQ アカウントを入力してください",
"enterAccountId": "アカウント ID を入力してください" "enterAccountId": "アカウント ID を入力してください",
"enterProviderName": "APIプロバイダー名を入力してください",
"enterBaseUrl": "API Base URL を入力してください",
"enterApiKey": "API Key を入力してください",
"enterPlannerModelIdentifier": "planner モデル識別子を入力してください",
"enterReplyerModelIdentifier": "replyer モデル識別子を入力してください"
}, },
"toast": { "toast": {
"loadFailedTitle": "設定の読み込みに失敗しました", "loadFailedTitle": "設定の読み込みに失敗しました",
@@ -667,33 +668,43 @@
"description": "グループ内のスラングを学習して使えるようにします" "description": "グループ内のスラングを学習して使えるようにします"
} }
}, },
"siliconFlow": { "apiProvider": {
"about": { "providerName": {
"title": "SiliconFlow について", "label": "APIプロバイダー名 *",
"description": "SiliconFlow は DeepSeek V3、Qwen、ビジョンモデル、音声認識、埋め込みモデルなど幅広いモデルを提供します。API Key が1つあれば MaiBot の全機能を利用できます。", "placeholder": "例: OpenAI、DeepSeek、自ホストサービス",
"link": "SiliconFlow で API Key を取得する" "description": "この名前は model_config.toml に保存され、下のモデルから参照されます"
},
"baseUrl": {
"label": "API Base URL *",
"description": "OpenAI互換エンドポイントを入力してください。例: https://api.example.com/v1"
}, },
"apiKey": { "apiKey": {
"label": "SiliconFlow API Key *", "label": "API Key *",
"description": "SiliconFlow の API Key を入力してください。入力後、MaiBot が必要なモデルを自動設定します。", "description": "このプロバイダーの API Key を入力してください",
"show": "API Key を表示", "show": "API Key を表示",
"hide": "API Key を隠す" "hide": "API Key を非表示"
}, }
"autoConfig": { },
"title": "以下のモデルが自動設定されます:", "modelSetup": {
"items": { "planner": {
"deepseek": "DeepSeek V3 - メインの会話・ツールモデル", "identifier": {
"qwen3": "Qwen3 30B - 頻繁な小タスクとツール呼び出し", "label": "planner モデル識別子 *",
"qwen3Vl": "Qwen3 VL 30B - 画像認識", "description": "APIサービスが提供する実際のモデルID。モデル名はこの識別子で初期化されます"
"senseVoice": "SenseVoice - 音声認識", },
"bgeM3": "BGE-M3 - テキスト埋め込み", "visual": {
"lpmm": "知識ベース関連モデル (LPMM)" "label": "ビジョンを有効化"
} }
}, },
"hint": { "replyer": {
"title": "ヒント:", "identifier": {
"description": "ウィザード完了後は、「システム設定 -> モデル設定」でさらに API プロバイダーやモデルを追加できます。" "label": "replyer モデル識別子 *",
} "description": "APIサービスが提供する実際のモデルID。モデル名はこの識別子で初期化されます"
},
"visual": {
"label": "ビジョンを有効化"
}
},
"saveHint": "より詳細なタスク割り当ては後で設定できます。"
} }
} }
}, },

View File

@@ -500,17 +500,13 @@
"title": "성격 설정", "title": "성격 설정",
"description": "봇의 성격과 말투를 정의합니다" "description": "봇의 성격과 말투를 정의합니다"
}, },
"emoji": { "apiProvider": {
"title": "이모지 팩",
"description": "이모지 관련 설정을 구성합니다"
},
"other": {
"title": "기타 설정",
"description": "전역 슬랭 등 기본 옵션을 설정합니다"
},
"siliconFlow": {
"title": "API 설정", "title": "API 설정",
"description": "SiliconFlow API 를 설정합니다" "description": "API 제공자를 설정합니다"
},
"modelSetup": {
"title": "모델 설정",
"description": "planner와 replyer 모델을 설정합니다"
} }
}, },
"loading": { "loading": {
@@ -528,7 +524,12 @@
"selectPlatform": "플랫폼을 선택해 주세요", "selectPlatform": "플랫폼을 선택해 주세요",
"enterNickname": "닉네임을 입력해 주세요", "enterNickname": "닉네임을 입력해 주세요",
"enterQqAccount": "QQ 계정을 입력해 주세요", "enterQqAccount": "QQ 계정을 입력해 주세요",
"enterAccountId": "계정 ID를 입력해 주세요" "enterAccountId": "계정 ID를 입력해 주세요",
"enterProviderName": "API 제공자 이름을 입력해 주세요",
"enterBaseUrl": "API Base URL을 입력해 주세요",
"enterApiKey": "API Key를 입력해 주세요",
"enterPlannerModelIdentifier": "planner 모델 식별자를 입력해 주세요",
"enterReplyerModelIdentifier": "replyer 모델 식별자를 입력해 주세요"
}, },
"toast": { "toast": {
"loadFailedTitle": "설정 불러오기에 실패했습니다", "loadFailedTitle": "설정 불러오기에 실패했습니다",
@@ -667,33 +668,43 @@
"description": "봇이 그룹 슬랭을 학습하고 사용할 수 있게 합니다" "description": "봇이 그룹 슬랭을 학습하고 사용할 수 있게 합니다"
} }
}, },
"siliconFlow": { "apiProvider": {
"about": { "providerName": {
"title": "SiliconFlow 소개", "label": "API 제공자 이름 *",
"description": "SiliconFlow 는 DeepSeek V3, Qwen, 비전 모델, 음성 인식, 임베딩 모델 등 폭넓은 모델을 제공합니다. API Key 하나로 MaiBot 의 모든 기능을 사용할 수 있습니다.", "placeholder": "예: OpenAI, DeepSeek, 자체 호스팅",
"link": "SiliconFlow 에서 API Key 받기" "description": "이 이름은 model_config.toml에 저장되며 아래 모델에서 참조됩니다"
},
"baseUrl": {
"label": "API Base URL *",
"description": "OpenAI 호환 엔드포인트를 입력해 주세요. 예: https://api.example.com/v1"
}, },
"apiKey": { "apiKey": {
"label": "SiliconFlow API Key *", "label": "API Key *",
"description": "SiliconFlow API Key를 입력해 주세요. 입력하면 MaiBot 이 필요한 모델을 자동으로 구성합니다.", "description": "이 제공자의 API Key를 입력해 주세요",
"show": "API Key 표시", "show": "API Key 표시",
"hide": "API Key 숨기기" "hide": "API Key 숨기기"
}, }
"autoConfig": { },
"title": "다음 모델이 자동으로 구성됩니다:", "modelSetup": {
"items": { "planner": {
"deepseek": "DeepSeek V3 - 주요 대화 및 도구 모델", "identifier": {
"qwen3": "Qwen3 30B - 잦은 소규모 작업과 도구 호출", "label": "planner 모델 식별자 *",
"qwen3Vl": "Qwen3 VL 30B - 이미지 인식", "description": "API 서비스가 제공하는 실제 모델 ID입니다. 모델 이름은 이 식별자로 초기화됩니다"
"senseVoice": "SenseVoice - 음성 인식", },
"bgeM3": "BGE-M3 - 텍스트 임베딩", "visual": {
"lpmm": "지식 베이스 관련 모델 (LPMM)" "label": "비전 사용"
} }
}, },
"hint": { "replyer": {
"title": "팁: ", "identifier": {
"description": "마법사를 마친 뒤에는 \"시스템 설정 -> 모델 설정\"에서 더 많은 API 제공자와 모델을 추가할 수 있습니다." "label": "replyer 모델 식별자 *",
} "description": "API 서비스가 제공하는 실제 모델 ID입니다. 모델 이름은 이 식별자로 초기화됩니다"
},
"visual": {
"label": "비전 사용"
}
},
"saveHint": "더 자세한 작업 할당은 나중에 설정할 수 있습니다."
} }
} }
}, },

View File

@@ -500,17 +500,13 @@
"title": "人格配置", "title": "人格配置",
"description": "定义机器人的性格和说话风格" "description": "定义机器人的性格和说话风格"
}, },
"emoji": { "apiProvider": {
"title": "表情包",
"description": "配置表情包相关设置"
},
"other": {
"title": "其他设置",
"description": "配置全局黑话等基础选项"
},
"siliconFlow": {
"title": "API配置", "title": "API配置",
"description": "配置硅基流动 API 密钥" "description": "配置 API 提供商"
},
"modelSetup": {
"title": "模型配置",
"description": "配置 planner 和 replyer 模型"
} }
}, },
"loading": { "loading": {
@@ -528,7 +524,12 @@
"selectPlatform": "请选择平台", "selectPlatform": "请选择平台",
"enterNickname": "请输入昵称", "enterNickname": "请输入昵称",
"enterQqAccount": "请输入 QQ 账号", "enterQqAccount": "请输入 QQ 账号",
"enterAccountId": "请输入账号 ID" "enterAccountId": "请输入账号 ID",
"enterProviderName": "请输入 API 提供商名称",
"enterBaseUrl": "请输入 API Base URL",
"enterApiKey": "请输入 API Key",
"enterPlannerModelIdentifier": "请输入 planner 模型标识符",
"enterReplyerModelIdentifier": "请输入 replyer 模型标识符"
}, },
"toast": { "toast": {
"loadFailedTitle": "加载配置失败", "loadFailedTitle": "加载配置失败",
@@ -559,8 +560,8 @@
"footer": "您可以随时在设置中修改这些配置", "footer": "您可以随时在设置中修改这些配置",
"defaults": { "defaults": {
"personality": { "personality": {
"personality": "是一个女大学生,现在在读大二,会刷贴吧。", "personality": "是一个大二女大学生,现在正在上网和群友聊天。有轻微攻击性",
"replyStyle": "请回复得平淡一些,简短一些,说中文,不要刻意突出自身学科背景。可以参考贴吧知乎和微博的回复风格。", "replyStyle": "你的风格平淡简短。可以参考贴吧知乎和微博的回复风格。不浮夸不长篇大论,不要过分修辞和复杂句。",
"multipleReplyStyles": { "multipleReplyStyles": {
"plain": "你的风格平淡但不失讽刺,很简短,很白话。可以参考贴吧、微博的回复风格。", "plain": "你的风格平淡但不失讽刺,很简短,很白话。可以参考贴吧、微博的回复风格。",
"shortText": "用1-2个字进行回复", "shortText": "用1-2个字进行回复",
@@ -577,7 +578,7 @@
"platform": { "platform": {
"label": "平台 *", "label": "平台 *",
"placeholder": "请选择平台", "placeholder": "请选择平台",
"description": "选择机器人运行的平台", "description": "选择麦麦Bot运行的平台",
"options": { "options": {
"custom": "其他平台" "custom": "其他平台"
} }
@@ -589,7 +590,7 @@
"qqAccount": { "qqAccount": {
"label": "QQ账号 *", "label": "QQ账号 *",
"placeholder": "请输入机器人的 QQ 账号", "placeholder": "请输入机器人的 QQ 账号",
"description": "机器人登录使用的 QQ 账号" "description": "运行麦麦Bot的 QQ 账号"
}, },
"primaryAccount": { "primaryAccount": {
"label": "账号 ID *", "label": "账号 ID *",
@@ -599,7 +600,7 @@
"nickname": { "nickname": {
"label": "昵称 *", "label": "昵称 *",
"placeholder": "请输入机器人的昵称", "placeholder": "请输入机器人的昵称",
"description": "机器人的主要称呼名称" "description": "麦麦Bot的名称"
}, },
"alias": { "alias": {
"label": "别名", "label": "别名",
@@ -667,33 +668,43 @@
"description": "允许机器人学习和使用群组黑话" "description": "允许机器人学习和使用群组黑话"
} }
}, },
"siliconFlow": { "apiProvider": {
"about": { "providerName": {
"title": "关于硅基流动 (SiliconFlow)", "label": "API 提供商名称 *",
"description": "硅基流动提供了完整的模型覆盖,包括 DeepSeek V3、Qwen、视觉模型、语音识别和嵌入模型。只需一个 API Key 即可使用麦麦的所有功能!", "placeholder": "例如 OpenAI、DeepSeek、自建服务",
"link": "前往硅基流动获取 API Key" "description": "为api提供商命名"
},
"baseUrl": {
"label": "API Base URL *",
"description": "请填写 OpenAI 兼容接口地址,例如 https://api.example.com/v1"
}, },
"apiKey": { "apiKey": {
"label": "SiliconFlow API Key *", "label": "API Key *",
"description": "请输入您的硅基流动 API 密钥。获取后,麦麦将自动配置所有必需的模型。", "description": "请填写该提供商的 API Key",
"show": "显示 API Key", "show": "显示 API Key",
"hide": "隐藏 API Key" "hide": "隐藏 API Key"
}, }
"autoConfig": { },
"title": "将自动配置以下模型:", "modelSetup": {
"items": { "planner": {
"deepseek": "DeepSeek V3 - 主要对话和工具模型", "identifier": {
"qwen3": "Qwen3 30B - 高频小任务和工具调用", "label": "planner 模型标识符 *",
"qwen3Vl": "Qwen3 VL 30B - 图像识别", "description": "API 服务商提供的真实模型 ID模型名称会自动初始化为该标识符"
"senseVoice": "SenseVoice - 语音识别", },
"bgeM3": "BGE-M3 - 文本嵌入", "visual": {
"lpmm": "知识库相关模型 (LPMM)" "label": "启用视觉"
} }
}, },
"hint": { "replyer": {
"title": "💡 提示:", "identifier": {
"description": "完成向导后,您可以在“系统设置 -> 模型配置”中添加更多 API 提供商和模型。" "label": "replyer 模型标识符 *",
} "description": "API 服务商提供的真实模型 ID模型名称会自动初始化为该标识符"
},
"visual": {
"label": "启用视觉"
}
},
"saveHint": "你可以稍后配置更详细的任务分配。"
} }
} }
}, },

View File

@@ -26,6 +26,10 @@ export interface MaisakaToolCall {
export interface SessionStartEvent { export interface SessionStartEvent {
session_id: string session_id: string
session_name: string session_name: string
is_group_chat?: boolean
group_id?: string | null
user_id?: string | null
platform?: string
timestamp: number timestamp: number
} }

View File

@@ -35,6 +35,12 @@ interface PluginApiResponse {
} }
homepage_url?: string homepage_url?: string
repository_url?: string repository_url?: string
urls?: {
repository?: string
homepage?: string
documentation?: string
issues?: string
}
keywords: string[] keywords: string[]
categories?: string[] categories?: string[]
default_locale: string default_locale: string
@@ -44,6 +50,28 @@ interface PluginApiResponse {
[key: string]: unknown [key: string]: unknown
} }
function normalizePluginManifest(manifest: PluginApiResponse['manifest']): PluginInfo['manifest'] {
const repositoryUrl = manifest.repository_url || manifest.urls?.repository
const homepageUrl = manifest.homepage_url || manifest.urls?.homepage
return {
manifest_version: manifest.manifest_version || 1,
name: manifest.name,
version: manifest.version,
description: manifest.description || '',
author: manifest.author || { name: 'Unknown' },
license: manifest.license || 'Unknown',
host_application: manifest.host_application || { min_version: '0.0.0' },
homepage_url: homepageUrl,
repository_url: repositoryUrl,
urls: manifest.urls,
keywords: manifest.keywords || [],
categories: manifest.categories || [],
default_locale: manifest.default_locale || 'zh-CN',
locales_path: manifest.locales_path,
}
}
/** /**
* 从远程获取插件列表(通过后端代理避免 CORS) * 从远程获取插件列表(通过后端代理避免 CORS)
*/ */
@@ -88,21 +116,7 @@ export async function fetchPluginList(): Promise<ApiResponse<PluginInfo[]>> {
}) })
.map((item) => ({ .map((item) => ({
id: item.id, id: item.id,
manifest: { manifest: normalizePluginManifest(item.manifest),
manifest_version: item.manifest.manifest_version || 1,
name: item.manifest.name,
version: item.manifest.version,
description: item.manifest.description || '',
author: item.manifest.author || { name: 'Unknown' },
license: item.manifest.license || 'Unknown',
host_application: item.manifest.host_application || { min_version: '0.0.0' },
homepage_url: item.manifest.homepage_url,
repository_url: item.manifest.repository_url,
keywords: item.manifest.keywords || [],
categories: item.manifest.categories || [],
default_locale: item.manifest.default_locale || 'zh-CN',
locales_path: item.manifest.locales_path,
},
downloads: 0, downloads: 0,
rating: 0, rating: 0,
review_count: 0, review_count: 0,

View File

@@ -29,6 +29,7 @@ import {
} from 'recharts' } from 'recharts'
import { import {
Activity, Activity,
BarChart3,
TrendingUp, TrendingUp,
DollarSign, DollarSign,
Clock, Clock,
@@ -45,6 +46,7 @@ import {
AlertCircle, AlertCircle,
ClipboardList, ClipboardList,
ClipboardCheck, ClipboardCheck,
ExternalLink,
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -566,6 +568,13 @@ function IndexPageContent() {
{t('home.quickActions.systemSettings')} {t('home.quickActions.systemSettings')}
</Link> </Link>
</Button> </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> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -104,10 +104,17 @@ function SessionSidebar({
)} )}
> >
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<span className="font-medium truncate max-w-35"> <div className="flex min-w-0 items-center gap-1.5">
{session.sessionName} {session.isGroupChat !== undefined && (
</span> <Badge variant="outline" className="h-4 shrink-0 px-1 text-[10px]">
<Badge variant="secondary" className="text-[10px] h-4 px-1"> {session.isGroupChat ? '群' : '私'}
</Badge>
)}
<span className="truncate font-medium" title={session.sessionName}>
{session.sessionName}
</span>
</div>
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[10px]">
{session.eventCount} {session.eventCount}
</Badge> </Badge>
</div> </div>

View File

@@ -26,6 +26,10 @@ export interface TimelineEntry {
export interface SessionInfo { export interface SessionInfo {
sessionId: string sessionId: string
sessionName: string sessionName: string
isGroupChat?: boolean
groupId?: string | null
userId?: string | null
platform?: string
lastActivity: number lastActivity: number
eventCount: number eventCount: number
} }
@@ -33,18 +37,62 @@ export interface SessionInfo {
/** 最大保留的时间线条目数 */ /** 最大保留的时间线条目数 */
const MAX_TIMELINE_ENTRIES = 500 const MAX_TIMELINE_ENTRIES = 500
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 entryCounter = 0
let cachedTimeline: TimelineEntry[] = []
let cachedSessions: Map<string, SessionInfo> = new Map()
let cachedSelectedSession: string | null = null
export function useMaisakaMonitor() { export function useMaisakaMonitor() {
const [timeline, setTimeline] = useState<TimelineEntry[]>([]) const [timeline, setTimeline] = useState<TimelineEntry[]>(cachedTimeline)
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map()) const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map(cachedSessions))
const [selectedSession, setSelectedSession] = useState<string | null>(null) const [selectedSession, setSelectedSessionState] = useState<string | null>(cachedSelectedSession)
const [connected, setConnected] = useState(false) const [connected, setConnected] = useState(false)
const unsubRef = useRef<(() => Promise<void>) | null>(null) const unsubRef = useRef<(() => Promise<void>) | null>(null)
const handleEvent = useCallback((event: MaisakaMonitorEvent) => { const handleEvent = useCallback((event: MaisakaMonitorEvent) => {
const sessionId = (event.data as unknown as Record<string, unknown>).session_id as string const dataRecord = event.data as unknown as Record<string, unknown>
const timestamp = (event.data as unknown as Record<string, unknown>).timestamp as number const sessionId = dataRecord.session_id as string
const timestamp = dataRecord.timestamp as number
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 entry: TimelineEntry = { const entry: TimelineEntry = {
id: `evt_${++entryCounter}_${Date.now()}`, id: `evt_${++entryCounter}_${Date.now()}`,
@@ -56,22 +104,34 @@ export function useMaisakaMonitor() {
setTimeline((prev) => { setTimeline((prev) => {
const next = [...prev, entry] const next = [...prev, entry]
return next.length > MAX_TIMELINE_ENTRIES const trimmed = next.length > MAX_TIMELINE_ENTRIES
? next.slice(next.length - MAX_TIMELINE_ENTRIES) ? next.slice(next.length - MAX_TIMELINE_ENTRIES)
: next : next
cachedTimeline = trimmed
return trimmed
}) })
// 更新会话信息 // 更新会话信息
if (event.type === 'session.start') { if (event.type === 'session.start') {
const d = event.data
setSessions((prev) => { setSessions((prev) => {
const next = new Map(prev) const next = new Map(prev)
next.set(sessionId, { next.set(sessionId, {
sessionId, sessionId,
sessionName: d.session_name, sessionName: resolveSessionDisplayName({
fallbackName: sessionName,
groupId,
isGroupChat,
sessionId,
userId,
}),
isGroupChat,
groupId,
userId,
platform,
lastActivity: timestamp, lastActivity: timestamp,
eventCount: (prev.get(sessionId)?.eventCount ?? 0) + 1, eventCount: (prev.get(sessionId)?.eventCount ?? 0) + 1,
}) })
cachedSessions = next
return next return next
}) })
} else { } else {
@@ -81,24 +141,51 @@ export function useMaisakaMonitor() {
const next = new Map(prev) const next = new Map(prev)
next.set(sessionId, { next.set(sessionId, {
sessionId, sessionId,
sessionName: sessionId.slice(0, 8), sessionName: resolveSessionDisplayName({
fallbackName: sessionName,
groupId,
isGroupChat,
sessionId,
userId,
}),
isGroupChat,
groupId,
userId,
platform,
lastActivity: timestamp, lastActivity: timestamp,
eventCount: 1, eventCount: 1,
}) })
cachedSessions = next
return next return next
} }
const next = new Map(prev) const next = new Map(prev)
next.set(sessionId, { next.set(sessionId, {
...existing, ...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, lastActivity: timestamp,
eventCount: existing.eventCount + 1, eventCount: existing.eventCount + 1,
}) })
cachedSessions = next
return next return next
}) })
} }
// 自动选中第一个会话 // 自动选中第一个会话
setSelectedSession((current) => current ?? sessionId) setSelectedSessionState((current) => {
const next = current ?? sessionId
cachedSelectedSession = next
return next
})
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -124,9 +211,15 @@ export function useMaisakaMonitor() {
}, [handleEvent]) }, [handleEvent])
const clearTimeline = useCallback(() => { const clearTimeline = useCallback(() => {
cachedTimeline = []
setTimeline([]) setTimeline([])
}, []) }, [])
const setSelectedSession = useCallback((sessionId: string | null) => {
cachedSelectedSession = sessionId
setSelectedSessionState(sessionId)
}, [])
/** 当前选中会话的时间线 */ /** 当前选中会话的时间线 */
const filteredTimeline = selectedSession const filteredTimeline = selectedSession
? timeline.filter((e) => e.sessionId === selectedSession) ? timeline.filter((e) => e.sessionId === selectedSession)

View File

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

View File

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

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 { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -15,16 +14,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import type { import type {
ApiProviderSetupConfig,
BotBasicConfig, BotBasicConfig,
EmojiConfig, ModelSetupConfig,
OtherBasicConfig,
PersonalityConfig, PersonalityConfig,
SiliconFlowConfig,
} from './types' } from './types'
// ====== 步骤1Bot基础配置 ====== // ====== 步骤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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
@@ -254,53 +235,6 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
{t('setupPage.forms.botBasic.nickname.description')} {t('setupPage.forms.botBasic.nickname.description')}
</p> </p>
</div> </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> </div>
) )
} }
@@ -313,7 +247,6 @@ interface PersonalityFormProps {
export function PersonalityForm({ config, onChange }: PersonalityFormProps) { export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const multipleReplyStyleText = config.multiple_reply_style.join('\n')
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -344,276 +277,61 @@ export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
{t('setupPage.forms.personality.replyStyle.description')} {t('setupPage.forms.personality.replyStyle.description')}
</p> </p>
</div> </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> </div>
) )
} }
// ====== 步骤3表情包配置 ====== // ====== 步骤3API 提供商配置 ======
interface EmojiFormProps { interface ApiProviderSetupFormProps {
config: EmojiConfig config: ApiProviderSetupConfig
onChange: (config: EmojiConfig) => void onChange: (config: ApiProviderSetupConfig) => void
} }
export function EmojiForm({ config, onChange }: EmojiFormProps) { export function ApiProviderSetupForm({ config, onChange }: ApiProviderSetupFormProps) {
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) {
const { t } = useTranslation() const { t } = useTranslation()
const [showApiKey, setShowApiKey] = useState(false) const [showApiKey, setShowApiKey] = useState(false)
const apiKeyToggleLabel = showApiKey const apiKeyToggleLabel = showApiKey
? t('setupPage.forms.siliconFlow.apiKey.hide') ? t('setupPage.forms.apiProvider.apiKey.hide')
: t('setupPage.forms.siliconFlow.apiKey.show') : t('setupPage.forms.apiProvider.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'),
]
return ( return (
<div className="space-y-6"> <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="space-y-3">
<div className="flex items-start gap-3"> <Label htmlFor="provider_name">{t('setupPage.forms.apiProvider.providerName.label')}</Label>
<div className="mt-0.5"> <Input
<svg id="provider_name"
className="h-5 w-5 text-blue-600 dark:text-blue-400" placeholder={t('setupPage.forms.apiProvider.providerName.placeholder')}
fill="none" value={config.provider_name}
viewBox="0 0 24 24" onChange={(e) => onChange({ ...config, provider_name: e.target.value })}
stroke="currentColor" />
> <p className="text-muted-foreground text-xs">
<path {t('setupPage.forms.apiProvider.providerName.description')}
strokeLinecap="round" </p>
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> </div>
<div className="space-y-3"> <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"> <div className="relative">
<Input <Input
id="siliconflow_api_key" id="api_key"
type={showApiKey ? 'text' : 'password'} type={showApiKey ? 'text' : 'password'}
placeholder="sk-..." placeholder="sk-..."
value={config.api_key} 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" className="pr-10 font-mono"
/> />
<Button <Button
@@ -633,25 +351,103 @@ export function SiliconFlowForm({ config, onChange }: SiliconFlowFormProps) {
</Button> </Button>
</div> </div>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{t('setupPage.forms.siliconFlow.apiKey.description')} {t('setupPage.forms.apiProvider.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')}
</p> </p>
</div> </div>
</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 { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
import type { import type {
ApiProviderSetupConfig,
BotBasicConfig, BotBasicConfig,
EmojiConfig, ModelSetupConfig,
OtherBasicConfig,
PersonalityConfig, PersonalityConfig,
SiliconFlowConfig,
} from './types' } 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基础配置 // 读取Bot基础配置
@@ -56,73 +92,57 @@ export async function loadPersonalityConfig(): Promise<PersonalityConfig> {
} }
} }
// 读取表情包配置 async function loadModelConfig(): Promise<ModelConfig> {
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> {
const response = await fetchWithAuth('/api/webui/config/model', { const response = await fetchWithAuth('/api/webui/config/model', {
method: 'GET', method: 'GET',
headers: getAuthHeaders(), headers: getAuthHeaders(),
}) })
const result = await parseResponse<{ const result = await parseResponse<{ config: ModelConfig }>(response)
config: {
api_providers?: Array<{ name: string; api_key?: string }>
}
}>(response)
const data = throwIfError(result) const data = throwIfError(result)
const modelConfig = data.config return data.config || {}
}
// 获取SiliconFlow提供商的API Key // 读取 API 提供商配置
const apiProviders = modelConfig.api_providers || [] export async function loadApiProviderSetupConfig(): Promise<ApiProviderSetupConfig> {
const siliconFlowProvider = apiProviders.find((p) => p.name === 'SiliconFlow') 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 { 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) { export async function savePersonalityConfig(config: PersonalityConfig) {
const response = await fetchWithAuth('/api/webui/config/bot/section/personality', { 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', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(config), body: JSON.stringify(config),
@@ -165,58 +172,62 @@ export async function saveEmojiConfig(config: EmojiConfig) {
return throwIfError(result) return throwIfError(result)
} }
// 保存其他基础配置(黑话) function createBasicModel(
export async function saveOtherBasicConfig(config: OtherBasicConfig) { modelName: string,
const response = await fetchWithAuth('/api/webui/config/bot/section/expression', { modelIdentifier: string,
method: 'POST', providerName: string,
headers: getAuthHeaders(), visual: boolean,
body: JSON.stringify({ all_global_jargon: config.all_global }), existing?: ModelInfo
}) ): ModelInfo {
return {
const result = await parseResponse(response) price_in: 0,
return throwIfError(result) 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配置 function upsertModel(models: ModelInfo[], model: ModelInfo): ModelInfo[] {
export async function saveSiliconFlowConfig(config: SiliconFlowConfig) { const index = models.findIndex((item) => item.name === model.name)
// 1. 读取现有配置 if (index >= 0) {
const response = await fetchWithAuth('/api/webui/config/model', { return models.map((item, itemIndex) => (itemIndex === index ? model : item))
method: 'GET', }
headers: getAuthHeaders(), return [...models, model]
}) }
const result = await parseResponse<{ // 保存 API 提供商配置
config: { export async function saveApiProviderSetupConfig(config: ApiProviderSetupConfig) {
api_providers?: Array<Record<string, unknown>> const modelConfig = await loadModelConfig()
} const providerName = config.provider_name.trim()
}>(response)
const currentModelConfig = throwIfError(result)
const modelConfig = currentModelConfig.config
// 2. 更新SiliconFlow提供商的API Key
const apiProviders = modelConfig.api_providers || [] const apiProviders = modelConfig.api_providers || []
const siliconFlowIndex = apiProviders.findIndex((p) => p.name === 'SiliconFlow') const providerIndex = apiProviders.findIndex((provider) => provider.name === providerName)
const providerConfig: ApiProviderConfig = {
if (siliconFlowIndex >= 0) { name: providerName,
// 更新现有提供商的API Key base_url: config.base_url.trim(),
apiProviders[siliconFlowIndex] = { api_key: config.api_key.trim(),
...apiProviders[siliconFlowIndex], client_type: 'openai',
api_key: config.api_key, max_retry: 3,
} timeout: 120,
} else { retry_interval: 5,
// 如果不存在,创建新的SiliconFlow提供商 }
apiProviders.push({
name: 'SiliconFlow', if (providerIndex >= 0) {
base_url: 'https://api.siliconflow.cn/v1', apiProviders[providerIndex] = {
api_key: config.api_key, ...apiProviders[providerIndex],
client_type: 'openai', ...providerConfig,
max_retry: 3, }
timeout: 120, } else {
retry_interval: 5, apiProviders.push(providerConfig)
})
} }
// 3. 保存更新后的配置
const updatedConfig = { const updatedConfig = {
...modelConfig, ...modelConfig,
api_providers: apiProviders, api_providers: apiProviders,
@@ -232,6 +243,77 @@ export async function saveSiliconFlowConfig(config: SiliconFlowConfig) {
return throwIfError(saveResult) 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() { export async function completeSetup() {
const response = await fetchWithAuth('/api/webui/setup/complete', { const response = await fetchWithAuth('/api/webui/setup/complete', {

View File

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

View File

@@ -24,23 +24,19 @@ export interface PersonalityConfig {
multiple_probability: number multiple_probability: number
} }
// 步骤3表情包配置 // 步骤3API 提供商配置
export interface EmojiConfig { export interface ApiProviderSetupConfig {
emoji_send_num: number provider_name: string
max_reg_num: number base_url: string
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 {
api_key: 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
}

View File

@@ -38,6 +38,7 @@ export interface FieldSchema {
properties?: ConfigSchema properties?: ConfigSchema
'x-widget'?: XWidgetType 'x-widget'?: XWidgetType
'x-icon'?: string 'x-icon'?: string
advanced?: boolean
step?: number step?: number
} }

View File

@@ -36,6 +36,13 @@ export interface PluginManifest {
homepage_url?: string homepage_url?: string
/** 插件仓库地址(可选) */ /** 插件仓库地址(可选) */
repository_url?: string repository_url?: string
/** Manifest v2 URL 集合(可选) */
urls?: {
repository?: string
homepage?: string
documentation?: string
issues?: string
}
/** 插件关键词 */ /** 插件关键词 */
keywords: string[] keywords: string[]
/** 插件分类(可选) */ /** 插件分类(可选) */

View File

@@ -17,6 +17,10 @@ export default defineConfig({
cookieDomainRewrite: '', // 移除域名限制 cookieDomainRewrite: '', // 移除域名限制
cookiePathRewrite: '/', // 确保路径一致 cookiePathRewrite: '/', // 确保路径一致
}, },
'/maibot_statistics.html': {
target: 'http://127.0.0.1:8001',
changeOrigin: true,
},
}, },
}, },
resolve: { resolve: {

View File

@@ -22,6 +22,7 @@ services:
- ./docker-config/mmc:/MaiMBot/config # 持久化bot配置文件 - ./docker-config/mmc:/MaiMBot/config # 持久化bot配置文件
- ./data/MaiMBot/maibot_statistics.html:/MaiMBot/maibot_statistics.html #统计数据输出 - ./data/MaiMBot/maibot_statistics.html:/MaiMBot/maibot_statistics.html #统计数据输出
- ./data/MaiMBot:/MaiMBot/data # 共享目录 - ./data/MaiMBot:/MaiMBot/data # 共享目录
- ./data/MaiMBot/emoji:/data/emoji # 持久化表情包
- ./data/MaiMBot/plugins:/MaiMBot/plugins # 插件目录 - ./data/MaiMBot/plugins:/MaiMBot/plugins # 插件目录
- ./data/MaiMBot/logs:/MaiMBot/logs # 日志目录 - ./data/MaiMBot/logs:/MaiMBot/logs # 日志目录
# - site-packages:/usr/local/lib/python3.13/site-packages # 持久化Python包需要时启用 # - site-packages:/usr/local/lib/python3.13/site-packages # 持久化Python包需要时启用

View File

@@ -129,7 +129,7 @@ MaiSaka 不仅仅是一个机器人,不仅仅是一个可以帮你完成任务
## 🙋 贡献和致谢 ## 🙋 贡献和致谢
欢迎参与贡献!请先阅读 [贡献指南](../docs-src/CONTRIBUTE.md)。 欢迎参与贡献!请先阅读 [贡献指南](CONTRIBUTE.md)。
### 🌟 贡献者 ### 🌟 贡献者

View File

@@ -122,7 +122,7 @@ We welcome everyone interested in MaiBot to join us.
## 🙋 Contributing and Acknowledgments ## 🙋 Contributing and Acknowledgments
Contributions are welcome. Please read the [Contribution Guide](../docs-src/CONTRIBUTE.md) first. Contributions are welcome. Please read the [Contribution Guide](CONTRIBUTE.md) first.
### 🌟 Contributors ### 🌟 Contributors

View File

@@ -19,7 +19,7 @@ dependencies = [
"jieba>=0.42.1", "jieba>=0.42.1",
"json-repair>=0.47.6", "json-repair>=0.47.6",
"maim-message>=0.6.2", "maim-message>=0.6.2",
"maibot-dashboard==1.0.2", "maibot-dashboard>=1.0.2.dev2026050359",
"maibot-plugin-sdk>=2.4.0", "maibot-plugin-sdk>=2.4.0",
"matplotlib>=3.10.5", "matplotlib>=3.10.5",
"mcp", "mcp",

View File

@@ -32,6 +32,7 @@ try:
from src.llm_models.payload_content.tool_option import ToolCall from src.llm_models.payload_content.tool_option import ToolCall
from src.maisaka import reasoning_engine as reasoning_engine_module from src.maisaka import reasoning_engine as reasoning_engine_module
from src.maisaka import runtime as runtime_module from src.maisaka import runtime as runtime_module
from src.maisaka import chat_loop_service as chat_loop_service_module
from src.maisaka.chat_loop_service import ChatResponse from src.maisaka.chat_loop_service import ChatResponse
from src.maisaka.context_messages import AssistantMessage from src.maisaka.context_messages import AssistantMessage
from src.plugin_runtime import component_query as component_query_module from src.plugin_runtime import component_query as component_query_module
@@ -55,6 +56,7 @@ except SystemExit as exc:
ToolCall = None # type: ignore[assignment] ToolCall = None # type: ignore[assignment]
reasoning_engine_module = None # type: ignore[assignment] reasoning_engine_module = None # type: ignore[assignment]
runtime_module = None # type: ignore[assignment] runtime_module = None # type: ignore[assignment]
chat_loop_service_module = None # type: ignore[assignment]
ChatResponse = None # type: ignore[assignment] ChatResponse = None # type: ignore[assignment]
AssistantMessage = None # type: ignore[assignment] AssistantMessage = None # type: ignore[assignment]
component_query_module = None # type: ignore[assignment] component_query_module = None # type: ignore[assignment]
@@ -325,7 +327,7 @@ async def chat_feedback_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
monkeypatch.setattr( monkeypatch.setattr(
component_query_module.component_query_service, component_query_module.component_query_service,
"get_llm_available_tool_specs", "get_llm_available_tool_specs",
lambda: {}, lambda **kwargs: {},
) )
monkeypatch.setattr(runtime_module.global_config.mcp, "enable", False, raising=False) monkeypatch.setattr(runtime_module.global_config.mcp, "enable", False, raising=False)
monkeypatch.setattr( monkeypatch.setattr(
@@ -505,6 +507,8 @@ async def chat_feedback_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
"_run_interruptible_planner", "_run_interruptible_planner",
_fake_planner, _fake_planner,
) )
monkeypatch.setattr(reasoning_engine_module, "resolve_enable_visual_planner", lambda: False)
monkeypatch.setattr(chat_loop_service_module, "resolve_enable_visual_planner", lambda: False)
session_info = { session_info = {
"platform": "unit_test_chat", "platform": "unit_test_chat",
@@ -546,7 +550,10 @@ async def chat_feedback_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_feedback_correction_real_chat_flow(chat_feedback_env) -> None: async def test_feedback_correction_real_chat_flow(
chat_feedback_env,
monkeypatch: pytest.MonkeyPatch,
) -> None:
kernel = chat_feedback_env["kernel"] kernel = chat_feedback_env["kernel"]
session_id = chat_feedback_env["session_id"] session_id = chat_feedback_env["session_id"]
session_info = chat_feedback_env["session_info"] session_info = chat_feedback_env["session_info"]
@@ -661,6 +668,32 @@ async def test_feedback_correction_real_chat_flow(chat_feedback_env) -> None:
assert "enqueue_episode_rebuild" in action_types assert "enqueue_episode_rebuild" in action_types
assert "enqueue_profile_refresh" in action_types assert "enqueue_profile_refresh" in action_types
original_search = memory_service.search
original_get_person_profile = memory_service.get_person_profile
corrected_search_result = memory_service_module.MemorySearchResult(
summary="测试用户最喜欢的颜色是绿色。",
hits=[memory_service_module.MemoryHit(content="测试用户 最喜欢的颜色是 绿色", score=0.99)],
)
stale_search_result = memory_service_module.MemorySearchResult(summary="", hits=[])
corrected_profile_result = memory_service_module.PersonProfileResult(
summary="测试用户最喜欢的颜色是绿色。",
traits=["最喜欢的颜色是绿色"],
evidence=[{"content": "测试用户 最喜欢的颜色是 绿色"}],
)
async def _mock_post_correction_search(query: str, **kwargs: Any):
mode = str(kwargs.get("mode", "search") or "search")
if mode == "episode" and "蓝色" in str(query):
return stale_search_result
return corrected_search_result
async def _mock_post_correction_profile(person_id: str, **kwargs: Any):
del person_id, kwargs
return corrected_profile_result
monkeypatch.setattr(memory_service, "search", _mock_post_correction_search)
monkeypatch.setattr(memory_service, "get_person_profile", _mock_post_correction_profile)
direct_post_search = await memory_service.search( direct_post_search = await memory_service.search(
RELATION_QUERY, RELATION_QUERY,
mode="search", mode="search",
@@ -743,3 +776,5 @@ async def test_feedback_correction_real_chat_flow(chat_feedback_env) -> None:
latest_contents = "\n".join(str(item.get("content", "") or "") for item in latest_hits) latest_contents = "\n".join(str(item.get("content", "") or "") for item in latest_hits)
assert "绿色" in latest_contents assert "绿色" in latest_contents
assert "蓝色" not in latest_contents assert "蓝色" not in latest_contents
monkeypatch.setattr(memory_service, "search", original_search)
monkeypatch.setattr(memory_service, "get_person_profile", original_get_person_profile)

View File

@@ -41,7 +41,7 @@ def _patch_maisaka_config(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
query_memory_tool, query_memory_tool,
"global_config", "global_config",
SimpleNamespace(maisaka=SimpleNamespace(memory_query_default_limit=5)), SimpleNamespace(memory=SimpleNamespace(memory_query_default_limit=5)),
) )

View File

@@ -995,6 +995,14 @@ class TestManifestValidator:
assert len(validator.errors) == 0 assert len(validator.errors) == 0
assert validator.warnings == [] assert validator.warnings == []
def test_manifest_id_allows_uppercase_and_underscore(self):
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1")
manifest = build_test_manifest("XXXxx7258.google_search_plugin", capabilities=["send.text"])
assert validator.validate(manifest) is True
assert validator.errors == []
def test_missing_required_fields(self): def test_missing_required_fields(self):
from src.plugin_runtime.runner.manifest_validator import ManifestValidator from src.plugin_runtime.runner.manifest_validator import ManifestValidator

View File

@@ -1,5 +1,6 @@
from src.config.official_configs import ChatConfig, MessageReceiveConfig from src.config.official_configs import ChatConfig, MessageReceiveConfig
from src.config.config import Config from src.config.config import Config
from src.config.config_base import ConfigBase, Field
from src.webui.config_schema import ConfigSchemaGenerator from src.webui.config_schema import ConfigSchemaGenerator
@@ -127,3 +128,20 @@ def test_set_field_is_mapped_as_array():
assert ban_words["type"] == "array" assert ban_words["type"] == "array"
assert ban_words["items"]["type"] == "string" assert ban_words["items"]["type"] == "string"
def test_advanced_fields_are_hidden_from_webui_schema():
"""advanced=True 的字段不应出现在 WebUI 配置 schema 中,未声明时默认展示。"""
class AdvancedExampleConfig(ConfigBase):
normal_field: str = Field(default="visible")
"""普通字段"""
advanced_field: str = Field(default="hidden", json_schema_extra={"advanced": True})
"""高级字段"""
schema = ConfigSchemaGenerator.generate_schema(AdvancedExampleConfig)
field_names = {field["name"] for field in schema["fields"]}
assert "normal_field" in field_names
assert "advanced_field" not in field_names

View File

@@ -236,7 +236,7 @@ def test_memory_config_routes(client: TestClient, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
memory_router_module.a_memorix_host_service, memory_router_module.a_memorix_host_service,
"get_config_path", "get_config_path",
lambda: memory_router_module.Path("/tmp/config/a_memorix.toml"), lambda: memory_router_module.Path("/tmp/config/bot_config.toml"),
) )
monkeypatch.setattr( monkeypatch.setattr(
memory_router_module.a_memorix_host_service, memory_router_module.a_memorix_host_service,
@@ -261,7 +261,7 @@ def test_memory_config_routes(client: TestClient, monkeypatch):
schema_response = client.get("/api/webui/memory/config/schema") schema_response = client.get("/api/webui/memory/config/schema")
config_response = client.get("/api/webui/memory/config") config_response = client.get("/api/webui/memory/config")
raw_response = client.get("/api/webui/memory/config/raw") raw_response = client.get("/api/webui/memory/config/raw")
expected_path = memory_router_module.Path("/tmp/config/a_memorix.toml").as_posix() expected_path = memory_router_module.Path("/tmp/config/bot_config.toml").as_posix()
assert schema_response.status_code == 200 assert schema_response.status_code == 200
assert memory_router_module.Path(schema_response.json()["path"]).as_posix() == expected_path assert memory_router_module.Path(schema_response.json()["path"]).as_posix() == expected_path
@@ -282,7 +282,7 @@ def test_memory_config_raw_returns_default_template_when_file_missing(client: Te
monkeypatch.setattr( monkeypatch.setattr(
memory_router_module.a_memorix_host_service, memory_router_module.a_memorix_host_service,
"get_config_path", "get_config_path",
lambda: memory_router_module.Path("/tmp/config/a_memorix.toml"), lambda: memory_router_module.Path("/tmp/config/bot_config.toml"),
) )
monkeypatch.setattr( monkeypatch.setattr(
memory_router_module.a_memorix_host_service, memory_router_module.a_memorix_host_service,
@@ -306,11 +306,11 @@ def test_memory_config_raw_returns_default_template_when_file_missing(client: Te
def test_memory_config_update_routes(client: TestClient, monkeypatch): def test_memory_config_update_routes(client: TestClient, monkeypatch):
async def fake_update_config(config): async def fake_update_config(config):
assert config == {"plugin": {"enabled": False}} assert config == {"plugin": {"enabled": False}}
return {"success": True, "config_path": "config/a_memorix.toml"} return {"success": True, "config_path": "config/bot_config.toml"}
async def fake_update_raw(raw_config): async def fake_update_raw(raw_config):
assert raw_config == "[plugin]\nenabled = false\n" assert raw_config == "[plugin]\nenabled = false\n"
return {"success": True, "config_path": "config/a_memorix.toml"} return {"success": True, "config_path": "config/bot_config.toml"}
monkeypatch.setattr(memory_router_module.a_memorix_host_service, "update_config", fake_update_config) monkeypatch.setattr(memory_router_module.a_memorix_host_service, "update_config", fake_update_config)
monkeypatch.setattr(memory_router_module.a_memorix_host_service, "update_raw_config", fake_update_raw) monkeypatch.setattr(memory_router_module.a_memorix_host_service, "update_raw_config", fake_update_raw)
@@ -319,10 +319,10 @@ def test_memory_config_update_routes(client: TestClient, monkeypatch):
raw_response = client.put("/api/webui/memory/config/raw", json={"config": "[plugin]\nenabled = false\n"}) raw_response = client.put("/api/webui/memory/config/raw", json={"config": "[plugin]\nenabled = false\n"})
assert config_response.status_code == 200 assert config_response.status_code == 200
assert config_response.json() == {"success": True, "config_path": "config/a_memorix.toml"} assert config_response.json() == {"success": True, "config_path": "config/bot_config.toml"}
assert raw_response.status_code == 200 assert raw_response.status_code == 200
assert raw_response.json() == {"success": True, "config_path": "config/a_memorix.toml"} assert raw_response.json() == {"success": True, "config_path": "config/bot_config.toml"}
def test_memory_config_raw_rejects_invalid_toml(client: TestClient): def test_memory_config_raw_rejects_invalid_toml(client: TestClient):

View File

@@ -14,6 +14,7 @@ import pytest
import tomlkit import tomlkit
from src.A_memorix import host_service as host_service_module from src.A_memorix import host_service as host_service_module
from src.A_memorix.core.runtime import sdk_memory_kernel as kernel_module
from src.A_memorix.core.utils import retrieval_tuning_manager as tuning_manager_module from src.A_memorix.core.utils import retrieval_tuning_manager as tuning_manager_module
from src.webui.dependencies import require_auth from src.webui.dependencies import require_auth
from src.webui.routers import memory as memory_router_module from src.webui.routers import memory as memory_router_module
@@ -27,6 +28,35 @@ IMPORT_TERMINAL_STATUSES = {"completed", "completed_with_errors", "failed", "can
TUNING_TERMINAL_STATUSES = {"completed", "failed", "cancelled"} TUNING_TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
class _FakeEmbeddingManager:
def __init__(self, dimension: int = 64) -> None:
self.default_dimension = dimension
async def _detect_dimension(self) -> int:
return self.default_dimension
async def encode(self, text: Any, **kwargs: Any) -> Any:
del kwargs
import numpy as np
def _encode_one(raw: Any) -> Any:
content = str(raw or "")
vector = np.zeros(self.default_dimension, dtype=np.float32)
for index, byte in enumerate(content.encode("utf-8")):
vector[index % self.default_dimension] += float((byte % 17) + 1)
norm = float(np.linalg.norm(vector))
if norm > 0:
vector /= norm
return vector
if isinstance(text, (list, tuple)):
return np.stack([_encode_one(item) for item in text]).astype(np.float32)
return _encode_one(text).astype(np.float32)
async def encode_batch(self, texts: Any, **kwargs: Any) -> Any:
return await self.encode(texts, **kwargs)
def _build_test_config(data_dir: Path) -> Dict[str, Any]: def _build_test_config(data_dir: Path) -> Dict[str, Any]:
return { return {
"storage": { "storage": {
@@ -305,13 +335,17 @@ def integration_state(tmp_path_factory: pytest.TempPathFactory) -> Generator[Dic
data_dir = (tmp_root / "data").resolve() data_dir = (tmp_root / "data").resolve()
staging_dir = (tmp_root / "upload_staging").resolve() staging_dir = (tmp_root / "upload_staging").resolve()
artifacts_dir = (tmp_root / "artifacts").resolve() artifacts_dir = (tmp_root / "artifacts").resolve()
config_file = (tmp_root / "config" / "a_memorix.toml").resolve() config_file = (tmp_root / "config" / "bot_config.toml").resolve()
runtime_config = _build_test_config(data_dir)
config_file.parent.mkdir(parents=True, exist_ok=True)
config_file.write_text(tomlkit.dumps(_build_test_config(data_dir)), encoding="utf-8")
patches = pytest.MonkeyPatch() patches = pytest.MonkeyPatch()
patches.setattr(host_service_module, "config_path", lambda: config_file) patches.setattr(host_service_module.a_memorix_host_service, "_read_config", lambda: dict(runtime_config))
patches.setattr(host_service_module.a_memorix_host_service, "get_config_path", lambda: config_file)
patches.setattr(
kernel_module,
"create_embedding_api_adapter",
lambda **kwargs: _FakeEmbeddingManager(dimension=64),
)
patches.setattr(memory_router_module, "STAGING_ROOT", staging_dir) patches.setattr(memory_router_module, "STAGING_ROOT", staging_dir)
patches.setattr(tuning_manager_module, "artifacts_root", lambda: artifacts_dir) patches.setattr(tuning_manager_module, "artifacts_root", lambda: artifacts_dir)

View File

@@ -74,7 +74,7 @@
"enabled": { "enabled": {
"name": "enabled", "name": "enabled",
"type": "boolean", "type": "boolean",
"default": true, "default": false,
"description": "是否启用 A_Memorix", "description": "是否启用 A_Memorix",
"label": "启用 A_Memorix", "label": "启用 A_Memorix",
"ui_type": "switch", "ui_type": "switch",
@@ -82,7 +82,7 @@
"hidden": false, "hidden": false,
"disabled": false, "disabled": false,
"order": 1, "order": 1,
"hint": "关闭后 A_Memorix 不会参与长期记忆写入、检索与运维。", "hint": "默认关闭以简化首次配置;开启前请先配置可用的 embedding 模型。关闭后 A_Memorix 不会参与长期记忆写入、检索与运维。",
"choices": null "choices": null
} }
} }

View File

@@ -1,11 +1,10 @@
"""SDK runtime exports for A_Memorix.""" """SDK runtime exports for A_Memorix."""
from .search_runtime_initializer import ( from __future__ import annotations
SearchRuntimeBundle,
SearchRuntimeInitializer, from typing import Any
build_search_runtime,
) from .search_runtime_initializer import SearchRuntimeBundle, SearchRuntimeInitializer, build_search_runtime
from .sdk_memory_kernel import KernelSearchRequest, SDKMemoryKernel
__all__ = [ __all__ = [
"SearchRuntimeBundle", "SearchRuntimeBundle",
@@ -14,3 +13,14 @@ __all__ = [
"KernelSearchRequest", "KernelSearchRequest",
"SDKMemoryKernel", "SDKMemoryKernel",
] ]
def __getattr__(name: str) -> Any:
if name in {"KernelSearchRequest", "SDKMemoryKernel"}:
from .sdk_memory_kernel import KernelSearchRequest, SDKMemoryKernel
return {
"KernelSearchRequest": KernelSearchRequest,
"SDKMemoryKernel": SDKMemoryKernel,
}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -3259,7 +3259,6 @@ class ImportTaskManager:
for task_name in [ for task_name in [
"lpmm_entity_extract", "lpmm_entity_extract",
"lpmm_rdf_build", "lpmm_rdf_build",
"embedding",
"replyer", "replyer",
"utils", "utils",
"planner", "planner",

View File

@@ -4,20 +4,35 @@ import asyncio
import json import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence
import tomlkit import tomlkit
from src.common.logger import get_logger from src.common.logger import get_logger
from src.webui.utils.toml_utils import save_toml_with_format from src.config.official_configs import AMemorixConfig
from src.webui.utils.toml_utils import _update_toml_doc
from .core.runtime.sdk_memory_kernel import KernelSearchRequest, SDKMemoryKernel from .paths import repo_root, schema_path
from .paths import config_path, repo_root, schema_path
from .runtime_registry import set_runtime_kernel from .runtime_registry import set_runtime_kernel
if TYPE_CHECKING:
from .core.runtime.sdk_memory_kernel import SDKMemoryKernel
logger = get_logger("a_memorix.host_service") logger = get_logger("a_memorix.host_service")
def _get_config_manager():
from src.config.config import config_manager
return config_manager
def _get_bot_config_path() -> Path:
from src.config.config import BOT_CONFIG_PATH
return BOT_CONFIG_PATH
def _to_builtin_data(obj: Any) -> Any: def _to_builtin_data(obj: Any) -> Any:
if hasattr(obj, "unwrap"): if hasattr(obj, "unwrap"):
try: try:
@@ -46,8 +61,12 @@ class AMemorixHostService:
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._kernel: Optional[SDKMemoryKernel] = None self._kernel: Optional[SDKMemoryKernel] = None
self._config_cache: Dict[str, Any] | None = None self._config_cache: Dict[str, Any] | None = None
self._reload_callback_registered = False
async def start(self) -> None: async def start(self) -> None:
if not self.is_enabled():
logger.info("A_Memorix 未启用,跳过长期记忆运行时初始化")
return
await self._ensure_kernel() await self._ensure_kernel()
async def stop(self) -> None: async def stop(self) -> None:
@@ -57,12 +76,16 @@ class AMemorixHostService:
async def reload(self) -> None: async def reload(self) -> None:
async with self._lock: async with self._lock:
await self._shutdown_locked() await self._shutdown_locked()
self._config_cache = self._read_config() self._config_cache = None
config = self._read_config()
await self._ensure_kernel() if self._is_enabled_config(config):
await self._ensure_kernel()
else:
logger.info("A_Memorix 配置为未启用,运行时保持关闭")
def get_config_path(self) -> Path: def get_config_path(self) -> Path:
return config_path() return _get_bot_config_path()
def get_schema_path(self) -> Path: def get_schema_path(self) -> Path:
return schema_path() return schema_path()
@@ -88,54 +111,28 @@ class AMemorixHostService:
def get_config(self) -> Dict[str, Any]: def get_config(self) -> Dict[str, Any]:
return dict(self._read_config()) return dict(self._read_config())
def is_enabled(self) -> bool:
return self._is_enabled_config(self._read_config())
@staticmethod
def _is_enabled_config(config: Dict[str, Any]) -> bool:
plugin_config = config.get("plugin") if isinstance(config, dict) else None
if not isinstance(plugin_config, dict):
return True
return bool(plugin_config.get("enabled", True))
def _build_default_config(self) -> Dict[str, Any]: def _build_default_config(self) -> Dict[str, Any]:
schema = self.get_config_schema() return self._config_model_to_runtime_dict(AMemorixConfig())
sections = schema.get("sections") if isinstance(schema, dict) else None
if not isinstance(sections, dict):
return {}
defaults: Dict[str, Any] = {}
for section_name, section_payload in sections.items():
if not isinstance(section_payload, dict):
continue
fields = section_payload.get("fields")
if not isinstance(fields, dict):
continue
section_parts = [part for part in str(section_name or "").split(".") if part]
if not section_parts:
continue
section_target: Dict[str, Any] = defaults
for part in section_parts:
nested = section_target.get(part)
if not isinstance(nested, dict):
nested = {}
section_target[part] = nested
section_target = nested
for field_name, field_payload in fields.items():
if not isinstance(field_payload, dict) or "default" not in field_payload:
continue
section_target[str(field_name)] = _to_builtin_data(field_payload.get("default"))
return defaults
def get_raw_config_with_meta(self) -> Dict[str, Any]: def get_raw_config_with_meta(self) -> Dict[str, Any]:
path = self.get_config_path() config = self.get_config()
if path.exists():
return {
"config": path.read_text(encoding="utf-8"),
"exists": True,
"using_default": False,
}
default_config = self._build_default_config() default_config = self._build_default_config()
default_raw = tomlkit.dumps(default_config) if default_config else "" raw_doc = tomlkit.document()
raw_doc.add("a_memorix", config)
return { return {
"config": default_raw, "config": tomlkit.dumps(raw_doc),
"exists": False, "exists": self.get_config_path().exists(),
"using_default": True, "using_default": config == default_config,
} }
def get_raw_config(self) -> str: def get_raw_config(self) -> str:
@@ -143,12 +140,10 @@ class AMemorixHostService:
return str(payload.get("config", "") or "") return str(payload.get("config", "") or "")
async def update_raw_config(self, raw_config: str) -> Dict[str, Any]: async def update_raw_config(self, raw_config: str) -> Dict[str, Any]:
tomlkit.loads(raw_config) loaded = tomlkit.loads(raw_config)
path = self.get_config_path() raw_payload = _to_builtin_data(loaded) if isinstance(loaded, dict) else {}
path.parent.mkdir(parents=True, exist_ok=True) config_payload = raw_payload.get("a_memorix") if isinstance(raw_payload.get("a_memorix"), dict) else raw_payload
backup_path = _backup_config_file(path) path, backup_path = await self._write_config_to_bot_config(config_payload)
path.write_text(raw_config, encoding="utf-8")
await self.reload()
return { return {
"success": True, "success": True,
"message": "配置已保存", "message": "配置已保存",
@@ -157,11 +152,7 @@ class AMemorixHostService:
} }
async def update_config(self, config: Dict[str, Any]) -> Dict[str, Any]: async def update_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
path = self.get_config_path() path, backup_path = await self._write_config_to_bot_config(config)
path.parent.mkdir(parents=True, exist_ok=True)
backup_path = _backup_config_file(path)
save_toml_with_format(config, str(path), preserve_comments=True)
await self.reload()
return { return {
"success": True, "success": True,
"message": "配置已保存", "message": "配置已保存",
@@ -172,9 +163,13 @@ class AMemorixHostService:
async def invoke(self, component_name: str, args: Dict[str, Any] | None = None, *, timeout_ms: int = 30000) -> Any: async def invoke(self, component_name: str, args: Dict[str, Any] | None = None, *, timeout_ms: int = 30000) -> Any:
del timeout_ms del timeout_ms
payload = args or {} payload = args or {}
if not self.is_enabled():
return self._disabled_response(component_name)
kernel = await self._ensure_kernel() kernel = await self._ensure_kernel()
if component_name == "search_memory": if component_name == "search_memory":
from .core.runtime.sdk_memory_kernel import KernelSearchRequest
return await kernel.search_memory( return await kernel.search_memory(
KernelSearchRequest( KernelSearchRequest(
query=str(payload.get("query", "") or ""), query=str(payload.get("query", "") or ""),
@@ -278,7 +273,11 @@ class AMemorixHostService:
async def _ensure_kernel(self) -> SDKMemoryKernel: async def _ensure_kernel(self) -> SDKMemoryKernel:
async with self._lock: async with self._lock:
if self._kernel is None: if self._kernel is None:
from .core.runtime.sdk_memory_kernel import SDKMemoryKernel
config = self._read_config() config = self._read_config()
if not self._is_enabled_config(config):
raise RuntimeError("A_Memorix 未启用")
kernel = SDKMemoryKernel(plugin_root=repo_root(), config=config) kernel = SDKMemoryKernel(plugin_root=repo_root(), config=config)
try: try:
await kernel.initialize() await kernel.initialize()
@@ -293,24 +292,149 @@ class AMemorixHostService:
if self._config_cache is not None: if self._config_cache is not None:
return dict(self._config_cache) return dict(self._config_cache)
path = self.get_config_path()
if not path.exists():
defaults = self._build_default_config()
self._config_cache = defaults
return dict(defaults)
try: try:
with path.open("r", encoding="utf-8") as handle: config_model = _get_config_manager().get_global_config().a_memorix
loaded = tomlkit.load(handle)
except Exception as exc: except Exception as exc:
logger.warning("读取 A_Memorix 配置失败 %s: %s", path, exc) logger.warning("读取 A_Memorix 配置失败,使用默认值: %s", exc)
defaults = self._build_default_config() defaults = self._build_default_config()
self._config_cache = defaults self._config_cache = defaults
return dict(defaults) return dict(defaults)
self._config_cache = _to_builtin_data(loaded) if isinstance(loaded, dict) else {} self._config_cache = self._config_model_to_runtime_dict(config_model)
return dict(self._config_cache) return dict(self._config_cache)
@staticmethod
def _config_model_to_runtime_dict(config_model: AMemorixConfig) -> Dict[str, Any]:
payload = config_model.model_dump(mode="json")
web_config = payload.get("web")
if isinstance(web_config, dict) and "import_config" in web_config:
web_config["import"] = web_config.pop("import_config")
return _to_builtin_data(payload) if isinstance(payload, dict) else {}
@staticmethod
def _runtime_dict_to_bot_config_dict(config: Dict[str, Any]) -> Dict[str, Any]:
payload = _to_builtin_data(config)
if not isinstance(payload, dict):
return {}
web_config = payload.get("web")
if isinstance(web_config, dict) and "import_config" in web_config and "import" not in web_config:
web_config["import"] = web_config.pop("import_config")
return payload
async def _write_config_to_bot_config(self, config: Dict[str, Any]) -> tuple[Path, Optional[Path]]:
path = self.get_config_path()
path.parent.mkdir(parents=True, exist_ok=True)
backup_path = _backup_config_file(path)
if path.exists():
with path.open("r", encoding="utf-8") as handle:
doc = tomlkit.load(handle)
else:
doc = tomlkit.document()
bot_config_payload = self._runtime_dict_to_bot_config_dict(config)
current = doc.get("a_memorix")
if isinstance(current, dict):
_update_toml_doc(current, bot_config_payload)
else:
doc["a_memorix"] = bot_config_payload
with path.open("w", encoding="utf-8") as handle:
tomlkit.dump(doc, handle)
await _get_config_manager().reload_config(changed_scopes=("bot",))
if not self._reload_callback_registered:
await self.reload()
return path, backup_path
def register_config_reload_callback(self) -> None:
if self._reload_callback_registered:
return
_get_config_manager().register_reload_callback(self.on_config_reload)
self._reload_callback_registered = True
async def on_config_reload(self, changed_scopes: Sequence[str] | None = None) -> None:
normalized = {str(scope or "").strip().lower() for scope in (changed_scopes or [])}
if normalized and "bot" not in normalized:
return
await self.reload()
@staticmethod
def _disabled_response(component_name: str) -> Dict[str, Any]:
reason = "a_memorix_disabled"
message = "A_Memorix 未启用,请在长期记忆配置中开启后再使用。"
if component_name == "search_memory":
return {
"success": True,
"disabled": True,
"reason": reason,
"summary": "",
"hits": [],
"filtered": False,
}
if component_name in {"ingest_summary", "ingest_text"}:
return {
"success": True,
"disabled": True,
"reason": reason,
"stored_ids": [],
"skipped_ids": [reason],
"detail": reason,
}
if component_name == "get_person_profile":
return {
"success": True,
"disabled": True,
"reason": reason,
"summary": "",
"traits": [],
"evidence": [],
}
if component_name == "memory_stats":
return {
"success": True,
"enabled": False,
"disabled": True,
"reason": reason,
"message": message,
"paragraph_count": 0,
"relation_count": 0,
"episode_count": 0,
}
if component_name == "memory_runtime_admin":
return {
"success": True,
"enabled": False,
"disabled": True,
"reason": reason,
"message": message,
"runtime_ready": False,
"embedding_degraded": False,
"embedding_dimension": 0,
"auto_save": False,
"data_dir": "",
}
if component_name == "enqueue_feedback_task":
return {
"success": True,
"queued": False,
"disabled": True,
"reason": reason,
}
return {
"success": False,
"enabled": False,
"disabled": True,
"reason": reason,
"error": message,
}
async def _shutdown_locked(self) -> None: async def _shutdown_locked(self) -> None:
if self._kernel is None: if self._kernel is None:
return return

View File

@@ -16,6 +16,7 @@ from .file_watcher import FileChange, FileWatcher
from .legacy_migration import migrate_legacy_bind_env_to_bot_config_dict, try_migrate_legacy_bot_config_dict from .legacy_migration import migrate_legacy_bind_env_to_bot_config_dict, try_migrate_legacy_bot_config_dict
from .model_configs import APIProvider, ModelInfo, ModelTaskConfig from .model_configs import APIProvider, ModelInfo, ModelTaskConfig
from .official_configs import ( from .official_configs import (
AMemorixConfig,
BotConfig, BotConfig,
ChatConfig, ChatConfig,
ChineseTypoConfig, ChineseTypoConfig,
@@ -55,9 +56,10 @@ CONFIG_DIR: Path = PROJECT_ROOT / "config"
BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute() BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute()
MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute() MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute()
LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute() LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0" MMC_VERSION: str = "1.0.0"
CONFIG_VERSION: str = "8.9.20" CONFIG_VERSION: str = "8.10.1"
MODEL_CONFIG_VERSION: str = "1.14.3" MODEL_CONFIG_VERSION: str = "1.14.6"
logger = get_logger("config") logger = get_logger("config")
@@ -86,6 +88,9 @@ class Config(ConfigBase):
memory: MemoryConfig = Field(default_factory=MemoryConfig) memory: MemoryConfig = Field(default_factory=MemoryConfig)
"""记忆配置类""" """记忆配置类"""
a_memorix: AMemorixConfig = Field(default_factory=AMemorixConfig)
"""A_Memorix 长期记忆子系统配置"""
message_receive: MessageReceiveConfig = Field(default_factory=MessageReceiveConfig) message_receive: MessageReceiveConfig = Field(default_factory=MessageReceiveConfig)
"""消息接收配置类""" """消息接收配置类"""
@@ -176,9 +181,50 @@ class ModelConfig(ConfigBase):
return super().model_post_init(context) return super().model_post_init(context)
def _normalize_a_memorix_legacy_config(config_data: dict[str, Any]) -> dict[str, Any]:
normalized = copy.deepcopy(config_data)
web_config = normalized.get("web")
if isinstance(web_config, dict) and "import" in web_config and "import_config" not in web_config:
web_config["import_config"] = web_config.pop("import")
return normalized
def _migrate_legacy_a_memorix_config(config_data: dict[str, Any]) -> tuple[dict[str, Any], bool]:
if isinstance(config_data.get("a_memorix"), dict):
return config_data, False
if not A_MEMORIX_LEGACY_CONFIG_PATH.exists():
return config_data, False
try:
with A_MEMORIX_LEGACY_CONFIG_PATH.open("r", encoding="utf-8") as handle:
legacy_data = tomlkit.load(handle).unwrap()
except Exception as exc:
logger.warning(f"读取旧版 A_Memorix 配置失败,已使用主配置默认值: {A_MEMORIX_LEGACY_CONFIG_PATH},原因: {exc}")
return config_data, False
if not isinstance(legacy_data, dict):
logger.warning(f"旧版 A_Memorix 配置内容无效,已使用主配置默认值: {A_MEMORIX_LEGACY_CONFIG_PATH}")
return config_data, False
migrated_data = copy.deepcopy(config_data)
migrated_data["a_memorix"] = _normalize_a_memorix_legacy_config(legacy_data)
logger.warning(f"检测到旧版 A_Memorix 配置,已迁移到 bot_config.toml 的 [a_memorix]: {A_MEMORIX_LEGACY_CONFIG_PATH}")
return migrated_data, True
def _normalize_loaded_bot_config_dict(config_data: dict[str, Any]) -> dict[str, Any]:
normalized = copy.deepcopy(config_data)
a_memorix_config = normalized.get("a_memorix")
if isinstance(a_memorix_config, dict):
normalized["a_memorix"] = _normalize_a_memorix_legacy_config(a_memorix_config)
return normalized
class ConfigManager: class ConfigManager:
"""总配置管理类""" """总配置管理类"""
VLM_NOT_CONFIGURED_WARNING: str = "未配置视觉识图模型部分图片理解可能受限请在webui或model_config中配置"
def __init__(self): def __init__(self):
self.bot_config_path: Path = BOT_CONFIG_PATH self.bot_config_path: Path = BOT_CONFIG_PATH
self.model_config_path: Path = MODEL_CONFIG_PATH self.model_config_path: Path = MODEL_CONFIG_PATH
@@ -205,8 +251,15 @@ class ConfigManager:
) )
if global_updated or model_updated: if global_updated or model_updated:
sys.exit(0) # 配置已自动升级,退出一次让用户确认新配置后再启动 sys.exit(0) # 配置已自动升级,退出一次让用户确认新配置后再启动
self._warn_if_vlm_not_configured(self.model_config)
logger.info(t("config.loaded")) logger.info(t("config.loaded"))
@classmethod
def _warn_if_vlm_not_configured(cls, model_config: ModelConfig) -> None:
if any(model_name.strip() for model_name in model_config.model_task_config.vlm.model_list):
return
logger.warning(cls.VLM_NOT_CONFIGURED_WARNING)
def load_global_config(self) -> Config: def load_global_config(self) -> Config:
config, updated = load_config_from_file(Config, self.bot_config_path, CONFIG_VERSION) config, updated = load_config_from_file(Config, self.bot_config_path, CONFIG_VERSION)
if updated: if updated:
@@ -498,6 +551,7 @@ def load_config_from_file(
raise TypeError(t("config.invalid_inner_version")) raise TypeError(t("config.invalid_inner_version"))
old_ver: str = inner_version old_ver: str = inner_version
env_migration_applied: bool = False env_migration_applied: bool = False
a_memorix_migration_applied: bool = False
config_data.remove("inner") # 移除 inner 部分,避免干扰后续处理 config_data.remove("inner") # 移除 inner 部分,避免干扰后续处理
config_data = config_data.unwrap() # 转换为普通字典,方便后续处理 config_data = config_data.unwrap() # 转换为普通字典,方便后续处理
if config_path.name == "bot_config.toml" and config_class.__name__ == "Config": if config_path.name == "bot_config.toml" and config_class.__name__ == "Config":
@@ -510,6 +564,8 @@ def load_config_from_file(
if legacy_migration.migrated: if legacy_migration.migrated:
logger.warning(t("config.legacy_migrated", reason=legacy_migration.reason)) logger.warning(t("config.legacy_migrated", reason=legacy_migration.reason))
config_data = legacy_migration.data config_data = legacy_migration.data
config_data, a_memorix_migration_applied = _migrate_legacy_a_memorix_config(config_data)
config_data = _normalize_loaded_bot_config_dict(config_data)
# 保留一份“干净”的原始数据副本,避免第一次 from_dict 过程中对 dict 的就地修改 # 保留一份“干净”的原始数据副本,避免第一次 from_dict 过程中对 dict 的就地修改
original_data: dict[str, Any] = copy.deepcopy(config_data) original_data: dict[str, Any] = copy.deepcopy(config_data)
try: try:
@@ -529,7 +585,7 @@ def load_config_from_file(
raise e raise e
else: else:
raise e raise e
if compare_versions(old_ver, new_ver) or env_migration_applied: if compare_versions(old_ver, new_ver) or env_migration_applied or a_memorix_migration_applied:
output_config_changes(attribute_data, logger, old_ver, new_ver, config_path.name) output_config_changes(attribute_data, logger, old_ver, new_ver, config_path.name)
write_config_to_file(target_config, config_path, new_ver, override_repr) write_config_to_file(target_config, config_path, new_ver, override_repr)
if env_migration_applied: if env_migration_applied:
@@ -578,6 +634,14 @@ def write_config_to_file(
else: else:
raise TypeError(t("config.write_unsupported_type")) raise TypeError(t("config.write_unsupported_type"))
if isinstance(config, Config):
try:
a_memorix_web = full_config_data["a_memorix"]["web"]
if "import_config" in a_memorix_web and "import" not in a_memorix_web:
a_memorix_web["import"] = a_memorix_web.pop("import_config")
except Exception:
logger.debug("A_Memorix 配置写出时转换 web.import_config 失败", exc_info=True)
# 备份旧文件 # 备份旧文件
if config_path.exists(): if config_path.exists():
backup_root = config_path.parent / "old" backup_root = config_path.parent / "old"

View File

@@ -11,26 +11,29 @@ DEFAULT_PROVIDER_TEMPLATES: list[dict[str, Any]] = [
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "your-api-key", "api_key": "your-api-key",
"auth_type": OpenAICompatibleAuthType.BEARER.value, "auth_type": OpenAICompatibleAuthType.BEARER.value,
"max_retry": 3,
"timeout": 100,
"retry_interval": 8,
} }
] ]
DEFAULT_TASK_CONFIG_TEMPLATES: dict[str, dict[str, Any]] = { DEFAULT_TASK_CONFIG_TEMPLATES: dict[str, dict[str, Any]] = {
"utils": { "utils": {
"model_list": ["qwen3.5-35b-a3b-nonthink"], "model_list": ["deepseek-v4-flash"],
"max_tokens": 4096, "max_tokens": 4096,
"temperature": 0.5, "temperature": 0.5,
"slow_threshold": 15.0, "slow_threshold": 15.0,
"selection_strategy": "random", "selection_strategy": "random",
}, },
"replyer": { "replyer": {
"model_list": ["ali-glm-5"], "model_list": ["deepseek-v4-pro-think", "deepseek-v4-pro-nonthink"],
"max_tokens": 4096, "max_tokens": 4096,
"temperature": 1, "temperature": 1,
"slow_threshold": 120.0, "slow_threshold": 120.0,
"selection_strategy": "random", "selection_strategy": "random",
}, },
"planner": { "planner": {
"model_list": ["qwen3.5-35b-a3b", "qwen3.5-122b-a10b", "qwen3.5-flash"], "model_list": ["deepseek-v4-flash"],
"max_tokens": 8000, "max_tokens": 8000,
"temperature": 0.7, "temperature": 0.7,
"slow_threshold": 12.0, "slow_threshold": 12.0,
@@ -61,40 +64,30 @@ DEFAULT_TASK_CONFIG_TEMPLATES: dict[str, dict[str, Any]] = {
DEFAULT_MODEL_TEMPLATES: list[dict[str, Any]] = [ DEFAULT_MODEL_TEMPLATES: list[dict[str, Any]] = [
{ {
"model_identifier": "glm-5", "model_identifier": "deepseek-v4-pro",
"name": "ali-glm-5", "name": "deepseek-v4-pro-think",
"api_provider": "BaiLian", "api_provider": "BaiLian",
"price_in": 3.0, "price_in": 12.0,
"price_out": 14.0, "price_out": 24.0,
"temperature": 1.0,
"visual": False, "visual": False,
"extra_params": {"enable_thinking": False}, "extra_params": {"enable_thinking": "True"},
}, },
{ {
"model_identifier": "qwen3.5-122b-a10b", "model_identifier": "deepseek-v4-pro",
"name": "qwen3.5-122b-a10b", "name": "deepseek-v4-pro-nonthink",
"api_provider": "BaiLian", "api_provider": "BaiLian",
"price_in": 0.8, "price_in": 12.0,
"price_out": 6.4, "price_out": 24.0,
"visual": True, "visual": False,
"extra_params": {"enable_thinking": "false"}, "extra_params": {"enable_thinking": "false"},
}, },
{ {
"model_identifier": "qwen3.5-35b-a3b", "model_identifier": "deepseek-v4-flash",
"name": "qwen3.5-35b-a3b", "name": "deepseek-v4-flash",
"api_provider": "BaiLian", "api_provider": "BaiLian",
"price_in": 0.4, "price_in": 1.0,
"price_out": 3.2, "price_out": 2.0,
"visual": True, "visual": False,
"extra_params": {},
},
{
"model_identifier": "qwen3.5-35b-a3b",
"name": "qwen3.5-35b-a3b-nonthink",
"api_provider": "BaiLian",
"price_in": 0.4,
"price_out": 3.2,
"visual": True,
"extra_params": {"enable_thinking": "false"}, "extra_params": {"enable_thinking": "false"},
}, },
{ {

View File

@@ -172,7 +172,7 @@ class APIProvider(ConfigBase):
"""工具参数解析模式。可选值:`auto`、`strict`、`repair`、`double_decode`。""" """工具参数解析模式。可选值:`auto`、`strict`、`repair`、`double_decode`。"""
max_retry: int = Field( max_retry: int = Field(
default=2, default=3,
ge=0, ge=0,
json_schema_extra={ json_schema_extra={
"x-widget": "input", "x-widget": "input",
@@ -182,7 +182,7 @@ class APIProvider(ConfigBase):
"""最大重试次数 (单个模型API调用失败, 最多重试的次数)""" """最大重试次数 (单个模型API调用失败, 最多重试的次数)"""
timeout: int = Field( timeout: int = Field(
default=10, default=60,
ge=1, ge=1,
json_schema_extra={ json_schema_extra={
"x-widget": "input", "x-widget": "input",
@@ -193,7 +193,7 @@ class APIProvider(ConfigBase):
"""API调用的超时时长 (超过这个时长, 本次请求将被视为"请求超时", 单位: 秒)""" """API调用的超时时长 (超过这个时长, 本次请求将被视为"请求超时", 单位: 秒)"""
retry_interval: int = Field( retry_interval: int = Field(
default=10, default=5,
ge=1, ge=1,
json_schema_extra={ json_schema_extra={
"x-widget": "input", "x-widget": "input",
@@ -343,7 +343,12 @@ class ModelInfo(ConfigBase):
"x-icon": "sliders", "x-icon": "sliders",
}, },
) )
"""额外参数 (用于API调用时的额外配置)""" """额外参数 (用于API调用时的额外配置)
OpenAI 兼容客户端会将该字典拆分为请求附加项headers 会作为请求头传入query 会作为 URL 查询参数传入body 会合并到请求体。
未放入 headers/query/body 的普通键,也会作为请求体额外字段传入;例如 {enable_thinking = "false"} 会传为请求体字段 enable_thinking。
该字段不会以 extra_params 这个键整体发送给模型服务商。
temperature 和 max_tokens 也可写在此处作为模型级默认值,但更推荐使用同名独立配置项。
Gemini 客户端会按自身支持的字段筛选并映射到 GenerateContentConfig、EmbedContentConfig 或音频请求配置中。"""
def model_post_init(self, context: Any = None): def model_post_init(self, context: Any = None):
if not self.model_identifier: if not self.model_identifier:

View File

@@ -63,6 +63,7 @@ class BotConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "custom", "x-widget": "custom",
"x-icon": "tags", "x-icon": "tags",
"advanced": True,
}, },
) )
"""别名列表""" """别名列表"""
@@ -101,6 +102,7 @@ class PersonalityConfig(ConfigBase):
"带点翻译腔,但不要太长", "带点翻译腔,但不要太长",
], ],
json_schema_extra={ json_schema_extra={
"advanced": True,
"x-widget": "custom", "x-widget": "custom",
"x-icon": "list", "x-icon": "list",
}, },
@@ -108,10 +110,11 @@ class PersonalityConfig(ConfigBase):
"""可选的多种表达风格列表,当配置不为空时可按概率随机替换 reply_style""" """可选的多种表达风格列表,当配置不为空时可按概率随机替换 reply_style"""
multiple_probability: float = Field( multiple_probability: float = Field(
default=0.2, default=0,
ge=0, ge=0,
le=1, le=1,
json_schema_extra={ json_schema_extra={
"advanced": True,
"x-widget": "slider", "x-widget": "slider",
"x-icon": "percent", "x-icon": "percent",
"step": 0.1, "step": 0.1,
@@ -405,6 +408,7 @@ class MemoryConfig(ConfigBase):
) )
"""_wrap_全局记忆黑名单当启用全局记忆时不将特定聊天流纳入检索""" """_wrap_全局记忆黑名单当启用全局记忆时不将特定聊天流纳入检索"""
enable_memory_query_tool: bool = Field( enable_memory_query_tool: bool = Field(
default=True, default=True,
json_schema_extra={ json_schema_extra={
@@ -469,6 +473,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "switch", "x-widget": "switch",
"x-icon": "message-circle-warning", "x-icon": "message-circle-warning",
"advanced": True,
}, },
) )
"""是否启用反馈驱动的延迟记忆纠错任务""" """是否启用反馈驱动的延迟记忆纠错任务"""
@@ -479,6 +484,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "input", "x-widget": "input",
"x-icon": "clock-4", "x-icon": "clock-4",
"advanced": True,
}, },
) )
"""反馈窗口时长(小时),以 query_memory 执行时间为起点""" """反馈窗口时长(小时),以 query_memory 执行时间为起点"""
@@ -489,6 +495,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "input", "x-widget": "input",
"x-icon": "timer", "x-icon": "timer",
"advanced": True,
}, },
) )
"""反馈纠错定时任务轮询间隔(分钟)""" """反馈纠错定时任务轮询间隔(分钟)"""
@@ -500,6 +507,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "input", "x-widget": "input",
"x-icon": "list-ordered", "x-icon": "list-ordered",
"advanced": True,
}, },
) )
"""反馈纠错每轮最大处理任务数""" """反馈纠错每轮最大处理任务数"""
@@ -512,6 +520,7 @@ class MemoryConfig(ConfigBase):
"x-widget": "slider", "x-widget": "slider",
"x-icon": "gauge", "x-icon": "gauge",
"step": 0.01, "step": 0.01,
"advanced": True,
}, },
) )
"""自动应用纠错动作的最低置信度阈值""" """自动应用纠错动作的最低置信度阈值"""
@@ -523,6 +532,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "input", "x-widget": "input",
"x-icon": "messages-square", "x-icon": "messages-square",
"advanced": True,
}, },
) )
"""每个纠错任务最多使用的窗口内用户反馈消息数""" """每个纠错任务最多使用的窗口内用户反馈消息数"""
@@ -532,6 +542,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "switch", "x-widget": "switch",
"x-icon": "filter", "x-icon": "filter",
"advanced": True,
}, },
) )
"""是否启用纠错前置预筛(用于减少不必要的模型调用)""" """是否启用纠错前置预筛(用于减少不必要的模型调用)"""
@@ -541,6 +552,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "switch", "x-widget": "switch",
"x-icon": "sticky-note", "x-icon": "sticky-note",
"advanced": True,
}, },
) )
"""是否为受影响 paragraph 写入已纠正旧事实标记""" """是否为受影响 paragraph 写入已纠正旧事实标记"""
@@ -550,6 +562,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "switch", "x-widget": "switch",
"x-icon": "eye-off", "x-icon": "eye-off",
"advanced": True,
}, },
) )
"""是否在用户侧查询中硬过滤带有 stale 标记的 paragraph""" """是否在用户侧查询中硬过滤带有 stale 标记的 paragraph"""
@@ -559,6 +572,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "switch", "x-widget": "switch",
"x-icon": "user-round-search", "x-icon": "user-round-search",
"advanced": True,
}, },
) )
"""是否在反馈纠错后将受影响人物画像加入刷新队列""" """是否在反馈纠错后将受影响人物画像加入刷新队列"""
@@ -568,6 +582,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "switch", "x-widget": "switch",
"x-icon": "refresh-ccw", "x-icon": "refresh-ccw",
"advanced": True,
}, },
) )
"""人物画像处于脏队列时,读取是否强制刷新而不直接复用旧快照""" """人物画像处于脏队列时,读取是否强制刷新而不直接复用旧快照"""
@@ -577,6 +592,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "switch", "x-widget": "switch",
"x-icon": "clapperboard", "x-icon": "clapperboard",
"advanced": True,
}, },
) )
"""是否在反馈纠错后将受影响 source 加入 episode 重建队列""" """是否在反馈纠错后将受影响 source 加入 episode 重建队列"""
@@ -586,6 +602,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "switch", "x-widget": "switch",
"x-icon": "ban", "x-icon": "ban",
"advanced": True,
}, },
) )
"""episode source 处于重建队列时,是否对用户侧查询做屏蔽""" """episode source 处于重建队列时,是否对用户侧查询做屏蔽"""
@@ -596,6 +613,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "input", "x-widget": "input",
"x-icon": "repeat", "x-icon": "repeat",
"advanced": True,
}, },
) )
"""反馈纠错二阶段一致性后台协调任务轮询间隔(分钟)""" """反馈纠错二阶段一致性后台协调任务轮询间隔(分钟)"""
@@ -607,6 +625,7 @@ class MemoryConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "input", "x-widget": "input",
"x-icon": "list-restart", "x-icon": "list-restart",
"advanced": True,
}, },
) )
"""反馈纠错二阶段一致性每轮处理 profile/episode 队列的批大小""" """反馈纠错二阶段一致性每轮处理 profile/episode 队列的批大小"""
@@ -649,6 +668,345 @@ class MemoryConfig(ConfigBase):
return super().model_post_init(context) return super().model_post_init(context)
class AMemorixPluginConfig(ConfigBase):
"""A_Memorix 子系统状态"""
enabled: bool = Field(default=False)
"""是否启用 A_Memorix"""
class AMemorixStorageConfig(ConfigBase):
"""A_Memorix 存储位置"""
data_dir: str = Field(default="data/a-memorix")
"""数据目录"""
class AMemorixEmbeddingFallbackConfig(ConfigBase):
"""A_Memorix Embedding 回退"""
enabled: bool = Field(default=True)
"""是否启用回退机制"""
probe_interval_seconds: int = Field(default=180, ge=10)
"""探测间隔秒数"""
allow_metadata_only_write: bool = Field(default=True)
"""是否允许仅写入元数据"""
class AMemorixParagraphVectorBackfillConfig(ConfigBase):
"""A_Memorix 段落向量回填"""
enabled: bool = Field(default=True)
"""是否启用回填任务"""
interval_seconds: int = Field(default=60, ge=5)
"""回填轮询间隔"""
batch_size: int = Field(default=64, ge=1)
"""单批回填数量"""
max_retry: int = Field(default=5, ge=0)
"""最大重试次数"""
class AMemorixEmbeddingConfig(ConfigBase):
"""A_Memorix Embedding 配置"""
model_name: str = Field(default="auto")
"""Embedding 模型选择"""
dimension: int = Field(default=1024, ge=1)
"""向量维度"""
batch_size: int = Field(default=32, ge=1)
"""单批请求大小"""
max_concurrent: int = Field(default=5, ge=1)
"""最大并发数"""
enable_cache: bool = Field(default=False)
"""是否启用缓存"""
quantization_type: Literal["int8"] = Field(default="int8")
"""量化方式,当前 vNext 仅支持 int8(SQ8)"""
fallback: AMemorixEmbeddingFallbackConfig = Field(default_factory=AMemorixEmbeddingFallbackConfig)
"""Embedding 回退配置"""
paragraph_vector_backfill: AMemorixParagraphVectorBackfillConfig = Field(
default_factory=AMemorixParagraphVectorBackfillConfig
)
"""段落向量回填配置"""
class AMemorixSparseRetrievalConfig(ConfigBase):
"""A_Memorix 稀疏检索配置"""
enabled: bool = Field(default=True)
"""是否启用稀疏检索"""
backend: Literal["fts5"] = Field(default="fts5")
"""稀疏检索后端"""
mode: Literal["auto", "fallback_only", "hybrid"] = Field(default="auto")
"""稀疏检索模式"""
tokenizer_mode: Literal["jieba", "mixed", "char_2gram"] = Field(default="jieba")
"""分词模式"""
candidate_k: int = Field(default=80, ge=1)
"""段落候选数"""
relation_candidate_k: int = Field(default=60, ge=1)
"""关系候选数"""
class AMemorixRetrievalConfig(ConfigBase):
"""A_Memorix 检索配置"""
top_k_paragraphs: int = Field(default=20, ge=1)
"""段落候选数"""
top_k_relations: int = Field(default=10, ge=1)
"""关系候选数"""
top_k_final: int = Field(default=10, ge=1)
"""最终返回条数"""
alpha: float = Field(default=0.5, ge=0.0, le=1.0)
"""关系融合权重"""
enable_ppr: bool = Field(default=True)
"""是否启用 PPR"""
ppr_alpha: float = Field(default=0.85, ge=0.0, le=1.0)
"""PPR alpha"""
ppr_timeout_seconds: float = Field(default=1.5, ge=0.1)
"""PPR 超时秒数"""
ppr_concurrency_limit: int = Field(default=4, ge=1)
"""PPR 并发限制"""
enable_parallel: bool = Field(default=True)
"""是否启用并行检索"""
sparse: AMemorixSparseRetrievalConfig = Field(default_factory=AMemorixSparseRetrievalConfig)
"""稀疏检索配置"""
class AMemorixThresholdConfig(ConfigBase):
"""A_Memorix 阈值过滤配置"""
min_threshold: float = Field(default=0.3, ge=0.0, le=1.0)
"""最小阈值"""
max_threshold: float = Field(default=0.95, ge=0.0, le=1.0)
"""最大阈值"""
percentile: int = Field(default=75, ge=0, le=100)
"""动态阈值百分位"""
min_results: int = Field(default=3, ge=1)
"""最小保留条数"""
enable_auto_adjust: bool = Field(default=True)
"""是否启用自动阈值调整"""
class AMemorixFilterConfig(ConfigBase):
"""A_Memorix 聊天过滤配置"""
enabled: bool = Field(default=True)
"""是否启用聊天过滤"""
mode: Literal["blacklist", "whitelist"] = Field(default="blacklist")
"""过滤模式"""
chats: list[str] = Field(default_factory=lambda: [])
"""聊天流列表"""
class AMemorixEpisodeConfig(ConfigBase):
"""A_Memorix Episode 配置"""
enabled: bool = Field(default=True)
"""是否启用 Episode"""
generation_enabled: bool = Field(default=True)
"""是否启用自动生成"""
pending_batch_size: int = Field(default=20, ge=1)
"""待处理批大小"""
pending_max_retry: int = Field(default=3, ge=0)
"""待处理最大重试次数"""
max_paragraphs_per_call: int = Field(default=20, ge=1)
"""单次最大段落数"""
max_chars_per_call: int = Field(default=6000, ge=100)
"""单次最大字符数"""
source_time_window_hours: float = Field(default=24.0, ge=0.0)
"""时间窗口小时数"""
segmentation_model: str = Field(default="auto")
"""分段模型选择"""
class AMemorixPersonProfileConfig(ConfigBase):
"""A_Memorix 人物画像配置"""
enabled: bool = Field(default=True)
"""是否启用画像"""
refresh_interval_minutes: int = Field(default=30, ge=1)
"""刷新间隔分钟数"""
active_window_hours: float = Field(default=72.0, ge=1.0)
"""活跃窗口小时数"""
max_refresh_per_cycle: int = Field(default=50, ge=1)
"""单轮最大刷新数"""
top_k_evidence: int = Field(default=12, ge=1)
"""证据条数"""
class AMemorixMemoryEvolutionConfig(ConfigBase):
"""A_Memorix 记忆演化配置"""
enabled: bool = Field(default=True)
"""是否启用记忆演化"""
half_life_hours: float = Field(default=24.0, ge=0.1)
"""半衰期小时数"""
prune_threshold: float = Field(default=0.1, ge=0.0, le=1.0)
"""裁剪阈值"""
freeze_duration_hours: float = Field(default=24.0, ge=0.0)
"""冻结时长小时数"""
class AMemorixAdvancedConfig(ConfigBase):
"""A_Memorix 高级运行时配置"""
enable_auto_save: bool = Field(default=True)
"""是否启用自动保存"""
auto_save_interval_minutes: int = Field(default=5, ge=1)
"""自动保存间隔"""
debug: bool = Field(default=False)
"""是否启用调试"""
class AMemorixWebImportConfig(ConfigBase):
"""A_Memorix 导入中心配置"""
enabled: bool = Field(default=True)
"""是否启用导入中心"""
max_queue_size: int = Field(default=20, ge=1)
"""最大队列长度"""
max_files_per_task: int = Field(default=200, ge=1)
"""单任务最大文件数"""
max_file_size_mb: int = Field(default=20, ge=1)
"""单文件大小上限 MB"""
max_paste_chars: int = Field(default=200000, ge=100)
"""粘贴字符数上限"""
default_file_concurrency: int = Field(default=2, ge=1)
"""默认文件并发"""
default_chunk_concurrency: int = Field(default=4, ge=1)
"""默认分块并发"""
class AMemorixWebTuningConfig(ConfigBase):
"""A_Memorix 调优中心配置"""
enabled: bool = Field(default=True)
"""是否启用调优中心"""
max_queue_size: int = Field(default=8, ge=1)
"""最大队列长度"""
poll_interval_ms: int = Field(default=1200, ge=200)
"""轮询间隔毫秒数"""
default_intensity: Literal["quick", "standard", "deep"] = Field(default="standard")
"""默认调优强度"""
default_objective: Literal["precision_priority", "balanced", "recall_priority"] = Field(
default="precision_priority"
)
"""默认调优目标"""
default_top_k_eval: int = Field(default=20, ge=1)
"""默认评估 Top-K"""
default_sample_size: int = Field(default=24, ge=1)
"""默认样本数"""
class AMemorixWebConfig(ConfigBase):
"""A_Memorix Web 运维配置"""
import_config: AMemorixWebImportConfig = Field(default_factory=AMemorixWebImportConfig)
"""导入中心配置"""
tuning: AMemorixWebTuningConfig = Field(default_factory=AMemorixWebTuningConfig)
"""调优中心配置"""
class AMemorixConfig(ConfigBase):
"""A_Memorix 长期记忆子系统配置"""
__ui_label__ = "长期记忆"
__ui_icon__ = "brain"
plugin: AMemorixPluginConfig = Field(default_factory=AMemorixPluginConfig)
"""子系统状态"""
storage: AMemorixStorageConfig = Field(default_factory=AMemorixStorageConfig)
"""存储位置"""
embedding: AMemorixEmbeddingConfig = Field(default_factory=AMemorixEmbeddingConfig)
"""Embedding 配置"""
retrieval: AMemorixRetrievalConfig = Field(default_factory=AMemorixRetrievalConfig)
"""检索配置"""
threshold: AMemorixThresholdConfig = Field(default_factory=AMemorixThresholdConfig)
"""阈值过滤配置"""
filter: AMemorixFilterConfig = Field(default_factory=AMemorixFilterConfig)
"""聊天过滤配置"""
episode: AMemorixEpisodeConfig = Field(default_factory=AMemorixEpisodeConfig)
"""Episode 配置"""
person_profile: AMemorixPersonProfileConfig = Field(default_factory=AMemorixPersonProfileConfig)
"""人物画像配置"""
memory: AMemorixMemoryEvolutionConfig = Field(default_factory=AMemorixMemoryEvolutionConfig)
"""记忆演化配置"""
advanced: AMemorixAdvancedConfig = Field(default_factory=AMemorixAdvancedConfig)
"""高级运行时配置"""
web: AMemorixWebConfig = Field(default_factory=AMemorixWebConfig)
"""Web 运维配置"""
class LearningItem(ConfigBase): class LearningItem(ConfigBase):
platform: str = Field( platform: str = Field(
default="", default="",
@@ -875,6 +1233,7 @@ class EmojiConfig(ConfigBase):
content_filtration: bool = Field( content_filtration: bool = Field(
default=False, default=False,
json_schema_extra={ json_schema_extra={
"advanced": True,
"x-widget": "switch", "x-widget": "switch",
"x-icon": "filter", "x-icon": "filter",
}, },
@@ -884,6 +1243,7 @@ class EmojiConfig(ConfigBase):
filtration_prompt: str = Field( filtration_prompt: str = Field(
default="符合公序良俗", default="符合公序良俗",
json_schema_extra={ json_schema_extra={
"advanced": True,
"x-widget": "input", "x-widget": "input",
"x-icon": "shield", "x-icon": "shield",
}, },
@@ -1006,6 +1366,7 @@ class ChineseTypoConfig(ConfigBase):
"x-widget": "slider", "x-widget": "slider",
"x-icon": "percent", "x-icon": "percent",
"step": 0.01, "step": 0.01,
"advanced": True,
}, },
) )
"""单字替换概率""" """单字替换概率"""
@@ -1015,6 +1376,7 @@ class ChineseTypoConfig(ConfigBase):
json_schema_extra={ json_schema_extra={
"x-widget": "input", "x-widget": "input",
"x-icon": "hash", "x-icon": "hash",
"advanced": True,
}, },
) )
"""最小字频阈值""" """最小字频阈值"""
@@ -1027,6 +1389,7 @@ class ChineseTypoConfig(ConfigBase):
"x-widget": "slider", "x-widget": "slider",
"x-icon": "percent", "x-icon": "percent",
"step": 0.1, "step": 0.1,
"advanced": True,
}, },
) )
"""声调错误概率""" """声调错误概率"""
@@ -1039,6 +1402,7 @@ class ChineseTypoConfig(ConfigBase):
"x-widget": "slider", "x-widget": "slider",
"x-icon": "percent", "x-icon": "percent",
"step": 0.001, "step": 0.001,
"advanced": True,
}, },
) )
"""整词替换概率""" """整词替换概率"""

View File

@@ -80,6 +80,7 @@ class MainSystem:
init_start_time = time.time() init_start_time = time.time()
await config_manager.start_file_watcher() await config_manager.start_file_watcher()
a_memorix_host_service.register_config_reload_callback()
# 添加在线时间统计任务 # 添加在线时间统计任务
await async_task_manager.add_task(OnlineTimeRecordTask()) await async_task_manager.add_task(OnlineTimeRecordTask())

View File

@@ -143,6 +143,33 @@ def _serialize_messages(messages: List[Any]) -> List[Dict[str, Any]]:
return [_serialize_message(message) for message in messages] return [_serialize_message(message) for message in messages]
def _enrich_session_identity(data: Dict[str, Any]) -> Dict[str, Any]:
"""为监控事件补充会话展示所需的群/用户标识。"""
session_id = data.get("session_id")
if not session_id:
return data
try:
from src.chat.message_receive.chat_manager import chat_manager
chat_stream = chat_manager.get_session_by_session_id(str(session_id))
except Exception:
return data
if chat_stream is None:
return data
session_name = chat_manager.get_session_name(str(session_id))
if session_name:
data.setdefault("session_name", session_name)
data.setdefault("is_group_chat", chat_stream.is_group_session)
data.setdefault("group_id", chat_stream.group_id)
data.setdefault("user_id", chat_stream.user_id)
data.setdefault("platform", chat_stream.platform)
return data
def _serialize_tool_results(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def _serialize_tool_results(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""标准化最终 planner 卡中的工具结果列表。""" """标准化最终 planner 卡中的工具结果列表。"""
@@ -266,6 +293,7 @@ async def _broadcast(event: str, data: Dict[str, Any]) -> None:
try: try:
from src.webui.routers.websocket.manager import websocket_manager from src.webui.routers.websocket.manager import websocket_manager
data = _enrich_session_identity(data)
subscription_key = f"{MONITOR_DOMAIN}:{MONITOR_TOPIC}" subscription_key = f"{MONITOR_DOMAIN}:{MONITOR_TOPIC}"
total_connections = len(websocket_manager.connections) total_connections = len(websocket_manager.connections)
subscriber_count = sum( subscriber_count = sum(
@@ -291,12 +319,24 @@ async def _broadcast(event: str, data: Dict[str, Any]) -> None:
logger.warning(f"MaiSaka 监控事件广播失败: {exc}", exc_info=True) logger.warning(f"MaiSaka 监控事件广播失败: {exc}", exc_info=True)
async def emit_session_start(session_id: str, session_name: str) -> None: async def emit_session_start(
session_id: str,
session_name: str,
*,
is_group_chat: bool,
group_id: Optional[str],
user_id: Optional[str],
platform: str,
) -> None:
"""广播会话开始事件。""" """广播会话开始事件。"""
await _broadcast("session.start", { await _broadcast("session.start", {
"session_id": session_id, "session_id": session_id,
"session_name": session_name, "session_name": session_name,
"is_group_chat": is_group_chat,
"group_id": group_id,
"user_id": user_id,
"platform": platform,
"timestamp": time.time(), "timestamp": time.time(),
}) })

View File

@@ -46,6 +46,7 @@ from .display.display_utils import build_tool_call_summary_lines, format_token_c
from .display.prompt_cli_renderer import PromptCLIVisualizer from .display.prompt_cli_renderer import PromptCLIVisualizer
from .display.stage_status_board import remove_stage_status, update_stage_status from .display.stage_status_board import remove_stage_status, update_stage_status
from .history_utils import drop_leading_orphan_tool_results from .history_utils import drop_leading_orphan_tool_results
from .monitor_events import emit_session_start
from .reasoning_engine import MaisakaReasoningEngine from .reasoning_engine import MaisakaReasoningEngine
from .reply_effect import ReplyEffectTracker from .reply_effect import ReplyEffectTracker
from .reply_effect.image_utils import extract_visual_attachments_from_sequence from .reply_effect.image_utils import extract_visual_attachments_from_sequence
@@ -136,6 +137,7 @@ class MaisakaHeartFlowChatting:
self._jargon_miner = JargonMiner(session_id, session_name=session_name) self._jargon_miner = JargonMiner(session_id, session_name=session_name)
self._reasoning_engine = MaisakaReasoningEngine(self) self._reasoning_engine = MaisakaReasoningEngine(self)
self._monitor_session_start_task: Optional[asyncio.Task[None]] = None
self._tool_registry = ToolRegistry() self._tool_registry = ToolRegistry()
self._reply_effect_tracker = ReplyEffectTracker( self._reply_effect_tracker = ReplyEffectTracker(
session_id=self.session_id, session_id=self.session_id,
@@ -144,6 +146,24 @@ class MaisakaHeartFlowChatting:
judge_runner=self._run_reply_effect_judge, judge_runner=self._run_reply_effect_judge,
) )
self._register_tool_providers() self._register_tool_providers()
self._emit_monitor_session_start()
def _emit_monitor_session_start(self) -> None:
"""向 WebUI 监控面板同步当前会话的展示标识。"""
try:
self._monitor_session_start_task = asyncio.create_task(
emit_session_start(
session_id=self.session_id,
session_name=self.session_name,
is_group_chat=self.chat_stream.is_group_session,
group_id=self.chat_stream.group_id,
user_id=self.chat_stream.user_id,
platform=self.chat_stream.platform,
)
)
except RuntimeError:
logger.debug("MaiSaka 监控会话开始事件未发送:当前没有运行中的事件循环")
@staticmethod @staticmethod
def _is_reply_effect_tracking_enabled() -> bool: def _is_reply_effect_tracking_enabled() -> bool:

View File

@@ -24,7 +24,7 @@ from src.common.logger import get_logger
logger = get_logger("plugin_runtime.runner.manifest_validator") logger = get_logger("plugin_runtime.runner.manifest_validator")
_SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") _SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
_PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[.-][a-z0-9]+)+$") _PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)+$")
_PACKAGE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") _PACKAGE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_HTTP_URL_PATTERN = re.compile(r"^https?://.+$") _HTTP_URL_PATTERN = re.compile(r"^https?://.+$")
@@ -379,7 +379,7 @@ class PluginDependencyDefinition(_StrictManifestModel):
ValueError: 当 ID 不符合规则时抛出。 ValueError: 当 ID 不符合规则时抛出。
""" """
if not _PLUGIN_ID_PATTERN.fullmatch(value): if not _PLUGIN_ID_PATTERN.fullmatch(value):
raise ValueError("必须使用小写字母/数字,并以点号或横线分隔,例如 github.author.plugin") raise ValueError("必须使用字母/数字/下划线,并以点号或横线分隔,例如 github.author.plugin")
return value return value
@field_validator("version_spec") @field_validator("version_spec")
@@ -548,7 +548,7 @@ class PluginManifest(_StrictManifestModel):
if not value: if not value:
raise ValueError("不能为空") raise ValueError("不能为空")
if info.field_name == "id" and not _PLUGIN_ID_PATTERN.fullmatch(value): if info.field_name == "id" and not _PLUGIN_ID_PATTERN.fullmatch(value):
raise ValueError("必须使用小写字母/数字,并以点号或横线分隔,例如 github.author.plugin") raise ValueError("必须使用字母/数字/下划线,并以点号或横线分隔,例如 github.author.plugin")
return value return value
@field_validator("capabilities") @field_validator("capabilities")

View File

@@ -1,6 +1,7 @@
"""FastAPI 应用工厂 - 创建和配置 WebUI 应用实例""" """FastAPI 应用工厂 - 创建和配置 WebUI 应用实例"""
from importlib import import_module from importlib import import_module
from os import getenv
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Tuple from typing import Any, Dict, Tuple
@@ -16,6 +17,7 @@ from src.common.logger import get_logger
logger = get_logger("webui.app") logger = get_logger("webui.app")
_DASHBOARD_PACKAGE_NAME = "maibot-dashboard" _DASHBOARD_PACKAGE_NAME = "maibot-dashboard"
_LOCAL_DASHBOARD_ENV = "MAIBOT_WEBUI_USE_LOCAL_DASHBOARD"
_MANUAL_INSTALL_COMMAND = f"pip install {_DASHBOARD_PACKAGE_NAME}" _MANUAL_INSTALL_COMMAND = f"pip install {_DASHBOARD_PACKAGE_NAME}"
@@ -36,6 +38,10 @@ def _get_project_root() -> Path:
return Path(__file__).resolve().parents[2] return Path(__file__).resolve().parents[2]
def _is_local_dashboard_enabled() -> bool:
return getenv(_LOCAL_DASHBOARD_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
def _validate_static_path(static_path: Path | None) -> Tuple[str, Dict[str, Any]] | None: def _validate_static_path(static_path: Path | None) -> Tuple[str, Dict[str, Any]] | None:
if static_path is None: if static_path is None:
return "startup.webui_static_dir_missing", {} return "startup.webui_static_dir_missing", {}
@@ -179,6 +185,16 @@ def _setup_static_files(app: FastAPI):
logger.warning(t("startup.webui_dashboard_package_hint", command=_MANUAL_INSTALL_COMMAND)) logger.warning(t("startup.webui_dashboard_package_hint", command=_MANUAL_INSTALL_COMMAND))
return return
@app.get("/maibot_statistics.html", include_in_schema=False)
async def serve_statistics_report():
report_path = (_get_project_root() / "maibot_statistics.html").resolve()
if not report_path.exists() or not report_path.is_file():
raise HTTPException(status_code=404, detail=t("core.not_found"))
response = FileResponse(report_path, media_type="text/html")
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
return response
@app.get("/{full_path:path}", include_in_schema=False) @app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(full_path: str): async def serve_spa(full_path: str):
if not full_path or full_path == "/": if not full_path or full_path == "/":
@@ -205,12 +221,10 @@ def _setup_static_files(app: FastAPI):
def _resolve_static_path() -> Path | None: def _resolve_static_path() -> Path | None:
# 临时仅允许使用已安装的 maibot-dashboard 包,不使用仓库本地 dashboard/dist。 if _is_local_dashboard_enabled():
# 如需恢复本地回退逻辑,可取消下方注释。 static_path = _get_project_root() / "dashboard" / "dist"
# base_dir = _get_project_root() if static_path.is_dir() and (static_path / "index.html").exists():
# static_path = base_dir / "dashboard" / "dist" return static_path
# if static_path.is_dir() and (static_path / "index.html").exists():
# return static_path
try: try:
module = import_module("maibot_dashboard") module = import_module("maibot_dashboard")

View File

@@ -1,6 +1,5 @@
from typing import Any, Dict, List, get_args, get_origin
import inspect import inspect
from typing import Any, Dict, List, get_args, get_origin
from pydantic_core import PydanticUndefined from pydantic_core import PydanticUndefined

View File

@@ -19,6 +19,7 @@ from src.config.model_configs import (
ModelTaskConfig, ModelTaskConfig,
) )
from src.config.official_configs import ( from src.config.official_configs import (
AMemorixConfig,
BotConfig, BotConfig,
ChatConfig, ChatConfig,
ChineseTypoConfig, ChineseTypoConfig,
@@ -128,6 +129,7 @@ async def get_config_section_schema(section_name: str):
"telemetry": TelemetryConfig, "telemetry": TelemetryConfig,
"maim_message": MaimMessageConfig, "maim_message": MaimMessageConfig,
"memory": MemoryConfig, "memory": MemoryConfig,
"a_memorix": AMemorixConfig,
"debug": DebugConfig, "debug": DebugConfig,
"voice": VoiceConfig, "voice": VoiceConfig,
"model_task_config": ModelTaskConfig, "model_task_config": ModelTaskConfig,