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

Dev
This commit is contained in:
SengokuCola
2026-05-08 21:14:25 +08:00
committed by GitHub
30 changed files with 507 additions and 113 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "maibot-dashboard", "name": "maibot-dashboard",
"private": true, "private": true,
"version": "1.0.9", "version": "1.0.10",
"type": "module", "type": "module",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"scripts": { "scripts": {

View File

@@ -21,6 +21,7 @@ interface PluginApiResponse {
id: string id: string
manifest: { manifest: {
manifest_version: number manifest_version: number
id?: string
name: string name: string
version: string version: string
description: string description: string
@@ -56,6 +57,7 @@ function normalizePluginManifest(manifest: PluginApiResponse['manifest']): Plugi
return { return {
manifest_version: manifest.manifest_version || 1, manifest_version: manifest.manifest_version || 1,
id: manifest.id,
name: manifest.name, name: manifest.name,
version: manifest.version, version: manifest.version,
description: manifest.description || '', description: manifest.description || '',
@@ -104,10 +106,15 @@ export async function fetchPluginList(): Promise<ApiResponse<PluginInfo[]>> {
const pluginList = data const pluginList = data
.filter(item => { .filter(item => {
if (!item?.id || !item?.manifest) { if (!item?.manifest) {
console.warn('跳过无效插件数据:', item) console.warn('跳过无效插件数据:', item)
return false return false
} }
const pluginId = item.manifest.id || item.id
if (!pluginId) {
console.warn('跳过缺少 ID 的插件:', item)
return false
}
if (!item.manifest.name || !item.manifest.version) { if (!item.manifest.name || !item.manifest.version) {
console.warn('跳过缺少必需字段的插件:', item.id) console.warn('跳过缺少必需字段的插件:', item.id)
return false return false
@@ -115,7 +122,7 @@ export async function fetchPluginList(): Promise<ApiResponse<PluginInfo[]>> {
return true return true
}) })
.map((item) => ({ .map((item) => ({
id: item.id, id: item.manifest.id || item.id,
manifest: normalizePluginManifest(item.manifest), manifest: normalizePluginManifest(item.manifest),
downloads: 0, downloads: 0,
rating: 0, rating: 0,

View File

@@ -25,6 +25,7 @@ export interface InstalledPlugin {
id: string id: string
manifest: { manifest: {
manifest_version: number manifest_version: number
id?: string
name: string name: string
version: string version: string
description: string description: string

View File

@@ -5,7 +5,7 @@
* 修改此处的版本号后,所有展示版本的地方都会自动更新 * 修改此处的版本号后,所有展示版本的地方都会自动更新
*/ */
export const APP_VERSION = '1.0.9' export const APP_VERSION = '1.0.10'
export const APP_NAME = 'MaiBot Dashboard' export const APP_NAME = 'MaiBot Dashboard'
export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}` export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}`

View File

@@ -104,21 +104,23 @@ export function PluginDetailPage() {
} }
const pluginList = JSON.parse(result.data) const pluginList = JSON.parse(result.data)
const foundPlugin = pluginList.find((p: any) => p.id === search.pluginId) const foundPlugin = pluginList.find((p: any) => (p.manifest?.id || p.id) === search.pluginId)
if (!foundPlugin) { if (!foundPlugin) {
throw new Error('未找到该插件') throw new Error('未找到该插件')
} }
const rawManifest = foundPlugin.manifest || {} const rawManifest = foundPlugin.manifest || {}
const pluginId = rawManifest.id || foundPlugin.id
const repositoryUrl = rawManifest.repository_url || rawManifest.urls?.repository const repositoryUrl = rawManifest.repository_url || rawManifest.urls?.repository
const homepageUrl = rawManifest.homepage_url || rawManifest.urls?.homepage const homepageUrl = rawManifest.homepage_url || rawManifest.urls?.homepage
// 转换为 PluginInfo 格式 // 转换为 PluginInfo 格式
const pluginInfo: PluginInfo = { const pluginInfo: PluginInfo = {
id: foundPlugin.id, id: pluginId,
manifest: { manifest: {
...rawManifest, ...rawManifest,
id: pluginId,
homepage_url: homepageUrl, homepage_url: homepageUrl,
repository_url: repositoryUrl, repository_url: repositoryUrl,
default_locale: rawManifest.default_locale || rawManifest.i18n?.default_locale || 'zh-CN', default_locale: rawManifest.default_locale || rawManifest.i18n?.default_locale || 'zh-CN',
@@ -170,8 +172,8 @@ export function PluginDetailPage() {
return return
} }
setIsInstalled(checkPluginInstalled(search.pluginId, installedPlugins.data)) setIsInstalled(checkPluginInstalled(pluginId, installedPlugins.data))
setInstalledVersion(getInstalledPluginVersion(search.pluginId, installedPlugins.data)) setInstalledVersion(getInstalledPluginVersion(pluginId, installedPlugins.data))
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载失败') setError(err instanceof Error ? err.message : '加载失败')
} finally { } finally {
@@ -196,7 +198,7 @@ export function PluginDetailPage() {
// 如果插件已安装,优先尝试从本地读取 README // 如果插件已安装,优先尝试从本地读取 README
if (isInstalled && search.pluginId) { if (isInstalled && search.pluginId) {
try { try {
const localResponse = await fetchWithAuth(`/api/webui/plugins/local-readme/${search.pluginId}`) const localResponse = await fetchWithAuth(`/api/webui/plugins/local-readme/${plugin.id}`)
if (localResponse.ok) { if (localResponse.ok) {
const localResult = await localResponse.json() const localResult = await localResponse.json()

View File

@@ -67,7 +67,7 @@ export function InstalledTab({
}) })
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-5">
{filteredPlugins.map((plugin) => ( {filteredPlugins.map((plugin) => (
<PluginCard <PluginCard
key={plugin.id} key={plugin.id}

View File

@@ -68,7 +68,7 @@ export function MarketplaceTab({
}) })
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-5">
{filteredPlugins.map((plugin) => ( {filteredPlugins.map((plugin) => (
<PluginCard <PluginCard
key={plugin.id} key={plugin.id}

View File

@@ -44,10 +44,10 @@ export function PluginCard({
key={plugin.id} key={plugin.id}
className="flex flex-col hover:shadow-lg transition-shadow h-full" className="flex flex-col hover:shadow-lg transition-shadow h-full"
> >
<CardHeader> <CardHeader className="p-5 pb-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-3">
<CardTitle className="text-xl">{plugin.manifest?.name || plugin.id}</CardTitle> <CardTitle className="text-lg leading-snug">{plugin.manifest?.name || plugin.id}</CardTitle>
<div className="flex flex-col gap-1"> <div className="flex flex-col items-end gap-1 shrink-0">
{plugin.manifest?.categories && plugin.manifest.categories[0] && ( {plugin.manifest?.categories && plugin.manifest.categories[0] && (
<Badge variant="secondary" className="text-xs whitespace-nowrap"> <Badge variant="secondary" className="text-xs whitespace-nowrap">
{CATEGORY_NAMES[plugin.manifest.categories[0]] || plugin.manifest.categories[0]} {CATEGORY_NAMES[plugin.manifest.categories[0]] || plugin.manifest.categories[0]}
@@ -56,18 +56,18 @@ export function PluginCard({
{getStatusBadge(plugin)} {getStatusBadge(plugin)}
</div> </div>
</div> </div>
<CardDescription className="line-clamp-2">{plugin.manifest?.description || '无描述'}</CardDescription> <CardDescription className="line-clamp-2 text-sm leading-snug">{plugin.manifest?.description || '无描述'}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex-1"> <CardContent className="px-5 pb-3">
<div className="space-y-3"> <div className="space-y-2.5">
{/* 统计信息 */} {/* 统计信息 */}
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Download className="h-4 w-4" /> <Download className="h-3.5 w-3.5" />
<span>{(pluginStats[plugin.id]?.downloads ?? plugin.downloads ?? 0).toLocaleString()}</span> <span>{(pluginStats[plugin.id]?.downloads ?? plugin.downloads ?? 0).toLocaleString()}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" /> <Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" />
<span>{(pluginStats[plugin.id]?.rating ?? plugin.rating ?? 0).toFixed(1)}</span> <span>{(pluginStats[plugin.id]?.rating ?? plugin.rating ?? 0).toFixed(1)}</span>
</div> </div>
</div> </div>
@@ -85,7 +85,7 @@ export function PluginCard({
)} )}
</div> </div>
{/* 版本和作者 */} {/* 版本和作者 */}
<div className="text-xs text-muted-foreground pt-2 border-t space-y-1"> <div className="text-xs text-muted-foreground pt-2.5 border-t space-y-1">
<div>v{plugin.manifest?.version || 'unknown'} · {plugin.manifest?.author?.name || 'Unknown'}</div> <div>v{plugin.manifest?.version || 'unknown'} · {plugin.manifest?.author?.name || 'Unknown'}</div>
{/* 支持版本 */} {/* 支持版本 */}
{plugin.manifest?.host_application && ( {plugin.manifest?.host_application && (
@@ -103,7 +103,7 @@ export function PluginCard({
</div> </div>
</div> </div>
</CardContent> </CardContent>
<CardFooter className="pt-4"> <CardFooter className="px-5 pt-2 pb-5">
<div className="flex items-center justify-end gap-2 w-full"> <div className="flex items-center justify-end gap-2 w-full">
<Button <Button
variant="outline" variant="outline"
@@ -169,7 +169,7 @@ export function PluginCard({
(loadProgress.stage === 'loading' || loadProgress.stage === 'success' || loadProgress.stage === 'error') && (loadProgress.stage === 'loading' || loadProgress.stage === 'success' || loadProgress.stage === 'error') &&
loadProgress.operation !== 'fetch' && loadProgress.operation !== 'fetch' &&
loadProgress.plugin_id === plugin.id && ( loadProgress.plugin_id === plugin.id && (
<div className="px-6 pb-4 -mt-2"> <div className="px-5 pb-5 -mt-1">
<div className={`space-y-2 p-3 rounded-lg border ${ <div className={`space-y-2 p-3 rounded-lg border ${
loadProgress.stage === 'success' loadProgress.stage === 'success'
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900' ? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'

View File

@@ -48,6 +48,9 @@ export function PluginsPage() {
function PluginsPageContent() { function PluginsPageContent() {
const navigate = useNavigate() const navigate = useNavigate()
const { triggerRestart, isRestarting } = useRestart() const { triggerRestart, isRestarting } = useRestart()
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
() => localStorage.getItem('plugins-restart-notice-dismissed') !== 'true'
)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState('all') const [categoryFilter, setCategoryFilter] = useState('all')
const [activeTab, setActiveTab] = useState('all') // all | installed | updates const [activeTab, setActiveTab] = useState('all') // all | installed | updates
@@ -67,6 +70,11 @@ function PluginsPageContent() {
const { toast } = useToast() const { toast } = useToast()
const dismissRestartNotice = () => {
localStorage.setItem('plugins-restart-notice-dismissed', 'true')
setRestartNoticeVisible(false)
}
// 加载插件统计数据 // 加载插件统计数据
const loadPluginStats = async (pluginList: PluginInfo[]) => { const loadPluginStats = async (pluginList: PluginInfo[]) => {
const statsPromises = pluginList.map(async (plugin) => { const statsPromises = pluginList.map(async (plugin) => {
@@ -220,6 +228,7 @@ function PluginsPageContent() {
id: installedPlugin.id, id: installedPlugin.id,
manifest: { manifest: {
manifest_version: installedPlugin.manifest.manifest_version || 1, manifest_version: installedPlugin.manifest.manifest_version || 1,
id: installedPlugin.manifest.id || installedPlugin.id,
name: installedPlugin.manifest.name, name: installedPlugin.manifest.name,
version: installedPlugin.manifest.version, version: installedPlugin.manifest.version,
description: installedPlugin.manifest.description || '', description: installedPlugin.manifest.description || '',
@@ -704,16 +713,23 @@ function PluginsPageContent() {
</div> </div>
{/* 安装提示 */} {/* 安装提示 */}
<Card className="border-blue-200 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-900"> {restartNoticeVisible && (
<CardContent className="py-3"> <Card className="border-blue-200 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-900">
<div className="flex items-center gap-2"> <CardContent className="py-3">
<Info className="h-4 w-4 text-blue-600 flex-shrink-0" /> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-blue-800 dark:text-blue-200"> <div className="flex items-center gap-2">
<span className="font-semibold"></span>使 <Info className="h-4 w-4 text-blue-600 flex-shrink-0" />
</p> <p className="text-sm text-blue-800 dark:text-blue-200">
</div> <span className="font-semibold"></span>
</CardContent> </p>
</Card> </div>
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
</Button>
</div>
</CardContent>
</Card>
)}
{/* Git 状态警告 */} {/* Git 状态警告 */}
{gitStatus && !gitStatus.installed && ( {gitStatus && !gitStatus.installed && (

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { import {
Clock, Clock,
Code2, Code2,
Copy,
FileCode2, FileCode2,
FileText, FileText,
RefreshCw, RefreshCw,
@@ -10,6 +11,7 @@ import {
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useToast } from '@/hooks/use-toast'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { import {
@@ -49,6 +51,7 @@ function formatSize(size: number): string {
} }
export function ReasoningProcessPage() { export function ReasoningProcessPage() {
const { toast } = useToast()
const [items, setItems] = useState<ReasoningPromptFile[]>([]) const [items, setItems] = useState<ReasoningPromptFile[]>([])
const [stages, setStages] = useState<string[]>([]) const [stages, setStages] = useState<string[]>([])
const [sessions, setSessions] = useState<string[]>([]) const [sessions, setSessions] = useState<string[]>([])
@@ -165,6 +168,31 @@ export function ReasoningProcessPage() {
setPage(1) setPage(1)
} }
async function handleCopyPrompt() {
if (!textContent || contentLoading) {
toast({
title: '暂无可复制内容',
description: '请先选择一条包含 txt 的 prompt 记录',
variant: 'destructive',
})
return
}
try {
await navigator.clipboard.writeText(textContent)
toast({
title: '已复制完整 Prompt',
description: selected ? `${selected.stage}/${selected.session_id}/${selected.stem}` : undefined,
})
} catch (err) {
toast({
title: '复制失败',
description: err instanceof Error ? err.message : '请手动选择文本复制',
variant: 'destructive',
})
}
}
return ( return (
<div className="flex h-full min-h-0 flex-col gap-3 overflow-hidden p-3 lg:p-4"> <div className="flex h-full min-h-0 flex-col gap-3 overflow-hidden p-3 lg:p-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
@@ -328,6 +356,17 @@ export function ReasoningProcessPage() {
</div> </div>
{selected && ( {selected && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5"
onClick={handleCopyPrompt}
disabled={!selected.text_path || contentLoading || !textContent}
title="复制完整 Prompt"
>
<Copy className="h-3.5 w-3.5" />
</Button>
{selected.text_path && ( {selected.text_path && (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<FileText className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5" />

View File

@@ -20,6 +20,8 @@ export interface HostApplication {
export interface PluginManifest { export interface PluginManifest {
/** 清单文件版本 */ /** 清单文件版本 */
manifest_version: number manifest_version: number
/** Manifest 声明的插件唯一标识 */
id?: string
/** 插件名称 */ /** 插件名称 */
name: string name: string
/** 插件版本 */ /** 插件版本 */

View File

@@ -12,6 +12,7 @@ services:
- EULA_AGREE=1b662741904d7155d1ce1c00b3530d0d - EULA_AGREE=1b662741904d7155d1ce1c00b3530d0d
- PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6 - PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6
- MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1 # Docker 无法交互确认旧版升级迁移,默认跳过确认提示 - MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1 # Docker 无法交互确认旧版升级迁移,默认跳过确认提示
- MAIBOT_STATISTICS_REPORT_PATH=/MaiMBot/data/maibot_statistics.html # 统计数据输出到共享目录,首次运行可自动创建文件
# - EULA_AGREE=1b662741904d7155d1ce1c00b3530d0d # 同意EULA # - EULA_AGREE=1b662741904d7155d1ce1c00b3530d0d # 同意EULA
# - PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6 # 同意EULA # - PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6 # 同意EULA
ports: ports:
@@ -20,7 +21,6 @@ services:
volumes: volumes:
# 监听地址和端口已迁移到 ./docker-config/mmc/bot_config.toml 的 maim_message 与 webui 配置段 # 监听地址和端口已迁移到 ./docker-config/mmc/bot_config.toml 的 maim_message 与 webui 配置段
- ./docker-config/mmc:/MaiMBot/config # 持久化bot配置文件 - ./docker-config/mmc:/MaiMBot/config # 持久化bot配置文件
- ./data/MaiMBot/maibot_statistics.html:/MaiMBot/maibot_statistics.html #统计数据输出
- ./data/MaiMBot:/MaiMBot/data # 共享目录 - ./data/MaiMBot:/MaiMBot/data # 共享目录
- ./data/MaiMBot/emoji:/data/emoji # 持久化表情包 - ./data/MaiMBot/emoji:/data/emoji # 持久化表情包
- ./data/MaiMBot/plugins:/MaiMBot/plugins # 插件目录 - ./data/MaiMBot/plugins:/MaiMBot/plugins # 插件目录

View File

@@ -10,4 +10,6 @@ if [ ! -e "$ADAPTER_TARGET" ] && [ -d "$ADAPTER_TEMPLATE" ]; then
cp -a "$ADAPTER_TEMPLATE" "$ADAPTER_TARGET" cp -a "$ADAPTER_TEMPLATE" "$ADAPTER_TARGET"
fi fi
uv pip install --python "/MaiMBot/.venv/bin/python" --upgrade maibot-dashboard
exec /MaiMBot/.venv/bin/python bot.py "$@" exec /MaiMBot/.venv/bin/python bot.py "$@"

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.8", "maibot-dashboard>=1.0.10",
"maibot-plugin-sdk>=2.4.0", "maibot-plugin-sdk>=2.4.0",
"matplotlib>=3.10.5", "matplotlib>=3.10.5",
"mcp", "mcp",

View File

@@ -60,6 +60,60 @@ def test_load_prompt_with_category_falls_back_to_default_locale_root(tmp_path: P
assert rendered == "你好Mai" assert rendered == "你好Mai"
def test_load_prompt_prefers_custom_prompt_override(tmp_path: Path) -> None:
prompts_root = tmp_path / "prompts"
custom_prompts_root = tmp_path / "data" / "custom_prompts"
write_prompt(prompts_root, "zh-CN", "replyer", "Base {user_name}")
write_prompt(custom_prompts_root, "zh-CN", "replyer", "Custom {user_name}")
rendered = load_prompt(
"replyer",
locale="zh-CN",
prompts_root=prompts_root,
custom_prompts_root=custom_prompts_root,
user_name="Mai",
)
assert rendered == "Custom Mai"
def test_load_prompt_prefers_custom_prompt_requested_locale(tmp_path: Path) -> None:
prompts_root = tmp_path / "prompts"
custom_prompts_root = tmp_path / "data" / "custom_prompts"
write_prompt(prompts_root, "zh-CN", "replyer", "Base zh {user_name}")
write_prompt(prompts_root, "en-US", "replyer", "Base en {user_name}")
write_prompt(custom_prompts_root, "zh-CN", "replyer", "Custom zh {user_name}")
write_prompt(custom_prompts_root, "en-US", "replyer", "Custom en {user_name}")
rendered = load_prompt(
"replyer",
locale="en-US",
prompts_root=prompts_root,
custom_prompts_root=custom_prompts_root,
user_name="Mai",
)
assert rendered == "Custom en Mai"
def test_load_prompt_uses_requested_locale_source_before_default_custom(tmp_path: Path) -> None:
prompts_root = tmp_path / "prompts"
custom_prompts_root = tmp_path / "data" / "custom_prompts"
write_prompt(prompts_root, "zh-CN", "replyer", "Base zh {user_name}")
write_prompt(prompts_root, "en-US", "replyer", "Base en {user_name}")
write_prompt(custom_prompts_root, "zh-CN", "replyer", "Custom zh {user_name}")
rendered = load_prompt(
"replyer",
locale="en-US",
prompts_root=prompts_root,
custom_prompts_root=custom_prompts_root,
user_name="Mai",
)
assert rendered == "Base en Mai"
def test_load_prompt_strict_mode_raises_on_missing_placeholder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_load_prompt_strict_mode_raises_on_missing_placeholder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
prompts_root = tmp_path / "prompts" prompts_root = tmp_path / "prompts"
write_prompt(prompts_root, "zh-CN", "replyer", "你好,{user_name},现在是 {current_time}") write_prompt(prompts_root, "zh-CN", "replyer", "你好,{user_name},现在是 {current_time}")

View File

@@ -47,3 +47,17 @@ def test_installed_plugins_only_scan_plugins_dir_and_exclude_a_memorix(client: T
assert ids == ["test.demo"] assert ids == ["test.demo"]
assert "a-dawn.a-memorix" not in ids assert "a-dawn.a-memorix" not in ids
assert all("/src/plugins/built_in/" not in plugin["path"] for plugin in payload["plugins"]) assert all("/src/plugins/built_in/" not in plugin["path"] for plugin in payload["plugins"])
def test_resolve_installed_plugin_path_falls_back_to_manifest_id(client: TestClient):
plugin_path = support_module.resolve_installed_plugin_path("test.demo")
assert plugin_path is not None
assert plugin_path.name == "demo_plugin"
def test_resolve_installed_plugin_path_accepts_manifest_id_case_mismatch(client: TestClient):
plugin_path = support_module.resolve_installed_plugin_path("Test.Demo")
assert plugin_path is not None
assert plugin_path.name == "demo_plugin"

View File

@@ -33,4 +33,4 @@ tomlkit>=0.13.3
typing-extensions typing-extensions
uvicorn>=0.35.0 uvicorn>=0.35.0
watchfiles>=1.1.1 watchfiles>=1.1.1
maibot-dashboard>=1.0.8 maibot-dashboard>=1.0.10

View File

@@ -2608,7 +2608,9 @@ class MetadataStore:
Returns: Returns:
段落列表 段落列表
""" """
return self.query("SELECT * FROM paragraphs WHERE source = ?", (source,)) cursor = self._conn.cursor()
cursor.execute("SELECT * FROM paragraphs WHERE source = ?", (source,))
return [self._row_to_dict(row, "paragraph") for row in cursor.fetchall()]
def get_all_sources(self) -> List[Dict[str, Any]]: def get_all_sources(self) -> List[Dict[str, Any]]:
""" """

View File

@@ -283,7 +283,13 @@ class PersonProfileService:
logger.warning(f"解析人物别名失败: person_id={person_id}, err={e}") logger.warning(f"解析人物别名失败: person_id={person_id}, err={e}")
return aliases, primary_name, memory_traits return aliases, primary_name, memory_traits
def _collect_relation_evidence(self, aliases: List[str], limit: int = 30) -> List[Dict[str, Any]]: def _collect_relation_evidence(
self,
aliases: List[str],
limit: int = 30,
*,
person_id: str = "",
) -> List[Dict[str, Any]]:
relation_by_hash: Dict[str, Dict[str, Any]] = {} relation_by_hash: Dict[str, Dict[str, Any]] = {}
for alias in aliases: for alias in aliases:
for rel in self.metadata_store.get_relations(subject=alias, include_inactive=False): for rel in self.metadata_store.get_relations(subject=alias, include_inactive=False):
@@ -296,6 +302,12 @@ class PersonProfileService:
relation_by_hash[h] = rel relation_by_hash[h] = rel
relations = list(relation_by_hash.values()) relations = list(relation_by_hash.values())
if person_id:
relations = [
rel
for rel in relations
if self._is_relation_bound_to_person(rel, person_id=person_id)
]
relations.sort(key=lambda item: float(item.get("confidence", 0.0)), reverse=True) relations.sort(key=lambda item: float(item.get("confidence", 0.0)), reverse=True)
relations = relations[: max(1, int(limit))] relations = relations[: max(1, int(limit))]
@@ -312,6 +324,38 @@ class PersonProfileService:
) )
return edges return edges
def _is_relation_bound_to_person(
self,
relation: Dict[str, Any],
*,
person_id: str,
) -> bool:
pid = str(person_id or "").strip()
if not pid:
return False
metadata = self._metadata_dict(relation.get("metadata"))
if str(metadata.get("person_id", "") or "").strip() == pid:
return True
if pid in self._list_tokens(metadata.get("person_ids")):
return True
source_paragraph = str(relation.get("source_paragraph", "") or "").strip()
if source_paragraph:
try:
paragraph = self.metadata_store.get_paragraph(source_paragraph)
except Exception:
paragraph = None
if isinstance(paragraph, dict):
payload = {
"hash": source_paragraph,
"source": str(paragraph.get("source", "") or ""),
"metadata": self._metadata_dict(paragraph.get("metadata")),
}
return self._is_evidence_bound_to_person(payload, person_id=pid)
return False
def _collect_person_fact_evidence(self, person_id: str, limit: int = 4) -> List[Dict[str, Any]]: def _collect_person_fact_evidence(self, person_id: str, limit: int = 4) -> List[Dict[str, Any]]:
token = str(person_id or "").strip() token = str(person_id or "").strip()
if not token: if not token:
@@ -346,6 +390,42 @@ class PersonProfileService:
) )
return self._filter_stale_paragraph_evidence(evidence) return self._filter_stale_paragraph_evidence(evidence)
@staticmethod
def _metadata_dict(value: Any) -> Dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
@staticmethod
def _list_tokens(value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, (list, tuple, set)):
return [str(item or "").strip() for item in value if str(item or "").strip()]
token = str(value or "").strip()
return [token] if token else []
def _is_evidence_bound_to_person(
self,
item: Dict[str, Any],
*,
person_id: str,
) -> bool:
"""画像证据必须显式绑定到 person_id避免别名全局召回串人。"""
pid = str(person_id or "").strip()
if not pid:
return False
metadata = self._metadata_dict(item.get("metadata"))
source = str(item.get("source", "") or metadata.get("source", "") or "").strip()
if source == f"person_fact:{pid}":
return True
if str(metadata.get("person_id", "") or "").strip() == pid:
return True
if pid in self._list_tokens(metadata.get("person_ids")):
return True
return False
@staticmethod @staticmethod
def _source_type_from_source(source: str) -> str: def _source_type_from_source(source: str) -> str:
token = str(source or "").strip() token = str(source or "").strip()
@@ -360,7 +440,7 @@ class PersonProfileService:
paragraph_hash: str, paragraph_hash: str,
metadata: Dict[str, Any], metadata: Dict[str, Any],
) -> Tuple[Dict[str, Any], str]: ) -> Tuple[Dict[str, Any], str]:
merged = dict(metadata or {}) merged = self._metadata_dict(metadata)
source = str(merged.get("source", "") or "").strip() source = str(merged.get("source", "") or "").strip()
try: try:
paragraph = self.metadata_store.get_paragraph(paragraph_hash) paragraph = self.metadata_store.get_paragraph(paragraph_hash)
@@ -458,9 +538,11 @@ class PersonProfileService:
"score": 0.0, "score": 0.0,
"content": str(para.get("content", ""))[:180], "content": str(para.get("content", ""))[:180],
"source": str(para.get("source", "") or ""), "source": str(para.get("source", "") or ""),
"metadata": dict(para.get("metadata", {}) or {}), "metadata": self._metadata_dict(para.get("metadata")),
} }
) )
if not self._is_evidence_bound_to_person(fallback[-1], person_id=person_id):
fallback.pop()
return self._filter_stale_paragraph_evidence(fallback[:top_k]) return self._filter_stale_paragraph_evidence(fallback[:top_k])
per_alias_top_k = max(2, int(top_k / max(1, len(alias_queries)))) per_alias_top_k = max(2, int(top_k / max(1, len(alias_queries))))
@@ -483,21 +565,22 @@ class PersonProfileService:
h = str(getattr(item, "hash_value", "") or "") h = str(getattr(item, "hash_value", "") or "")
if not h or h in seen_hash: if not h or h in seen_hash:
continue continue
seen_hash.add(h)
metadata, source = self._enrich_paragraph_evidence_metadata( metadata, source = self._enrich_paragraph_evidence_metadata(
h, h,
dict(getattr(item, "metadata", {}) or {}), self._metadata_dict(getattr(item, "metadata", {})),
)
evidence.append(
{
"hash": h,
"type": str(getattr(item, "result_type", "")),
"score": float(getattr(item, "score", 0.0) or 0.0),
"content": str(getattr(item, "content", "") or "")[:220],
"source": source,
"metadata": metadata,
}
) )
payload = {
"hash": h,
"type": str(getattr(item, "result_type", "")),
"score": float(getattr(item, "score", 0.0) or 0.0),
"content": str(getattr(item, "content", "") or "")[:220],
"source": source,
"metadata": metadata,
}
if not self._is_evidence_bound_to_person(payload, person_id=person_id):
continue
seen_hash.add(h)
evidence.append(payload)
evidence.sort(key=lambda x: x.get("score", 0.0), reverse=True) evidence.sort(key=lambda x: x.get("score", 0.0), reverse=True)
return self._filter_stale_paragraph_evidence(evidence[:top_k]) return self._filter_stale_paragraph_evidence(evidence[:top_k])
@@ -640,7 +723,7 @@ class PersonProfileService:
if not aliases and person_keyword: if not aliases and person_keyword:
aliases = [person_keyword.strip()] aliases = [person_keyword.strip()]
primary_name = person_keyword.strip() primary_name = person_keyword.strip()
relation_edges = self._collect_relation_evidence(aliases, limit=max(10, top_k * 2)) relation_edges = self._collect_relation_evidence(aliases, limit=max(10, top_k * 2), person_id=pid)
vector_evidence = await self._collect_vector_evidence(aliases, top_k=max(4, top_k), person_id=pid) vector_evidence = await self._collect_vector_evidence(aliases, top_k=max(4, top_k), person_id=pid)
evidence_ids = [ evidence_ids = [

View File

@@ -16,7 +16,7 @@ import traceback
from src.common.logger import get_logger from src.common.logger import get_logger
from src.services import llm_service as llm_api from src.services import llm_service as llm_api
from src.services import message_service as message_api from src.services import message_service as message_api
from src.config.config import global_config, model_config as host_model_config from src.config.config import config_manager, global_config
from src.config.model_configs import TaskConfig from src.config.model_configs import TaskConfig
from ..storage import ( from ..storage import (
@@ -150,36 +150,57 @@ class SummaryImporter:
return True return True
def _normalize_summary_model_selectors(self, raw_value: Any) -> List[str]: def _normalize_summary_model_selectors(self, raw_value: Any) -> List[str]:
"""标准化 summarization.model_name 配置vNext 仅接受字符串数组)""" """标准化 summarization.model_name 配置。"""
if raw_value is None: if raw_value is None:
return ["auto"] return ["auto"]
if isinstance(raw_value, list): if isinstance(raw_value, list):
selectors = [str(x).strip() for x in raw_value if str(x).strip()] selectors = [str(x).strip() for x in raw_value if str(x).strip()]
return selectors or ["auto"] return selectors or ["auto"]
if isinstance(raw_value, str):
selector = raw_value.strip()
if selector:
logger.warning("summarization.model_name 建议使用 List[str],当前字符串配置已兼容处理。")
return [selector]
return ["auto"]
raise ValueError( raise ValueError(
"summarization.model_name 在 vNext 必须为 List[str]。" "summarization.model_name 必须为 List[str] 或 str"
" 请执行 scripts/release_vnext_migrate.py migrate。" " 请执行 scripts/release_vnext_migrate.py migrate。"
) )
def _pick_default_summary_task(self, available_tasks: Dict[str, TaskConfig]) -> Tuple[Optional[str], Optional[TaskConfig]]: def _pick_default_summary_task(self, available_tasks: Dict[str, TaskConfig]) -> Tuple[Optional[str], Optional[TaskConfig]]:
""" """
选择总结默认任务,避免错误落到 embedding 任务。 选择总结默认任务,避免错误落到 embedding 任务。
优先级:memory > utils > planner;不再顺延到 replyer 或其他任务 优先级:replyer > utils > planner > tool_use > 其他非 embedding
""" """
preferred = ("memory", "utils", "planner") preferred = ("replyer", "utils", "planner", "tool_use")
for name in preferred: for name in preferred:
cfg = available_tasks.get(name) cfg = available_tasks.get(name)
if cfg and cfg.model_list: if cfg and cfg.model_list:
return name, cfg return name, cfg
for name, cfg in available_tasks.items():
if name != "embedding" and cfg.model_list:
return name, cfg
for name, cfg in available_tasks.items():
if cfg.model_list:
return name, cfg
return None, None return None, None
def _resolve_summary_model_config(self) -> Optional[TaskConfig]: @staticmethod
def _current_model_dict() -> Dict[str, Any]:
try:
return getattr(config_manager.get_model_config(), "models_dict", {}) or {}
except Exception as exc:
logger.warning(f"读取当前模型字典失败: {exc}")
return {}
def _resolve_summary_model_config(self) -> Optional[Tuple[str, TaskConfig]]:
""" """
解析 summarization.model_name 为 TaskConfig。 解析 summarization.model_name 为 (task_name, TaskConfig)
支持: 支持:
- "auto" - "auto"
- "memory"(任务名)
- "replyer"(任务名) - "replyer"(任务名)
- "some-model-name"(具体模型名) - "some-model-name"(具体模型名)
- ["utils:model1", "utils:model2", "replyer"](数组混合语法) - ["utils:model1", "utils:model2", "replyer"](数组混合语法)
@@ -192,16 +213,18 @@ class SummaryImporter:
# 避免默认值本身触发类型校验异常。 # 避免默认值本身触发类型校验异常。
raw_cfg = self.plugin_config.get("summarization", {}).get("model_name", ["auto"]) raw_cfg = self.plugin_config.get("summarization", {}).get("model_name", ["auto"])
selectors = self._normalize_summary_model_selectors(raw_cfg) selectors = self._normalize_summary_model_selectors(raw_cfg)
_default_task_name, default_task_cfg = self._pick_default_summary_task(available_tasks) default_task_name, default_task_cfg = self._pick_default_summary_task(available_tasks)
selected_models: List[str] = []
base_cfg: Optional[TaskConfig] = None base_cfg: Optional[TaskConfig] = None
model_dict = getattr(host_model_config, "models_dict", {}) base_task_name: Optional[str] = None
model_dict = self._current_model_dict()
def _append_models(models: List[str]): def _find_task_for_model(model_name: str) -> Tuple[Optional[str], Optional[TaskConfig]]:
for model_name in models: for task_name, task_cfg in available_tasks.items():
if model_name and model_name not in selected_models: task_models = [str(item).strip() for item in (getattr(task_cfg, "model_list", []) or []) if str(item).strip()]
selected_models.append(model_name) if model_name in task_models:
return task_name, task_cfg
return None, None
for raw_selector in selectors: for raw_selector in selectors:
selector = raw_selector.strip() selector = raw_selector.strip()
@@ -210,9 +233,9 @@ class SummaryImporter:
if selector.lower() == "auto": if selector.lower() == "auto":
if default_task_cfg: if default_task_cfg:
_append_models(default_task_cfg.model_list)
if base_cfg is None: if base_cfg is None:
base_cfg = default_task_cfg base_cfg = default_task_cfg
base_task_name = default_task_name
continue continue
if ":" in selector: if ":" in selector:
@@ -226,42 +249,60 @@ class SummaryImporter:
if base_cfg is None: if base_cfg is None:
base_cfg = task_cfg base_cfg = task_cfg
base_task_name = task_name
if not model_name or model_name.lower() == "auto": if not model_name or model_name.lower() == "auto":
_append_models(task_cfg.model_list)
continue continue
if model_name in model_dict or model_name in task_cfg.model_list: if model_name in task_cfg.model_list:
_append_models([model_name]) logger.info(
f"总结模型选择器 '{selector}' 已定位到任务 '{task_name}'"
"当前 LLM 服务按任务候选列表执行,不单独覆盖具体模型。"
)
else: else:
logger.warning(f"总结模型选择器 '{selector}' 的模型 '{model_name}'在,已跳过") logger.warning(f"总结模型选择器 '{selector}' 的模型 '{model_name}' 不在任务 '{task_name}',已跳过")
continue continue
task_cfg = available_tasks.get(selector) task_cfg = available_tasks.get(selector)
if task_cfg: if task_cfg:
_append_models(task_cfg.model_list)
if base_cfg is None: if base_cfg is None:
base_cfg = task_cfg base_cfg = task_cfg
base_task_name = selector
continue continue
if selector in model_dict: if selector in model_dict:
_append_models([selector]) task_name, task_cfg = _find_task_for_model(selector)
if task_name and task_cfg:
if base_cfg is None:
base_cfg = task_cfg
base_task_name = task_name
logger.info(
f"总结模型选择器 '{selector}' 已映射到任务 '{task_name}'"
"当前 LLM 服务按任务候选列表执行,不单独覆盖具体模型。"
)
continue
logger.warning(f"总结模型选择器 '{selector}' 未归属于任何任务,已跳过")
continue continue
logger.warning(f"总结模型选择器 '{selector}' 无法识别,已跳过") logger.warning(f"总结模型选择器 '{selector}' 无法识别,已跳过")
if not selected_models: if base_cfg is None or not base_task_name:
if default_task_cfg: if default_task_cfg:
_append_models(default_task_cfg.model_list)
if base_cfg is None: if base_cfg is None:
base_cfg = default_task_cfg base_cfg = default_task_cfg
base_task_name = default_task_name
else:
base_task_name, first_cfg = next(iter(available_tasks.items()))
if base_cfg is None:
base_cfg = first_cfg
if not selected_models: if base_cfg is None or not base_task_name:
return None return None
template_cfg = base_cfg or default_task_cfg or TaskConfig() template_cfg = base_cfg
return TaskConfig( task_name_to_use = base_task_name
model_list=selected_models, return task_name_to_use, TaskConfig(
model_list=list(template_cfg.model_list),
max_tokens=template_cfg.max_tokens, max_tokens=template_cfg.max_tokens,
temperature=template_cfg.temperature, temperature=template_cfg.temperature,
slow_threshold=template_cfg.slow_threshold, slow_threshold=template_cfg.slow_threshold,
@@ -331,12 +372,13 @@ class SummaryImporter:
chat_history=chat_history_text chat_history=chat_history_text
) )
model_config_to_use = self._resolve_summary_model_config() resolved_model = self._resolve_summary_model_config()
if model_config_to_use is None: if resolved_model is None:
return False, "未找到可用的总结模型配置" return False, "未找到可用的总结模型配置"
task_name_to_use = llm_api.resolve_task_name_from_model_config(model_config_to_use) task_name_to_use, model_config_to_use = resolved_model
logger.info(f"正在为流 {stream_id} 执行总结,消息条数: {len(messages)}") logger.info(f"正在为流 {stream_id} 执行总结,消息条数: {len(messages)}")
logger.info(f"总结模型任务: {task_name_to_use}")
logger.info(f"总结模型候选列表: {model_config_to_use.model_list}") logger.info(f"总结模型候选列表: {model_config_to_use.model_list}")
result = await llm_api.generate( result = await llm_api.generate(

View File

@@ -1,11 +1,13 @@
from collections import defaultdict
from datetime import datetime, timedelta
from os import getenv
from pathlib import Path
from typing import cast
import asyncio import asyncio
import concurrent.futures import concurrent.futures
import json import json
from collections import defaultdict
from datetime import datetime, timedelta
from typing import cast
from typing_extensions import TypedDict from typing_extensions import TypedDict
from sqlmodel import col, select from sqlmodel import col, select
@@ -26,6 +28,17 @@ from src.services.statistics_service import (
logger = get_logger("maibot_statistic") logger = get_logger("maibot_statistic")
STATISTICS_REPORT_PATH_ENV = "MAIBOT_STATISTICS_REPORT_PATH"
DEFAULT_STATISTICS_REPORT_PATH = "maibot_statistics.html"
def _resolve_statistics_report_path(record_file_path: str | None = None) -> str:
if record_file_path:
return record_file_path
configured_path = getenv(STATISTICS_REPORT_PATH_ENV, "").strip()
return configured_path or DEFAULT_STATISTICS_REPORT_PATH
class StatPeriodData(TypedDict): class StatPeriodData(TypedDict):
total_requests: int total_requests: int
@@ -233,7 +246,7 @@ class StatisticOutputTask(AsyncTask):
SEP_LINE = "-" * 84 SEP_LINE = "-" * 84
def __init__(self, record_file_path: str = "maibot_statistics.html"): def __init__(self, record_file_path: str | None = None):
# 延迟300秒启动运行间隔300秒 # 延迟300秒启动运行间隔300秒
super().__init__(task_name="Statistics Data Output Task", wait_before_start=0, run_interval=300) super().__init__(task_name="Statistics Data Output Task", wait_before_start=0, run_interval=300)
@@ -243,7 +256,7 @@ class StatisticOutputTask(AsyncTask):
注:设计记录时间的目的是方便更新名称,使联系人/群聊名称保持最新 注:设计记录时间的目的是方便更新名称,使联系人/群聊名称保持最新
""" """
self.record_file_path: str = record_file_path self.record_file_path: str = _resolve_statistics_report_path(record_file_path)
""" """
记录文件路径 记录文件路径
""" """
@@ -1730,7 +1743,11 @@ class StatisticOutputTask(AsyncTask):
""" """
) )
with open(self.record_file_path, "w", encoding="utf-8") as f: record_file = Path(self.record_file_path)
if record_file.parent != Path("."):
record_file.parent.mkdir(parents=True, exist_ok=True)
with open(record_file, "w", encoding="utf-8") as f:
f.write(html_template) f.write(html_template)
def _generate_chart_data(self, stat: StatPeriodMapping) -> dict[str, dict[str, object]]: def _generate_chart_data(self, stat: StatPeriodMapping) -> dict[str, dict[str, object]]:
@@ -2431,7 +2448,7 @@ class StatisticOutputTask(AsyncTask):
class AsyncStatisticOutputTask(AsyncTask): class AsyncStatisticOutputTask(AsyncTask):
"""完全异步的统计输出任务 - 更高性能版本""" """完全异步的统计输出任务 - 更高性能版本"""
def __init__(self, record_file_path: str = "maibot_statistics.html"): def __init__(self, record_file_path: str | None = None):
# 延迟0秒启动运行间隔300秒 # 延迟0秒启动运行间隔300秒
super().__init__(task_name="Async Statistics Data Output Task", wait_before_start=0, run_interval=300) super().__init__(task_name="Async Statistics Data Output Task", wait_before_start=0, run_interval=300)

View File

@@ -18,10 +18,12 @@ logger = logging.getLogger("maibot.prompt_i18n")
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
PROMPTS_ROOT = (PROJECT_ROOT / "prompts").resolve() PROMPTS_ROOT = (PROJECT_ROOT / "prompts").resolve()
CUSTOM_PROMPTS_ROOT = (PROJECT_ROOT / "data" / "custom_prompts").resolve()
PROMPT_EXTENSIONS = (".prompt",) PROMPT_EXTENSIONS = (".prompt",)
SAFE_SEGMENT_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$") SAFE_SEGMENT_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$")
STRICT_ENV_KEYS = ("MAIBOT_PROMPT_I18N_STRICT", "MAIBOT_I18N_STRICT") STRICT_ENV_KEYS = ("MAIBOT_PROMPT_I18N_STRICT", "MAIBOT_I18N_STRICT")
STRICT_ENV_VALUES = {"1", "true", "yes", "on"} STRICT_ENV_VALUES = {"1", "true", "yes", "on"}
_PROMPT_CACHE_REVISION = 0
extract_prompt_placeholders = extract_placeholders extract_prompt_placeholders = extract_placeholders
@@ -43,6 +45,17 @@ def get_prompts_root(prompts_root: Path | None = None) -> Path:
return (prompts_root or PROMPTS_ROOT).resolve() return (prompts_root or PROMPTS_ROOT).resolve()
def get_custom_prompts_root(
custom_prompts_root: Path | None = None,
prompts_root: Path | None = None,
) -> Path:
if custom_prompts_root is not None:
return custom_prompts_root.resolve()
if prompts_root is not None:
return (prompts_root.resolve().parent / "data" / "custom_prompts").resolve()
return CUSTOM_PROMPTS_ROOT
def normalize_prompt_name(name: str) -> str: def normalize_prompt_name(name: str) -> str:
candidate_name = name.strip() candidate_name = name.strip()
for suffix in PROMPT_EXTENSIONS: for suffix in PROMPT_EXTENSIONS:
@@ -194,6 +207,28 @@ def _iter_locale_candidates(requested_locale: str) -> list[str]:
return locale_candidates return locale_candidates
def _iter_prompt_path_candidates(base_dir: Path, name: str, category: str | None = None) -> list[Path]:
candidates: list[Path] = []
for suffix in PROMPT_EXTENSIONS:
if category is not None:
candidates.append((base_dir / category / f"{name}{suffix}").resolve())
candidates.append((base_dir / f"{name}{suffix}").resolve())
return candidates
def _resolve_custom_prompt_path(
name: str,
locale: str,
category: str | None,
custom_prompts_root: Path,
) -> Path | None:
custom_locale_dir = custom_prompts_root / locale
for candidate_path in _iter_prompt_path_candidates(custom_locale_dir, name, category):
if candidate_path.is_file():
return candidate_path
return None
def list_prompt_templates(locale: str | None = None, prompts_root: Path | None = None) -> dict[str, PromptTemplateInfo]: def list_prompt_templates(locale: str | None = None, prompts_root: Path | None = None) -> dict[str, PromptTemplateInfo]:
resolved_prompts_root = get_prompts_root(prompts_root) resolved_prompts_root = get_prompts_root(prompts_root)
requested_locale = normalize_locale(locale or get_locale()) requested_locale = normalize_locale(locale or get_locale())
@@ -206,15 +241,29 @@ def list_prompt_templates(locale: str | None = None, prompts_root: Path | None =
def resolve_prompt_path( def resolve_prompt_path(
name: str, locale: str | None = None, category: str | None = None, prompts_root: Path | None = None name: str,
locale: str | None = None,
category: str | None = None,
prompts_root: Path | None = None,
custom_prompts_root: Path | None = None,
) -> Path: ) -> Path:
resolved_prompts_root = get_prompts_root(prompts_root) resolved_prompts_root = get_prompts_root(prompts_root)
resolved_custom_prompts_root = get_custom_prompts_root(custom_prompts_root, prompts_root)
normalized_name = normalize_prompt_name(name) normalized_name = normalize_prompt_name(name)
normalized_category = normalize_prompt_category(category) normalized_category = normalize_prompt_category(category)
requested_locale = normalize_locale(locale or get_locale()) requested_locale = normalize_locale(locale or get_locale())
if normalized_category is not None: if normalized_category is not None:
for locale_candidate in _iter_locale_candidates(requested_locale): for locale_candidate in _iter_locale_candidates(requested_locale):
custom_path = _resolve_custom_prompt_path(
normalized_name,
locale_candidate,
normalized_category,
resolved_custom_prompts_root,
)
if custom_path is not None:
return custom_path
base_dir = resolved_prompts_root / locale_candidate base_dir = resolved_prompts_root / locale_candidate
for suffix in PROMPT_EXTENSIONS: for suffix in PROMPT_EXTENSIONS:
candidate_path = (base_dir / normalized_category / f"{normalized_name}{suffix}").resolve() candidate_path = (base_dir / normalized_category / f"{normalized_name}{suffix}").resolve()
@@ -226,9 +275,20 @@ def resolve_prompt_path(
if fallback_path.is_file(): if fallback_path.is_file():
return fallback_path return fallback_path
else: else:
prompt_paths = list_prompt_templates(locale=requested_locale, prompts_root=resolved_prompts_root) for locale_candidate in _iter_locale_candidates(requested_locale):
if normalized_name in prompt_paths: custom_path = _resolve_custom_prompt_path(
return prompt_paths[normalized_name].path normalized_name,
locale_candidate,
None,
resolved_custom_prompts_root,
)
if custom_path is not None:
return custom_path
base_dir = resolved_prompts_root / locale_candidate
for candidate_path in _iter_prompt_path_candidates(base_dir, normalized_name):
if candidate_path.is_file():
return candidate_path
raise FileNotFoundError(t("prompt.template_not_found", locale=requested_locale, name=normalized_name)) raise FileNotFoundError(t("prompt.template_not_found", locale=requested_locale, name=normalized_name))
@@ -263,13 +323,26 @@ def load_prompt(
locale: str | None = None, locale: str | None = None,
category: str | None = None, category: str | None = None,
prompts_root: Path | None = None, prompts_root: Path | None = None,
custom_prompts_root: Path | None = None,
**kwargs: object, **kwargs: object,
) -> str: ) -> str:
normalized_name = normalize_prompt_name(name) normalized_name = normalize_prompt_name(name)
prompt_path = resolve_prompt_path(name=normalized_name, locale=locale, category=category, prompts_root=prompts_root) prompt_path = resolve_prompt_path(
name=normalized_name,
locale=locale,
category=category,
prompts_root=prompts_root,
custom_prompts_root=custom_prompts_root,
)
template = _read_prompt_template(prompt_path) template = _read_prompt_template(prompt_path)
return _format_prompt_template(normalized_name, template, **kwargs) return _format_prompt_template(normalized_name, template, **kwargs)
def clear_prompt_cache() -> None: def clear_prompt_cache() -> None:
global _PROMPT_CACHE_REVISION
_PROMPT_CACHE_REVISION += 1
_read_prompt_template.cache_clear() _read_prompt_template.cache_clear()
def get_prompt_cache_revision() -> int:
return _PROMPT_CACHE_REVISION

View File

@@ -56,7 +56,7 @@ 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() A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0-pre.15" MMC_VERSION: str = "1.0.0-pre.16"
CONFIG_VERSION: str = "8.10.15" CONFIG_VERSION: str = "8.10.15"
MODEL_CONFIG_VERSION: str = "1.16.1" MODEL_CONFIG_VERSION: str = "1.16.1"

View File

@@ -388,7 +388,6 @@ class EmojiManager:
if existing_record := session.exec(statement).first(): if existing_record := session.exec(statement).first():
existing_record.full_path = str(emoji.full_path) existing_record.full_path = str(emoji.full_path)
existing_record.no_file_flag = False existing_record.no_file_flag = False
existing_record.is_banned = False
existing_record.last_used_time = datetime.now() existing_record.last_used_time = datetime.now()
existing_record.query_count += 1 existing_record.query_count += 1
session.add(existing_record) session.add(existing_record)
@@ -473,7 +472,6 @@ class EmojiManager:
image_record.full_path = str(new_emoji.full_path) image_record.full_path = str(new_emoji.full_path)
image_record.description = new_emoji.description image_record.description = new_emoji.description
image_record.no_file_flag = False image_record.no_file_flag = False
image_record.is_banned = False
session.add(image_record) session.add(image_record)
except Exception as exc: except Exception as exc:
logger.error(f"Update cached emoji description failed: {exc}") logger.error(f"Update cached emoji description failed: {exc}")
@@ -531,6 +529,9 @@ class EmojiManager:
statement = select(Images).filter_by(image_hash=emoji.file_hash, image_type=ImageType.EMOJI).limit(1) statement = select(Images).filter_by(image_hash=emoji.file_hash, image_type=ImageType.EMOJI).limit(1)
existing_record = session.exec(statement).first() existing_record = session.exec(statement).first()
if existing_record: if existing_record:
if existing_record.is_banned:
logger.info(f"[register_emoji] Emoji is banned, skipping: {emoji.file_hash}")
return "skipped"
if existing_record.is_registered and _is_available_emoji_record(existing_record): if existing_record.is_registered and _is_available_emoji_record(existing_record):
# logger.info(f"[register_emoji] Emoji already registered, skipping: {emoji.file_hash}") # logger.info(f"[register_emoji] Emoji already registered, skipping: {emoji.file_hash}")
return "skipped" return "skipped"
@@ -1085,6 +1086,10 @@ class EmojiManager:
return "failed" return "failed"
if existing_record is not None: if existing_record is not None:
if existing_record.is_banned:
logger.info(f"[register_emoji] Emoji is banned, skipping: {target_emoji.file_name}")
return "skipped"
if existing_record.is_registered and _is_available_emoji_record(existing_record): if existing_record.is_registered and _is_available_emoji_record(existing_record):
logger.info(f"[register_emoji] Emoji already registered, skipping: {target_emoji.file_name}") logger.info(f"[register_emoji] Emoji already registered, skipping: {target_emoji.file_name}")
return "skipped" return "skipped"

View File

@@ -291,7 +291,7 @@ async def handle_tool(
) )
return tool_ctx.build_success_result( return tool_ctx.build_success_result(
invocation.tool_name, invocation.tool_name,
"回复已生成并发送。", f'已生成并发送回复"{combined_reply_text}"\n发送对象:{target_user_name}',
structured_content={ structured_content={
"msg_id": target_message_id, "msg_id": target_message_id,
"set_quote": set_quote, "set_quote": set_quote,

View File

@@ -10,7 +10,7 @@ from rich.console import RenderableType
from src.common.data_models.llm_service_data_models import LLMGenerationOptions from src.common.data_models.llm_service_data_models import LLMGenerationOptions
from src.common.i18n import get_locale from src.common.i18n import get_locale
from src.common.logger import get_logger from src.common.logger import get_logger
from src.common.prompt_i18n import load_prompt from src.common.prompt_i18n import get_prompt_cache_revision, load_prompt
from src.common.utils.utils_config import ChatConfigUtils from src.common.utils.utils_config import ChatConfigUtils
from src.config.config import global_config from src.config.config import global_config
from src.core.tooling import ToolAvailabilityContext, ToolRegistry from src.core.tooling import ToolAvailabilityContext, ToolRegistry
@@ -219,6 +219,7 @@ class MaisakaChatLoopService:
self._interrupt_flag: asyncio.Event | None = None self._interrupt_flag: asyncio.Event | None = None
self._tool_registry: ToolRegistry | None = None self._tool_registry: ToolRegistry | None = None
self._prompts_loaded = chat_system_prompt is not None self._prompts_loaded = chat_system_prompt is not None
self._prompt_cache_revision = get_prompt_cache_revision()
self._prompt_load_lock = asyncio.Lock() self._prompt_load_lock = asyncio.Lock()
self._personality_prompt = self._build_personality_prompt() self._personality_prompt = self._build_personality_prompt()
if chat_system_prompt is None: if chat_system_prompt is None:
@@ -354,6 +355,7 @@ class MaisakaChatLoopService:
self._chat_system_prompt = f"{self._personality_prompt}\n\nYou are a helpful AI assistant." self._chat_system_prompt = f"{self._personality_prompt}\n\nYou are a helpful AI assistant."
self._prompts_loaded = True self._prompts_loaded = True
self._prompt_cache_revision = get_prompt_cache_revision()
def build_prompt_template_context(self, tools_section: str = "") -> dict[str, str]: def build_prompt_template_context(self, tools_section: str = "") -> dict[str, str]:
"""构造 Maisaka prompt 模板的公共渲染参数。""" """构造 Maisaka prompt 模板的公共渲染参数。"""
@@ -519,7 +521,7 @@ class MaisakaChatLoopService:
ChatResponse: 本轮规划器返回结果。 ChatResponse: 本轮规划器返回结果。
""" """
if not self._prompts_loaded: if not self._prompts_loaded or self._prompt_cache_revision != get_prompt_cache_revision():
await self.ensure_chat_prompt_loaded() await self.ensure_chat_prompt_loaded()
enable_visual_message = self._resolve_enable_visual_message(request_kind) enable_visual_message = self._resolve_enable_visual_message(request_kind)
selected_history, selection_reason = self.select_llm_context_messages( selected_history, selection_reason = self.select_llm_context_messages(

View File

@@ -18,6 +18,8 @@ logger = get_logger("webui.app")
_DASHBOARD_PACKAGE_NAME = "maibot-dashboard" _DASHBOARD_PACKAGE_NAME = "maibot-dashboard"
_LOCAL_DASHBOARD_ENV = "MAIBOT_WEBUI_USE_LOCAL_DASHBOARD" _LOCAL_DASHBOARD_ENV = "MAIBOT_WEBUI_USE_LOCAL_DASHBOARD"
_STATISTICS_REPORT_PATH_ENV = "MAIBOT_STATISTICS_REPORT_PATH"
_DEFAULT_STATISTICS_REPORT_PATH = "maibot_statistics.html"
_MANUAL_INSTALL_COMMAND = f"pip install {_DASHBOARD_PACKAGE_NAME}" _MANUAL_INSTALL_COMMAND = f"pip install {_DASHBOARD_PACKAGE_NAME}"
@@ -38,6 +40,15 @@ def _get_project_root() -> Path:
return Path(__file__).resolve().parents[2] return Path(__file__).resolve().parents[2]
def _resolve_statistics_report_path() -> Path:
configured_path = getenv(_STATISTICS_REPORT_PATH_ENV, "").strip()
report_path = Path(configured_path or _DEFAULT_STATISTICS_REPORT_PATH)
if report_path.is_absolute():
return report_path.resolve()
return (_get_project_root() / report_path).resolve()
def _is_local_dashboard_enabled() -> bool: def _is_local_dashboard_enabled() -> bool:
return getenv(_LOCAL_DASHBOARD_ENV, "").strip().lower() in {"1", "true", "yes", "on"} return getenv(_LOCAL_DASHBOARD_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
@@ -187,7 +198,7 @@ def _setup_static_files(app: FastAPI):
@app.get("/maibot_statistics.html", include_in_schema=False) @app.get("/maibot_statistics.html", include_in_schema=False)
async def serve_statistics_report(): async def serve_statistics_report():
report_path = (_get_project_root() / "maibot_statistics.html").resolve() report_path = _resolve_statistics_report_path()
if not report_path.exists() or not report_path.is_file(): if not report_path.exists() or not report_path.is_file():
raise HTTPException(status_code=404, detail=t("core.not_found")) raise HTTPException(status_code=404, detail=t("core.not_found"))

View File

@@ -14,7 +14,7 @@ from pydantic import BaseModel, Field
import tomlkit import tomlkit
from src.common.logger import get_logger from src.common.logger import get_logger
from src.common.prompt_i18n import list_prompt_templates from src.common.prompt_i18n import clear_prompt_cache, list_prompt_templates
from src.config.config import CONFIG_DIR, PROJECT_ROOT, Config, ModelConfig from src.config.config import CONFIG_DIR, PROJECT_ROOT, Config, ModelConfig
from src.config.config_base import AttributeData, ConfigBase from src.config.config_base import AttributeData, ConfigBase
from src.config.model_configs import ( from src.config.model_configs import (
@@ -323,6 +323,7 @@ async def update_prompt_file(language: str, filename: str, content: PromptConten
try: try:
custom_prompt_path.parent.mkdir(parents=True, exist_ok=True) custom_prompt_path.parent.mkdir(parents=True, exist_ok=True)
custom_prompt_path.write_text(content, encoding="utf-8", newline="\n") custom_prompt_path.write_text(content, encoding="utf-8", newline="\n")
clear_prompt_cache()
return PromptFileResponse(language=language, filename=filename, content=content, customized=True) return PromptFileResponse(language=language, filename=filename, content=content, customized=True)
except Exception as e: except Exception as e:
logger.error(f"保存 Prompt 文件失败: {prompt_path} {e}", exc_info=True) logger.error(f"保存 Prompt 文件失败: {prompt_path} {e}", exc_info=True)
@@ -341,6 +342,7 @@ async def reset_prompt_file(language: str, filename: str):
try: try:
if custom_prompt_path.exists(): if custom_prompt_path.exists():
custom_prompt_path.unlink() custom_prompt_path.unlink()
clear_prompt_cache()
content = prompt_path.read_text(encoding="utf-8") content = prompt_path.read_text(encoding="utf-8")
return PromptFileResponse(language=language, filename=filename, content=content, customized=False) return PromptFileResponse(language=language, filename=filename, content=content, customized=False)
except Exception as e: except Exception as e:

View File

@@ -213,7 +213,7 @@ def resolve_installed_plugin_path(plugin_id: str) -> Optional[Path]:
return _resolve_safe_plugin_directory(new_format_path, plugins_dir, strict=True) return _resolve_safe_plugin_directory(new_format_path, plugins_dir, strict=True)
if old_format_path.exists(): if old_format_path.exists():
return _resolve_safe_plugin_directory(old_format_path, plugins_dir, strict=True) return _resolve_safe_plugin_directory(old_format_path, plugins_dir, strict=True)
return None return find_plugin_path_by_id(plugin_id)
def parse_repository_url(repository_url: str) -> Tuple[str, str, str]: def parse_repository_url(repository_url: str) -> Tuple[str, str, str]:
@@ -256,11 +256,29 @@ def iter_plugin_directories() -> List[Path]:
def find_plugin_path_by_id(plugin_id: str) -> Optional[Path]: def find_plugin_path_by_id(plugin_id: str) -> Optional[Path]:
casefold_matched_path: Optional[Path] = None
normalized_plugin_id = plugin_id.casefold()
for plugin_path in iter_plugin_directories(): for plugin_path in iter_plugin_directories():
manifest_path = resolve_plugin_file_path(plugin_path, "_manifest.json") manifest_path = resolve_plugin_file_path(plugin_path, "_manifest.json")
manifest = load_manifest_json(manifest_path) manifest = load_manifest_json(manifest_path)
if manifest is not None and (manifest.get("id") == plugin_id or plugin_path.name == plugin_id): if manifest is None:
continue
manifest_id = str(manifest.get("id", ""))
if manifest_id == plugin_id or plugin_path.name == plugin_id:
return plugin_path return plugin_path
if (
casefold_matched_path is None
and (manifest_id.casefold() == normalized_plugin_id or plugin_path.name.casefold() == normalized_plugin_id)
):
casefold_matched_path = plugin_path
if casefold_matched_path is not None:
logger.warning(f"插件 ID 大小写不一致,已按大小写不敏感匹配: {plugin_id} -> {casefold_matched_path}")
return casefold_matched_path
return None return None
@@ -269,7 +287,9 @@ def backup_file(file_path: Path, action: str, move_file: bool = False) -> Option
return None return None
backup_name = f"{file_path.name}.{action}.{datetime.now().strftime('%Y%m%d%H%M%S')}" backup_name = f"{file_path.name}.{action}.{datetime.now().strftime('%Y%m%d%H%M%S')}"
backup_path = file_path.parent / backup_name backup_dir = file_path.parent / "config_back"
backup_dir.mkdir(parents=True, exist_ok=True)
backup_path = backup_dir / backup_name
if move_file: if move_file:
shutil.move(file_path, backup_path) shutil.move(file_path, backup_path)
else: else:

8
uv.lock generated
View File

@@ -1511,7 +1511,7 @@ requires-dist = [
{ name = "httpx", extras = ["socks"] }, { name = "httpx", extras = ["socks"] },
{ name = "jieba", specifier = ">=0.42.1" }, { name = "jieba", specifier = ">=0.42.1" },
{ name = "json-repair", specifier = ">=0.47.6" }, { name = "json-repair", specifier = ">=0.47.6" },
{ name = "maibot-dashboard", specifier = ">=1.0.8" }, { name = "maibot-dashboard", specifier = ">=1.0.9" },
{ name = "maibot-plugin-sdk", specifier = ">=2.4.0" }, { name = "maibot-plugin-sdk", specifier = ">=2.4.0" },
{ name = "maim-message", specifier = ">=0.6.2" }, { name = "maim-message", specifier = ">=0.6.2" },
{ name = "matplotlib", specifier = ">=3.10.5" }, { name = "matplotlib", specifier = ">=3.10.5" },
@@ -1549,11 +1549,11 @@ dev = [
[[package]] [[package]]
name = "maibot-dashboard" name = "maibot-dashboard"
version = "1.0.8" version = "1.0.9"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/9f/e59b1a6299cc4f8c9ac16c7c2774581220fdd27227ac9c2fdfb947dfc2f5/maibot_dashboard-1.0.8.tar.gz", hash = "sha256:a47309072d8154905738d02ccad17a543d5159a1e62ca87076ac4dce39e6c922", size = 2496374, upload-time = "2026-05-07T13:58:39.386Z" } sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/5b/e90896cbdddc89ec5586873de07a3d70c0107e4dc76db8666a0c0fde6ae8/maibot_dashboard-1.0.9.tar.gz", hash = "sha256:0e5c00be021419686105238cded501024f0383a3815bd85f9a1e747f3f04d0cd", size = 2496957, upload-time = "2026-05-07T18:37:51.291Z" }
wheels = [ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/60/fde671bf332133f1403673096eefcd49f36133141a6b9229e72c2588b221/maibot_dashboard-1.0.8-py3-none-any.whl", hash = "sha256:39da973fed56f1491245109615d81ea79add859467798af92d4ace7d8a5d7557", size = 2563243, upload-time = "2026-05-07T13:58:37.868Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/27/ab227a84e55356039004a375e78031e5e8aaf4192e11908a568498816d5e/maibot_dashboard-1.0.9-py3-none-any.whl", hash = "sha256:197b26c5c3d0e6ba1238b91d12c88e57db71c65303cc602fcccdca84ce4db582", size = 2563281, upload-time = "2026-05-07T18:37:49.648Z" },
] ]
[[package]] [[package]]