From 5b3742382b619aab583be829e59df723e93d4ef4 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 21 May 2026 00:01:09 +0800 Subject: [PATCH 01/32] =?UTF-8?q?build:=20=E8=B0=83=E6=95=B4=20Windows=20?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E5=8C=85=E5=8F=91=E5=B8=83=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-windows.yml | 4 +-- README.md | 33 ++++++++++++----- docs/release.md | 17 +++++---- package.json | 11 +----- scripts/release/build-windows-variants.ts | 44 +++++------------------ 5 files changed, 47 insertions(+), 62 deletions(-) diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 4f7418b..dc03899 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: payload_url: - description: Zip URL containing runtime/ and modules/. runtime/python and runtime/git are required for the full installer. + description: Zip URL containing runtime/ and modules/. required: false type: string payload_sha256: @@ -92,7 +92,7 @@ jobs: - name: Check release payload run: bun run release:check - - name: Build installers + - name: Build installer run: bun run release:win - name: Upload installer artifacts diff --git a/README.md b/README.md index 156dd8b..3f0d9a9 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ bun run build ## Windows 打包 -Windows x64 NSIS 安装包会同时产出两个变体:`full` 完整包包含内置 Python 与 Git,`lite` 精简包不包含内置 Python 与 Git,会在运行时自动寻找系统 Python 3.12+ 与系统 Git。打包前需要在仓库根目录放好完整 payload: +Windows x64 NSIS 安装包当前产出正式版:`MaiBot OK--win.exe`。正式版会打包干净的基础 Python、内置 Git、MaiBot、NapCat、SnowLuma 以及 NapCat/SnowLuma 适配器插件,但不会打包 MaiBot Python 依赖,也不会打包 `python-overrides` 覆盖层;首次启动时再由启动器安装运行依赖。 + +打包前需要在仓库根目录放好 payload: ```text runtime/ @@ -37,33 +39,46 @@ runtime/ bin/git.exe modules/ MaiBot/ - MaiBot-Napcat-Adapter/ + plugins/ + napcat-adapter/ + snowluma-adapter/ napcat/ + SnowLuma/ ``` -只构建 `lite` 变体时,`runtime/python/` 与 `runtime/git/` 可以省略: +`runtime/python` 必须保持干净,只允许 Python 自身和 `pip`/`setuptools`/`wheel` 等基础启动包;不要把 MaiBot、dashboard 或其它应用依赖预装进 `runtime/python/Lib/site-packages`。`release-assets/python-overrides` 不会进入安装包。 + +发布前检查: ```bash -bun run release:win:lite +bun run release:check ``` -发布前检查: +构建 Windows 安装包: ```bash -bun run release:check +bun run release:patch-nsis +bun run build +bun run scripts/release/build-windows-variants.ts ``` -生成两个安装包: +也可以直接执行: ```bash bun run release:win ``` -产物输出到 `release/`,文件名会带上 `full` 或 `lite` 后缀。`runtime/` 和 `modules/` 会作为 `extraResources` 放进完整包;`lite` 变体会排除 `runtime/python/` 与 `runtime/git/`,缺失时会在环境检查中提供 Python 和 Git 下载入口。 +产物输出到 `release/`: + +```text +release/MaiBot OK--win.exe +release/MaiBot OK--win.exe.blockmap +release/latest-win.yml +``` ## CI - `.github/workflows/ci.yml`:在 Linux、macOS、Windows 上执行依赖安装、类型检查和 Electron 构建,不需要 release payload。 -- `.github/workflows/release-windows.yml`:手动触发 Windows x64 安装包构建,可输入 payload zip URL;构建完整包时 zip 内需要包含 `runtime/` 和 `modules/`。 +- `.github/workflows/release-windows.yml`:手动触发 Windows x64 安装包构建,可输入 payload zip URL;zip 内需要包含 `runtime/` 和 `modules/`。 更多发布细节见 [docs/release.md](docs/release.md)。 diff --git a/docs/release.md b/docs/release.md index 59017c4..8a82a4d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -10,7 +10,7 @@ bun install ``` -2. 准备 release payload。构建完整包时,仓库根目录必须存在: +2. 准备 release payload。仓库根目录必须存在: ```text runtime/python/python.exe @@ -19,9 +19,14 @@ runtime/python/Scripts/pip.exe runtime/git/bin/git.exe modules/MaiBot/bot.py + modules/MaiBot/plugins/napcat-adapter/ + modules/MaiBot/plugins/snowluma-adapter/ modules/napcat/NapCatWinBootMain.exe + modules/SnowLuma/index.mjs ``` + `runtime/python` 必须保持为基础 Python,只保留 Python 自身和 `pip`/`setuptools`/`wheel` 等启动包。不要把 MaiBot、dashboard 或其它应用依赖预装进 `runtime/python/Lib/site-packages`;`release-assets/python-overrides` 不会进入安装包。 + 3. 执行发布检查: ```bash @@ -34,12 +39,12 @@ bun run release:win ``` -安装包会输出到 `release/`,默认同时生成 `full` 完整包和 `lite` 精简包。`lite` 不包含 `runtime/python/` 与 `runtime/git/`,运行时会寻找系统 Python 3.12+ 与系统 Git,并在缺失时给出下载入口。 - -只构建精简包时,payload 可以省略 `runtime/python/` 与 `runtime/git/`: +安装包会输出到 `release/`: -```bash -bun run release:win:lite +```text +release/MaiBot OK--win.exe +release/MaiBot OK--win.exe.blockmap +release/latest-win.yml ``` ## GitHub Actions 发布 diff --git a/package.json b/package.json index f2ef1b5..1a41660 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maibot-onekey-desktop", - "version": "0.3.3", + "version": "0.3.4", "description": "Electron desktop shell for MaiBot OneKey.", "author": "MotricSeven", "license": "GPL-3.0-only", @@ -89,15 +89,6 @@ "!**/*.pyc" ] }, - { - "from": "release-assets/python-overrides", - "to": "python-overrides", - "filter": [ - "**/*", - "!**/__pycache__/**", - "!**/*.pyc" - ] - }, { "from": "modules", "to": "modules", diff --git a/scripts/release/build-windows-variants.ts b/scripts/release/build-windows-variants.ts index 9f1d303..f5a4244 100644 --- a/scripts/release/build-windows-variants.ts +++ b/scripts/release/build-windows-variants.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; import process from "node:process"; @@ -24,9 +24,6 @@ function hasEmbeddedPython(): boolean { ].some((path) => existsSync(path)); } -type Variant = "basic" | "full"; -type ArtifactVariant = "lite" | "full"; - function run(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -55,52 +52,29 @@ async function main(): Promise { throw new Error("Cannot build the standard installer because runtime/python is missing."); } - for (const variant of ["basic", "full"] as Variant[]) { - await buildVariant(variant); - } + await buildWindowsInstaller(); } -async function buildVariant(variant: Variant): Promise { - console.log(`[release] Preparing ${variant} Python overlay`); - await run(process.execPath, [ - join(root, "scripts", "release", "prepare-python-overrides.ts"), - variant, - ]); - - console.log(`[release] Building Windows x64 ${variant} installer`); - const artifactVariant = variant === "basic" ? "lite" : "full"; +async function buildWindowsInstaller(): Promise { + console.log("[release] Building Windows x64 installer without bundled Python dependencies"); await run(process.execPath, [ join(root, "node_modules", "electron-builder", "cli.js"), "--win", "nsis", "--x64", - `--config.win.artifactName=MaiBot OK-\${version}-win-${artifactVariant}.\${ext}`, + "--config.win.artifactName=MaiBot OK-${version}-win.${ext}", ]); - if (variant === "basic") { - await copyLatestMetadata("lite"); - } else { - await copyLatestMetadata("full"); - } + await copyLatestMetadata(); } -async function copyLatestMetadata(variant: ArtifactVariant): Promise { +async function copyLatestMetadata(): Promise { const releaseRoot = join(root, "release"); await mkdir(releaseRoot, { recursive: true }); const source = join(releaseRoot, "latest.yml"); if (existsSync(source)) { - await copyFile(source, join(releaseRoot, `latest-${variant}.yml`)); + await copyFile(source, join(releaseRoot, "latest-win.yml")); } } -async function cleanupPythonOverrides(): Promise { - await rm(join(root, "release-assets", "python-overrides"), { recursive: true, force: true }); - await mkdir(join(root, "release-assets", "python-overrides"), { recursive: true }); - await writeFile(join(root, "release-assets", "python-overrides", ".keep"), "", "utf8"); -} - -try { - await main(); -} finally { - await cleanupPythonOverrides(); -} +await main(); From 4487e3d7d4843a84ebc0eb270b738bee4a99436e Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 21 May 2026 00:01:32 +0800 Subject: [PATCH 02/32] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E9=85=8D=E7=BD=AE=E5=90=8D=E7=A7=B0=E5=92=8C=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/maibot-plugin-client.ts | 741 +++++++++++++++++- .../src/components/app/PluginMarketPanel.tsx | 14 +- 2 files changed, 747 insertions(+), 8 deletions(-) diff --git a/src/main/services/maibot-plugin-client.ts b/src/main/services/maibot-plugin-client.ts index ddf5172..233e13b 100644 --- a/src/main/services/maibot-plugin-client.ts +++ b/src/main/services/maibot-plugin-client.ts @@ -1,10 +1,12 @@ import { execFile } from "node:child_process"; -import { copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import type { MaiBotPluginConfigSaveResult, + MaiBotPluginConfigField, MaiBotPluginConfigSchema, + MaiBotPluginConfigSection, MaiBotPluginConfigState, MaiBotPluginConfigValue, MaiBotPluginConfigLocalizedText, @@ -49,6 +51,46 @@ interface CacheFile { data: T; } +interface LocalPythonConfigInspection { + classes: Map; + configModel?: string; +} + +interface LocalPythonConfigClass { + name: string; + description?: string; + label?: string; + icon?: string; + order?: number; + fields: LocalPythonConfigField[]; +} + +interface LocalPythonConfigField { + name: string; + annotation: string; + defaultFactory?: string; + defaultValue?: MaiBotPluginConfigValue; + label?: MaiBotPluginConfigLocalizedText; + description?: MaiBotPluginConfigLocalizedText; + hint?: MaiBotPluginConfigLocalizedText; + placeholder?: MaiBotPluginConfigLocalizedText; + uiType?: string; + inputType?: string; + choices?: Array; + min?: number; + max?: number; + step?: number; + rows?: number; + required?: boolean; + hidden?: boolean; + disabled?: boolean; + order?: number; + icon?: string; + itemType?: string; + minItems?: number; + maxItems?: number; +} + export class MaiBotPluginClient { private readonly maibotRoot: string; @@ -228,6 +270,8 @@ export class MaiBotPluginClient { const exists = await pathExists(configPath); const raw = exists ? await readFile(configPath, "utf8") : ""; const config = exists ? parsePluginConfig(raw, configPath) : {}; + const schema = await buildLocalPluginConfigSchema(pluginPath, config) + ?? buildPluginConfigSchema(config, "local"); return { pluginId, @@ -235,7 +279,7 @@ export class MaiBotPluginClient { configPath, exists, config, - schema: buildPluginConfigSchema(config, "local"), + schema, raw, }; } @@ -272,7 +316,8 @@ export class MaiBotPluginClient { pluginId, configPath, config: normalizedConfig, - schema: buildPluginConfigSchema(normalizedConfig, "local"), + schema: await buildLocalPluginConfigSchema(pluginPath, normalizedConfig) + ?? buildPluginConfigSchema(normalizedConfig, "local"), raw, backupPath, savedAt: Date.now(), @@ -760,14 +805,22 @@ function normalizeInstalledPlugin(raw: unknown): MaiBotInstalledPlugin | null { if (!id || !manifest.name || !manifest.version) { return null; } + const loadStatus = typeof raw.load_status === "string" ? raw.load_status : undefined; + const loaded = loadStatus === "success" + ? true + : loadStatus === "failed" + ? false + : raw.loaded === true + ? true + : undefined; return { id, manifest: { ...manifest, id: manifest.id?.trim() || id }, path: typeof raw.path === "string" ? raw.path : "", enabled: typeof raw.enabled === "boolean" ? raw.enabled : typeof raw.disabled === "boolean" ? !raw.disabled : true, - loaded: typeof raw.loaded === "boolean" ? raw.loaded : undefined, - load_status: typeof raw.load_status === "string" ? raw.load_status : undefined, + loaded, + load_status: loadStatus, }; } @@ -977,6 +1030,684 @@ function readPluginEnabled(config: Record): boo return typeof enabled === "boolean" ? enabled : true; } +async function buildLocalPluginConfigSchema( + pluginPath: string, + config: Record, +): Promise { + const pythonFiles = await collectPluginPythonFiles(pluginPath).catch(() => []); + if (pythonFiles.length === 0) { + return null; + } + + const sources: string[] = []; + for (const filePath of pythonFiles) { + try { + if ((await stat(filePath)).size > 512 * 1024) { + continue; + } + sources.push(await readFile(filePath, "utf8")); + } catch { + // Ignore unreadable plugin helper files and keep the weak TOML fallback available. + } + } + + if (sources.length === 0) { + return null; + } + + const inspection = parseLocalPythonConfigInspection(sources); + if (inspection.classes.size === 0) { + return null; + } + return buildPluginConfigSchemaFromLocalPython(inspection, config); +} + +async function collectPluginPythonFiles( + pluginPath: string, + currentPath = pluginPath, + depth = 0, + files: string[] = [], +): Promise { + if (depth > 2 || files.length >= 80) { + return files; + } + + const entries = await readdir(currentPath, { withFileTypes: true }); + for (const entry of entries) { + if (files.length >= 80) { + break; + } + if (entry.name.startsWith(".") || entry.name === "__pycache__" || entry.name === "node_modules") { + continue; + } + + const entryPath = resolve(currentPath, entry.name); + if (!isPathInside(pluginPath, entryPath)) { + continue; + } + if (entry.isDirectory()) { + await collectPluginPythonFiles(pluginPath, entryPath, depth + 1, files); + } else if (entry.isFile() && entry.name.endsWith(".py")) { + files.push(entryPath); + } + } + return files; +} + +function parseLocalPythonConfigInspection(sources: string[]): LocalPythonConfigInspection { + const classes = new Map(); + let configModel: string | undefined; + + for (const source of sources) { + configModel ??= extractPythonConfigModel(source); + for (const configClass of parsePythonConfigClasses(source)) { + if (configClass.fields.length > 0 || configClass.label || configClass.description) { + classes.set(configClass.name, configClass); + } + } + } + + return { classes, configModel }; +} + +function extractPythonConfigModel(source: string): string | undefined { + const match = source.match(/^\s{4}config_model(?:\s*:[^=\n]+)?\s*=\s*([A-Za-z_]\w*)/mu) + ?? source.match(/^config_model(?:\s*:[^=\n]+)?\s*=\s*([A-Za-z_]\w*)/mu); + return match?.[1]; +} + +function parsePythonConfigClasses(source: string): LocalPythonConfigClass[] { + const classHeaders: Array<{ name: string; headerEnd: number; start: number }> = []; + const classRegex = /^class\s+([A-Za-z_]\w*)\([^)]*\):/gmu; + let classMatch: RegExpExecArray | null; + while ((classMatch = classRegex.exec(source)) !== null) { + classHeaders.push({ + name: classMatch[1], + headerEnd: classMatch.index + classMatch[0].length, + start: classMatch.index, + }); + } + + return classHeaders.map((header, index) => { + const nextHeader = classHeaders[index + 1]; + const block = source.slice(header.headerEnd, nextHeader?.start); + return parsePythonConfigClass(header.name, block); + }); +} + +function parsePythonConfigClass(name: string, block: string): LocalPythonConfigClass { + return { + name, + description: extractPythonClassDocstring(block), + label: extractPythonClassStringAttribute(block, "__ui_label__"), + icon: extractPythonClassStringAttribute(block, "__ui_icon__"), + order: extractPythonClassNumberAttribute(block, "__ui_order__"), + fields: parsePythonConfigFields(block), + }; +} + +function parsePythonConfigFields(block: string): LocalPythonConfigField[] { + const fields: LocalPythonConfigField[] = []; + const fieldRegex = /^ {4}([A-Za-z_]\w*)\s*:\s*([^=\n]+?)\s*=\s*Field\s*\(/gmu; + let fieldMatch: RegExpExecArray | null; + while ((fieldMatch = fieldRegex.exec(block)) !== null) { + const openParenIndex = fieldMatch.index + fieldMatch[0].lastIndexOf("("); + const closeParenIndex = findMatchingDelimiter(block, openParenIndex, "(", ")"); + if (closeParenIndex < 0) { + continue; + } + + const name = fieldMatch[1]; + const annotation = fieldMatch[2].trim(); + const expression = block.slice(openParenIndex + 1, closeParenIndex); + const extra = extractPythonFieldExtra(expression); + fields.push({ + name, + annotation, + defaultFactory: extractPythonIdentifierKeyword(expression, "default_factory"), + defaultValue: extractPythonDefaultValue(expression, annotation), + label: extra.label, + description: extractPythonStringKeyword(expression, "description") ?? extra.description, + hint: extra.hint, + placeholder: extra.placeholder, + uiType: extra.uiType, + inputType: extra.inputType, + choices: extra.choices ?? extractLiteralChoices(annotation), + min: extra.min, + max: extra.max, + step: extra.step, + rows: extra.rows, + required: extra.required, + hidden: extra.hidden, + disabled: extra.disabled, + order: extra.order, + icon: extra.icon, + itemType: extra.itemType, + minItems: extra.minItems, + maxItems: extra.maxItems, + }); + fieldRegex.lastIndex = closeParenIndex + 1; + } + return fields; +} + +function buildPluginConfigSchemaFromLocalPython( + inspection: LocalPythonConfigInspection, + config: Record, +): MaiBotPluginConfigSchema | null { + const rootClass = resolveLocalPythonRootConfigClass(inspection, config); + if (!rootClass) { + return null; + } + + const sections: MaiBotPluginConfigSection[] = []; + const usedConfigKeys = new Set(); + const rootFields = [...rootClass.fields].sort(compareLocalPythonFields); + + for (const rootField of rootFields) { + const sectionValue = config[rootField.name]; + const sectionClassName = resolveLocalPythonFieldClassName(rootField, inspection.classes); + const sectionClass = sectionClassName ? inspection.classes.get(sectionClassName) : undefined; + if (!sectionClass && !isConfigRecord(sectionValue)) { + continue; + } + + usedConfigKeys.add(rootField.name); + sections.push(buildLocalPythonConfigSection( + rootField.name, + isConfigRecord(sectionValue) ? sectionValue : {}, + sectionClass, + rootField, + )); + } + + const generalFields = rootFields + .filter((field) => !usedConfigKeys.has(field.name) && field.hidden !== true) + .map((field) => buildLocalPythonConfigField([field.name], field, config[field.name])) + .filter((field): field is MaiBotPluginConfigField => field !== null); + + const extraGeneralFields = Object.entries(config) + .filter(([key, value]) => !usedConfigKeys.has(key) && !rootClass.fields.some((field) => field.name === key) && !isConfigRecord(value)) + .map(([key, value]) => buildPluginConfigField([key], key, value)); + + if (generalFields.length > 0 || extraGeneralFields.length > 0) { + sections.unshift({ + name: "general", + title: "General", + fields: [...generalFields, ...extraGeneralFields], + }); + } + + for (const [sectionName, sectionValue] of Object.entries(config)) { + if (usedConfigKeys.has(sectionName) || !isConfigRecord(sectionValue)) { + continue; + } + sections.push(buildLocalPythonConfigSection(sectionName, sectionValue)); + } + + if (sections.length === 0) { + return null; + } + return { + pluginInfo: { + name: rootClass.label, + description: rootClass.description, + }, + sections: sections.sort((left, right) => (left.order ?? 0) - (right.order ?? 0)), + source: "local", + }; +} + +function resolveLocalPythonRootConfigClass( + inspection: LocalPythonConfigInspection, + config: Record, +): LocalPythonConfigClass | undefined { + if (inspection.configModel && inspection.classes.has(inspection.configModel)) { + return inspection.classes.get(inspection.configModel); + } + + const configKeys = new Set(Object.keys(config)); + return [...inspection.classes.values()] + .map((configClass) => { + let score = 0; + for (const field of configClass.fields) { + const className = resolveLocalPythonFieldClassName(field, inspection.classes); + if (className) { + score += 4; + } + if (configKeys.has(field.name)) { + score += 2; + } + } + return { configClass, score }; + }) + .filter((candidate) => candidate.score > 0) + .sort((left, right) => right.score - left.score)[0]?.configClass; +} + +function buildLocalPythonConfigSection( + sectionName: string, + sectionConfig: Record, + sectionClass?: LocalPythonConfigClass, + rootField?: LocalPythonConfigField, +): MaiBotPluginConfigSection { + const metadataFields = [...(sectionClass?.fields ?? [])].sort(compareLocalPythonFields); + const metadataNames = new Set(metadataFields.map((field) => field.name)); + const fields = [ + ...metadataFields + .filter((field) => field.hidden !== true) + .map((field) => buildLocalPythonConfigField([sectionName, field.name], field, sectionConfig[field.name])) + .filter((field): field is MaiBotPluginConfigField => field !== null), + ...Object.entries(sectionConfig) + .filter(([fieldName]) => !metadataNames.has(fieldName)) + .map(([fieldName, fieldValue]) => buildPluginConfigField([sectionName, fieldName], fieldName, fieldValue)), + ]; + + return { + name: sectionName, + title: sectionClass?.label ?? rootField?.label ?? labelFromKey(sectionName), + description: sectionClass?.description ?? rootField?.description, + icon: sectionClass?.icon ?? rootField?.icon, + order: sectionClass?.order ?? rootField?.order, + fields, + }; +} + +function buildLocalPythonConfigField( + path: string[], + metadata: LocalPythonConfigField, + currentValue: MaiBotPluginConfigValue | undefined, +): MaiBotPluginConfigField | null { + if (metadata.hidden === true) { + return null; + } + const value = currentValue + ?? metadata.defaultValue + ?? defaultValueForPythonAnnotation(metadata.annotation, metadata.defaultFactory); + return { + name: metadata.name, + label: metadata.label ?? metadata.description ?? labelFromKey(metadata.name), + path, + type: pluginConfigValueType(value), + value, + description: metadata.description, + hint: metadata.hint, + placeholder: metadata.placeholder, + uiType: metadata.uiType, + inputType: metadata.inputType, + choices: metadata.choices, + min: metadata.min, + max: metadata.max, + step: metadata.step, + rows: metadata.rows, + required: metadata.required, + disabled: metadata.disabled, + order: metadata.order, + icon: metadata.icon, + default: metadata.defaultValue, + itemType: metadata.itemType, + minItems: metadata.minItems, + maxItems: metadata.maxItems, + }; +} + +function compareLocalPythonFields(left: LocalPythonConfigField, right: LocalPythonConfigField): number { + return (left.order ?? 0) - (right.order ?? 0); +} + +function resolveLocalPythonFieldClassName( + field: LocalPythonConfigField, + classes: Map, +): string | undefined { + const candidates = [ + ...extractPythonIdentifierTokens(field.annotation), + ...(field.defaultFactory ? extractPythonIdentifierTokens(field.defaultFactory) : []), + ]; + return candidates.find((candidate) => classes.has(candidate)); +} + +function defaultValueForPythonAnnotation(annotation: string, defaultFactory?: string): MaiBotPluginConfigValue { + const normalized = annotation.toLowerCase(); + const factory = defaultFactory?.toLowerCase(); + if (factory === "list" || normalized.includes("list[")) { + return []; + } + if (factory === "dict" || normalized.includes("dict[") || normalized.includes("mapping[")) { + return {}; + } + if (normalized.includes("bool")) { + return false; + } + if (normalized.includes("int") || normalized.includes("float")) { + return 0; + } + return ""; +} + +function extractPythonClassDocstring(block: string): string | undefined { + const firstContent = block.match(/^\s*(?:(?:\r?\n)\s*)*/u)?.[0].length ?? 0; + const literal = readPythonStringLiteral(block, firstContent); + return literal?.value.trim() || undefined; +} + +function extractPythonClassStringAttribute(block: string, attribute: string): string | undefined { + const regex = new RegExp(`^ {4}${escapeRegExp(attribute)}(?:\\s*:[^=\\n]+)?\\s*=\\s*`, "mu"); + const match = regex.exec(block); + if (!match) { + return undefined; + } + return readPythonStringLiteral(block, match.index + match[0].length)?.value; +} + +function extractPythonClassNumberAttribute(block: string, attribute: string): number | undefined { + const regex = new RegExp(`^ {4}${escapeRegExp(attribute)}(?:\\s*:[^=\\n]+)?\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)`, "mu"); + const value = Number(regex.exec(block)?.[1]); + return Number.isFinite(value) ? value : undefined; +} + +function extractPythonFieldExtra(expression: string): Partial { + return { + label: extractPythonDictStringValue(expression, "label"), + description: extractPythonDictStringValue(expression, "description"), + hint: extractPythonDictStringValue(expression, "hint"), + placeholder: extractPythonDictStringValue(expression, "placeholder"), + uiType: extractPythonDictStringValue(expression, "ui_type"), + inputType: extractPythonDictStringValue(expression, "input_type"), + icon: extractPythonDictStringValue(expression, "icon"), + itemType: extractPythonDictStringValue(expression, "item_type"), + hidden: extractPythonDictBooleanValue(expression, "hidden"), + disabled: extractPythonDictBooleanValue(expression, "disabled"), + required: extractPythonDictBooleanValue(expression, "required"), + order: extractPythonDictNumberValue(expression, "order"), + min: extractPythonDictNumberValue(expression, "min"), + max: extractPythonDictNumberValue(expression, "max"), + step: extractPythonDictNumberValue(expression, "step"), + rows: extractPythonDictNumberValue(expression, "rows"), + minItems: extractPythonDictNumberValue(expression, "min_items"), + maxItems: extractPythonDictNumberValue(expression, "max_items"), + choices: extractPythonDictChoices(expression, "choices"), + }; +} + +function extractPythonDefaultValue( + expression: string, + annotation: string, +): MaiBotPluginConfigValue | undefined { + const rawDefault = extractPythonKeywordExpression(expression, "default"); + if (rawDefault !== undefined) { + return parsePythonLiteral(rawDefault); + } + + const factory = extractPythonIdentifierKeyword(expression, "default_factory")?.toLowerCase(); + if (factory === "list") { + return []; + } + if (factory === "dict") { + return {}; + } + return undefined; +} + +function extractPythonStringKeyword(expression: string, keyword: string): string | undefined { + const rawValue = extractPythonKeywordExpression(expression, keyword); + if (rawValue === undefined) { + return undefined; + } + return readPythonStringLiteral(rawValue, 0)?.value; +} + +function extractPythonIdentifierKeyword(expression: string, keyword: string): string | undefined { + const rawValue = extractPythonKeywordExpression(expression, keyword); + return rawValue?.trim().match(/^[A-Za-z_]\w*/u)?.[0]; +} + +function extractPythonKeywordExpression(expression: string, keyword: string): string | undefined { + const regex = new RegExp(`\\b${escapeRegExp(keyword)}\\s*=`, "u"); + const match = regex.exec(expression); + if (!match) { + return undefined; + } + return readPythonExpressionUntilComma(expression, match.index + match[0].length).trim(); +} + +function extractPythonDictStringValue(expression: string, key: string): string | undefined { + for (const rawValue of extractPythonDictExpressions(expression, key)) { + const literal = readPythonStringLiteral(rawValue, 0); + if (literal?.value) { + return literal.value; + } + } + return undefined; +} + +function extractPythonDictBooleanValue(expression: string, key: string): boolean | undefined { + for (const rawValue of extractPythonDictExpressions(expression, key)) { + const parsed = parsePythonLiteral(rawValue); + if (typeof parsed === "boolean") { + return parsed; + } + } + return undefined; +} + +function extractPythonDictNumberValue(expression: string, key: string): number | undefined { + for (const rawValue of extractPythonDictExpressions(expression, key)) { + const parsed = parsePythonLiteral(rawValue); + if (typeof parsed === "number") { + return parsed; + } + } + return undefined; +} + +function extractPythonDictChoices( + expression: string, + key: string, +): Array | undefined { + for (const rawValue of extractPythonDictExpressions(expression, key)) { + const parsed = parsePythonLiteral(rawValue); + if (Array.isArray(parsed)) { + return parsed; + } + } + return undefined; +} + +function extractPythonDictExpressions(expression: string, key: string): string[] { + const values: string[] = []; + const regex = new RegExp(`["']${escapeRegExp(key)}["']\\s*:`, "gu"); + let match: RegExpExecArray | null; + while ((match = regex.exec(expression)) !== null) { + values.push(readPythonExpressionUntilComma(expression, match.index + match[0].length).trim()); + } + return values; +} + +function extractLiteralChoices(annotation: string): MaiBotPluginConfigValue[] | undefined { + const literalMatch = annotation.match(/Literal\s*\[(.*)\]/u); + if (!literalMatch) { + return undefined; + } + const values: MaiBotPluginConfigValue[] = []; + const content = literalMatch[1]; + let index = 0; + while (index < content.length) { + const rawExpression = readPythonExpressionUntilComma(content, index); + const expression = rawExpression.trim(); + const parsed = parsePythonLiteral(expression); + if (parsed !== undefined) { + values.push(parsed); + } + index += rawExpression.length + 1; + } + return values.length > 0 ? values : undefined; +} + +function parsePythonLiteral(rawValue: string): MaiBotPluginConfigValue | undefined { + const value = rawValue.trim(); + if (!value) { + return undefined; + } + if (value === "True") { + return true; + } + if (value === "False") { + return false; + } + if (value === "None") { + return null; + } + + const stringLiteral = readPythonStringLiteral(value, 0); + if (stringLiteral && value.slice(stringLiteral.end).trim().length === 0) { + return stringLiteral.value; + } + + if (/^[-+]?\d+(?:\.\d+)?$/u.test(value)) { + return Number(value); + } + + if (value === "[]" || value.toLowerCase() === "list()") { + return []; + } + if (value === "{}" || value.toLowerCase() === "dict()") { + return {}; + } + if (value.startsWith("[") && value.endsWith("]")) { + return parsePythonListLiteral(value); + } + return undefined; +} + +function parsePythonListLiteral(value: string): MaiBotPluginConfigValue[] | undefined { + const content = value.slice(1, -1); + const values: MaiBotPluginConfigValue[] = []; + let index = 0; + while (index < content.length) { + const rawExpression = readPythonExpressionUntilComma(content, index); + const item = rawExpression.trim(); + if (item) { + const parsed = parsePythonLiteral(item); + if (parsed === undefined || isConfigRecord(parsed)) { + return undefined; + } + values.push(parsed); + } + index += rawExpression.length + 1; + } + return values; +} + +function readPythonExpressionUntilComma(text: string, startIndex: number): string { + let depth = 0; + for (let index = startIndex; index < text.length; index++) { + const stringEnd = findPythonStringEnd(text, index); + if (stringEnd > index) { + index = stringEnd - 1; + continue; + } + + const char = text[index]; + if (char === "(" || char === "[" || char === "{") { + depth++; + } else if (char === ")" || char === "]" || char === "}") { + if (depth === 0) { + return text.slice(startIndex, index); + } + depth--; + } else if (char === "," && depth === 0) { + return text.slice(startIndex, index); + } + } + return text.slice(startIndex); +} + +function findMatchingDelimiter(text: string, openIndex: number, open: string, close: string): number { + let depth = 0; + for (let index = openIndex; index < text.length; index++) { + const stringEnd = findPythonStringEnd(text, index); + if (stringEnd > index) { + index = stringEnd - 1; + continue; + } + + if (text[index] === open) { + depth++; + } else if (text[index] === close) { + depth--; + if (depth === 0) { + return index; + } + } + } + return -1; +} + +function readPythonStringLiteral(text: string, startIndex: number): { value: string; end: number } | null { + let index = skipWhitespace(text, startIndex); + while (/[rRuUbBfF]/u.test(text[index] ?? "") && (text[index + 1] === "\"" || text[index + 1] === "'")) { + index++; + } + + const quote = text[index]; + if (quote !== "\"" && quote !== "'") { + return null; + } + + const triple = text.slice(index, index + 3) === quote.repeat(3); + const contentStart = index + (triple ? 3 : 1); + let value = ""; + for (let cursor = contentStart; cursor < text.length; cursor++) { + if (triple && text.slice(cursor, cursor + 3) === quote.repeat(3)) { + return { value, end: cursor + 3 }; + } + if (!triple && text[cursor] === quote) { + return { value, end: cursor + 1 }; + } + if (text[cursor] === "\\" && cursor + 1 < text.length) { + value += decodePythonEscapedChar(text[cursor + 1]); + cursor++; + } else { + value += text[cursor]; + } + } + return null; +} + +function findPythonStringEnd(text: string, index: number): number { + const literal = readPythonStringLiteral(text, index); + return literal?.end ?? index; +} + +function decodePythonEscapedChar(char: string): string { + switch (char) { + case "n": + return "\n"; + case "r": + return "\r"; + case "t": + return "\t"; + default: + return char; + } +} + +function skipWhitespace(text: string, startIndex: number): number { + let index = startIndex; + while (/\s/u.test(text[index] ?? "")) { + index++; + } + return index; +} + +function extractPythonIdentifierTokens(value: string): string[] { + return [...value.matchAll(/[A-Za-z_]\w*/gu)].map((match) => match[0]); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + interface DashboardConfigFieldSchema { name?: unknown; type?: unknown; diff --git a/src/renderer/src/components/app/PluginMarketPanel.tsx b/src/renderer/src/components/app/PluginMarketPanel.tsx index 629419f..5acfba9 100644 --- a/src/renderer/src/components/app/PluginMarketPanel.tsx +++ b/src/renderer/src/components/app/PluginMarketPanel.tsx @@ -65,7 +65,7 @@ type InstalledPluginView = InstalledPlugin & { type DetailPlugin = MarketPlugin | InstalledPluginView; -type PluginRuntimeState = "disabled" | "failed" | "loaded" | "inactive"; +type PluginRuntimeState = "disabled" | "failed" | "loaded" | "inactive" | "loading"; type AdapterConfigPage = "connection" | "chat"; const HIDDEN_ADAPTER_CHAT_FIELDS = new Set([ "enable_chat_list_filter", @@ -930,15 +930,22 @@ function pluginRuntimeState(plugin: InstalledPluginView, maibotRunning: boolean) if (!maibotRunning) { return "inactive"; } + const loadStatus = plugin.load_status?.toLowerCase(); if (plugin.loaded === true) { return "loaded"; } - if (plugin.load_status === "success") { + if (loadStatus === "success") { return "loaded"; } - if (plugin.loaded === false || plugin.load_status === "failed") { + if (loadStatus === "failed") { return "failed"; } + if (loadStatus === "inactive") { + return "inactive"; + } + if (!loadStatus || loadStatus === "unknown" || loadStatus === "loading") { + return "loading"; + } return "inactive"; } @@ -946,6 +953,7 @@ function PluginRuntimeLight({ state }: { state: PluginRuntimeState }): React.JSX const meta = { disabled: { label: "未启用", className: "bg-muted-foreground/55" }, inactive: { label: "未加载", className: "bg-muted-foreground/55" }, + loading: { label: "加载中", className: "bg-sky-500" }, failed: { label: "加载失败", className: "bg-destructive" }, loaded: { label: "加载成功", className: "bg-emerald-500" }, }[state]; From a0b872862cbaeef359f12ba156bcc24e39825127 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 21 May 2026 00:01:59 +0800 Subject: [PATCH 03/32] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E6=97=A7?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E8=81=8A=E5=A4=A9=E8=B4=A6=E5=8F=B7=E5=86=99?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/init-manager.ts | 39 ++++--------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/src/main/services/init-manager.ts b/src/main/services/init-manager.ts index 919454b..c1bff4b 100644 --- a/src/main/services/init-manager.ts +++ b/src/main/services/init-manager.ts @@ -255,8 +255,6 @@ const NAPCAT_ADAPTER_HOST = "127.0.0.1"; const NAPCAT_ADAPTER_PORT = 7998; const SNOWLUMA_ONEBOT_PORT = 7988; const SNOWLUMA_WEBUI_PORT = 5099; -const LOCAL_CHAT_PLATFORM = "onekey-local-chat"; -const LOCAL_CHAT_BOT_ACCOUNT = "onekey-local-bot"; interface NapcatWebsocketServerConfig { host: string; @@ -506,25 +504,18 @@ function ensureBotQqConfig(content: string, account: string): string { return ensureBotPlatformConfig(content, { platform: "qq", qqAccount: account, - extraPlatformAccount: `${LOCAL_CHAT_PLATFORM}:${LOCAL_CHAT_BOT_ACCOUNT}`, - }); -} - -function ensureLocalChatBotConfig(content: string): string { - return ensureBotPlatformConfig(content, { - extraPlatformAccount: `${LOCAL_CHAT_PLATFORM}:${LOCAL_CHAT_BOT_ACCOUNT}`, }); } function ensureBotPlatformConfig( content: string, - options: { platform?: string; qqAccount?: string; extraPlatformAccount: string }, + options: { platform?: string; qqAccount?: string }, ): string { const botSectionMatch = content.match(/(^|\r?\n)(\s*\[bot\]\s*(?:#.*)?)(?:\r?\n|$)/u); if (!botSectionMatch) { const platformLine = options.platform ? `platform = "${options.platform}"\n` : ""; const qqAccountLine = options.qqAccount ? `qq_account = ${options.qqAccount}\n` : ""; - return `${content.trimEnd()}\n\n[bot]\n${platformLine}${qqAccountLine}platforms = [\n "${options.extraPlatformAccount}",\n]\n`; + return `${content.trimEnd()}\n\n[bot]\n${platformLine}${qqAccountLine}`; } const botSectionStart = (botSectionMatch.index ?? 0) + botSectionMatch[0].length; @@ -560,24 +551,6 @@ function ensureBotPlatformConfig( } } - const platformsMatch = nextBotSection.match(/(^|\r?\n)(\s*platforms\s*=\s*)(\[[\s\S]*?\])(\s*(?:#.*)?)(?=\r?\n|$)/u); - const platformEntries = platformsMatch - ? Array.from(platformsMatch[3].matchAll(/["']([^"']+)["']/gu), (match) => match[1]) - : []; - const nextPlatformEntries = [ - ...platformEntries.filter((entry) => { - const [platformName] = entry.split(":", 1); - return platformName.trim().toLowerCase() !== LOCAL_CHAT_PLATFORM; - }), - options.extraPlatformAccount, - ]; - if (platformsMatch) { - const nextListBody = nextPlatformEntries.map((entry) => ` "${entry}",`).join("\n"); - nextBotSection = `${nextBotSection.slice(0, platformsMatch.index)}${platformsMatch[1] ?? ""}${platformsMatch[2]}[\n${nextListBody}\n]${platformsMatch[4]}${nextBotSection.slice((platformsMatch.index ?? 0) + platformsMatch[0].length)}`; - } else { - nextBotSection = `${nextBotSection.trimEnd()}\nplatforms = [\n "${options.extraPlatformAccount}",\n]\n`; - } - return `${beforeBotSection}${nextBotSection}${afterBotSection}`; } @@ -2002,11 +1975,11 @@ export class InitManager { const botConfigPath = this.botConfigPath(); const configVersion = maibotInitialConfigVersion(await this.readMaiBotConfigVersion()); if (!existsSync(botConfigPath)) { - return ensureLocalChatBotConfig(`[inner]\nversion = "${configVersion}"\n\n[bot]\nplatform = "qq"\n`); + return `[inner]\nversion = "${configVersion}"\n\n[bot]\nplatform = "qq"\n`; } const content = await readFile(botConfigPath, "utf8"); - return ensureLocalChatBotConfig(ensureInnerVersion(content, configVersion)); + return ensureInnerVersion(content, configVersion); } private async repairBotConfigVersionInfo(): Promise { @@ -2016,9 +1989,7 @@ export class InitManager { } const content = await readFile(botConfigPath, "utf8"); - const repaired = ensureLocalChatBotConfig( - ensureInnerVersion(content, maibotInitialConfigVersion(await this.readMaiBotConfigVersion())), - ); + const repaired = ensureInnerVersion(content, maibotInitialConfigVersion(await this.readMaiBotConfigVersion())); if (repaired === content) { return undefined; } From 174a87103853a2a287a2720dd4b7aaffe6c33513 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 21 May 2026 00:02:27 +0800 Subject: [PATCH 04/32] =?UTF-8?q?fix:=20=E7=A8=B3=E5=AE=9A=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E5=B0=BA=E5=AF=B8=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/pty/pty-session-manager.ts | 3 +++ src/main/services/service-manager.ts | 2 +- src/renderer/src/components/app/TerminalPanel.tsx | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/pty/pty-session-manager.ts b/src/main/pty/pty-session-manager.ts index 3dd04e6..8d96f80 100644 --- a/src/main/pty/pty-session-manager.ts +++ b/src/main/pty/pty-session-manager.ts @@ -330,6 +330,9 @@ class PtySession { resize(request: Omit): void { const cols = clampDimension(request.cols, this.snapshot.cols); const rows = normalizeRows(request.rows); + if (cols === this.snapshot.cols && rows === this.snapshot.rows) { + return; + } this.snapshot = { ...this.snapshot, diff --git a/src/main/services/service-manager.ts b/src/main/services/service-manager.ts index b2e6df9..ec435eb 100644 --- a/src/main/services/service-manager.ts +++ b/src/main/services/service-manager.ts @@ -104,7 +104,7 @@ const STOP_FORCE_AFTER_MS = 10_000; const WATCHDOG_INTERVAL_MS = 5_000; const MAX_RESTART_ATTEMPTS = 3; const RESTART_DELAY_MS = 2_500; -const SERVICE_TERMINAL_COLS = 120; +const SERVICE_TERMINAL_COLS = 260; const SERVICE_TERMINAL_ROWS = 36; const COMMAND_CONFIG_FILE = "service-commands.json"; const RUNTIME_PATH_CONFIG_FILE = "runtime-paths.json"; diff --git a/src/renderer/src/components/app/TerminalPanel.tsx b/src/renderer/src/components/app/TerminalPanel.tsx index 7ee60ff..d654979 100644 --- a/src/renderer/src/components/app/TerminalPanel.tsx +++ b/src/renderer/src/components/app/TerminalPanel.tsx @@ -295,6 +295,11 @@ export function TerminalPanel({ }); }), terminal.onResize(({ cols, rows }) => { + const currentSession = sessionsRef.current.get(sessionId); + if (currentSession?.cols === cols && currentSession.rows === rows) { + return; + } + const pane = panesRef.current.get(sessionId); const rect = pane?.getBoundingClientRect(); if ( From 8b84ba5d27b0801773a0f2182213c447488ade12 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 21 May 2026 00:03:20 +0800 Subject: [PATCH 05/32] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=99=A8=E4=BB=A3=E7=90=86=E6=9B=B4=E6=96=B0=E5=92=8C?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 9 +- src/main/ipc/app.ts | 260 ++++++++++++++++-- src/main/services/network-proxy-manager.ts | 168 +++++++++++ src/preload/index.ts | 10 + .../src/components/app/DesktopShell.tsx | 173 +++++++++++- src/renderer/src/components/app/HomePanel.tsx | 74 +++++ .../components/app/SettingsStatusPanel.tsx | 104 +++++++ src/renderer/src/lib/desktop-api.ts | 7 + src/shared/contracts.ts | 22 ++ 9 files changed, 796 insertions(+), 31 deletions(-) create mode 100644 src/main/services/network-proxy-manager.ts diff --git a/src/main/index.ts b/src/main/index.ts index 3922c4c..a48db06 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,7 @@ import { InitManager } from "./services/init-manager"; import { acquireInstallInstanceLock } from "./services/instance-lock"; import { LogStore } from "./services/log-store"; import { ModuleUpdater } from "./services/module-updater"; +import { NetworkProxyManager } from "./services/network-proxy-manager"; import { configureRuntimePaths } from "./services/paths"; import { PythonDependencyManager } from "./services/python-dependency-manager"; import { ResourceLocationManager } from "./services/resource-location-manager"; @@ -20,6 +21,7 @@ const resourceLock = instanceLock.acquired : { acquired: true }; const logStore = new LogStore(runtimePaths); const initManager = new InitManager(runtimePaths); +const networkProxyManager = new NetworkProxyManager(runtimePaths); const moduleUpdater = new ModuleUpdater(runtimePaths, initManager); const pythonDependencyManager = new PythonDependencyManager(runtimePaths, initManager); const ptySessionManager = new PtySessionManager(); @@ -72,6 +74,7 @@ function createMainWindow(): BrowserWindow { height: 820, minWidth: 1080, minHeight: 720, + resizable: true, show: false, backgroundColor: "#00000000", transparent: true, @@ -202,7 +205,10 @@ if (!instanceLock.acquired || !resourceLock.acquired) { ptySessionManager.dispose(); app.quit(); } else { - app.whenReady().then(() => { + app.whenReady().then(async () => { + await networkProxyManager.applyStoredSettings().catch((error: unknown) => { + logStore.append("desktop", "system", `network proxy apply failed: ${String(error)}`); + }); mainWindow = createMainWindow(); tray = createTray(); @@ -210,6 +216,7 @@ if (!instanceLock.acquired || !resourceLock.acquired) { paths: runtimePaths, initManager, moduleUpdater, + networkProxyManager, pythonDependencyManager, resourceLocationManager, serviceManager, diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 457a14f..49fe9fa 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -32,6 +32,7 @@ import type { ManagedPythonPackageName, ModuleRuntimeVersions, ModuleUpdateResult, + NetworkProxySettings, ModuleSourceConfig, ModuleSourceUpdate, ModuleTagOption, @@ -56,6 +57,7 @@ import type { StartupAgreementConfirmResult, StartupAgreementState, TerminalSettings, + WindowResizeEdge, WindowState, } from "../../shared/contracts"; import { InitManager } from "../services/init-manager"; @@ -63,6 +65,7 @@ import { LogStore } from "../services/log-store"; import { LocalChatAdapter } from "../services/local-chat-adapter"; import { MaiBotPluginClient } from "../services/maibot-plugin-client"; import { ModuleUpdater } from "../services/module-updater"; +import { NetworkProxyManager } from "../services/network-proxy-manager"; import { PythonDependencyManager } from "../services/python-dependency-manager"; import { ResourceLocationManager } from "../services/resource-location-manager"; import { ServiceManager } from "../services/service-manager"; @@ -77,6 +80,7 @@ const LAUNCHER_SETTING_FILES = [ "message-platform.json", "module-sources.json", "python-dependency-source.json", + "network-proxy.json", ]; const LAUNCHER_RUNTIME_DIRECTORIES = ["modules", "python-overrides", "logs"]; const RETIRED_ENTRY_DIRECTORY = ".reset-pending-delete"; @@ -86,11 +90,24 @@ const FLOATING_BALL_SIZE = { width: 96, height: 96 }; const FLOATING_PANEL_SIZE = { width: 380, height: 520 }; const FLOATING_STRIP_SIZE = { width: 28, height: 112 }; const FLOATING_EDGE_SNAP_DISTANCE = 18; +const WINDOW_RESIZE_EDGES = new Set([ + "top", + "right", + "bottom", + "left", + "top-left", + "top-right", + "bottom-right", + "bottom-left", +]); +const ONEKEY_REPOSITORY_URL = "https://github.com/DrSmoothl/MaiBotOneKey.git"; +const ONEKEY_TAGS_API_URL = "https://api.github.com/repos/DrSmoothl/MaiBotOneKey/tags?per_page=100"; interface RegisterAppIpcOptions { paths: RuntimePaths; initManager: InitManager; moduleUpdater: ModuleUpdater; + networkProxyManager: NetworkProxyManager; pythonDependencyManager: PythonDependencyManager; resourceLocationManager: ResourceLocationManager; serviceManager: ServiceManager; @@ -100,6 +117,13 @@ interface RegisterAppIpcOptions { showMainWindow: () => void; } +interface WindowResizeState { + edge: WindowResizeEdge; + startScreenX: number; + startScreenY: number; + bounds: Electron.Rectangle; +} + export interface RegisteredAppIpcDisposables { localChatAdapter: LocalChatAdapter; dispose: () => void; @@ -381,6 +405,14 @@ function pickLatestTags( }; } +function pickLatestVersionTag(rawTags: string[]): string | undefined { + return rawTags + .map(parseVersionTag) + .filter((tag): tag is ParsedVersionTag => Boolean(tag)) + .sort(compareParsedTags) + .at(-1)?.tag; +} + function parsePackageVersion(version: string): ParsedVersionTag | undefined { const normalized = version.replace(/^v/iu, ""); const match = normalized.match(/^(\d+(?:\.\d+){0,3})(?:(?:[-._]?(?:dev|a|alpha|b|beta|rc|pre|preview))\d*)?/iu); @@ -461,6 +493,10 @@ function readStatisticCount(text: string, label: string): number | undefined { return value ? Number(value) : undefined; } +function isStatisticStyleLine(line: string): boolean { + return /^[a-z][\w-]*\s*:\s*[-\w.%#()'",\s]+$/iu.test(line); +} + function parseChatStatistics(text: string): MaiBotStatisticSummary["chatStats"] { const lines = text.split("\n").map((line) => line.trim()).filter(Boolean); const startIndex = lines.findIndex((line) => line.includes("聊天消息统计")); @@ -470,6 +506,9 @@ function parseChatStatistics(text: string): MaiBotStatisticSummary["chatStats"] const stats: MaiBotStatisticSummary["chatStats"] = []; for (const line of lines.slice(startIndex + 1)) { + if (isStatisticStyleLine(line)) { + continue; + } if (line.startsWith("-") || line.includes("Token/") || line.includes("花费/")) { break; } @@ -480,7 +519,11 @@ function parseChatStatistics(text: string): MaiBotStatisticSummary["chatStats"] if (!match) { continue; } - stats.push({ name: match[1].trim(), messageCount: Number(match[2]) }); + const name = match[1].trim(); + if (name.endsWith(":")) { + continue; + } + stats.push({ name, messageCount: Number(match[2]) }); } return stats; } @@ -526,6 +569,7 @@ export function registerAppIpc({ paths, initManager, moduleUpdater, + networkProxyManager, pythonDependencyManager, resourceLocationManager, serviceManager, @@ -535,12 +579,14 @@ export function registerAppIpc({ showMainWindow, }: RegisterAppIpcOptions): RegisteredAppIpcDisposables { let remoteModuleVersionsCache: ModuleRuntimeVersions = {}; + let remoteAppVersionCache: Pick = {}; let remoteModuleVersionsRefreshPromise: Promise | null = null; let initDependencyRefreshPromise: Promise | null = null; let floatingMode = false; let floatingPanelExpanded = false; let floatingEdgeSide: "left" | "right" | null = null; let normalBounds: Electron.Rectangle | null = null; + let resizeState: WindowResizeState | null = null; const sendWindowState = (window: BrowserWindow | null): WindowState => { const state = readWindowState(window, floatingMode, floatingEdgeSide); @@ -569,6 +615,96 @@ export function registerAppIpc({ }; }; + const activeFloatingSize = (): { width: number; height: number } => + floatingEdgeSide + ? FLOATING_STRIP_SIZE + : floatingPanelExpanded + ? FLOATING_PANEL_SIZE + : FLOATING_BALL_SIZE; + + const withActiveFloatingSize = (bounds: Electron.Rectangle): Electron.Rectangle => { + const size = activeFloatingSize(); + return { + x: bounds.x, + y: bounds.y, + width: size.width, + height: size.height, + }; + }; + + const restoreNormalWindowChrome = (window: BrowserWindow): void => { + window.setResizable(true); + window.setMaximizable(true); + window.setMinimumSize(NORMAL_MINIMUM_SIZE.width, NORMAL_MINIMUM_SIZE.height); + }; + + const startWindowResize = ( + edge: WindowResizeEdge, + screenX: number, + screenY: number, + ): WindowState => { + const window = getMainWindow(); + if (!window || window.isDestroyed()) { + return readWindowState(window, floatingMode, floatingEdgeSide); + } + if (floatingMode || window.isMaximized() || window.isFullScreen() || !WINDOW_RESIZE_EDGES.has(edge)) { + return sendWindowState(window); + } + + restoreNormalWindowChrome(window); + resizeState = { + edge, + startScreenX: Math.round(screenX), + startScreenY: Math.round(screenY), + bounds: window.getBounds(), + }; + return sendWindowState(window); + }; + + const resizeWindowTo = (screenX: number, screenY: number): WindowState => { + const window = getMainWindow(); + if (!window || window.isDestroyed()) { + resizeState = null; + return readWindowState(window, floatingMode, floatingEdgeSide); + } + if (!resizeState || floatingMode || window.isMaximized() || window.isFullScreen()) { + return sendWindowState(window); + } + + const deltaX = Math.round(screenX) - resizeState.startScreenX; + const deltaY = Math.round(screenY) - resizeState.startScreenY; + const { edge, bounds } = resizeState; + const minWidth = NORMAL_MINIMUM_SIZE.width; + const minHeight = NORMAL_MINIMUM_SIZE.height; + let x = bounds.x; + let y = bounds.y; + let width = bounds.width; + let height = bounds.height; + + if (edge === "right" || edge.endsWith("-right")) { + width = Math.max(minWidth, bounds.width + deltaX); + } + if (edge === "left" || edge.endsWith("-left")) { + width = Math.max(minWidth, bounds.width - deltaX); + x = bounds.x + bounds.width - width; + } + if (edge === "bottom" || edge.startsWith("bottom-")) { + height = Math.max(minHeight, bounds.height + deltaY); + } + if (edge === "top" || edge.startsWith("top-")) { + height = Math.max(minHeight, bounds.height - deltaY); + y = bounds.y + bounds.height - height; + } + + window.setBounds({ x, y, width, height }, false); + return sendWindowState(window); + }; + + const finishWindowResize = (): WindowState => { + resizeState = null; + return sendWindowState(getMainWindow()); + }; + const applyFloatingMode = (enabled: boolean): WindowState => { const window = getMainWindow(); if (!window || window.isDestroyed()) { @@ -576,6 +712,7 @@ export function registerAppIpc({ } if (enabled && !floatingMode) { + resizeState = null; normalBounds = window.getBounds(); if (window.isMaximized()) { window.unmaximize(); @@ -583,7 +720,7 @@ export function registerAppIpc({ floatingMode = true; floatingPanelExpanded = false; floatingEdgeSide = null; - window.setMinimumSize(72, 72); + window.setMinimumSize(1, 1); window.setResizable(false); window.setAlwaysOnTop(true, "floating"); window.setBounds(floatingBounds(window, FLOATING_BALL_SIZE), true); @@ -592,14 +729,17 @@ export function registerAppIpc({ return sendWindowState(window); } - if (!enabled && floatingMode) { + if (!enabled) { + resizeState = null; + const shouldRestoreBounds = floatingMode; floatingMode = false; floatingPanelExpanded = false; floatingEdgeSide = null; window.setAlwaysOnTop(false); - window.setResizable(true); - window.setMinimumSize(NORMAL_MINIMUM_SIZE.width, NORMAL_MINIMUM_SIZE.height); - window.setBounds(normalBounds ?? { x: 80, y: 80, width: 1280, height: 820 }, true); + restoreNormalWindowChrome(window); + if (shouldRestoreBounds) { + window.setBounds(normalBounds ?? { x: 80, y: 80, width: 1280, height: 820 }, true); + } normalBounds = null; window.show(); window.focus(); @@ -617,6 +757,9 @@ export function registerAppIpc({ if (!floatingMode) { return sendWindowState(window); } + if (floatingPanelExpanded === expanded && (expanded || !floatingEdgeSide)) { + return sendWindowState(window); + } floatingPanelExpanded = expanded; floatingEdgeSide = null; const currentBounds = window.getBounds(); @@ -658,9 +801,10 @@ export function registerAppIpc({ const bounds = window.getBounds(); window.setBounds( clampFloatingBounds({ - ...bounds, x: bounds.x + Math.round(deltaX), y: bounds.y + Math.round(deltaY), + width: activeFloatingSize().width, + height: activeFloatingSize().height, }), false, ); @@ -692,22 +836,24 @@ export function registerAppIpc({ ); } - const bounds = window.getBounds(); + const size = activeFloatingSize(); + const cursorPoint = screen.getCursorScreenPoint(); const safeOffsetX = wasEdgeDocked && !floatingPanelExpanded ? Math.round(FLOATING_BALL_SIZE.width / 2) - : Math.min(Math.max(Math.round(offsetX), 0), bounds.width); + : Math.min(Math.max(Math.round(offsetX), 0), size.width); const safeOffsetY = wasEdgeDocked && !floatingPanelExpanded ? Math.round(FLOATING_BALL_SIZE.height / 2) - : Math.min(Math.max(Math.round(offsetY), 0), bounds.height); + : Math.min(Math.max(Math.round(offsetY), 0), size.height); window.setBounds( clampFloatingBounds({ - ...bounds, - x: Math.round(screenX) - safeOffsetX, - y: Math.round(screenY) - safeOffsetY, + x: Math.round(cursorPoint.x) - safeOffsetX, + y: Math.round(cursorPoint.y) - safeOffsetY, + width: size.width, + height: size.height, }), false, ); - return sendWindowState(window); + return readWindowState(window, floatingMode, floatingEdgeSide); }; const finishFloatingDrag = (): WindowState => { @@ -719,7 +865,7 @@ export function registerAppIpc({ return sendWindowState(window); } - const currentBounds = window.getBounds(); + const currentBounds = withActiveFloatingSize(window.getBounds()); const display = screen.getDisplayMatching(currentBounds); const workArea = display.workArea; const isNearLeft = currentBounds.x <= workArea.x + FLOATING_EDGE_SNAP_DISTANCE; @@ -744,7 +890,7 @@ export function registerAppIpc({ } floatingEdgeSide = null; - window.setBounds(clampFloatingBounds(currentBounds), true); + window.setBounds(clampFloatingBounds(withActiveFloatingSize(currentBounds)), true); return sendWindowState(window); }; @@ -806,6 +952,51 @@ export function registerAppIpc({ return versions; }; + const readRemoteAppVersion = async (): Promise> => { + const gitPath = initManager.getGitPath(); + if (existsSync(gitPath)) { + const tagsOutput = await runProcess( + gitPath, + ["ls-remote", "--tags", "--refs", ONEKEY_REPOSITORY_URL], + paths.installRoot, + ); + const tags = tagsOutput + ?.split(/\r?\n/u) + .map((line) => line.match(/refs\/tags\/(.+)$/u)?.[1]) + .filter((tag): tag is string => Boolean(tag)) ?? []; + const latestTag = pickLatestVersionTag(Array.from(new Set(tags))); + if (latestTag) { + return { + appLatestTag: latestTag, + appLatestSource: "DrSmoothl/MaiBotOneKey", + }; + } + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const response = await fetch(ONEKEY_TAGS_API_URL, { signal: controller.signal }); + if (!response.ok) { + return {}; + } + const data = (await response.json()) as Array<{ name?: unknown }>; + const latestTag = pickLatestVersionTag( + data.map((tag) => typeof tag.name === "string" ? tag.name : "").filter(Boolean), + ); + return latestTag + ? { + appLatestTag: latestTag, + appLatestSource: "DrSmoothl/MaiBotOneKey", + } + : {}; + } catch { + return {}; + } finally { + clearTimeout(timeout); + } + }; + const readModuleVersions = async (): Promise => ({ ...remoteModuleVersionsCache, ...(await readLocalModuleVersions()), @@ -818,7 +1009,10 @@ export function registerAppIpc({ runtimePathConfigs: serviceManager.getRuntimePathConfigs(), runtimeResourcePathConfigs: resourceLocationManager.getPathConfigs(), terminalSettings: serviceManager.getTerminalSettings(), + networkProxySettings: networkProxyManager.getSettings(), appVersion: app.getVersion(), + appLatestTag: remoteAppVersionCache.appLatestTag, + appLatestSource: remoteAppVersionCache.appLatestSource, moduleVersions: await readModuleVersions(), platform: process.platform, windowState: readWindowState(getMainWindow(), floatingMode, floatingEdgeSide), @@ -839,9 +1033,10 @@ export function registerAppIpc({ if (remoteModuleVersionsRefreshPromise) { return; } - remoteModuleVersionsRefreshPromise = readRemoteModuleVersions() - .then(async (versions) => { + remoteModuleVersionsRefreshPromise = Promise.all([readRemoteModuleVersions(), readRemoteAppVersion()]) + .then(async ([versions, appVersion]) => { remoteModuleVersionsCache = versions; + remoteAppVersionCache = appVersion; await broadcastSnapshot(); }) .catch((error: unknown) => { @@ -916,6 +1111,7 @@ export function registerAppIpc({ await serviceManager.resetRuntimePathConfig("python"); await serviceManager.resetRuntimePathConfig("git"); await serviceManager.saveTerminalSettings({ ...serviceManager.getTerminalSettings(), useEmbeddedTerminal: true }); + await networkProxyManager.resetSettings(); for (const key of ["maibot", "napcat"] as const) { const config = resourceLocationManager.getPathConfigs().find((item) => item.key === key); @@ -1216,6 +1412,22 @@ export function registerAppIpc({ return resetLauncherAll(); }); + ipcMain.handle( + "launcher:saveNetworkProxySettings", + async (_event, settings: NetworkProxySettings): Promise => { + const result = await networkProxyManager.saveSettings(settings); + logStore.append( + "desktop", + "system", + result.enabled + ? `\u7f51\u7edc\u4ee3\u7406\u5df2\u542f\u7528: 127.0.0.1:${result.port}` + : "\u7f51\u7edc\u4ee3\u7406\u5df2\u5173\u95ed", + ); + await broadcastSnapshot(); + return result; + }, + ); + ipcMain.handle("plugins:listMarket", async ( _event, serviceUrl?: string, @@ -1548,6 +1760,18 @@ export function registerAppIpc({ ipcMain.handle("desktop:window:finishFloatingDrag", (): WindowState => finishFloatingDrag()); + ipcMain.handle( + "desktop:window:startResize", + (_event, edge: WindowResizeEdge, screenX: number, screenY: number): WindowState => + startWindowResize(edge, screenX, screenY), + ); + + ipcMain.handle("desktop:window:resizeTo", (_event, screenX: number, screenY: number): WindowState => + resizeWindowTo(screenX, screenY), + ); + + ipcMain.handle("desktop:window:finishResize", (): WindowState => finishWindowResize()); + ipcMain.handle("desktop:window:getState", (): WindowState => readWindowState(getMainWindow(), floatingMode, floatingEdgeSide), ); diff --git a/src/main/services/network-proxy-manager.ts b/src/main/services/network-proxy-manager.ts new file mode 100644 index 0000000..75fd61b --- /dev/null +++ b/src/main/services/network-proxy-manager.ts @@ -0,0 +1,168 @@ +import { app, net, session } from "electron"; +import { readFileSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { NetworkProxySettings, RuntimePaths } from "../../shared/contracts"; + +const NETWORK_PROXY_CONFIG_FILE = "network-proxy.json"; +const DEFAULT_PROXY_PORT = 7890; +const ELECTRON_PROXY_BYPASS_RULES = "localhost,127.0.0.1,::1,"; +const PROXY_ENV_BYPASS_RULES = "localhost,127.0.0.1,::1"; +const PROXY_ENV_KEYS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +] as const; +const FORWARDED_PROXY_ENV_KEYS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", +] as const; + +export class NetworkProxyManager { + private readonly configPath: string; + private readonly originalProxyEnv = new Map(); + private cache: NetworkProxySettings; + private fetchPatched = false; + + constructor(paths: RuntimePaths) { + this.configPath = join(paths.userDataRoot, NETWORK_PROXY_CONFIG_FILE); + for (const key of PROXY_ENV_KEYS) { + this.originalProxyEnv.set(key, process.env[key]); + } + this.cache = this.read(); + this.applyEnvironment(this.cache); + } + + getSettings(): NetworkProxySettings { + return { ...this.cache }; + } + + async applyStoredSettings(): Promise { + this.installFetchHook(); + this.applyEnvironment(this.cache); + await this.applyElectronProxy(this.cache); + return this.getSettings(); + } + + async saveSettings(settings: NetworkProxySettings): Promise { + const normalized = normalizeNetworkProxySettings(settings); + await mkdir(dirname(this.configPath), { recursive: true }); + await writeFile( + this.configPath, + `${JSON.stringify(normalized, null, 2)}\n`, + "utf8", + ); + this.cache = normalized; + await this.applyStoredSettings(); + return this.getSettings(); + } + + async resetSettings(): Promise { + this.cache = defaultNetworkProxySettings(); + await this.applyStoredSettings(); + return this.getSettings(); + } + + private read(): NetworkProxySettings { + try { + const raw = JSON.parse(readFileSync(this.configPath, "utf8")) as Partial; + return normalizeNetworkProxySettings(raw); + } catch { + return defaultNetworkProxySettings(); + } + } + + private installFetchHook(): void { + if (this.fetchPatched || !app.isReady() || typeof globalThis.fetch !== "function") { + return; + } + + const originalFetch = globalThis.fetch.bind(globalThis); + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof URL ? input.toString() : input; + if (typeof request === "string" || isRequestLike(request)) { + return net.fetch(request, init) as unknown as Promise; + } + return originalFetch(input, init); + }) as typeof fetch; + this.fetchPatched = true; + } + + private async applyElectronProxy(settings: NetworkProxySettings): Promise { + if (!app.isReady()) { + return; + } + + const defaultSession = session.defaultSession; + if (settings.enabled) { + await defaultSession.setProxy({ + mode: "fixed_servers", + proxyRules: localProxyUrl(settings.port), + proxyBypassRules: ELECTRON_PROXY_BYPASS_RULES, + }); + } else { + await defaultSession.setProxy({ mode: "system" }); + } + await defaultSession.closeAllConnections(); + } + + private applyEnvironment(settings: NetworkProxySettings): void { + if (settings.enabled) { + const proxyUrl = localProxyUrl(settings.port); + for (const key of FORWARDED_PROXY_ENV_KEYS) { + process.env[key] = proxyUrl; + } + process.env.NO_PROXY = PROXY_ENV_BYPASS_RULES; + process.env.no_proxy = PROXY_ENV_BYPASS_RULES; + return; + } + + for (const key of PROXY_ENV_KEYS) { + const originalValue = this.originalProxyEnv.get(key); + if (originalValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalValue; + } + } + } +} + +function defaultNetworkProxySettings(): NetworkProxySettings { + return { + enabled: false, + port: DEFAULT_PROXY_PORT, + }; +} + +function normalizeNetworkProxySettings(value: Partial): NetworkProxySettings { + return { + enabled: value.enabled === true, + port: normalizeProxyPort(value.port ?? DEFAULT_PROXY_PORT), + }; +} + +function normalizeProxyPort(value: unknown): number { + const port = typeof value === "number" ? value : Number(String(value).trim()); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("\u4ee3\u7406\u7aef\u53e3\u9700\u8981\u662f 1-65535 \u4e4b\u95f4\u7684\u6574\u6570\u3002"); + } + return port; +} + +function localProxyUrl(port: number): string { + return `http://127.0.0.1:${normalizeProxyPort(port)}`; +} + +function isRequestLike(value: unknown): value is Request { + return typeof value === "object" && value !== null && "url" in value && "method" in value; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index bf72726..84d3577 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -29,6 +29,7 @@ import type { MaiBotStatisticSummary, ManagedPythonPackageName, ModuleUpdateResult, + NetworkProxySettings, ModuleSourceConfig, ModuleSourceUpdate, ModuleTagOption, @@ -61,6 +62,7 @@ import type { StartupAgreementConfirmResult, StartupAgreementState, TerminalSettings, + WindowResizeEdge, WindowState, } from "../shared/contracts"; @@ -104,6 +106,12 @@ const desktopBridge: DesktopBridge = { ipcRenderer.invoke("desktop:window:moveFloatingTo", screenX, screenY, offsetX, offsetY) as Promise, finishFloatingDrag: () => ipcRenderer.invoke("desktop:window:finishFloatingDrag") as Promise, + startResize: (edge: WindowResizeEdge, screenX: number, screenY: number) => + ipcRenderer.invoke("desktop:window:startResize", edge, screenX, screenY) as Promise, + resizeTo: (screenX: number, screenY: number) => + ipcRenderer.invoke("desktop:window:resizeTo", screenX, screenY) as Promise, + finishResize: () => + ipcRenderer.invoke("desktop:window:finishResize") as Promise, getState: () => ipcRenderer.invoke("desktop:window:getState") as Promise, onState: (callback: (state: WindowState) => void) => onIpc("desktop:window-state", callback), }, @@ -140,6 +148,8 @@ const desktopBridge: DesktopBridge = { ipcRenderer.invoke("data:resetMaibotData") as Promise, }, launcher: { + saveNetworkProxySettings: (settings: NetworkProxySettings) => + ipcRenderer.invoke("launcher:saveNetworkProxySettings", settings) as Promise, resetSettings: () => ipcRenderer.invoke("launcher:resetSettings") as Promise, resetAll: () => diff --git a/src/renderer/src/components/app/DesktopShell.tsx b/src/renderer/src/components/app/DesktopShell.tsx index 0331682..487667b 100644 --- a/src/renderer/src/components/app/DesktopShell.tsx +++ b/src/renderer/src/components/app/DesktopShell.tsx @@ -12,12 +12,13 @@ Square, TerminalSquare, } from "lucide-react"; -import { type PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type MouseEvent, type PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DesktopSnapshot, ServiceDescriptor, ServiceId, ServiceStatus, + WindowResizeEdge, } from "@shared/contracts"; import { getDesktopSnapshot, normalizeDesktopSnapshot } from "@/lib/desktop-api"; import { useShortcut } from "@/lib/use-shortcut"; @@ -64,6 +65,76 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +const resizeHandles: Array<{ edge: WindowResizeEdge; className: string }> = [ + { edge: "top", className: "inset-x-3 top-0 h-1.5 cursor-ns-resize" }, + { edge: "right", className: "inset-y-3 right-0 w-1.5 cursor-ew-resize" }, + { edge: "bottom", className: "inset-x-3 bottom-0 h-1.5 cursor-ns-resize" }, + { edge: "left", className: "inset-y-3 left-0 w-1.5 cursor-ew-resize" }, + { edge: "top-left", className: "left-0 top-0 size-3 cursor-nwse-resize" }, + { edge: "top-right", className: "right-0 top-0 size-3 cursor-nesw-resize" }, + { edge: "bottom-right", className: "bottom-0 right-0 size-3 cursor-nwse-resize" }, + { edge: "bottom-left", className: "bottom-0 left-0 size-3 cursor-nesw-resize" }, +]; + +function WindowResizeHandles(): React.JSX.Element { + const resizingRef = useRef(false); + const pointerIdRef = useRef(null); + + const startResize = useCallback((edge: WindowResizeEdge, event: PointerEvent) => { + const bridge = window.maibotDesktop?.window; + if (!bridge) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + resizingRef.current = true; + pointerIdRef.current = event.pointerId; + event.currentTarget.setPointerCapture(event.pointerId); + void bridge.startResize(edge, event.screenX, event.screenY); + }, []); + + const resize = useCallback((event: PointerEvent) => { + if (!resizingRef.current || pointerIdRef.current !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + void window.maibotDesktop?.window.resizeTo(event.screenX, event.screenY); + }, []); + + const finishResize = useCallback((event: PointerEvent) => { + if (!resizingRef.current || pointerIdRef.current !== event.pointerId) { + return; + } + + resizingRef.current = false; + pointerIdRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + event.preventDefault(); + event.stopPropagation(); + void window.maibotDesktop?.window.finishResize(); + }, []); + + return ( +
+ {resizeHandles.map((handle) => ( +
startResize(handle.edge, event)} + onPointerMove={resize} + onPointerUp={finishResize} + /> + ))} +
+ ); +} + function ServiceChip({ service, busy, @@ -172,9 +243,67 @@ function FloatingShell({ moved: boolean; pointerId: number; } | null>(null); + const dragRequestPendingRef = useRef(false); + const dragPointRef = useRef<{ + clientX: number; + clientY: number; + screenX: number; + screenY: number; + } | null>(null); + const dragFrameRef = useRef(null); + const suppressNextClickRef = useRef(false); const updateFloatingState = useCallback(() => undefined, []); + const suppressNextClickBriefly = useCallback(() => { + suppressNextClickRef.current = true; + window.setTimeout(() => { + suppressNextClickRef.current = false; + }, 250); + }, []); + + const expandFromClick = useCallback((event: MouseEvent) => { + if (suppressNextClickRef.current) { + event.preventDefault(); + event.stopPropagation(); + return; + } + onExpand(); + }, [onExpand]); + + const flushDragMove = useCallback(() => { + if (dragFrameRef.current !== null) { + window.cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + } + if (dragRequestPendingRef.current) { + return; + } + const point = dragPointRef.current; + if (!point) { + return; + } + dragPointRef.current = null; + dragRequestPendingRef.current = true; + void window.maibotDesktop?.window + .moveFloatingTo(point.screenX, point.screenY, point.clientX, point.clientY) + .then(updateFloatingState) + .finally(() => { + dragRequestPendingRef.current = false; + flushDragMove(); + }); + }, [updateFloatingState]); + + const scheduleDragMove = useCallback(() => { + if (dragFrameRef.current !== null) { + return; + } + dragFrameRef.current = window.requestAnimationFrame(() => { + dragFrameRef.current = null; + flushDragMove(); + }); + }, [flushDragMove]); + const startDrag = useCallback((event: PointerEvent) => { if (!event.isPrimary || (event.pointerType === "mouse" && event.button !== 0)) { return; @@ -192,6 +321,7 @@ function FloatingShell({ moved: false, pointerId: event.pointerId, }; + dragPointRef.current = null; }, []); const cancelDrag = useCallback((event: PointerEvent) => { @@ -200,6 +330,11 @@ function FloatingShell({ return; } dragRef.current = null; + dragPointRef.current = null; + if (dragFrameRef.current !== null) { + window.cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + } if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } @@ -218,27 +353,38 @@ function FloatingShell({ if (movedDistance < 4) { return; } + if (event.screenX === current.startScreenX && event.screenY === current.startScreenY) { + return; + } current.moved = true; - void window.maibotDesktop?.window - .moveFloatingTo(event.screenX, event.screenY, current.offsetX, current.offsetY) - .then(updateFloatingState); - }, [cancelDrag, updateFloatingState]); + dragPointRef.current = { + screenX: event.screenX, + screenY: event.screenY, + clientX: current.offsetX, + clientY: current.offsetY, + }; + scheduleDragMove(); + }, [cancelDrag, scheduleDragMove]); - const finishDrag = useCallback((event: PointerEvent, clickAction?: () => void) => { + const finishDrag = useCallback((event: PointerEvent) => { const current = dragRef.current; if (!current || current.pointerId !== event.pointerId) { return; } dragRef.current = null; + dragPointRef.current = null; + if (dragFrameRef.current !== null) { + window.cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + } if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } if (current.moved) { + suppressNextClickBriefly(); void window.maibotDesktop?.window.finishFloatingDrag().then(updateFloatingState); - return; } - clickAction?.(); - }, [updateFloatingState]); + }, [suppressNextClickBriefly, updateFloatingState]); if (!expanded) { if (edge) { @@ -249,10 +395,11 @@ function FloatingShell({ edge === "left" ? "pl-0.5" : "pr-0.5", )} data-floating-shell="true" + onClick={expandFromClick} onPointerCancel={(event) => finishDrag(event)} onPointerDown={startDrag} onPointerMove={drag} - onPointerUp={(event) => finishDrag(event, onExpand)} + onPointerUp={(event) => finishDrag(event)} title="拖动悬浮条,点击展开" >
@@ -270,12 +417,13 @@ function FloatingShell({ return (
+
+ + ); +} + function MaiBotOverviewCard({ service, localVersion, @@ -1335,6 +1396,14 @@ export function HomePanel({ onOpenTab("pluginmanage"); }, [onOpenTab]); + const openLauncherRelease = useCallback(() => { + const tag = snapshot.appLatestTag?.trim(); + const url = tag + ? `https://github.com/DrSmoothl/MaiBotOneKey/releases/tag/${encodeURIComponent(tag)}` + : "https://github.com/DrSmoothl/MaiBotOneKey/releases"; + void window.maibotDesktop?.openExternal(url); + }, [snapshot.appLatestTag]); + const openMessagePlatformDialog = useCallback(() => { setError(null); setMessagePlatformBackend(snapshot.initState.qqBackend ?? "napcat"); @@ -1471,6 +1540,11 @@ export function HomePanel({ ) : ( )} +
setQuickActionsOpen(true)} diff --git a/src/renderer/src/components/app/SettingsStatusPanel.tsx b/src/renderer/src/components/app/SettingsStatusPanel.tsx index 9280b69..e6c32f0 100644 --- a/src/renderer/src/components/app/SettingsStatusPanel.tsx +++ b/src/renderer/src/components/app/SettingsStatusPanel.tsx @@ -35,6 +35,7 @@ import type { ModuleSourcePreset, ModuleTagOption, ModuleUpdateResult, + NetworkProxySettings, PythonOverridesState, PythonPackageInstallResult, PythonPackageVersionList, @@ -153,6 +154,11 @@ const scaleOptions: Array<{ value: InterfaceScale; label: string; description: s { value: "comfortable", label: "宽松", description: "字号更大,留白更多。" }, ]; +const defaultNetworkProxySettings: NetworkProxySettings = { + enabled: false, + port: 7890, +}; + const STARTUP_WIZARD_STORAGE_KEY = "maibot-startup-wizard-seen"; const managedPythonPackages: Array<{ name: ManagedPythonPackageName; label: string }> = [ @@ -768,6 +774,8 @@ export function SettingsStatusPanel({ const editableRuntimeResourcePathConfigs = runtimeResourcePathConfigs.filter((config) => config.key !== "pythonOverrides"); const customPythonRuntimeEnabled = runtimePathConfigs.some((config) => config.key === "python" && config.customized); const terminalSettings = snapshot.terminalSettings ?? { useEmbeddedTerminal: true, fontSize: 12 }; + const networkProxySettings = snapshot.networkProxySettings ?? defaultNetworkProxySettings; + const [networkProxyDraft, setNetworkProxyDraft] = useState(networkProxySettings); const [closePreference, setClosePreferenceState] = useState(() => getClosePreference()); const recentLogEntries = snapshot.recentLogs ?? []; const maibotService = services.find((service) => service.id === "maibot"); @@ -791,6 +799,14 @@ export function SettingsStatusPanel({ service.status === "running" || service.status === "stopping", ); + const networkProxyDirty = + networkProxyDraft.enabled !== networkProxySettings.enabled || + networkProxyDraft.port !== networkProxySettings.port; + + useEffect(() => { + setNetworkProxyDraft(networkProxySettings); + }, [networkProxySettings.enabled, networkProxySettings.port]); + useEffect(() => { setQqBackend(initState.qqBackend ?? "napcat"); }, [initState.qqBackend]); @@ -1263,6 +1279,25 @@ export function SettingsStatusPanel({ [onSnapshot, refreshSnapshot, snapshot], ); + const saveNetworkProxySettings = useCallback(async () => { + setBusy("network-proxy"); + setError(null); + try { + const nextNetworkProxySettings = + await window.maibotDesktop?.launcher.saveNetworkProxySettings(networkProxyDraft); + if (nextNetworkProxySettings) { + onSnapshot({ ...snapshot, networkProxySettings: nextNetworkProxySettings }); + setNetworkProxyDraft(nextNetworkProxySettings); + } + toast.success("网络代理设置已保存"); + await refreshSnapshot(); + } catch (nextError) { + setError(messageFromError(nextError)); + } finally { + setBusy(null); + } + }, [networkProxyDraft, onSnapshot, refreshSnapshot, snapshot]); + const migrateRuntimeResourcePath = useCallback(async (key: RuntimeResourcePathKey) => { setBusy(`resource:migrate:${key}`); setError(null); @@ -1526,6 +1561,75 @@ export function SettingsStatusPanel({
+
+
+
+ + + +
+

网络代理

+

+ 对接 Clash 等本机代理,地址固定为 127.0.0.1。 +

+
+
+ + {networkProxyDraft.enabled ? "已启用" : "未启用"} + +
+
+ + +

+ 保存后影响启动器网络请求、Git / pip 更新,以及之后启动的托管服务。 +

+ +
+
+
diff --git a/src/renderer/src/lib/desktop-api.ts b/src/renderer/src/lib/desktop-api.ts index 37a24a1..1f5c524 100644 --- a/src/renderer/src/lib/desktop-api.ts +++ b/src/renderer/src/lib/desktop-api.ts @@ -2,6 +2,8 @@ import type { DesktopSnapshot } from "@shared/contracts"; const fallbackSnapshot: DesktopSnapshot = { appVersion: "0.1.0", + appLatestTag: undefined, + appLatestSource: undefined, moduleVersions: {}, platform: typeof navigator !== "undefined" && /Mac/i.test(navigator.platform) @@ -119,6 +121,10 @@ const fallbackSnapshot: DesktopSnapshot = { useEmbeddedTerminal: true, fontSize: 12, }, + networkProxySettings: { + enabled: false, + port: 7890, + }, initState: { isReady: false, qqBackend: "napcat", @@ -162,6 +168,7 @@ export function normalizeDesktopSnapshot(snapshot: Partial): De runtimePathConfigs: snapshot.runtimePathConfigs ?? fallbackSnapshot.runtimePathConfigs, runtimeResourcePathConfigs: snapshot.runtimeResourcePathConfigs ?? fallbackSnapshot.runtimeResourcePathConfigs, terminalSettings: snapshot.terminalSettings ?? fallbackSnapshot.terminalSettings, + networkProxySettings: snapshot.networkProxySettings ?? fallbackSnapshot.networkProxySettings, moduleVersions: { ...fallbackSnapshot.moduleVersions, ...snapshot.moduleVersions, diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 56d4fa4..4a33151 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -129,6 +129,11 @@ export interface TerminalSettings { fontSize: number; } +export interface NetworkProxySettings { + enabled: boolean; + port: number; +} + export interface RuntimePaths { installRoot: string; userDataRoot: string; @@ -252,7 +257,10 @@ export interface DesktopSnapshot { runtimePathConfigs: RuntimePathConfig[]; runtimeResourcePathConfigs: RuntimeResourcePathConfig[]; terminalSettings: TerminalSettings; + networkProxySettings: NetworkProxySettings; appVersion: string; + appLatestTag?: string; + appLatestSource?: string; moduleVersions: ModuleRuntimeVersions; platform: NodeJS.Platform; windowState: WindowState; @@ -270,6 +278,16 @@ export interface WindowState { floatingEdge?: "left" | "right"; } +export type WindowResizeEdge = + | "top" + | "right" + | "bottom" + | "left" + | "top-left" + | "top-right" + | "bottom-right" + | "bottom-left"; + export interface InitCheck { id: string; label: string; @@ -795,6 +813,9 @@ export interface DesktopBridge { moveFloatingBy: (deltaX: number, deltaY: number) => Promise; moveFloatingTo: (screenX: number, screenY: number, offsetX: number, offsetY: number) => Promise; finishFloatingDrag: () => Promise; + startResize: (edge: WindowResizeEdge, screenX: number, screenY: number) => Promise; + resizeTo: (screenX: number, screenY: number) => Promise; + finishResize: () => Promise; getState: () => Promise; onState: (callback: (state: WindowState) => void) => () => void; }; @@ -821,6 +842,7 @@ export interface DesktopBridge { resetMaiBotData: () => Promise; }; launcher: { + saveNetworkProxySettings: (settings: NetworkProxySettings) => Promise; resetSettings: () => Promise; resetAll: () => Promise; }; From b004a19fe0fa955508baf982942f2413e1a6237f Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 21 May 2026 00:04:04 +0800 Subject: [PATCH 06/32] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=200.3.4=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a72ad4a..e6bc464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.3.4 - 2026-05-20 + +### 插件管理 +- 插件配置在 MaiBot Core 未启动时也会尝试读取本地 `config.toml` 和插件配置声明,提前显示中文配置名与字段说明。 +- MaiBot Core 仍在加载插件系统时,插件状态显示为“加载中”,不再误判为加载失败。 + +### 启动器设置 +- 设置中心新增简化的本机网络代理开关,可配置 `127.0.0.1:<端口>` 以对接 Clash / Mihomo 等代理软件。 +- 网络代理会应用到启动器网络请求、Git / pip 更新,以及之后启动的托管服务环境变量。 + +### 窗口 +- 修复无边框透明窗口无法拖动边缘缩放的问题。 +- 退出悬浮模式时会恢复普通窗口的可缩放状态,避免窗口卡在不可缩放。 + ## 0.3.3 - 2026-05-19 ### 内置模块 @@ -98,7 +112,7 @@ NSIS patch 脚本增强幂等与旧模板迁移能力。 设置中心新增重置 SnowLuma 组件入口,可在停止服务后清空 SnowLuma 目录及配置,并从一键包内置模板重新复制。 服务启动流程不再自动从内置模板补全 NapCat / SnowLuma 模块,模板复制收敛到首次初始化和“准备基础目录”操作,避免启动时影响用户手动修改的后端目录。 -初始化与修复 MaiBot 配置时会固定写入 `onekey-local-chat:onekey-local-bot`,确保一键包本地聊天平台拥有独立机器人账号。 +初始化与修复 MaiBot 配置时不再写入旧的 `onekey-local-chat` 本地聊天平台账号,本地聊天统一通过 WebUI 通道连接。 首页未配置 QQ 账号时,QQ 后端卡片改为“连接到消息软件平台.......”入口,可选择 QQ-NapCat 或 QQ-SnowLuma,自动写入对应适配器与 WebSocket 配置并启动后端。 ## 0.3.0 - 2026-05-16 From 32177f52a4d426c83f13add15d41be994b77c5f5 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sun, 24 May 2026 19:53:12 +0800 Subject: [PATCH 07/32] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E8=93=9D=E5=9B=BE=E5=9F=BA=E7=A1=80=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc/app.ts | 466 +++++++++-- src/main/services/maibot-plugin-client.ts | 673 ++++++++++++++- src/main/services/paths.ts | 2 + src/main/services/plugin-builder-library.ts | 215 +++++ src/preload/index.ts | 50 ++ src/renderer/src/lib/desktop-api.ts | 2 + src/renderer/src/lib/maibot-plugin-api.ts | 141 ++++ src/shared/contracts.ts | 272 +++++++ src/shared/plugin-blueprint.ts | 854 ++++++++++++++++++++ 9 files changed, 2597 insertions(+), 78 deletions(-) create mode 100644 src/main/services/plugin-builder-library.ts create mode 100644 src/shared/plugin-blueprint.ts diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 49fe9fa..ab59572 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -1,8 +1,8 @@ import { app, BrowserWindow, dialog, ipcMain, screen, shell } from "electron"; import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import type { CloseAction, DesktopSnapshot, @@ -10,6 +10,7 @@ import type { InitState, LauncherResetResult, LogEntry, + Live2dModelImportResult, LocalChatConnectionState, LocalChatConnectRequest, LocalChatMessageEvent, @@ -19,15 +20,31 @@ import type { MaiBotDataImportResult, MaiBotDataResetResult, MaiBotInstalledPlugin, + MaiBotPluginBlueprint, + MaiBotPluginBlueprintCreateRequest, + MaiBotPluginBlueprintCreateResult, + MaiBotPluginBlueprintParseResult, + MaiBotPluginBuilderBlueprintExportRequest, + MaiBotPluginBuilderBlueprintExportResult, + MaiBotPluginBuilderBlueprintImportResult, + MaiBotPluginBuilderLibraryDeleteResult, + MaiBotPluginBuilderLibraryListResult, + MaiBotPluginBuilderLibraryLoadResult, + MaiBotPluginBuilderLibrarySaveRequest, + MaiBotPluginBuilderLibrarySaveResult, MaiBotPluginConfigSaveResult, MaiBotPluginConfigState, MaiBotPluginConfigValue, MaiBotPluginListOptions, MaiBotPluginListResult, + MaiBotPluginDownloadResult, MaiBotPluginOperationRequest, MaiBotPluginOperationResult, + MaiBotPluginRatingResult, MaiBotPluginReadmeResult, MaiBotPluginStats, + MaiBotPluginUserState, + MaiBotPluginVoteResult, MaiBotStatisticSummary, ManagedPythonPackageName, ModuleRuntimeVersions, @@ -60,12 +77,18 @@ import type { WindowResizeEdge, WindowState, } from "../../shared/contracts"; +import { + buildMaiBotPluginBlueprintFiles, + defaultMaiBotPluginFolderName, + validateMaiBotPluginBlueprint, +} from "../../shared/plugin-blueprint"; import { InitManager } from "../services/init-manager"; import { LogStore } from "../services/log-store"; import { LocalChatAdapter } from "../services/local-chat-adapter"; import { MaiBotPluginClient } from "../services/maibot-plugin-client"; import { ModuleUpdater } from "../services/module-updater"; import { NetworkProxyManager } from "../services/network-proxy-manager"; +import { PluginBuilderLibrary } from "../services/plugin-builder-library"; import { PythonDependencyManager } from "../services/python-dependency-manager"; import { ResourceLocationManager } from "../services/resource-location-manager"; import { ServiceManager } from "../services/service-manager"; @@ -82,7 +105,7 @@ const LAUNCHER_SETTING_FILES = [ "python-dependency-source.json", "network-proxy.json", ]; -const LAUNCHER_RUNTIME_DIRECTORIES = ["modules", "python-overrides", "logs"]; +const LAUNCHER_RUNTIME_DIRECTORIES = ["modules", "python-overrides", "live2d", "logs"]; const RETIRED_ENTRY_DIRECTORY = ".reset-pending-delete"; const REMOVE_RETRY_OPTIONS = { recursive: true, force: true, maxRetries: 8, retryDelay: 250 } as const; const NORMAL_MINIMUM_SIZE = { width: 1080, height: 720 }; @@ -284,6 +307,80 @@ async function clearDirectoryContents(root: string, entryNames?: string[]): Prom return removedEntries; } +function isLive2dModelPath(path: string): boolean { + const cleanPath = path.toLowerCase().split(/[?#]/u)[0]; + return cleanPath.endsWith(".model3.json") || cleanPath.endsWith(".model.json"); +} + +function sanitizeLive2dFolderName(value: string): string { + const sanitized = value + .replace(/[<>:"/\\|?*\u0000-\u001F]/gu, "-") + .replace(/\s+/gu, " ") + .trim() + .replace(/[. ]+$/u, ""); + return sanitized || "live2d-model"; +} + +async function nextAvailableLive2dDirectory(root: string, preferredName: string): Promise { + const safeName = sanitizeLive2dFolderName(preferredName); + for (let index = 0; index < 100; index += 1) { + const candidate = join(root, index === 0 ? safeName : `${safeName}-${index + 1}`); + if (!existsSync(candidate)) { + return candidate; + } + } + return join(root, `${safeName}-${Date.now()}`); +} + +function live2dAssetUrlFromPath(paths: RuntimePaths, modelPath: string): string { + const root = resolve(paths.live2dRoot); + const target = resolve(modelPath); + if (!isPathInside(root, target)) { + throw new Error("Live2D model must be inside the launcher Live2D library."); + } + + const relativePath = relative(root, target); + const encodedPath = relativePath.split(/[\\/]+/u).map(encodeURIComponent).join("/"); + return `maibot-live2d://assets/${encodedPath}`; +} + +async function importLive2dModel(paths: RuntimePaths, sourcePath: string): Promise { + const sourceModelPath = resolve(sourcePath); + const sourceStat = await stat(sourceModelPath); + if (!sourceStat.isFile()) { + throw new Error("Please choose a Live2D model JSON file."); + } + if (!isLive2dModelPath(sourceModelPath)) { + throw new Error("Please choose a .model3.json or .model.json model file."); + } + + const libraryRoot = resolve(paths.live2dRoot); + const sourceDir = dirname(sourceModelPath); + await mkdir(libraryRoot, { recursive: true }); + + let modelPath = sourceModelPath; + let copied = false; + if (!samePath(libraryRoot, sourceDir) && !isPathInside(libraryRoot, sourceModelPath)) { + const targetDir = await nextAvailableLive2dDirectory(libraryRoot, basename(sourceDir) || basename(sourceModelPath)); + await cp(sourceDir, targetDir, { + recursive: true, + dereference: true, + errorOnExist: false, + force: true, + }); + modelPath = resolve(targetDir, relative(sourceDir, sourceModelPath)); + copied = true; + } + + return { + sourcePath: sourceModelPath, + modelPath, + modelUrl: live2dAssetUrlFromPath(paths, modelPath), + libraryRoot, + copied, + }; +} + interface ParsedVersionTag { tag: string; parts: number[]; @@ -463,7 +560,7 @@ function decodeStatisticText(content: string): string { .replace(/<\/(?:p|div|tr|li|h[1-6]|section|article)>/giu, "\n") .replace(/<[^>]+>/gu, " ") .replace(/ /giu, " ") - .replace(/¥/giu, "¥") + .replace(/¥/giu, "Yen") .replace(/&/giu, "&") .replace(/</giu, "<") .replace(/>/giu, ">") @@ -476,7 +573,7 @@ function escapeRegExp(value: string): string { } function readStatisticField(text: string, label: string): string | undefined { - const inlineValue = text.match(new RegExp(`${escapeRegExp(label)}\\s*[::]\\s*([^\\n]+)`, "u"))?.[1]?.trim(); + const inlineValue = text.match(new RegExp(`${escapeRegExp(label)}\\s*[:\\uFF1A]\\s*([^\\n]+)`, "u"))?.[1]?.trim(); if (inlineValue) { return inlineValue; } @@ -484,7 +581,7 @@ function readStatisticField(text: string, label: string): string | undefined { const lines = text.split("\n").map((line) => line.trim()).filter(Boolean); const labelIndex = lines.findIndex((line) => line === label); const nextLine = labelIndex >= 0 ? lines[labelIndex + 1] : undefined; - return nextLine && !nextLine.endsWith(":") && !nextLine.endsWith(":") ? nextLine : undefined; + return nextLine && !nextLine.endsWith(":") && !nextLine.endsWith("\uFF1A") ? nextLine : undefined; } function readStatisticCount(text: string, label: string): number | undefined { @@ -499,7 +596,7 @@ function isStatisticStyleLine(line: string): boolean { function parseChatStatistics(text: string): MaiBotStatisticSummary["chatStats"] { const lines = text.split("\n").map((line) => line.trim()).filter(Boolean); - const startIndex = lines.findIndex((line) => line.includes("聊天消息统计")); + const startIndex = lines.findIndex((line) => line.includes("\u804A\u5929\u6D88\u606F\u7EDF\u8BA1") || line.toLowerCase().includes("chat")); if (startIndex < 0) { return []; } @@ -509,10 +606,17 @@ function parseChatStatistics(text: string): MaiBotStatisticSummary["chatStats"] if (isStatisticStyleLine(line)) { continue; } - if (line.startsWith("-") || line.includes("Token/") || line.includes("花费/")) { + if (line.startsWith("-") || line.includes("Token/") || line.toLowerCase().includes("cost")) { break; } - if (line.includes("联系人") || line.includes("群组名称") || line.includes("消息数量")) { + if ( + line.includes("\u8054\u7CFB\u4EBA") || + line.includes("\u7FA4\u7EC4\u540D\u79F0") || + line.includes("\u6D88\u606F\u6570\u91CF") || + line.toLowerCase().includes("total") || + line.toLowerCase().includes("online") || + line.toLowerCase().includes("reply") + ) { continue; } const match = line.match(/^(.+?)\s+(\d+)$/u); @@ -541,8 +645,8 @@ async function readMaiBotStatistics(paths: RuntimePaths): Promise { + const floatingBounds = ( + window: BrowserWindow, + size: { width: number; height: number }, + ): Electron.Rectangle => { const display = screen.getDisplayMatching(window.getBounds()); return { x: Math.round(display.workArea.x + display.workArea.width - size.width - 18), @@ -633,7 +740,7 @@ export function registerAppIpc({ }; const restoreNormalWindowChrome = (window: BrowserWindow): void => { - window.setResizable(true); + window.setResizable(false); window.setMaximizable(true); window.setMinimumSize(NORMAL_MINIMUM_SIZE.width, NORMAL_MINIMUM_SIZE.height); }; @@ -853,7 +960,7 @@ export function registerAppIpc({ }), false, ); - return readWindowState(window, floatingMode, floatingEdgeSide); + return sendWindowState(window); }; const finishFloatingDrag = (): WindowState => { @@ -1072,6 +1179,7 @@ export function registerAppIpc({ getModuleSourceConfig: () => moduleUpdater.getSourceConfig(), }); let maibotPluginClient = createMaibotPluginClient(); + const pluginBuilderLibrary = new PluginBuilderLibrary(paths.pluginBuilderRoot); const localChatAdapter = new LocalChatAdapter(paths); const assertServicesStoppedForResourceMove = (): void => { @@ -1085,7 +1193,7 @@ export function registerAppIpc({ service.status === "stopping", ); if (active.length > 0) { - throw new Error(`请先停止服务,再调整覆盖路径组: ${active.map((service) => service.name).join(", ")}`); + throw new Error(`请先停止服务,再调整覆盖路径:${active.map((service) => service.name).join(", ")}`); } }; @@ -1136,7 +1244,7 @@ export function registerAppIpc({ await mkdir(paths.userDataRoot, { recursive: true }); await mkdir(paths.logsRoot, { recursive: true }); - logStore.append("desktop", "system", "启动器设置已清空,将重新进入启动引导"); + logStore.append("desktop", "system", "Launcher settings reset."); await broadcastSnapshot(); return { mode: "settings", @@ -1150,10 +1258,10 @@ export function registerAppIpc({ assertServicesStoppedForResourceMove(); const root = paths.defaultResourceRoot; if (samePath(root, paths.installRoot) || isPathInside(root, paths.installRoot)) { - throw new Error("当前运行时资源目录指向安装/开发目录,已阻止完整清空。请在打包版的独立运行时目录中执行。"); + throw new Error("Refusing to reset all because the target path contains the install root."); } if (!samePath(root, paths.userDataRoot) && !isPathInside(paths.userDataRoot, root)) { - throw new Error("当前运行时资源目录不在启动器数据目录内,已阻止完整清空。"); + throw new Error("Refusing to reset all because the target path is outside the user data root."); } await resetLauncherStores(); @@ -1205,13 +1313,56 @@ export function registerAppIpc({ await shell.openPath(path); }); + ipcMain.handle("live2d:getLibraryRoot", async (): Promise => { + await mkdir(paths.live2dRoot, { recursive: true }); + return paths.live2dRoot; + }); + + ipcMain.handle("live2d:openLibrary", async (): Promise => { + await mkdir(paths.live2dRoot, { recursive: true }); + await shell.openPath(paths.live2dRoot); + }); + + ipcMain.handle( + "live2d:importModel", + async (_event, sourcePath?: string): Promise => { + let nextSourcePath = sourcePath?.trim().replace(/^["']|["']$/gu, ""); + if (!nextSourcePath) { + const mainWindow = getMainWindow(); + const dialogOptions: Electron.OpenDialogOptions = { + title: "Select Live2D model", + properties: ["openFile"], + filters: [ + { name: "Live2D 模型 JSON", extensions: ["json"] }, + { name: "全部文件", extensions: ["*"] }, + ], + }; + const result = mainWindow + ? await dialog.showOpenDialog(mainWindow, dialogOptions) + : await dialog.showOpenDialog(dialogOptions); + if (result.canceled || result.filePaths.length === 0) { + return null; + } + nextSourcePath = result.filePaths[0]; + } + + const result = await importLive2dModel(paths, nextSourcePath); + logStore.append( + "desktop", + "system", + `Live2D 模型已导入: ${result.sourcePath} -> ${result.modelPath}`, + ); + return result; + }, + ); + ipcMain.handle("init:getState", async (): Promise => { return initManager.getState({ refreshDependencies: true }); }); ipcMain.handle("init:repair", async (): Promise => { const result = await initManager.repair(); - logStore.append("desktop", "system", `初始化准备完成,变更 ${result.changedFiles.length} 个文件`); + logStore.append("desktop", "system", `Initialization repair changed ${result.changedFiles.length} files.`); await broadcastSnapshot(); return result; }); @@ -1219,7 +1370,7 @@ export function registerAppIpc({ ipcMain.handle("init:resetSnowLuma", async (): Promise => { await serviceManager.refresh(); if (serviceManager.snapshot().some(isRuntimeBusy)) { - throw new Error("请先停止 MaiBot Core 和 QQ 后端,再重置 SnowLuma 组件。"); + throw new Error("Stop MaiBot Core and QQ backend before resetting SnowLuma."); } const result = await initManager.resetSnowLumaComponent(); @@ -1232,10 +1383,10 @@ export function registerAppIpc({ ipcMain.handle("init:setQqBackend", async (_event, backend: QqBackend): Promise => { const currentInitState = await initManager.getState(); if (backend !== "napcat" && backend !== "snowluma") { - throw new Error("未知 QQ 后端"); + throw new Error("Unsupported QQ backend."); } if (backend !== currentInitState.qqBackend && serviceManager.snapshot().some(isRuntimeBusy)) { - throw new Error("MaiBot Core 或 QQ 后端正在运行时不能切换 NapCat / SnowLuma,请先停止全部服务。"); + throw new Error("Stop MaiBot Core and QQ backend before switching QQ backend."); } await initManager.setQqBackend(backend); serviceManager.reloadRuntimePaths(); @@ -1254,7 +1405,7 @@ export function registerAppIpc({ requestedBackend !== currentInitState.qqBackend && serviceManager.snapshot().some(isRuntimeBusy) ) { - throw new Error("MaiBot Core 或 QQ 后端正在运行时不能切换 NapCat / SnowLuma,请先停止全部服务。"); + throw new Error("Stop MaiBot Core and QQ backend before switching QQ backend."); } const state = await initManager.setQqAccount( request.qqAccount, @@ -1275,7 +1426,7 @@ export function registerAppIpc({ ipcMain.handle("agreements:confirm", async (): Promise => { const result = await initManager.confirmAgreements(); - logStore.append("desktop", "system", `MaiBot EULA 与隐私政策已确认,写入 ${result.changedFiles.length} 个文件`); + logStore.append("desktop", "system", `Startup agreements confirmed, changed ${result.changedFiles.length} files.`); await broadcastSnapshot(); return result; }); @@ -1283,15 +1434,15 @@ export function registerAppIpc({ ipcMain.handle("modules:updateMaibot", async (_event, tag?: string): Promise => { const maibot = serviceManager.snapshot().find((service) => service.id === "maibot"); if (maibot?.managed || maibot?.status === "starting" || maibot?.status === "running" || maibot?.status === "stopping") { - throw new Error("请先停止 MaiBot Core,再更新 MaiBot 模块。"); + throw new Error("Stop MaiBot Core before updating MaiBot."); } - logStore.append("desktop", "system", "开始更新 MaiBot 模块:使用可用 Git 强制拉取远端代码"); + logStore.append("desktop", "system", "Updating MaiBot module from Git."); const result = await moduleUpdater.updateMaiBot(tag); logStore.append( "desktop", "system", - `MaiBot 模块更新完成: ${result.before ?? "-"} -> ${result.after ?? "-"} (${result.changed ? "已更新" : "已是最新"})`, + `MaiBot update finished: ${result.before ?? "-"} -> ${result.after ?? "-"} (${result.changed ? "changed" : "unchanged"})`, ); await broadcastSnapshot(); return result; @@ -1307,7 +1458,7 @@ export function registerAppIpc({ ipcMain.handle("modules:saveSourceConfig", async (_event, config: ModuleSourceUpdate): Promise => { const result = await moduleUpdater.saveSourceConfig(config); - logStore.append("desktop", "system", `模块更新源已切换: ${result.preset} (${result.maibotUrl})`); + logStore.append("desktop", "system", `Module source saved: ${result.preset} (${result.maibotUrl})`); return result; }); @@ -1315,16 +1466,16 @@ export function registerAppIpc({ ipcMain.handle("data:importMaibotDb", async (): Promise => { const maibot = serviceManager.snapshot().find((service) => service.id === "maibot"); if (maibot?.managed || maibot?.status === "starting" || maibot?.status === "running" || maibot?.status === "stopping") { - throw new Error("请先停止 MaiBot Core,再导入旧版本数据库。"); + throw new Error("Stop MaiBot Core before importing the database."); } const mainWindow = getMainWindow(); const dialogOptions: Electron.OpenDialogOptions = { - title: "选择旧版本 MaiBot.db", + title: "Import MaiBot database", properties: ["openFile"], filters: [ - { name: "MaiBot 数据库", extensions: ["db"] }, - { name: "全部文件", extensions: ["*"] }, + { name: "MaiBot database", extensions: ["db"] }, + { name: "All files", extensions: ["*"] }, ], }; const result = mainWindow @@ -1338,7 +1489,7 @@ export function registerAppIpc({ logStore.append( "desktop", "system", - `MaiBot.db 导入完成: ${importResult.sourcePath} -> ${importResult.destPath}`, + `MaiBot.db imported: ${importResult.sourcePath} -> ${importResult.destPath}`, ); await broadcastSnapshot(); return importResult; @@ -1354,20 +1505,20 @@ export function registerAppIpc({ maibot?.status === "running" || maibot?.status === "stopping" ) { - throw new Error("请先停止 MaiBot Core,再覆盖配置文件。"); + throw new Error("Stop MaiBot Core before importing config files."); } if (fileName !== "bot_config.toml" && fileName !== "model_config.toml") { - throw new Error(`不支持的配置文件名: ${fileName}`); + throw new Error(`Unsupported config file: ${fileName}`); } const mainWindow = getMainWindow(); const dialogOptions: Electron.OpenDialogOptions = { - title: `选择 ${fileName}`, + title: `Import ${fileName}`, properties: ["openFile"], filters: [ - { name: "TOML 配置", extensions: ["toml"] }, - { name: "全部文件", extensions: ["*"] }, + { name: "TOML files", extensions: ["toml"] }, + { name: "All files", extensions: ["*"] }, ], }; const result = mainWindow @@ -1381,7 +1532,7 @@ export function registerAppIpc({ logStore.append( "desktop", "system", - `MaiBot ${fileName} 导入完成: ${importResult.sourcePath} -> ${importResult.destPath}`, + `MaiBot ${fileName} imported: ${importResult.sourcePath} -> ${importResult.destPath}`, ); await broadcastSnapshot(); return importResult; @@ -1391,14 +1542,14 @@ export function registerAppIpc({ ipcMain.handle("data:resetMaibotData", async (): Promise => { const maibot = serviceManager.snapshot().find((service) => service.id === "maibot"); if (maibot?.managed || maibot?.status === "starting" || maibot?.status === "running" || maibot?.status === "stopping") { - throw new Error("请先停止 MaiBot Core,再重置数据。"); + throw new Error("Stop MaiBot Core before resetting data."); } const resetResult = await initManager.resetMaiBotData(); logStore.append( "desktop", "system", - `已清空 MaiBot data 目录 (${resetResult.removedEntries.length} 项): ${resetResult.dataDir}`, + `MaiBot data reset (${resetResult.removedEntries.length} entries): ${resetResult.dataDir}`, ); await broadcastSnapshot(); return resetResult; @@ -1481,6 +1632,172 @@ export function registerAppIpc({ }, ); + ipcMain.handle( + "plugins:createFromBlueprint", + async (_event, request: MaiBotPluginBlueprintCreateRequest): Promise => { + if (!request?.blueprint) { + throw new Error("Plugin blueprint is required."); + } + const result = await maibotPluginClient.createFromBlueprint(request.blueprint, request.overwrite === true); + logStore.append("desktop", "system", `MaiBot plugin generated from blueprint: ${result.pluginId}`); + await broadcastSnapshot(); + return result; + }, + ); + + ipcMain.handle("plugins:parseToBlueprint", async (_event, pluginId: string): Promise => { + if (!pluginId) { + throw new Error("Plugin id is required."); + } + return maibotPluginClient.parseToBlueprint(pluginId); + }); + + ipcMain.handle("plugins:listBuilderLibrary", async (): Promise => { + return pluginBuilderLibrary.list(); + }); + + ipcMain.handle( + "plugins:saveBuilderLibrary", + async (_event, request: MaiBotPluginBuilderLibrarySaveRequest): Promise => { + if (!request?.blueprint) { + throw new Error("Plugin blueprint is required."); + } + const result = await pluginBuilderLibrary.save(request.blueprint, request.overwrite !== false); + logStore.append("desktop", "system", `Builder plugin saved: ${result.item.pluginId}`); + return result; + }, + ); + + ipcMain.handle( + "plugins:loadBuilderLibrary", + async (_event, pluginId: string): Promise => { + if (!pluginId) { + throw new Error("Plugin id is required."); + } + return pluginBuilderLibrary.load(pluginId); + }, + ); + + ipcMain.handle( + "plugins:deleteBuilderLibrary", + async (_event, pluginId: string): Promise => { + if (!pluginId) { + throw new Error("Plugin id is required."); + } + const result = await pluginBuilderLibrary.delete(pluginId); + logStore.append("desktop", "system", `Builder plugin deleted: ${result.pluginId}`); + return result; + }, + ); + + ipcMain.handle( + "plugins:exportBuilderBlueprint", + async ( + _event, + request: MaiBotPluginBuilderBlueprintExportRequest, + ): Promise => { + if (!request?.blueprint) { + throw new Error("Plugin blueprint is required."); + } + const errors = validateMaiBotPluginBlueprint(request.blueprint); + if (errors.length > 0) { + throw new Error(errors.join("\n")); + } + + const pluginId = request.blueprint.manifest.pluginId.trim(); + const defaultPath = join( + pluginBuilderLibrary.getRoot(), + `${defaultMaiBotPluginFolderName(pluginId)}.maibot-plugin-blueprint.json`, + ); + const mainWindow = getMainWindow(); + const dialogOptions: Electron.SaveDialogOptions = { + title: "Export plugin blueprint", + defaultPath, + filters: [ + { name: "MaiBot 插件蓝图", extensions: ["maibot-plugin-blueprint.json", "json"] }, + { name: "JSON", extensions: ["json"] }, + ], + }; + const result = mainWindow + ? await dialog.showSaveDialog(mainWindow, dialogOptions) + : await dialog.showSaveDialog(dialogOptions); + if (result.canceled || !result.filePath) { + return null; + } + + const exportedAt = Date.now(); + const payload = { + version: 1, + exportedAt, + blueprint: request.blueprint, + files: buildMaiBotPluginBlueprintFiles(request.blueprint), + }; + await mkdir(dirname(result.filePath), { recursive: true }); + await writeFile(result.filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + logStore.append("desktop", "system", `Builder blueprint exported: ${pluginId} -> ${result.filePath}`); + return { + pluginId, + filePath: result.filePath, + exportedAt, + }; + }, + ); + + ipcMain.handle( + "plugins:importBuilderBlueprint", + async (_event, sourcePath?: string): Promise => { + let nextSourcePath = sourcePath?.trim().replace(/^["']|["']$/gu, ""); + if (!nextSourcePath) { + const mainWindow = getMainWindow(); + const dialogOptions: Electron.OpenDialogOptions = { + title: "Import plugin blueprint", + properties: ["openFile"], + filters: [ + { name: "MaiBot 插件蓝图", extensions: ["json"] }, + { name: "全部文件", extensions: ["*"] }, + ], + }; + const result = mainWindow + ? await dialog.showOpenDialog(mainWindow, dialogOptions) + : await dialog.showOpenDialog(dialogOptions); + if (result.canceled || result.filePaths.length === 0) { + return null; + } + nextSourcePath = result.filePaths[0]; + } + + const source = resolve(nextSourcePath); + const raw = JSON.parse(await readFile(source, "utf8")) as { + blueprint?: MaiBotPluginBlueprint; + version?: number; + } & Partial; + const blueprint = raw.blueprint ?? (raw.manifest && raw.components && raw.configFields ? raw as MaiBotPluginBlueprint : null); + if (!blueprint?.manifest?.pluginId) { + throw new Error("Not a valid MaiBot plugin blueprint file."); + } + const errors = validateMaiBotPluginBlueprint(blueprint); + if (errors.length > 0) { + throw new Error(errors.join("\n")); + } + + const saveResult = await pluginBuilderLibrary.save(blueprint, true); + logStore.append("desktop", "system", `Builder blueprint imported: ${source} -> ${saveResult.item.pluginId}`); + return { + item: saveResult.item, + blueprint, + files: saveResult.files, + sourcePath: source, + overwritten: saveResult.overwritten, + importedAt: saveResult.savedAt, + }; + }, + ); + + ipcMain.handle("plugins:openBuilderLibrary", async (): Promise => { + await mkdir(pluginBuilderLibrary.getRoot(), { recursive: true }); + await shell.openPath(pluginBuilderLibrary.getRoot()); + }); + ipcMain.handle("plugins:getConfig", async (_event, pluginId: string, serviceUrl?: string): Promise => { return maibotPluginClient.getConfig(pluginId, serviceUrl); }); @@ -1508,6 +1825,49 @@ export function registerAppIpc({ return maibotPluginClient.getStats(pluginId); }); + ipcMain.handle("plugins:getUserState", async ( + _event, + pluginId: string, + userId: string, + ): Promise => { + return maibotPluginClient.getUserState(pluginId, userId); + }); + + ipcMain.handle("plugins:like", async ( + _event, + pluginId: string, + userId: string, + ): Promise => { + return maibotPluginClient.likePlugin(pluginId, userId); + }); + + ipcMain.handle("plugins:dislike", async ( + _event, + pluginId: string, + userId: string, + ): Promise => { + return maibotPluginClient.dislikePlugin(pluginId, userId); + }); + + ipcMain.handle("plugins:rate", async ( + _event, + pluginId: string, + rating: number, + comment: string | undefined, + userId: string, + ): Promise => { + return maibotPluginClient.ratePlugin(pluginId, rating, comment, userId); + }); + + ipcMain.handle("plugins:recordDownload", async ( + _event, + pluginId: string, + userId?: string, + fingerprint?: string, + ): Promise => { + return maibotPluginClient.recordDownload(pluginId, userId, fingerprint); + }); + ipcMain.handle("statistics:getMaibot", async (): Promise => { return readMaiBotStatistics(paths); }); @@ -1529,15 +1889,15 @@ export function registerAppIpc({ ipcMain.handle("pythonDeps:installVersion", async (_event, request: PythonPackageInstallRequest): Promise => { const maibot = serviceManager.snapshot().find((service) => service.id === "maibot"); if (maibot?.managed || maibot?.status === "starting" || maibot?.status === "running" || maibot?.status === "stopping") { - throw new Error("请先停止 MaiBot Core,再更新 Python 依赖。"); + throw new Error("Stop MaiBot Core before updating Python dependencies."); } - logStore.append("desktop", "system", `开始更新 Python 覆盖依赖: ${request.packageName}==${request.version}`); + logStore.append("desktop", "system", `Installing Python dependency: ${request.packageName}==${request.version}`); const result = await pythonDependencyManager.installVersion(request); logStore.append( "desktop", "system", - `Python 覆盖依赖更新完成: ${result.packageName}==${result.version} -> ${result.targetDir}`, + `Python dependency installed: ${result.packageName}==${result.version} -> ${result.targetDir}`, ); await broadcastSnapshot(); return result; @@ -1610,7 +1970,7 @@ export function registerAppIpc({ ipcMain.handle("services:selectPythonRuntimePath", async (): Promise => { const mainWindow = getMainWindow(); const dialogOptions: Electron.OpenDialogOptions = { - title: "选择 Python 可执行文件", + title: "Select Python executable", properties: ["openFile"], filters: [ { name: "Python", extensions: process.platform === "win32" ? ["exe"] : ["*"] }, @@ -1645,7 +2005,7 @@ export function registerAppIpc({ "resources:migratePath", async (_event, key: RuntimeResourcePathKey): Promise => { assertServicesStoppedForResourceMove(); - const targetPath = await chooseResourcePath("选择迁移目标目录"); + const targetPath = await chooseResourcePath("Select migration target directory"); if (!targetPath) { return null; } @@ -1659,7 +2019,7 @@ export function registerAppIpc({ "resources:selectPath", async (_event, key: RuntimeResourcePathKey): Promise => { assertServicesStoppedForResourceMove(); - const targetPath = await chooseResourcePath("选择已有目录"); + const targetPath = await chooseResourcePath("Select existing directory"); if (!targetPath) { return null; } diff --git a/src/main/services/maibot-plugin-client.ts b/src/main/services/maibot-plugin-client.ts index 233e13b..d83e0e5 100644 --- a/src/main/services/maibot-plugin-client.ts +++ b/src/main/services/maibot-plugin-client.ts @@ -2,7 +2,20 @@ import { execFile } from "node:child_process"; import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import { + buildMaiBotPluginBlueprintFiles, + defaultMaiBotPluginFolderName, + sanitizeMaiBotPluginFolderName, + validateMaiBotPluginBlueprint, +} from "../../shared/plugin-blueprint"; import type { + MaiBotPluginBlueprint, + MaiBotPluginBlueprintCreateResult, + MaiBotPluginBlueprintParseResult, + MaiBotPluginBlueprintComponent, + MaiBotPluginBlueprintConfigField, + MaiBotPluginBlueprintParameter, + MaiBotPluginBlueprintScalarType, MaiBotPluginConfigSaveResult, MaiBotPluginConfigField, MaiBotPluginConfigSchema, @@ -11,13 +24,17 @@ import type { MaiBotPluginConfigValue, MaiBotPluginConfigLocalizedText, MaiBotInstalledPlugin, + MaiBotPluginDownloadResult, MaiBotPluginListOptions, MaiBotMarketPlugin, MaiBotPluginListResult, MaiBotPluginManifest, MaiBotPluginOperationResult, + MaiBotPluginRatingResult, MaiBotPluginReadmeResult, MaiBotPluginStats, + MaiBotPluginUserState, + MaiBotPluginVoteResult, ModuleSourceConfig, } from "../../shared/contracts"; @@ -178,6 +195,7 @@ export class MaiBotPluginClient { downloads: statsItem?.downloads ?? plugin.downloads, rating: statsItem?.rating ?? plugin.rating, likes: statsItem?.likes ?? plugin.likes, + comment_count: statsItem?.comment_count ?? plugin.comment_count, }; }); @@ -187,14 +205,14 @@ export class MaiBotPluginClient { async install(pluginId: string, repositoryUrl: string, branch = "main"): Promise { const targetPath = this.installTargetPath(pluginId); if (await pathExists(targetPath)) { - throw new Error("鎻掍欢宸插畨瑁咃紝璇峰厛鍗歌浇"); + throw new Error("插件已安装,请先卸载"); } await this.cloneRepository(await this.resolveSourceUrl(repositoryUrl), targetPath, branch); const manifest = await this.validateInstalledManifest(targetPath, pluginId); return { success: true, - message: "鎻掍欢瀹夎鎴愬姛", + message: "Plugin installed successfully", plugin_id: pluginId, plugin_name: pluginName({ id: pluginId, manifest }), new_version: pluginVersion(manifest), @@ -209,13 +227,13 @@ export class MaiBotPluginClient { ): Promise { const pluginPath = await this.resolveInstalledPluginPath(pluginId); if (!pluginPath) { - throw new Error("鎻掍欢鏈畨瑁咃紝璇峰厛瀹夎"); + throw new Error("Plugin is not installed; install it first"); } const oldManifest = await this.readManifest(pluginPath); const oldVersion = oldManifest ? pluginVersion(oldManifest) : "unknown"; if (latestVersion && !isNewerVersion(latestVersion, oldVersion)) { - throw new Error("褰撳墠宸叉槸鏈€鏂扮増鏈紝鏃犻渶鏇存柊"); + throw new Error("Already on the latest version; no update needed"); } const beforeCommit = await this.currentGitCommit(pluginPath); if (!beforeCommit) { @@ -249,17 +267,125 @@ export class MaiBotPluginClient { await this.removePluginPath(pluginPath); return { success: true, - message: "鎻掍欢鍗歌浇鎴愬姛", + message: "插件卸载成功", plugin_id: pluginId, plugin_name: manifest ? pluginName({ id: pluginId, manifest }) : pluginId, }; } + async createFromBlueprint( + blueprint: MaiBotPluginBlueprint, + overwrite = false, + ): Promise { + const errors = validateMaiBotPluginBlueprint(blueprint); + if (errors.length > 0) { + throw new Error(errors.join("\n")); + } + + const pluginId = validatePluginId(blueprint.manifest.pluginId); + const folderName = sanitizeMaiBotPluginFolderName( + blueprint.manifest.folderName ?? defaultMaiBotPluginFolderName(pluginId), + pluginId, + ); + const pluginPath = this.safePluginPath(folderName, false); + const alreadyExists = await pathExists(pluginPath); + + if (alreadyExists && !overwrite) { + throw new Error("插件目录已存在,请启用覆盖后再生成。"); + } + + const files = buildMaiBotPluginBlueprintFiles({ + ...blueprint, + manifest: { + ...blueprint.manifest, + pluginId, + folderName, + }, + }); + + if (alreadyExists && overwrite) { + await rm(pluginPath, { recursive: true, force: true }); + } + await mkdir(pluginPath, { recursive: true }); + + for (const file of files) { + const targetPath = resolve(pluginPath, file.relativePath); + if (!isPathInside(pluginPath, targetPath)) { + throw new Error(`拒绝写入插件目录外的文件: ${file.relativePath}`); + } + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, file.content, "utf8"); + } + + return { + pluginId, + pluginPath, + files, + overwritten: alreadyExists, + createdAt: Date.now(), + }; + } + + async parseToBlueprint(pluginId: string): Promise { + const pluginPath = await this.requireInstalledPluginPath(pluginId); + const manifest = await this.readManifest(pluginPath); + const config = await this.readPluginConfig(pluginPath).catch(() => ({})); + const schema = await buildLocalPluginConfigSchema(pluginPath, config) + ?? buildPluginConfigSchema(config, "local"); + const pythonFiles = await collectPluginPythonFiles(pluginPath).catch(() => []); + const sources: string[] = []; + for (const filePath of pythonFiles) { + try { + if ((await stat(filePath)).size <= 512 * 1024) { + sources.push(await readFile(filePath, "utf8")); + } + } catch { + // Keep parsing any readable files. + } + } + + const parsedComponents = parsePluginBlueprintComponents(sources); + const resolvedPluginId = validatePluginId(manifest?.id?.trim() || pluginId); + const blueprint: MaiBotPluginBlueprint = { + manifest: { + pluginId: resolvedPluginId, + folderName: basename(pluginPath), + name: manifest?.name?.trim() || resolvedPluginId, + version: manifest?.version?.trim() || "1.0.0", + description: manifest?.description?.trim() || "从现有插件解析生成的蓝图", + authorName: manifestAuthorName(manifest), + authorUrl: manifestAuthorUrl(manifest), + license: manifest?.license?.trim() || "MIT", + repositoryUrl: manifestRepositoryUrl(manifest), + minHostVersion: manifest?.host_application?.min_version?.trim() || "1.0.0", + maxHostVersion: manifest?.host_application?.max_version?.trim() || "1.99.99", + minSdkVersion: readManifestSdkVersion(manifest, "min_version") || "2.0.0", + maxSdkVersion: readManifestSdkVersion(manifest, "max_version") || "2.99.99", + capabilities: manifest?.capabilities?.length ? manifest.capabilities : ["send.text", "config.get"], + }, + components: parsedComponents.components, + configFields: blueprintFieldsFromConfigSchema(schema), + }; + + return { + pluginId: resolvedPluginId, + pluginPath, + blueprint, + parsed: { + manifest: manifest !== null, + configFields: blueprint.configFields.length, + tools: parsedComponents.tools, + commands: parsedComponents.commands, + unsupportedDecorators: parsedComponents.unsupportedDecorators, + }, + }; + } + async getConfig(pluginId: string, serviceUrl?: string): Promise { const pluginPath = await this.requireInstalledPluginPath(pluginId); const configPath = resolve(pluginPath, PLUGIN_CONFIG_FILE); if (!isPathInside(pluginPath, configPath)) { - throw new Error("鎻掍欢閰嶇疆璺緞瓒呭嚭鍏佽鑼冨洿"); + throw new Error("Plugin config path is outside the allowed range"); } const runtimeConfig = await this.getRuntimeConfig(pluginId, pluginPath, configPath, serviceUrl); @@ -292,7 +418,7 @@ export class MaiBotPluginClient { const pluginPath = await this.requireInstalledPluginPath(pluginId); const configPath = resolve(pluginPath, PLUGIN_CONFIG_FILE); if (!isPathInside(pluginPath, configPath)) { - throw new Error("鎻掍欢閰嶇疆璺緞瓒呭嚭鍏佽鑼冨洿"); + throw new Error("Plugin config path is outside the allowed range"); } const runtimeConfig = normalizePluginConfigRoot(config); @@ -342,7 +468,7 @@ export class MaiBotPluginClient { const remoteUrl = repositoryUrl ? githubRawReadmeUrl(await this.resolveSourceUrl(repositoryUrl)) : undefined; if (!remoteUrl) { - return { success: false, error: "鏈壘鍒版彃浠?README" }; + return { success: false, error: "Plugin README not found" }; } for (const branch of ["main", "master"]) { @@ -351,7 +477,7 @@ export class MaiBotPluginClient { return { success: true, content: await response.text() }; } } - return { success: false, error: "鏈壘鍒版彃浠?README" }; + return { success: false, error: "Plugin README not found" }; } async getStats(pluginId: string): Promise { @@ -363,6 +489,70 @@ export class MaiBotPluginClient { return normalizePluginStatsDetail(pluginId, data); } + async getUserState(pluginId: string, userId: string): Promise { + const query = new URLSearchParams({ plugin_id: pluginId, user_id: userId }); + const data = await requestPluginStatsService("GET", `/stats/user-state?${query.toString()}`); + return data ? normalizePluginUserState(data) : null; + } + + async likePlugin(pluginId: string, userId: string): Promise { + const result = await this.postPluginVote("/stats/like", pluginId, userId); + this.mergeCachedPluginStats(pluginId, { + likes: result.likes, + dislikes: result.dislikes, + }); + return result; + } + + async dislikePlugin(pluginId: string, userId: string): Promise { + const result = await this.postPluginVote("/stats/dislike", pluginId, userId); + this.mergeCachedPluginStats(pluginId, { + likes: result.likes, + dislikes: result.dislikes, + }); + return result; + } + + async ratePlugin( + pluginId: string, + rating: number, + comment: string | undefined, + userId: string, + ): Promise { + if (rating < 1 || rating > 5) { + return { success: false, error: "评分必须在 1-5 之间" }; + } + + const data = await requestPluginStatsService("POST", "/stats/rate", { + plugin_id: pluginId, + rating, + comment, + user_id: userId, + }); + const result = normalizePluginRatingResult(data); + this.mergeCachedPluginStats(pluginId, { + rating: result.rating, + rating_count: result.rating_count, + comment_count: result.comment_count, + }); + return result; + } + + async recordDownload( + pluginId: string, + userId?: string, + fingerprint?: string, + ): Promise { + const data = await requestPluginStatsService("POST", "/stats/download", { + plugin_id: pluginId, + user_id: userId, + fingerprint, + }); + const result = normalizePluginDownloadResult(data); + this.mergeCachedPluginStats(pluginId, { downloads: result.downloads }); + return result; + } + private installTargetPath(pluginId: string): string { return this.safePluginPath(validatePluginId(pluginId).replace(/\./gu, "_"), false); } @@ -467,6 +657,36 @@ export class MaiBotPluginClient { return this.statsRequest; } + private async postPluginVote(path: string, pluginId: string, userId: string): Promise { + const data = await requestPluginStatsService("POST", path, { + plugin_id: pluginId, + user_id: userId, + }); + return normalizePluginVoteResult(data); + } + + private mergeCachedPluginStats(pluginId: string, partialStats: Partial): void { + const currentCache = this.statsCache; + if (!currentCache) { + return; + } + + const previousStats = currentCache.data[pluginId] ?? createEmptyPluginStats(pluginId); + const nextStats = normalizePluginStats(pluginId, { + ...previousStats, + ...Object.fromEntries(Object.entries(partialStats).filter(([, value]) => value !== undefined)), + plugin_id: pluginId, + })?.[1] ?? previousStats; + + this.statsCache = { + timestamp: Date.now(), + data: { + ...currentCache.data, + [pluginId]: nextStats, + }, + }; + } + private async resolveSourceUrl(url: string): Promise { if (!this.getModuleSourceConfig) { return url; @@ -509,7 +729,7 @@ export class MaiBotPluginClient { private cachePath(fileName: string): string { const cachePath = resolve(this.maibotRoot, "data", fileName); if (!isPathInside(this.maibotRoot, cachePath)) { - throw new Error("鎻掍欢甯傚満缂撳瓨璺緞瓒呭嚭鍏佽鑼冨洿"); + throw new Error("Plugin market cache path is outside the allowed range"); } return cachePath; } @@ -520,7 +740,7 @@ export class MaiBotPluginClient { const result = await runGit(this.gitPath, args, this.maibotRoot); if (result.exitCode !== 0) { await rm(targetPath, { recursive: true, force: true }).catch(() => undefined); - throw new Error(result.output || "鍏嬮殕浠撳簱澶辫触"); + throw new Error(result.output || "克隆仓库失败"); } } @@ -559,7 +779,7 @@ export class MaiBotPluginClient { if (removeOnFailure) { await rm(pluginPath, { recursive: true, force: true }).catch(() => undefined); } - throw new Error("鏃犳晥鐨勬彃浠讹細缂哄皯 _manifest.json"); + throw new Error("无效的插件:缺少 _manifest.json"); } for (const field of ["name", "version", "author"]) { @@ -567,7 +787,7 @@ export class MaiBotPluginClient { if (removeOnFailure) { await rm(pluginPath, { recursive: true, force: true }).catch(() => undefined); } - throw new Error(`鏃犳晥鐨?_manifest.json锛氱己灏戝繀闇€瀛楁 ${field}`); + throw new Error(`Invalid _manifest.json: missing required field ${field}`); } } @@ -752,12 +972,12 @@ export class MaiBotPluginClient { private safePluginPath(folderName: string, mustExist: boolean): string { if (!folderName || folderName.includes("..") || /[\\/\0\r\n\t]/u.test(folderName)) { - throw new Error("鎻掍欢 ID 鍖呭惈闈炴硶瀛楃"); + throw new Error("Plugin ID contains invalid characters"); } const targetPath = resolve(this.pluginsRoot, folderName); if (!isPathInside(this.pluginsRoot, targetPath)) { - throw new Error("鎻掍欢璺緞瓒呭嚭鍏佽鑼冨洿"); + throw new Error("Plugin path is outside the allowed range"); } if (mustExist && targetPath === this.pluginsRoot) { throw new Error("拒绝操作插件根目录"); @@ -766,6 +986,300 @@ export class MaiBotPluginClient { } } +interface ParsedBlueprintComponents { + components: MaiBotPluginBlueprintComponent[]; + tools: number; + commands: number; + unsupportedDecorators: string[]; +} + +function manifestAuthorName(manifest: MaiBotPluginManifest | null): string { + if (typeof manifest?.author === "string") { + return manifest.author; + } + return manifest?.author?.name?.trim() || "MaiBot Developer"; +} + +function manifestAuthorUrl(manifest: MaiBotPluginManifest | null): string { + if (typeof manifest?.author === "object" && manifest.author?.url) { + return manifest.author.url; + } + return manifest?.homepage_url?.trim() || manifest?.urls?.homepage?.trim() || "https://example.com"; +} + +function manifestRepositoryUrl(manifest: MaiBotPluginManifest | null): string { + return manifest?.repository_url?.trim() + || manifest?.urls?.repository?.trim() + || manifest?.homepage_url?.trim() + || manifest?.urls?.homepage?.trim() + || "https://example.com/maibot-plugin"; +} + +function readManifestSdkVersion( + manifest: MaiBotPluginManifest | null, + key: "min_version" | "max_version", +): string | undefined { + return manifest?.sdk?.[key]?.trim(); +} + +function blueprintFieldsFromConfigSchema(schema: MaiBotPluginConfigSchema): MaiBotPluginBlueprintConfigField[] { + const fields: MaiBotPluginBlueprintConfigField[] = []; + for (const section of schema.sections) { + for (const field of section.fields) { + if (field.path.length < 2) { + continue; + } + const sectionName = field.path[0]; + const fieldName = field.path[1]; + if (sectionName === "plugin" && (fieldName === "enabled" || fieldName === "config_version")) { + continue; + } + const type = blueprintScalarTypeFromConfigValue(field.value); + if (!type) { + continue; + } + fields.push({ + id: `field-${sectionName}-${fieldName}`, + section: sectionName, + name: fieldName, + type, + label: localizedConfigTextToString(field.label, fieldName), + description: localizedConfigTextToString(field.description, ""), + defaultValue: blueprintDefaultFromConfigValue(field.value), + }); + } + } + return fields; +} + +function blueprintScalarTypeFromConfigValue(value: MaiBotPluginConfigValue): MaiBotPluginBlueprintScalarType | null { + if (typeof value === "boolean") { + return "boolean"; + } + if (typeof value === "number") { + return Number.isInteger(value) ? "integer" : "float"; + } + if (typeof value === "string") { + return "string"; + } + return null; +} + +function blueprintDefaultFromConfigValue(value: MaiBotPluginConfigValue): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return ""; +} + +function localizedConfigTextToString(value: MaiBotPluginConfigLocalizedText | undefined, fallback: string): string { + if (typeof value === "string") { + return value || fallback; + } + return value?.["zh-CN"] || value?.zh || value?.en || Object.values(value ?? {})[0] || fallback; +} + +function parsePluginBlueprintComponents(sources: string[]): ParsedBlueprintComponents { + const components: MaiBotPluginBlueprintComponent[] = []; + const unsupportedDecorators = new Set(); + + for (const source of sources) { + components.push(...parseDecoratorComponents(source, "Tool")); + components.push(...parseDecoratorComponents(source, "Command")); + for (const decorator of source.matchAll(/@(Action|EventHandler|API|Schedule)\s*\(/gu)) { + unsupportedDecorators.add(decorator[1]); + } + } + + return { + components: dedupeBlueprintComponents(components), + tools: components.filter((component) => component.kind === "tool").length, + commands: components.filter((component) => component.kind === "command").length, + unsupportedDecorators: [...unsupportedDecorators], + }; +} + +function parseDecoratorComponents(source: string, decoratorName: "Tool" | "Command"): MaiBotPluginBlueprintComponent[] { + const components: MaiBotPluginBlueprintComponent[] = []; + let index = 0; + const marker = `@${decoratorName}`; + while (index < source.length) { + const markerIndex = source.indexOf(marker, index); + if (markerIndex < 0) { + break; + } + const openIndex = source.indexOf("(", markerIndex + marker.length); + if (openIndex < 0) { + break; + } + const closeIndex = findMatchingDelimiter(source, openIndex, "(", ")"); + if (closeIndex < 0) { + break; + } + const argsText = source.slice(openIndex + 1, closeIndex); + const methodStart = source.indexOf("async def", closeIndex); + const nextDecorator = source.indexOf("\n @", closeIndex + 1); + const methodEnd = nextDecorator < 0 ? source.length : nextDecorator; + const methodText = methodStart >= 0 && methodStart < methodEnd ? source.slice(methodStart, methodEnd) : ""; + const component = decoratorName === "Tool" + ? parseToolComponent(argsText, methodText, components.length) + : parseCommandComponent(argsText, methodText, components.length); + if (component) { + components.push(component); + } + index = closeIndex + 1; + } + return components; +} + +function parseToolComponent( + argsText: string, + methodText: string, + index: number, +): MaiBotPluginBlueprintComponent | null { + const name = readDecoratorName(argsText) || readMethodName(methodText)?.replace(/^handle_/u, "") || `tool_${index + 1}`; + return { + id: `tool-${name}-${index}`, + kind: "tool", + name, + description: readDecoratorStringArg(argsText, ["description", "brief_description"]) || name, + detail: readDecoratorStringArg(argsText, ["detailed_description"]) || readDecoratorStringArg(argsText, ["description"]), + responseText: readMethodMessage(methodText) || "工具已执行。", + parameters: parseToolParameters(argsText, methodText), + }; +} + +function parseCommandComponent( + argsText: string, + methodText: string, + index: number, +): MaiBotPluginBlueprintComponent | null { + const name = readDecoratorName(argsText) || readMethodName(methodText)?.replace(/^handle_/u, "") || `command_${index + 1}`; + return { + id: `command-${name}-${index}`, + kind: "command", + name, + description: readDecoratorStringArg(argsText, ["description"]) || name, + trigger: readDecoratorStringArg(argsText, ["pattern"]) || `^/${name}$`, + responseText: readMethodMessage(methodText) || "命令已执行。", + }; +} + +function readDecoratorName(argsText: string): string | undefined { + const literal = readPythonStringLiteral(argsText, skipWhitespace(argsText, 0)); + return literal?.value.trim() || undefined; +} + +function readDecoratorStringArg(argsText: string, names: string[]): string | undefined { + for (const name of names) { + const match = new RegExp(`${escapeRegExp(name)}\\s*=\\s*([rRuUbBfF]*["'])`, "u").exec(argsText); + if (!match) { + continue; + } + const literalStart = match.index + match[0].lastIndexOf(match[1]); + const literal = readPythonStringLiteral(argsText, literalStart); + if (literal?.value.trim()) { + return literal.value.trim(); + } + } + return undefined; +} + +function parseToolParameters(argsText: string, methodText: string): MaiBotPluginBlueprintParameter[] { + const parameters: MaiBotPluginBlueprintParameter[] = []; + const parameterRegex = /ToolParameterInfo\s*\(/gu; + let match: RegExpExecArray | null; + while ((match = parameterRegex.exec(argsText)) !== null) { + const openIndex = argsText.indexOf("(", match.index); + const closeIndex = findMatchingDelimiter(argsText, openIndex, "(", ")"); + if (closeIndex < 0) { + break; + } + const parameterText = argsText.slice(openIndex + 1, closeIndex); + const name = readDecoratorStringArg(parameterText, ["name"]) || ""; + if (!name) { + parameterRegex.lastIndex = closeIndex + 1; + continue; + } + const type = readToolParameterType(parameterText); + parameters.push({ + id: `param-${name}`, + name, + type, + description: readDecoratorStringArg(parameterText, ["description"]) || name, + required: /required\s*=\s*True/u.test(parameterText), + defaultValue: readMethodParameterDefault(methodText, name) ?? defaultValueForBlueprintScalar(type), + }); + parameterRegex.lastIndex = closeIndex + 1; + } + return parameters; +} + +function readToolParameterType(parameterText: string): MaiBotPluginBlueprintScalarType { + const type = parameterText.match(/ToolParamType\.([A-Z_]+)/u)?.[1]; + switch (type) { + case "FLOAT": + case "NUMBER": + return "float"; + case "INTEGER": + case "INT": + return "integer"; + case "BOOLEAN": + case "BOOL": + return "boolean"; + default: + return "string"; + } +} + +function readMethodParameterDefault(methodText: string, name: string): string | undefined { + const signature = methodText.match(/async\s+def\s+\w+\s*\(([\s\S]*?)\)\s*:/u)?.[1]; + if (!signature) { + return undefined; + } + const match = new RegExp(`${escapeRegExp(name)}\\s*:[^=,]+=(\\s*[^,]+)`, "u").exec(signature); + return match?.[1]?.trim().replace(/^["']|["']$/gu, ""); +} + +function defaultValueForBlueprintScalar(type: MaiBotPluginBlueprintScalarType): string { + if (type === "boolean") return "false"; + if (type === "integer" || type === "float") return "0"; + return ""; +} + +function readMethodName(methodText: string): string | undefined { + return methodText.match(/async\s+def\s+(\w+)/u)?.[1]; +} + +function readMethodMessage(methodText: string): string | undefined { + const assignment = methodText.match(/message\s*=\s*([rRuUbBfF]*["'])/u); + if (assignment) { + const literalStart = assignment.index! + assignment[0].lastIndexOf(assignment[1]); + return readPythonStringLiteral(methodText, literalStart)?.value.trim(); + } + const sendText = methodText.match(/send\.text\s*\(\s*([rRuUbBfF]*["'])/u); + if (sendText) { + const literalStart = sendText.index! + sendText[0].lastIndexOf(sendText[1]); + return readPythonStringLiteral(methodText, literalStart)?.value.trim(); + } + return undefined; +} + +function dedupeBlueprintComponents(components: MaiBotPluginBlueprintComponent[]): MaiBotPluginBlueprintComponent[] { + const seen = new Set(); + return components.filter((component) => { + const key = `${component.kind}:${component.name}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + function normalizeMarketPlugin(raw: unknown): MaiBotMarketPlugin | null { if (!raw || typeof raw !== "object" || !("manifest" in raw)) { return null; @@ -778,6 +1292,8 @@ function normalizeMarketPlugin(raw: unknown): MaiBotMarketPlugin | null { downloads?: unknown; rating?: unknown; likes?: unknown; + comment_count?: unknown; + comments?: unknown; }; const manifest = item.manifest; const id = manifest?.id?.trim() || item.id?.trim(); @@ -792,6 +1308,7 @@ function normalizeMarketPlugin(raw: unknown): MaiBotMarketPlugin | null { downloads: normalizeStatsNumber(item.downloads), rating: normalizeStatsNumber(item.rating), likes: normalizeStatsNumber(item.likes), + comment_count: normalizeStatsNumber(item.comment_count) ?? normalizeStatsNumber(item.comments), }; } @@ -872,6 +1389,12 @@ function normalizePluginStats(pluginId: string, rawStats: unknown): [string, Mai } const normalizedId = String(rawStats.plugin_id ?? pluginId); + const recentRatings = normalizePluginRatings(rawStats.recent_ratings); + const ratingCount = normalizeStatsNumber(rawStats.rating_count) ?? 0; + const commentCount = normalizeStatsNumber(rawStats.comment_count) + ?? normalizeStatsNumber(rawStats.comments) + ?? recentRatings?.filter((rating) => rating.comment?.trim()).length + ?? ratingCount; return [ pluginId, { @@ -880,8 +1403,9 @@ function normalizePluginStats(pluginId: string, rawStats: unknown): [string, Mai dislikes: normalizeStatsNumber(rawStats.dislikes) ?? 0, downloads: normalizeStatsNumber(rawStats.downloads) ?? 0, rating: normalizeStatsNumber(rawStats.rating) ?? 0, - rating_count: normalizeStatsNumber(rawStats.rating_count) ?? 0, - recent_ratings: normalizePluginRatings(rawStats.recent_ratings), + rating_count: ratingCount, + comment_count: commentCount, + recent_ratings: recentRatings, }, ]; } @@ -902,14 +1426,89 @@ function normalizePluginRatings(rawRatings: unknown): MaiBotPluginStats["recent_ return rawRatings .filter(isUnknownRecord) .map((rating) => ({ - user_id: String(rating.user_id ?? "鍖垮悕鐢ㄦ埛"), + id: typeof rating.id === "string" ? rating.id : undefined, + user_id: String(rating.user_id ?? "匿名用户"), rating: normalizeStatsNumber(rating.rating) ?? 0, comment: typeof rating.comment === "string" ? rating.comment : undefined, created_at: String(rating.created_at ?? ""), + updated_at: typeof rating.updated_at === "string" ? rating.updated_at : undefined, + likes: normalizeStatsNumber(rating.likes) ?? normalizeStatsNumber(rating.like_count), + dislikes: normalizeStatsNumber(rating.dislikes) ?? normalizeStatsNumber(rating.dislike_count), })) .filter((rating) => rating.rating > 0 || rating.comment); } +function createEmptyPluginStats(pluginId: string): MaiBotPluginStats { + return { + plugin_id: pluginId, + likes: 0, + dislikes: 0, + downloads: 0, + rating: 0, + rating_count: 0, + comment_count: 0, + }; +} + +function normalizePluginUserState(rawData: unknown): MaiBotPluginUserState | null { + if (!isUnknownRecord(rawData) || rawData.success === false) { + return null; + } + + return { + liked: rawData.liked === true, + disliked: rawData.disliked === true, + rating: normalizeStatsNumber(rawData.rating) ?? 0, + comment: typeof rawData.comment === "string" ? rawData.comment : "", + }; +} + +function normalizePluginVoteResult(rawData: unknown): MaiBotPluginVoteResult { + if (!isUnknownRecord(rawData)) { + return { success: false, error: "插件统计服务响应格式无效" }; + } + + return { + success: rawData.success === true, + error: typeof rawData.error === "string" ? rawData.error : undefined, + liked: typeof rawData.liked === "boolean" ? rawData.liked : undefined, + disliked: typeof rawData.disliked === "boolean" ? rawData.disliked : undefined, + likes: normalizeStatsNumber(rawData.likes), + dislikes: normalizeStatsNumber(rawData.dislikes), + remaining: normalizeStatsNumber(rawData.remaining), + }; +} + +function normalizePluginRatingResult(rawData: unknown): MaiBotPluginRatingResult { + if (!isUnknownRecord(rawData)) { + return { success: false, error: "插件统计服务响应格式无效" }; + } + + return { + success: rawData.success === true, + error: typeof rawData.error === "string" ? rawData.error : undefined, + user_rating: normalizeStatsNumber(rawData.user_rating), + rating: normalizeStatsNumber(rawData.rating), + rating_count: normalizeStatsNumber(rawData.rating_count), + comment_count: normalizeStatsNumber(rawData.comment_count) ?? normalizeStatsNumber(rawData.rating_count), + remaining: normalizeStatsNumber(rawData.remaining), + }; +} + +function normalizePluginDownloadResult(rawData: unknown): MaiBotPluginDownloadResult { + if (!isUnknownRecord(rawData)) { + return { success: false, error: "插件统计服务响应格式无效" }; + } + + return { + success: rawData.success === true, + error: typeof rawData.error === "string" ? rawData.error : undefined, + counted: typeof rawData.counted === "boolean" ? rawData.counted : undefined, + downloads: normalizeStatsNumber(rawData.downloads), + remaining: normalizeStatsNumber(rawData.remaining), + }; +} + function githubRawReadmeUrl(repositoryUrl: string): ((branch: string) => string) | undefined { const match = repositoryUrl.match(/github\.com[/:]([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[/?#]|$)/iu); if (!match) { @@ -992,10 +1591,10 @@ function inferPluginId(folderName: string, manifest: MaiBotPluginManifest): stri function validatePluginId(pluginId: string): string { const normalized = pluginId.trim(); if (!normalized || normalized.startsWith(".") || normalized.endsWith(".")) { - throw new Error("鎻掍欢 ID 涓嶈兘涓虹┖锛屼笖涓嶈兘浠ョ偣寮€澶存垨缁撳熬"); + throw new Error("Plugin ID cannot be empty and cannot start or end with a dot"); } if ([".", ".."].includes(normalized) || /[\\/\0\r\n\t]/u.test(normalized) || normalized.includes("..")) { - throw new Error("鎻掍欢 ID 鍖呭惈闈炴硶瀛楃"); + throw new Error("Plugin ID contains invalid characters"); } return normalized; } @@ -1012,7 +1611,7 @@ function parsePluginConfig(raw: string, configPath: string): Record 0) { sections.unshift({ name: "general", - title: "甯歌", + title: "常规", fields: generalFields, }); } @@ -2159,6 +2758,30 @@ function tokenFromServiceUrl(serviceUrl: string | undefined): string | null { } } +async function requestPluginStatsService( + method: "GET" | "POST", + path: string, + payload?: Record, +): Promise { + const init: RequestInit = { + method, + headers: payload ? { "Content-Type": "application/json" } : undefined, + body: payload ? JSON.stringify(payload) : undefined, + }; + const response = await fetchWithTimeout(`${PLUGIN_STATS_BASE_URL}${path}`, MARKET_TIMEOUT_MS, init).catch(() => null); + if (!response) { + return { success: false, error: "插件统计服务暂不可用" }; + } + + const data = await response.json().catch(() => null) as unknown; + if (!response.ok) { + return isUnknownRecord(data) + ? { ...data, success: false } + : { success: false, error: `插件统计服务返回 HTTP ${response.status}` }; + } + return data; +} + async function fetchWithTimeout(url: string, timeoutMs = MARKET_TIMEOUT_MS, init?: RequestInit): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); diff --git a/src/main/services/paths.ts b/src/main/services/paths.ts index 1fd7607..5467f9f 100644 --- a/src/main/services/paths.ts +++ b/src/main/services/paths.ts @@ -115,6 +115,8 @@ export function configureRuntimePaths(): RuntimePaths { runtimeRoot: join(payloadRoot, "runtime"), defaultPythonOverridesRoot: defaults.pythonOverrides, pythonOverridesRoot: defaults.pythonOverrides, + live2dRoot: join(userDataRoot, "live2d"), + pluginBuilderRoot: join(userDataRoot, "plugin-builder", "plugins"), logsRoot: join(userDataRoot, "logs"), }; applyRuntimeResourcePaths(paths, stored); diff --git a/src/main/services/plugin-builder-library.ts b/src/main/services/plugin-builder-library.ts new file mode 100644 index 0000000..5de6e66 --- /dev/null +++ b/src/main/services/plugin-builder-library.ts @@ -0,0 +1,215 @@ +import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + buildMaiBotPluginBlueprintFiles, + defaultMaiBotPluginFolderName, + sanitizeMaiBotPluginFolderName, + validateMaiBotPluginBlueprint, +} from "../../shared/plugin-blueprint"; +import type { + MaiBotPluginBlueprint, + MaiBotPluginBuilderLibraryDeleteResult, + MaiBotPluginBuilderLibraryItem, + MaiBotPluginBuilderLibraryListResult, + MaiBotPluginBuilderLibraryLoadResult, + MaiBotPluginBuilderLibrarySaveResult, +} from "../../shared/contracts"; + +const BLUEPRINT_FILE_NAME = ".maibot-onekey-blueprint.json"; + +interface StoredBuilderBlueprint { + version: 1; + createdAt: number; + updatedAt: number; + blueprint: MaiBotPluginBlueprint; +} + +export class PluginBuilderLibrary { + private readonly root: string; + + constructor(root: string) { + this.root = resolve(root); + } + + getRoot(): string { + return this.root; + } + + async list(): Promise { + await mkdir(this.root, { recursive: true }); + const entries = await readdir(this.root, { withFileTypes: true }).catch(() => []); + const plugins: MaiBotPluginBuilderLibraryItem[] = []; + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".")) { + continue; + } + const pluginPath = this.safeLibraryPath(entry.name); + const stored = await this.readStoredBlueprint(pluginPath).catch(() => null); + if (!stored) { + continue; + } + plugins.push(await this.createItem(pluginPath, stored)); + } + + plugins.sort((left, right) => right.updatedAt - left.updatedAt); + return { root: this.root, plugins }; + } + + async save(blueprint: MaiBotPluginBlueprint, overwrite = true): Promise { + const errors = validateMaiBotPluginBlueprint(blueprint); + if (errors.length > 0) { + throw new Error(errors.join("\n")); + } + + const pluginId = blueprint.manifest.pluginId.trim(); + const folderName = sanitizeMaiBotPluginFolderName( + blueprint.manifest.folderName ?? defaultMaiBotPluginFolderName(pluginId), + pluginId, + ); + const pluginPath = this.safeLibraryPath(folderName); + const existed = await pathExists(pluginPath); + if (existed && !overwrite) { + throw new Error("Builder plugin already exists. Enable overwrite to update it."); + } + + const now = Date.now(); + const previous = existed ? await this.readStoredBlueprint(pluginPath).catch(() => null) : null; + if (existed) { + await rm(pluginPath, { recursive: true, force: true }); + } + await mkdir(pluginPath, { recursive: true }); + + const normalizedBlueprint: MaiBotPluginBlueprint = { + ...blueprint, + manifest: { + ...blueprint.manifest, + pluginId, + folderName, + }, + }; + const files = buildMaiBotPluginBlueprintFiles(normalizedBlueprint); + const stored: StoredBuilderBlueprint = { + version: 1, + createdAt: previous?.createdAt ?? now, + updatedAt: now, + blueprint: normalizedBlueprint, + }; + + await writeFile( + this.blueprintPath(pluginPath), + `${JSON.stringify(stored, null, 2)}\n`, + "utf8", + ); + for (const file of files) { + const targetPath = resolve(pluginPath, file.relativePath); + if (!isPathInsideOrSame(pluginPath, targetPath)) { + throw new Error(`Refusing to write outside builder plugin directory: ${file.relativePath}`); + } + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, file.content, "utf8"); + } + + return { + item: await this.createItem(pluginPath, stored), + files, + overwritten: existed, + savedAt: now, + }; + } + + async load(pluginId: string): Promise { + const pluginPath = await this.resolveLibraryPluginPath(pluginId); + const stored = await this.readStoredBlueprint(pluginPath); + return { + item: await this.createItem(pluginPath, stored), + blueprint: stored.blueprint, + files: buildMaiBotPluginBlueprintFiles(stored.blueprint), + }; + } + + async delete(pluginId: string): Promise { + const pluginPath = await this.resolveLibraryPluginPath(pluginId); + const stored = await this.readStoredBlueprint(pluginPath); + await rm(pluginPath, { recursive: true, force: true }); + return { + pluginId: stored.blueprint.manifest.pluginId, + path: pluginPath, + deletedAt: Date.now(), + }; + } + + private async resolveLibraryPluginPath(pluginId: string): Promise { + const list = await this.list(); + const item = list.plugins.find((plugin) => + plugin.pluginId === pluginId || plugin.folderName === pluginId + ); + if (!item) { + throw new Error(`Builder plugin not found: ${pluginId}`); + } + return this.safeLibraryPath(item.folderName); + } + + private async createItem( + pluginPath: string, + stored: StoredBuilderBlueprint, + ): Promise { + const manifest = stored.blueprint.manifest; + const folderName = pluginPath.split(/[\\/]+/u).at(-1) ?? defaultMaiBotPluginFolderName(manifest.pluginId); + const files = buildMaiBotPluginBlueprintFiles(stored.blueprint); + const pluginStat = await stat(pluginPath).catch(() => undefined); + return { + pluginId: manifest.pluginId, + name: manifest.name, + version: manifest.version, + description: manifest.description, + folderName, + path: pluginPath, + blueprintPath: this.blueprintPath(pluginPath), + updatedAt: stored.updatedAt || pluginStat?.mtimeMs || Date.now(), + createdAt: stored.createdAt, + fileCount: files.length, + }; + } + + private async readStoredBlueprint(pluginPath: string): Promise { + const raw = JSON.parse(await readFile(this.blueprintPath(pluginPath), "utf8")) as Partial; + if (raw.version !== 1 || !raw.blueprint?.manifest?.pluginId) { + throw new Error("Invalid builder plugin blueprint."); + } + return { + version: 1, + createdAt: Number(raw.createdAt) || Date.now(), + updatedAt: Number(raw.updatedAt) || Date.now(), + blueprint: raw.blueprint, + }; + } + + private blueprintPath(pluginPath: string): string { + return resolve(pluginPath, BLUEPRINT_FILE_NAME); + } + + private safeLibraryPath(folderName: string): string { + const targetPath = resolve(this.root, folderName); + if (!isPathInsideOrSame(this.root, targetPath) || targetPath === this.root) { + throw new Error("Invalid builder plugin path."); + } + return targetPath; + } +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +function isPathInsideOrSame(root: string, target: string): boolean { + const resolvedRoot = resolve(root); + const resolvedTarget = resolve(target); + const diff = relative(resolvedRoot, resolvedTarget); + return !diff || (diff !== ".." && !diff.startsWith(`..${sep}`) && !isAbsolute(diff)); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 84d3577..58758a0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,7 @@ import type { InitRepairResult, InitState, LogEntry, + Live2dModelImportResult, LocalChatConnectionState, LocalChatConnectRequest, LocalChatEvent, @@ -17,15 +18,30 @@ import type { MaiBotDataImportResult, MaiBotDataResetResult, MaiBotInstalledPlugin, + MaiBotPluginBlueprintCreateRequest, + MaiBotPluginBlueprintCreateResult, + MaiBotPluginBlueprintParseResult, + MaiBotPluginBuilderBlueprintExportRequest, + MaiBotPluginBuilderBlueprintExportResult, + MaiBotPluginBuilderBlueprintImportResult, + MaiBotPluginBuilderLibraryDeleteResult, + MaiBotPluginBuilderLibraryListResult, + MaiBotPluginBuilderLibraryLoadResult, + MaiBotPluginBuilderLibrarySaveRequest, + MaiBotPluginBuilderLibrarySaveResult, MaiBotPluginConfigSaveResult, MaiBotPluginConfigState, MaiBotPluginConfigValue, + MaiBotPluginDownloadResult, MaiBotPluginListOptions, MaiBotPluginListResult, MaiBotPluginOperationRequest, MaiBotPluginOperationResult, + MaiBotPluginRatingResult, MaiBotPluginReadmeResult, MaiBotPluginStats, + MaiBotPluginUserState, + MaiBotPluginVoteResult, MaiBotStatisticSummary, ManagedPythonPackageName, ModuleUpdateResult, @@ -155,6 +171,12 @@ const desktopBridge: DesktopBridge = { resetAll: () => ipcRenderer.invoke("launcher:resetAll") as Promise, }, + live2d: { + getLibraryRoot: () => ipcRenderer.invoke("live2d:getLibraryRoot") as Promise, + openLibrary: () => ipcRenderer.invoke("live2d:openLibrary") as Promise, + importModel: (sourcePath?: string) => + ipcRenderer.invoke("live2d:importModel", sourcePath) as Promise, + }, plugins: { listMarket: (serviceUrl?: string, options?: MaiBotPluginListOptions) => ipcRenderer.invoke("plugins:listMarket", serviceUrl, options) as Promise, @@ -166,6 +188,24 @@ const desktopBridge: DesktopBridge = { ipcRenderer.invoke("plugins:update", request) as Promise, uninstall: (pluginId: string) => ipcRenderer.invoke("plugins:uninstall", pluginId) as Promise, + createFromBlueprint: (request: MaiBotPluginBlueprintCreateRequest) => + ipcRenderer.invoke("plugins:createFromBlueprint", request) as Promise, + parseToBlueprint: (pluginId: string) => + ipcRenderer.invoke("plugins:parseToBlueprint", pluginId) as Promise, + listBuilderLibrary: () => + ipcRenderer.invoke("plugins:listBuilderLibrary") as Promise, + saveBuilderLibrary: (request: MaiBotPluginBuilderLibrarySaveRequest) => + ipcRenderer.invoke("plugins:saveBuilderLibrary", request) as Promise, + loadBuilderLibrary: (pluginId: string) => + ipcRenderer.invoke("plugins:loadBuilderLibrary", pluginId) as Promise, + deleteBuilderLibrary: (pluginId: string) => + ipcRenderer.invoke("plugins:deleteBuilderLibrary", pluginId) as Promise, + exportBuilderBlueprint: (request: MaiBotPluginBuilderBlueprintExportRequest) => + ipcRenderer.invoke("plugins:exportBuilderBlueprint", request) as Promise, + importBuilderBlueprint: (sourcePath?: string) => + ipcRenderer.invoke("plugins:importBuilderBlueprint", sourcePath) as Promise, + openBuilderLibrary: () => + ipcRenderer.invoke("plugins:openBuilderLibrary") as Promise, getConfig: (pluginId: string, serviceUrl?: string) => ipcRenderer.invoke("plugins:getConfig", pluginId, serviceUrl) as Promise, saveConfig: (pluginId: string, config: Record, serviceUrl?: string) => @@ -174,6 +214,16 @@ const desktopBridge: DesktopBridge = { ipcRenderer.invoke("plugins:getReadme", pluginId, repositoryUrl) as Promise, getStats: (pluginId: string) => ipcRenderer.invoke("plugins:getStats", pluginId) as Promise, + getUserState: (pluginId: string, userId: string) => + ipcRenderer.invoke("plugins:getUserState", pluginId, userId) as Promise, + like: (pluginId: string, userId: string) => + ipcRenderer.invoke("plugins:like", pluginId, userId) as Promise, + dislike: (pluginId: string, userId: string) => + ipcRenderer.invoke("plugins:dislike", pluginId, userId) as Promise, + rate: (pluginId: string, rating: number, comment: string | undefined, userId: string) => + ipcRenderer.invoke("plugins:rate", pluginId, rating, comment, userId) as Promise, + recordDownload: (pluginId: string, userId?: string, fingerprint?: string) => + ipcRenderer.invoke("plugins:recordDownload", pluginId, userId, fingerprint) as Promise, }, statistics: { getMaiBot: () => diff --git a/src/renderer/src/lib/desktop-api.ts b/src/renderer/src/lib/desktop-api.ts index 1f5c524..9c17f80 100644 --- a/src/renderer/src/lib/desktop-api.ts +++ b/src/renderer/src/lib/desktop-api.ts @@ -28,6 +28,8 @@ const fallbackSnapshot: DesktopSnapshot = { runtimeRoot: "开发预览/runtime", defaultPythonOverridesRoot: "开发预览/python-overrides", pythonOverridesRoot: "开发预览/python-overrides", + live2dRoot: "开发预览/live2d", + pluginBuilderRoot: "开发预览/plugin-builder/plugins", logsRoot: "开发预览/logs", }, services: [ diff --git a/src/renderer/src/lib/maibot-plugin-api.ts b/src/renderer/src/lib/maibot-plugin-api.ts index d35079d..8258d48 100644 --- a/src/renderer/src/lib/maibot-plugin-api.ts +++ b/src/renderer/src/lib/maibot-plugin-api.ts @@ -1,23 +1,49 @@ import type { MaiBotInstalledPlugin, + MaiBotPluginBlueprint, + MaiBotPluginBlueprintCreateResult, + MaiBotPluginBlueprintParseResult, + MaiBotPluginBuilderBlueprintExportResult, + MaiBotPluginBuilderBlueprintImportResult, + MaiBotPluginBuilderLibraryDeleteResult, + MaiBotPluginBuilderLibraryListResult, + MaiBotPluginBuilderLibraryLoadResult, + MaiBotPluginBuilderLibrarySaveResult, MaiBotPluginConfigSaveResult, MaiBotPluginConfigState, MaiBotPluginConfigValue, + MaiBotPluginDownloadResult, MaiBotMarketPlugin, MaiBotPluginListOptions, MaiBotPluginListResult, MaiBotPluginManifest, MaiBotPluginOperationResult, + MaiBotPluginRatingResult, MaiBotPluginReadmeResult, MaiBotPluginStats, + MaiBotPluginUserState, + MaiBotPluginVoteResult, ServiceDescriptor, } from "@shared/contracts"; export type PluginManifest = MaiBotPluginManifest; +export type PluginBlueprint = MaiBotPluginBlueprint; +export type PluginBlueprintCreateResponse = MaiBotPluginBlueprintCreateResult; +export type PluginBlueprintParseResponse = MaiBotPluginBlueprintParseResult; +export type PluginBuilderBlueprintExportResponse = MaiBotPluginBuilderBlueprintExportResult; +export type PluginBuilderBlueprintImportResponse = MaiBotPluginBuilderBlueprintImportResult; +export type PluginBuilderLibraryDeleteResponse = MaiBotPluginBuilderLibraryDeleteResult; +export type PluginBuilderLibraryListResponse = MaiBotPluginBuilderLibraryListResult; +export type PluginBuilderLibraryLoadResponse = MaiBotPluginBuilderLibraryLoadResult; +export type PluginBuilderLibrarySaveResponse = MaiBotPluginBuilderLibrarySaveResult; export type MarketPlugin = MaiBotMarketPlugin; export type InstalledPlugin = MaiBotInstalledPlugin; export type PluginOperationResponse = MaiBotPluginOperationResult; export type PluginStats = MaiBotPluginStats; +export type PluginUserState = MaiBotPluginUserState; +export type PluginVoteResponse = MaiBotPluginVoteResult; +export type PluginRatingResponse = MaiBotPluginRatingResult; +export type PluginDownloadResponse = MaiBotPluginDownloadResult; export type PluginReadmeResult = MaiBotPluginReadmeResult; export type PluginConfigState = MaiBotPluginConfigState; export type PluginConfigValue = MaiBotPluginConfigValue; @@ -181,6 +207,50 @@ export function updateMaiBotPlugin( }); } +export function createMaiBotPluginFromBlueprint( + blueprint: PluginBlueprint, + overwrite = false, +): Promise { + return requirePluginBridge().createFromBlueprint({ blueprint, overwrite }); +} + +export function parseMaiBotPluginToBlueprint(pluginId: string): Promise { + return requirePluginBridge().parseToBlueprint(pluginId); +} + +export function listPluginBuilderLibrary(): Promise { + return requirePluginBridge().listBuilderLibrary(); +} + +export function savePluginBuilderLibrary( + blueprint: PluginBlueprint, + overwrite = true, +): Promise { + return requirePluginBridge().saveBuilderLibrary({ blueprint, overwrite }); +} + +export function loadPluginBuilderLibrary(pluginId: string): Promise { + return requirePluginBridge().loadBuilderLibrary(pluginId); +} + +export function deletePluginBuilderLibrary(pluginId: string): Promise { + return requirePluginBridge().deleteBuilderLibrary(pluginId); +} + +export function exportPluginBuilderBlueprint( + blueprint: PluginBlueprint, +): Promise { + return requirePluginBridge().exportBuilderBlueprint({ blueprint }); +} + +export function importPluginBuilderBlueprint(sourcePath?: string): Promise { + return requirePluginBridge().importBuilderBlueprint(sourcePath); +} + +export function openPluginBuilderLibrary(): Promise { + return requirePluginBridge().openBuilderLibrary(); +} + export function fetchPluginConfig(pluginId: string, service?: ServiceDescriptor): Promise { return requirePluginBridge().getConfig(pluginId, service?.url); } @@ -200,3 +270,74 @@ export function fetchPluginReadme(pluginId: string, repositoryUrl?: string): Pro export function fetchPluginStats(pluginId: string): Promise { return requirePluginBridge().getStats(pluginId); } + +export function fetchPluginUserState(pluginId: string): Promise { + return requirePluginBridge().getUserState(pluginId, getPluginStatsUserId()); +} + +export function likePlugin(pluginId: string): Promise { + return requirePluginBridge().like(pluginId, getPluginStatsUserId()); +} + +export function dislikePlugin(pluginId: string): Promise { + return requirePluginBridge().dislike(pluginId, getPluginStatsUserId()); +} + +export function ratePlugin( + pluginId: string, + rating: number, + comment?: string, +): Promise { + return requirePluginBridge().rate(pluginId, rating, comment, getPluginStatsUserId()); +} + +export function recordPluginDownload(pluginId: string): Promise { + return requirePluginBridge().recordDownload(pluginId, getPluginStatsUserId(), generatePluginStatsFingerprint()); +} + +export function getPluginStatsUserId(): string { + const storageKey = "maibot_user_id"; + try { + const stored = window.localStorage.getItem(storageKey); + if (stored) { + return stored; + } + + const fingerprint = generatePluginStatsFingerprint(); + const userId = `${fingerprint}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 14)}`; + window.localStorage.setItem(storageKey, userId); + return userId; + } catch { + return `anon_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 14)}`; + } +} + +export function generatePluginStatsFingerprint(): string { + const nav = typeof navigator !== "undefined" + ? navigator as Navigator & { deviceMemory?: number } + : null; + const screenInfo = typeof screen !== "undefined" ? screen : null; + const features = [ + nav?.userAgent ?? "", + nav?.language ?? "", + nav?.languages?.join(",") ?? "", + nav?.platform ?? "", + nav?.hardwareConcurrency ?? 0, + screenInfo?.width ?? 0, + screenInfo?.height ?? 0, + screenInfo?.colorDepth ?? 0, + screenInfo?.pixelDepth ?? 0, + new Date().getTimezoneOffset(), + Intl.DateTimeFormat().resolvedOptions().timeZone, + nav?.maxTouchPoints ?? 0, + nav?.deviceMemory ?? 0, + ].join("|"); + + let hash = 0; + for (let index = 0; index < features.length; index++) { + hash = ((hash << 5) - hash) + features.charCodeAt(index); + hash &= hash; + } + + return `fp_${Math.abs(hash).toString(36)}`; +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 4a33151..5753979 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -150,9 +150,19 @@ export interface RuntimePaths { runtimeRoot: string; defaultPythonOverridesRoot: string; pythonOverridesRoot: string; + live2dRoot: string; + pluginBuilderRoot: string; logsRoot: string; } +export interface Live2dModelImportResult { + sourcePath: string; + modelPath: string; + modelUrl: string; + libraryRoot: string; + copied: boolean; +} + export interface LocalChatSendRequest { content: string; userName?: string; @@ -454,6 +464,9 @@ export interface MaiBotPluginManifest { keywords?: string[]; categories?: string[]; host_application?: { min_version?: string; max_version?: string }; + sdk?: { min_version?: string; max_version?: string }; + dependencies?: string[]; + capabilities?: string[]; manifest_version?: number; } @@ -466,6 +479,7 @@ export interface MaiBotMarketPlugin { downloads?: number; rating?: number; likes?: number; + comment_count?: number; } export interface MaiBotInstalledPlugin { @@ -494,14 +508,54 @@ export interface MaiBotPluginStats { downloads: number; rating: number; rating_count: number; + comment_count: number; recent_ratings?: MaiBotPluginRating[]; } export interface MaiBotPluginRating { + id?: string; user_id: string; rating: number; comment?: string; created_at: string; + updated_at?: string; + likes?: number; + dislikes?: number; +} + +export interface MaiBotPluginUserState { + liked: boolean; + disliked: boolean; + rating: number; + comment: string; +} + +export interface MaiBotPluginVoteResult { + success: boolean; + error?: string; + liked?: boolean; + disliked?: boolean; + likes?: number; + dislikes?: number; + remaining?: number; +} + +export interface MaiBotPluginRatingResult { + success: boolean; + error?: string; + user_rating?: number; + rating?: number; + rating_count?: number; + comment_count?: number; + remaining?: number; +} + +export interface MaiBotPluginDownloadResult { + success: boolean; + error?: string; + counted?: boolean; + downloads?: number; + remaining?: number; } export interface MaiBotPluginReadmeResult { @@ -526,6 +580,192 @@ export interface MaiBotPluginOperationResult { new_version?: string; } +export type MaiBotPluginBlueprintScalarType = "string" | "integer" | "float" | "boolean"; + +export type MaiBotPluginBlueprintComponentKind = "tool" | "command" | "hook"; + +export type MaiBotPluginBlueprintFlowNodeKind = + | "send_text" + | "read_config" + | "log_info" + | "set_variable" + | "if_condition" + | "compare" + | "boolean_logic" + | "math_operation" + | "join_text" + | "guard_config" + | "loop" + | "wait" + | "comment" + | "return_success"; + +export interface MaiBotPluginBlueprintManifest { + pluginId: string; + folderName?: string; + name: string; + version: string; + description: string; + authorName: string; + authorUrl: string; + license: string; + repositoryUrl: string; + minHostVersion: string; + maxHostVersion: string; + minSdkVersion: string; + maxSdkVersion: string; + capabilities: string[]; +} + +export interface MaiBotPluginBlueprintParameter { + id: string; + name: string; + type: MaiBotPluginBlueprintScalarType; + description: string; + required: boolean; + defaultValue: string; +} + +export interface MaiBotPluginBlueprintComponent { + id: string; + kind: MaiBotPluginBlueprintComponentKind; + name: string; + description: string; + detail?: string; + trigger?: string; + eventType?: string; + responseText?: string; + parameters?: MaiBotPluginBlueprintParameter[]; + flowNodes?: MaiBotPluginBlueprintFlowNode[]; + flowEdges?: MaiBotPluginBlueprintFlowEdge[]; +} + +export interface MaiBotPluginBlueprintFlowNode { + id: string; + kind: MaiBotPluginBlueprintFlowNodeKind; + label: string; + value?: string; + configPath?: string; + leftValue?: string; + rightValue?: string; + operator?: string; + targetName?: string; +} + +export interface MaiBotPluginBlueprintFlowEdge { + id: string; + fromNodeId: string; + toNodeId: string; +} + +export interface MaiBotPluginBlueprintConfigField { + id: string; + section: string; + name: string; + type: MaiBotPluginBlueprintScalarType; + label: string; + description: string; + defaultValue: string; +} + +export interface MaiBotPluginBlueprint { + manifest: MaiBotPluginBlueprintManifest; + components: MaiBotPluginBlueprintComponent[]; + configFields: MaiBotPluginBlueprintConfigField[]; +} + +export interface MaiBotPluginBlueprintFile { + relativePath: string; + content: string; +} + +export interface MaiBotPluginBlueprintCreateRequest { + blueprint: MaiBotPluginBlueprint; + overwrite?: boolean; +} + +export interface MaiBotPluginBlueprintCreateResult { + pluginId: string; + pluginPath: string; + files: MaiBotPluginBlueprintFile[]; + overwritten: boolean; + createdAt: number; +} + +export interface MaiBotPluginBlueprintParseResult { + pluginId: string; + pluginPath: string; + blueprint: MaiBotPluginBlueprint; + parsed: { + manifest: boolean; + configFields: number; + tools: number; + commands: number; + unsupportedDecorators: string[]; + }; +} + +export interface MaiBotPluginBuilderLibraryItem { + pluginId: string; + name: string; + version: string; + description: string; + folderName: string; + path: string; + blueprintPath: string; + updatedAt: number; + createdAt?: number; + fileCount: number; +} + +export interface MaiBotPluginBuilderLibraryListResult { + root: string; + plugins: MaiBotPluginBuilderLibraryItem[]; +} + +export interface MaiBotPluginBuilderLibrarySaveRequest { + blueprint: MaiBotPluginBlueprint; + overwrite?: boolean; +} + +export interface MaiBotPluginBuilderLibrarySaveResult { + item: MaiBotPluginBuilderLibraryItem; + files: MaiBotPluginBlueprintFile[]; + overwritten: boolean; + savedAt: number; +} + +export interface MaiBotPluginBuilderLibraryLoadResult { + item: MaiBotPluginBuilderLibraryItem; + blueprint: MaiBotPluginBlueprint; + files: MaiBotPluginBlueprintFile[]; +} + +export interface MaiBotPluginBuilderLibraryDeleteResult { + pluginId: string; + path: string; + deletedAt: number; +} + +export interface MaiBotPluginBuilderBlueprintExportRequest { + blueprint: MaiBotPluginBlueprint; +} + +export interface MaiBotPluginBuilderBlueprintExportResult { + pluginId: string; + filePath: string; + exportedAt: number; +} + +export interface MaiBotPluginBuilderBlueprintImportResult { + item: MaiBotPluginBuilderLibraryItem; + blueprint: MaiBotPluginBlueprint; + files: MaiBotPluginBlueprintFile[]; + sourcePath: string; + overwritten: boolean; + importedAt: number; +} + export type MaiBotPluginConfigPrimitive = string | number | boolean | null; export type MaiBotPluginConfigValue = @@ -846,12 +1086,30 @@ export interface DesktopBridge { resetSettings: () => Promise; resetAll: () => Promise; }; + live2d: { + getLibraryRoot: () => Promise; + openLibrary: () => Promise; + importModel: (sourcePath?: string) => Promise; + }; plugins: { listMarket: (serviceUrl?: string, options?: MaiBotPluginListOptions) => Promise; listInstalled: (serviceUrl?: string) => Promise; install: (request: MaiBotPluginOperationRequest) => Promise; update: (request: MaiBotPluginOperationRequest) => Promise; uninstall: (pluginId: string) => Promise; + createFromBlueprint: (request: MaiBotPluginBlueprintCreateRequest) => Promise; + parseToBlueprint: (pluginId: string) => Promise; + listBuilderLibrary: () => Promise; + saveBuilderLibrary: ( + request: MaiBotPluginBuilderLibrarySaveRequest, + ) => Promise; + loadBuilderLibrary: (pluginId: string) => Promise; + deleteBuilderLibrary: (pluginId: string) => Promise; + exportBuilderBlueprint: ( + request: MaiBotPluginBuilderBlueprintExportRequest, + ) => Promise; + importBuilderBlueprint: (sourcePath?: string) => Promise; + openBuilderLibrary: () => Promise; getConfig: (pluginId: string, serviceUrl?: string) => Promise; saveConfig: ( pluginId: string, @@ -860,6 +1118,20 @@ export interface DesktopBridge { ) => Promise; getReadme: (pluginId: string, repositoryUrl?: string) => Promise; getStats: (pluginId: string) => Promise; + getUserState: (pluginId: string, userId: string) => Promise; + like: (pluginId: string, userId: string) => Promise; + dislike: (pluginId: string, userId: string) => Promise; + rate: ( + pluginId: string, + rating: number, + comment: string | undefined, + userId: string, + ) => Promise; + recordDownload: ( + pluginId: string, + userId?: string, + fingerprint?: string, + ) => Promise; }; statistics: { getMaiBot: () => Promise; diff --git a/src/shared/plugin-blueprint.ts b/src/shared/plugin-blueprint.ts new file mode 100644 index 0000000..674e1b1 --- /dev/null +++ b/src/shared/plugin-blueprint.ts @@ -0,0 +1,854 @@ +import type { + MaiBotPluginBlueprint, + MaiBotPluginBlueprintComponent, + MaiBotPluginBlueprintConfigField, + MaiBotPluginBlueprintFlowNode, + MaiBotPluginBlueprintFile, + MaiBotPluginBlueprintManifest, + MaiBotPluginBlueprintParameter, + MaiBotPluginBlueprintScalarType, +} from "./contracts"; + +interface NormalizedBlueprint { + manifest: Required; + components: MaiBotPluginBlueprintComponent[]; + configFields: MaiBotPluginBlueprintConfigField[]; +} + +interface ConfigSection { + name: string; + title: string; + className: string; + fields: MaiBotPluginBlueprintConfigField[]; +} + +const DEFAULT_AUTHOR_URL = "https://example.com"; +const DEFAULT_REPOSITORY_URL = "https://example.com/maibot-plugin"; +const DEFAULT_LICENSE = "MIT"; +const DEFAULT_HOST_MIN_VERSION = "1.0.0"; +const DEFAULT_HOST_MAX_VERSION = "1.99.99"; +const DEFAULT_SDK_MIN_VERSION = "2.0.0"; +const DEFAULT_SDK_MAX_VERSION = "2.99.99"; + +const RESERVED_PYTHON_IDENTIFIERS = new Set([ + "False", + "None", + "True", + "and", + "as", + "assert", + "async", + "await", + "break", + "class", + "continue", + "def", + "del", + "elif", + "else", + "except", + "finally", + "for", + "from", + "global", + "if", + "import", + "in", + "is", + "lambda", + "nonlocal", + "not", + "or", + "pass", + "raise", + "return", + "try", + "while", + "with", + "yield", +]); + +export function buildMaiBotPluginBlueprintFiles( + blueprint: MaiBotPluginBlueprint, +): MaiBotPluginBlueprintFile[] { + const normalized = normalizeBlueprint(blueprint); + const manifest = buildManifestJson(normalized); + const pluginPy = buildPluginPython(normalized); + const configToml = buildConfigToml(normalized); + + return [ + { relativePath: "_manifest.json", content: `${JSON.stringify(manifest, null, 2)}\n` }, + { relativePath: "plugin.py", content: pluginPy }, + { relativePath: "config.toml", content: configToml }, + ]; +} + +export function defaultMaiBotPluginFolderName(pluginId: string): string { + const normalizedId = normalizePluginId(pluginId); + return normalizedId.replace(/\./gu, "_"); +} + +export function sanitizeMaiBotPluginFolderName(value: string, pluginId: string): string { + const fallback = defaultMaiBotPluginFolderName(pluginId); + const folderName = value + .trim() + .replace(/[<>:"/\\|?*\u0000-\u001f]+/gu, "_") + .replace(/\s+/gu, "_") + .replace(/[. ]+$/u, "") + .replace(/^\.+/u, ""); + return folderName || fallback; +} + +export function validateMaiBotPluginBlueprint(blueprint: MaiBotPluginBlueprint): string[] { + const errors: string[] = []; + const manifest = blueprint.manifest; + const pluginId = manifest.pluginId.trim(); + if (!isValidPluginId(pluginId)) { + errors.push("插件 ID 需要使用小写字母、数字、点号或横线,并且不能以点号开头或结尾。"); + } + if (!manifest.name.trim()) { + errors.push("插件名称不能为空。"); + } + if (!/^\d+\.\d+\.\d+$/u.test(manifest.version.trim())) { + errors.push("插件版本需要是三段式语义版本,例如 1.0.0。"); + } + if (!isHttpUrl(manifest.authorUrl || DEFAULT_AUTHOR_URL)) { + errors.push("作者 URL 需要是 http 或 https 地址。"); + } + if (!isHttpUrl(manifest.repositoryUrl || DEFAULT_REPOSITORY_URL)) { + errors.push("仓库地址需要是 http 或 https 地址。"); + } + + const componentNames = new Set(); + for (const component of blueprint.components) { + const name = component.name.trim(); + if (!name) { + errors.push("组件节点名称不能为空。"); + continue; + } + if (componentNames.has(name)) { + errors.push(`组件节点名称重复:${name}`); + } + componentNames.add(name); + for (const parameter of component.parameters ?? []) { + if (!parameter.name.trim()) { + errors.push(`组件 ${name} 存在空参数名。`); + } + } + } + + for (const field of blueprint.configFields) { + if (!field.section.trim() || !field.name.trim()) { + errors.push("配置字段需要同时填写分组和字段名。"); + } + } + + return errors; +} + +function normalizeBlueprint(blueprint: MaiBotPluginBlueprint): NormalizedBlueprint { + const pluginId = normalizePluginId(blueprint.manifest.pluginId); + const folderName = sanitizeMaiBotPluginFolderName(blueprint.manifest.folderName ?? "", pluginId); + const manifest: Required = { + pluginId, + folderName, + name: blueprint.manifest.name.trim() || "MaiBot 插件", + version: normalizeVersion(blueprint.manifest.version), + description: blueprint.manifest.description.trim() || "由 MaiBot OneKey 插件编写器生成的插件", + authorName: blueprint.manifest.authorName.trim() || "MaiBot Developer", + authorUrl: ensureHttpUrl(blueprint.manifest.authorUrl, DEFAULT_AUTHOR_URL), + license: blueprint.manifest.license.trim() || DEFAULT_LICENSE, + repositoryUrl: ensureHttpUrl(blueprint.manifest.repositoryUrl, DEFAULT_REPOSITORY_URL), + minHostVersion: normalizeVersion(blueprint.manifest.minHostVersion || DEFAULT_HOST_MIN_VERSION), + maxHostVersion: normalizeVersion(blueprint.manifest.maxHostVersion || DEFAULT_HOST_MAX_VERSION), + minSdkVersion: normalizeVersion(blueprint.manifest.minSdkVersion || DEFAULT_SDK_MIN_VERSION), + maxSdkVersion: normalizeVersion(blueprint.manifest.maxSdkVersion || DEFAULT_SDK_MAX_VERSION), + capabilities: normalizeCapabilities(blueprint.manifest.capabilities), + }; + + const components = blueprint.components + .map(normalizeComponent) + .filter((component) => component.name.length > 0); + const configFields = [ + createConfigField("builtin-enabled", "plugin", "enabled", "boolean", "启用插件", "是否启用插件", "true"), + createConfigField("builtin-config-version", "plugin", "config_version", "string", "配置版本", "配置文件版本", manifest.version), + ...blueprint.configFields.map(normalizeConfigField).filter((field) => field.name.length > 0), + ]; + + return { manifest, components, configFields: dedupeConfigFields(configFields) }; +} + +function buildManifestJson(blueprint: NormalizedBlueprint): Record { + return { + manifest_version: 2, + id: blueprint.manifest.pluginId, + version: blueprint.manifest.version, + name: blueprint.manifest.name, + description: blueprint.manifest.description, + author: { + name: blueprint.manifest.authorName, + url: blueprint.manifest.authorUrl, + }, + license: blueprint.manifest.license, + urls: { + repository: blueprint.manifest.repositoryUrl, + homepage: blueprint.manifest.repositoryUrl, + documentation: blueprint.manifest.repositoryUrl, + issues: blueprint.manifest.repositoryUrl, + }, + host_application: { + min_version: blueprint.manifest.minHostVersion, + max_version: blueprint.manifest.maxHostVersion, + }, + sdk: { + min_version: blueprint.manifest.minSdkVersion, + max_version: blueprint.manifest.maxSdkVersion, + }, + dependencies: [], + capabilities: blueprint.manifest.capabilities, + i18n: { + default_locale: "zh-CN", + supported_locales: ["zh-CN"], + }, + }; +} + +function buildPluginPython(blueprint: NormalizedBlueprint): string { + const className = toPascalCase(blueprint.manifest.name || blueprint.manifest.pluginId, "GeneratedPlugin"); + const pluginClassName = className.endsWith("Plugin") ? className : `${className}Plugin`; + const configClassName = `${pluginClassName}Config`; + const configSections = buildConfigSections(blueprint.configFields); + const imports = new Set(["Command", "Field", "MaiBotPlugin", "PluginConfigBase"]); + if (blueprint.components.some((component) => component.kind === "tool")) { + imports.add("Tool"); + } + if (blueprint.components.some((component) => component.kind === "hook")) { + imports.add("EventHandler"); + } + + const lines: string[] = [ + `"""${blueprint.manifest.name}`, + "", + "This plugin was generated by MaiBot OneKey Plugin Builder.", + `Plugin id: ${blueprint.manifest.pluginId}`, + '"""', + "", + "import asyncio", + "", + "from typing import Any", + "", + `from maibot_sdk import ${[...imports].sort().join(", ")}`, + ]; + + const typeImports = new Set(); + if (blueprint.components.some((component) => component.kind === "tool")) { + typeImports.add("ToolParameterInfo"); + typeImports.add("ToolParamType"); + } + if (blueprint.components.some((component) => component.kind === "hook")) { + typeImports.add("EventType"); + } + if (typeImports.size > 0) { + lines.push(`from maibot_sdk.types import ${[...typeImports].sort().join(", ")}`); + } + + lines.push("", ""); + for (const section of configSections) { + lines.push(...buildConfigSectionClass(section), ""); + } + + lines.push(`class ${configClassName}(PluginConfigBase):`); + lines.push(` """${blueprint.manifest.name} 配置。"""`); + lines.push(""); + for (const section of configSections) { + lines.push(` ${toPythonIdentifier(section.name)}: ${section.className} = Field(default_factory=${section.className})`); + } + lines.push("", ""); + + lines.push(`class ${pluginClassName}(MaiBotPlugin):`); + lines.push(` """${blueprint.manifest.description}"""`); + lines.push(""); + lines.push(` config_model = ${configClassName}`); + lines.push(""); + lines.push(" async def on_load(self) -> None:"); + lines.push(` self.ctx.logger.info("${escapePythonString(blueprint.manifest.name)} 已加载")`); + lines.push(""); + lines.push(" async def on_unload(self) -> None:"); + lines.push(` self.ctx.logger.info("${escapePythonString(blueprint.manifest.name)} 已卸载")`); + lines.push(""); + lines.push(" async def on_config_update(self, scope: str, config_data: dict, version: str) -> None:"); + lines.push(" del config_data"); + lines.push(' if scope == "self":'); + lines.push(' self.ctx.logger.info("插件配置已更新: version=%s", version)'); + lines.push(""); + + if (blueprint.components.length === 0) { + lines.push(" # 在编写器里添加 Tool、Command 或 Hook 节点后会生成对应组件。"); + lines.push(" pass"); + } else { + for (const component of blueprint.components) { + if (component.kind === "tool") { + lines.push(...buildToolMethod(component)); + } else if (component.kind === "command") { + lines.push(...buildCommandMethod(component)); + } else { + lines.push(...buildHookMethod(component)); + } + lines.push(""); + } + } + + lines.push(""); + lines.push(`def create_plugin() -> ${pluginClassName}:`); + lines.push(` return ${pluginClassName}()`); + lines.push(""); + + return `${lines.join("\n")}\n`; +} + +function buildConfigSectionClass(section: ConfigSection): string[] { + const lines = [ + `class ${section.className}(PluginConfigBase):`, + ` """${section.title} 配置。"""`, + "", + ` __ui_label__ = "${escapePythonString(section.title)}"`, + "", + ]; + + for (const field of section.fields) { + const identifier = toPythonIdentifier(field.name); + const pythonType = pythonTypeForScalar(field.type); + const defaultValue = pythonLiteralForScalar(field.defaultValue, field.type); + const description = field.description.trim() || field.label.trim() || field.name; + lines.push( + ` ${identifier}: ${pythonType} = Field(default=${defaultValue}, description="${escapePythonString(description)}")`, + ); + } + + return lines; +} + +function buildToolMethod(component: MaiBotPluginBlueprintComponent): string[] { + const name = toComponentName(component.name); + const handlerName = toPythonIdentifier(`handle_${name}`); + const parameters = normalizeParameters(component.parameters ?? []); + const lines = [ + " @Tool(", + ` "${escapePythonString(name)}",`, + ` brief_description="${escapePythonString(component.description || name)}",`, + ` detailed_description="${escapePythonString(component.detail || component.description || name)}",`, + ]; + + if (parameters.length > 0) { + lines.push(" parameters=["); + for (const parameter of parameters) { + lines.push( + ` ToolParameterInfo(name="${escapePythonString(parameter.name)}", param_type=ToolParamType.${toolParamType(parameter.type)}, description="${escapePythonString(parameter.description || parameter.name)}", required=${parameter.required ? "True" : "False"}),`, + ); + } + lines.push(" ],"); + } + + lines.push(" )"); + const signatureParameters = parameters.map((parameter) => ( + `${toPythonIdentifier(parameter.name)}: ${pythonTypeForScalar(parameter.type)} = ${pythonLiteralForScalar(parameter.defaultValue, parameter.type)}` + )); + lines.push(` async def ${handlerName}(self, ${[...signatureParameters, "**kwargs: Any"].join(", ")}) -> dict[str, Any]:`); + lines.push(" del kwargs"); + lines.push(...buildFlowBody(component, "tool", name, component.responseText || "工具已执行")); + return lines; +} + +function buildCommandMethod(component: MaiBotPluginBlueprintComponent): string[] { + const name = toComponentName(component.name); + const handlerName = toPythonIdentifier(`handle_${name}`); + const pattern = component.trigger?.trim() || `^/${escapeRegExp(name)}$`; + const lines = [ + ` @Command("${escapePythonString(name)}", description="${escapePythonString(component.description || name)}", pattern=r"${escapePythonRawString(pattern)}")`, + ` async def ${handlerName}(self, stream_id: str = "", **kwargs: Any):`, + " del kwargs", + ]; + lines.push(...buildFlowBody(component, "command", name, component.responseText || "命令已执行")); + return lines; +} + +function buildHookMethod(component: MaiBotPluginBlueprintComponent): string[] { + const name = toComponentName(component.name); + const handlerName = toPythonIdentifier(`handle_${name}`); + const eventType = normalizeEventType(component.eventType); + const lines = [ + ` @EventHandler("${escapePythonString(name)}", description="${escapePythonString(component.description || name)}", event_type=EventType.${eventType})`, + ` async def ${handlerName}(self, message: Any = None, stream_id: str = "", **kwargs: Any):`, + " del kwargs", + ]; + lines.push(...buildFlowBody(component, "hook", name, component.responseText || "Hook 已触发")); + return lines; +} + +function buildFlowBody( + component: MaiBotPluginBlueprintComponent, + mode: "tool" | "command" | "hook", + componentName: string, + defaultMessage: string, +): string[] { + const nodes = orderedFlowNodes(component); + if (nodes.length === 0) { + return buildDefaultFlowBody(mode, componentName, defaultMessage); + } + + const lines: string[] = [` message = "${escapePythonString(defaultMessage)}"`]; + let returned = false; + for (const node of nodes) { + if (node.kind === "comment") { + const comment = sanitizePythonComment(node.value || node.label || "flow note"); + if (comment) { + lines.push(` # ${comment}`); + } + } else if (node.kind === "log_info") { + const text = node.value?.trim() || node.label || "插件流程执行中"; + lines.push(` self.ctx.logger.info("${escapePythonString(text)}")`); + } else if (node.kind === "set_variable") { + const variableName = toPythonIdentifier(node.targetName || node.configPath || node.label || "value"); + const value = node.value?.trim() || ""; + lines.push(` ${variableName} = "${escapePythonString(value)}"`); + lines.push(` message = str(${variableName})`); + } else if (node.kind === "if_condition") { + const condition = sanitizePythonExpression(node.value || "True", "True"); + const failureMessage = node.rightValue?.trim() || node.configPath?.trim() || "条件不满足"; + lines.push(` if not (${condition}):`); + lines.push(` message = "${escapePythonString(failureMessage)}"`); + lines.push(...buildFailureReturnLines(mode, componentName, " ")); + } else if (node.kind === "compare") { + const left = sanitizePythonExpression(node.leftValue || "0", "0"); + const operator = sanitizePythonOperator(node.operator, ["==", "!=", ">", ">=", "<", "<="], "=="); + const right = sanitizePythonExpression(node.rightValue || "0", "0"); + const targetName = toPythonIdentifier(node.targetName || node.configPath || "compare_result"); + lines.push(` ${targetName} = (${left}) ${operator} (${right})`); + lines.push(` message = str(${targetName})`); + } else if (node.kind === "boolean_logic") { + const operator = sanitizePythonOperator(node.operator, ["and", "or", "not"], "and"); + const left = sanitizePythonExpression(node.leftValue || "True", "True"); + const right = sanitizePythonExpression(node.rightValue || "False", "False"); + const targetName = toPythonIdentifier(node.targetName || node.configPath || "logic_result"); + if (operator === "not") { + lines.push(` ${targetName} = not (${left})`); + } else { + lines.push(` ${targetName} = (${left}) ${operator} (${right})`); + } + lines.push(` message = str(${targetName})`); + } else if (node.kind === "math_operation") { + const left = sanitizePythonExpression(node.leftValue || "0", "0"); + const operator = sanitizePythonOperator(node.operator, ["+", "-", "*", "/", "//", "%"], "+"); + const right = sanitizePythonExpression(node.rightValue || "0", "0"); + const targetName = toPythonIdentifier(node.targetName || node.configPath || "math_result"); + lines.push(` ${targetName} = (${left}) ${operator} (${right})`); + lines.push(` message = str(${targetName})`); + } else if (node.kind === "join_text") { + const left = escapePythonString(node.leftValue ?? ""); + const right = escapePythonString(node.rightValue ?? ""); + const targetName = toPythonIdentifier(node.targetName || node.configPath || "joined_text"); + lines.push(` ${targetName} = "${left}" + "${right}"`); + lines.push(` message = str(${targetName})`); + } else if (node.kind === "guard_config") { + const configPath = normalizeConfigPath(node.configPath || ""); + if (configPath.length > 0) { + const expectedValue = (node.value?.trim() || "true").toLowerCase(); + lines.push(` guard_value = self.config.${configPath.join(".")}`); + lines.push(` if str(guard_value).lower() != "${escapePythonString(expectedValue)}":`); + lines.push(` message = "配置条件未满足: ${escapePythonString(configPath.join("."))}"`); + lines.push(...buildFailureReturnLines(mode, componentName, " ")); + } + } else if (node.kind === "loop") { + const variableName = toPythonIdentifier(node.configPath || node.label || "item"); + const iterable = sanitizePythonExpression(node.value || "range(3)", "range(3)"); + lines.push(` for ${variableName} in ${iterable}:`); + lines.push(` self.ctx.logger.info(f"${escapePythonString(variableName)}={${variableName}}")`); + } else if (node.kind === "wait") { + const seconds = sanitizePythonExpression(node.value || "1", "1"); + lines.push(` await asyncio.sleep(float(${seconds}))`); + } else if (node.kind === "read_config") { + const configPath = normalizeConfigPath(node.configPath || node.value || ""); + if (configPath.length > 0) { + lines.push(` config_value = self.config.${configPath.join(".")}`); + lines.push(" message = str(config_value)"); + } + } else if (node.kind === "send_text") { + const text = node.value?.trim() || defaultMessage; + lines.push(` message = "${escapePythonString(text)}"`); + lines.push(" if stream_id:"); + lines.push(" await self.ctx.send.text(message, stream_id)"); + } else if (node.kind === "return_success") { + lines.push(...buildReturnLines(mode, componentName)); + returned = true; + } + } + if (!returned) { + lines.push(...buildReturnLines(mode, componentName)); + } + return lines; +} + +function buildDefaultFlowBody( + mode: "tool" | "command" | "hook", + componentName: string, + defaultMessage: string, +): string[] { + const lines = [` message = "${escapePythonString(defaultMessage)}"`]; + if (mode !== "tool" || componentName.includes("send") || componentName.includes("greeting")) { + lines.push(" if stream_id:"); + lines.push(" await self.ctx.send.text(message, stream_id)"); + } + lines.push(...buildReturnLines(mode, componentName)); + return lines; +} + +function buildReturnLines(mode: "tool" | "command" | "hook", componentName: string): string[] { + if (mode === "tool") { + return [` return {"success": True, "name": "${escapePythonString(componentName)}", "message": message}`]; + } + if (mode === "hook") { + return [" return True, True, message, None, None"]; + } + return [" return True, message, True"]; +} + +function buildFailureReturnLines( + mode: "tool" | "command" | "hook", + componentName: string, + indent = " ", +): string[] { + if (mode === "tool") { + return [`${indent}return {"success": False, "name": "${escapePythonString(componentName)}", "message": message}`]; + } + if (mode === "hook") { + return [`${indent}return False, True, message, None, None`]; + } + return [`${indent}return False, message, True`]; +} + +function sanitizePythonComment(value: string): string { + return value + .replace(/[\r\n]+/gu, " ") + .replace(/\s+/gu, " ") + .trim(); +} + +function sanitizePythonExpression(value: string, fallback: string): string { + const expression = value.replace(/[\r\n:;]/gu, " ").trim(); + return expression || fallback; +} + +function sanitizePythonOperator(value: string | undefined, allowed: string[], fallback: string): string { + const operator = value?.trim() || fallback; + return allowed.includes(operator) ? operator : fallback; +} + +function orderedFlowNodes(component: MaiBotPluginBlueprintComponent): MaiBotPluginBlueprintFlowNode[] { + const nodes = component.flowNodes ?? []; + if (nodes.length <= 1) { + return nodes; + } + const byId = new Map(nodes.map((node) => [node.id, node])); + const incoming = new Set((component.flowEdges ?? []).map((edge) => edge.toNodeId)); + const nextById = new Map((component.flowEdges ?? []).map((edge) => [edge.fromNodeId, edge.toNodeId])); + const first = nodes.find((node) => !incoming.has(node.id)) ?? nodes[0]; + const ordered: MaiBotPluginBlueprintFlowNode[] = []; + const seen = new Set(); + let cursor: MaiBotPluginBlueprintFlowNode | undefined = first; + while (cursor && !seen.has(cursor.id)) { + ordered.push(cursor); + seen.add(cursor.id); + const nextId = nextById.get(cursor.id); + cursor = nextId ? byId.get(nextId) : undefined; + } + for (const node of nodes) { + if (!seen.has(node.id)) { + ordered.push(node); + } + } + return ordered; +} + +function normalizeConfigPath(value: string): string[] { + return value + .split(".") + .map((part) => toPythonIdentifier(part)) + .filter(Boolean); +} + +function normalizeEventType(value: string | undefined): string { + const normalized = (value || "ON_MESSAGE").trim().toUpperCase().replace(/[^A-Z0-9_]+/gu, "_"); + return normalized || "ON_MESSAGE"; +} + +function buildConfigToml(blueprint: NormalizedBlueprint): string { + const sections = buildConfigSections(blueprint.configFields); + const lines: string[] = []; + for (const section of sections) { + lines.push(`[${section.name}]`); + for (const field of section.fields) { + lines.push(`${toTomlKey(field.name)} = ${tomlLiteralForScalar(field.defaultValue, field.type)}`); + } + lines.push(""); + } + return lines.join("\n"); +} + +function buildConfigSections(fields: MaiBotPluginBlueprintConfigField[]): ConfigSection[] { + const sectionMap = new Map(); + for (const field of fields) { + const sectionName = toPythonIdentifier(field.section || "plugin"); + const existing = sectionMap.get(sectionName); + if (existing) { + existing.fields.push(field); + continue; + } + sectionMap.set(sectionName, { + name: sectionName, + title: titleFromIdentifier(sectionName), + className: `${toPascalCase(sectionName, "Plugin")}SectionConfig`, + fields: [field], + }); + } + return [...sectionMap.values()]; +} + +function normalizeComponent(component: MaiBotPluginBlueprintComponent): MaiBotPluginBlueprintComponent { + return { + ...component, + kind: component.kind === "command" || component.kind === "hook" ? component.kind : "tool", + name: toComponentName(component.name), + description: component.description.trim(), + detail: component.detail?.trim(), + trigger: component.trigger?.trim(), + eventType: component.eventType?.trim(), + responseText: component.responseText?.trim(), + parameters: normalizeParameters(component.parameters ?? []), + flowNodes: component.flowNodes ?? [], + flowEdges: component.flowEdges ?? [], + }; +} + +function normalizeParameters(parameters: MaiBotPluginBlueprintParameter[]): MaiBotPluginBlueprintParameter[] { + const seen = new Set(); + return parameters + .map((parameter) => ({ + ...parameter, + name: toPythonIdentifier(parameter.name), + description: parameter.description.trim(), + defaultValue: parameter.defaultValue, + })) + .filter((parameter) => { + if (!parameter.name || seen.has(parameter.name)) { + return false; + } + seen.add(parameter.name); + return true; + }); +} + +function normalizeConfigField(field: MaiBotPluginBlueprintConfigField): MaiBotPluginBlueprintConfigField { + return { + ...field, + section: toPythonIdentifier(field.section || "plugin"), + name: toPythonIdentifier(field.name), + label: field.label.trim(), + description: field.description.trim(), + defaultValue: field.defaultValue, + }; +} + +function dedupeConfigFields(fields: MaiBotPluginBlueprintConfigField[]): MaiBotPluginBlueprintConfigField[] { + const seen = new Set(); + return fields.filter((field) => { + const key = `${field.section}.${field.name}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function createConfigField( + id: string, + section: string, + name: string, + type: MaiBotPluginBlueprintScalarType, + label: string, + description: string, + defaultValue: string, +): MaiBotPluginBlueprintConfigField { + return { id, section, name, type, label, description, defaultValue }; +} + +function normalizeCapabilities(capabilities: string[]): string[] { + const values = capabilities + .flatMap((capability) => capability.split(/[,;\n]/u)) + .map((capability) => capability.trim()) + .filter(Boolean); + const normalized = values.length > 0 ? values : ["send.text", "config.get"]; + return [...new Set(normalized)]; +} + +function normalizePluginId(pluginId: string): string { + const normalized = pluginId + .trim() + .toLowerCase() + .replace(/[^a-z0-9.-]+/gu, "-") + .replace(/\.{2,}/gu, ".") + .replace(/-+/gu, "-") + .replace(/^[.-]+|[.-]+$/gu, ""); + return normalized || "com.example.maibot-plugin"; +} + +function normalizeVersion(version: string): string { + const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)/u); + return match ? `${match[1]}.${match[2]}.${match[3]}` : "1.0.0"; +} + +function isValidPluginId(pluginId: string): boolean { + return /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(pluginId) && !pluginId.includes(".."); +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +function ensureHttpUrl(value: string, fallback: string): string { + const trimmed = value.trim(); + return isHttpUrl(trimmed) ? trimmed : fallback; +} + +function toComponentName(value: string): string { + const name = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/gu, "_") + .replace(/^_+|_+$/gu, ""); + return toPythonIdentifier(name || "generated_component"); +} + +function toPythonIdentifier(value: string): string { + const identifier = value + .trim() + .replace(/[^A-Za-z0-9_]+/gu, "_") + .replace(/^_+|_+$/gu, "") + || "value"; + const withPrefix = /^[A-Za-z_]/u.test(identifier) ? identifier : `value_${identifier}`; + return RESERVED_PYTHON_IDENTIFIERS.has(withPrefix) ? `${withPrefix}_value` : withPrefix; +} + +function toPascalCase(value: string, fallback: string): string { + const words = value + .replace(/[^A-Za-z0-9]+/gu, " ") + .trim() + .split(/\s+/u) + .filter(Boolean); + const name = words.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join(""); + const normalized = name || fallback; + return /^[A-Za-z]/u.test(normalized) ? normalized : `${fallback}${normalized}`; +} + +function titleFromIdentifier(value: string): string { + if (value === "plugin") { + return "插件"; + } + return value + .split(/[_-]+/u) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function pythonTypeForScalar(type: MaiBotPluginBlueprintScalarType): string { + switch (type) { + case "boolean": + return "bool"; + case "float": + return "float"; + case "integer": + return "int"; + default: + return "str"; + } +} + +function toolParamType(type: MaiBotPluginBlueprintScalarType): string { + switch (type) { + case "boolean": + return "BOOLEAN"; + case "float": + return "FLOAT"; + case "integer": + return "INTEGER"; + default: + return "STRING"; + } +} + +function pythonLiteralForScalar(value: string | boolean, type: MaiBotPluginBlueprintScalarType): string { + switch (type) { + case "boolean": + return parseBoolean(value) ? "True" : "False"; + case "float": + return String(parseNumber(value, 0)); + case "integer": + return String(Math.trunc(parseNumber(value, 0))); + default: + return `"${escapePythonString(String(value ?? ""))}"`; + } +} + +function tomlLiteralForScalar(value: string | boolean, type: MaiBotPluginBlueprintScalarType): string { + switch (type) { + case "boolean": + return parseBoolean(value) ? "true" : "false"; + case "float": + return String(parseNumber(value, 0)); + case "integer": + return String(Math.trunc(parseNumber(value, 0))); + default: + return `"${escapeTomlString(String(value ?? ""))}"`; + } +} + +function parseBoolean(value: string | boolean): boolean { + if (typeof value === "boolean") { + return value; + } + return ["1", "true", "yes", "on", "enabled"].includes(value.trim().toLowerCase()); +} + +function parseNumber(value: string | boolean, fallback: number): number { + if (typeof value === "boolean") { + return value ? 1 : 0; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function toTomlKey(value: string): string { + return /^[A-Za-z0-9_-]+$/u.test(value) ? value : `"${escapeTomlString(value)}"`; +} + +function escapePythonString(value: string): string { + return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"').replace(/\r/gu, "\\r").replace(/\n/gu, "\\n"); +} + +function escapePythonRawString(value: string): string { + return value.replace(/"/gu, '\\"'); +} + +function escapeTomlString(value: string): string { + return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"').replace(/\r/gu, "\\r").replace(/\n/gu, "\\n"); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} From dfea8721f1d52f87436f90ccb3e144f148277c75 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sun, 24 May 2026 19:54:01 +0800 Subject: [PATCH 08/32] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E5=A4=96=E8=A7=82=E5=92=8C=E9=A6=96=E9=A1=B5=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/index.html | 2 +- .../renderer/src/assets/home-drops/emoji2.png | Bin .../renderer/src/assets/home-drops/mai.png | Bin .../renderer/src/assets/home-drops/mai2.png | Bin src/renderer/src/components/app/HomePanel.tsx | 29 +- .../src/components/app/LiquidGlassLayer.tsx | 108 ++++++ .../components/app/SettingsStatusPanel.tsx | 305 +++++++++++----- src/renderer/src/components/app/Titlebar.tsx | 6 +- src/renderer/src/lib/use-appearance.ts | 154 +++++--- src/renderer/src/styles/globals.css | 344 ++++++++++++++++++ 10 files changed, 804 insertions(+), 144 deletions(-) rename emoji2.png => src/renderer/src/assets/home-drops/emoji2.png (100%) rename mai.png => src/renderer/src/assets/home-drops/mai.png (100%) rename mai2.png => src/renderer/src/assets/home-drops/mai2.png (100%) create mode 100644 src/renderer/src/components/app/LiquidGlassLayer.tsx diff --git a/src/renderer/index.html b/src/renderer/index.html index 7cdda39..a48f0ee 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,7 +4,7 @@ MaiBot OneKey diff --git a/emoji2.png b/src/renderer/src/assets/home-drops/emoji2.png similarity index 100% rename from emoji2.png rename to src/renderer/src/assets/home-drops/emoji2.png diff --git a/mai.png b/src/renderer/src/assets/home-drops/mai.png similarity index 100% rename from mai.png rename to src/renderer/src/assets/home-drops/mai.png diff --git a/mai2.png b/src/renderer/src/assets/home-drops/mai2.png similarity index 100% rename from mai2.png rename to src/renderer/src/assets/home-drops/mai2.png diff --git a/src/renderer/src/components/app/HomePanel.tsx b/src/renderer/src/components/app/HomePanel.tsx index 1789542..e316adc 100644 --- a/src/renderer/src/components/app/HomePanel.tsx +++ b/src/renderer/src/components/app/HomePanel.tsx @@ -18,9 +18,9 @@ import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import emojiDropImage from "../../../../../emoji2.png"; -import maiDropImage from "../../../../../mai.png"; -import mai2DropImage from "../../../../../mai2.png"; +import emojiDropImage from "@/assets/home-drops/emoji2.png"; +import maiDropImage from "@/assets/home-drops/mai.png"; +import mai2DropImage from "@/assets/home-drops/mai2.png"; import maiMascotImage from "@/assets/mai2.png"; import type { DesktopSnapshot, @@ -55,6 +55,7 @@ type CompactChatState = "idle" | "connecting" | "connected" | "error"; const LOCAL_CHAT_USER_NAME_STORAGE_KEY = "maibot.localChat.userName"; const QQ_WEBUI_PORT_STORAGE_PREFIX = "maibot.qqWebuiPort"; const ADAPTER_CONFIG_PROMPTED_STORAGE_PREFIX = "maibot.adapterConfigPrompted"; +const MAIBOT_OFFICIAL_DOCS_URL = "https://docs.mai-mai.org/"; export function adapterPluginIdForBackend(backend: QqBackend): string { return backend === "snowluma" ? "maibot-team.snowluma-adapter" : "maibot-team.napcat-adapter"; @@ -741,6 +742,22 @@ function HomeStatsPanel({ return (
+