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/.gitignore b/.gitignore
index 320f3d8..ef68e89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,7 @@ __pycache__/
# C extensions
*.so
-
+python-overrides
/node_modules/
/out/
/release/
diff --git a/APPEARANCE_ARCHITECTURE.md b/APPEARANCE_ARCHITECTURE.md
new file mode 100644
index 0000000..6fbeb8b
--- /dev/null
+++ b/APPEARANCE_ARCHITECTURE.md
@@ -0,0 +1,170 @@
+# 外观架构维护说明
+
+这个文件记录桌面端三套外观的分层方式,方便以后调整样式时不把三种风格搅在一起。
+
+## 核心原则
+
+外观模式不是三套独立应用,而是:
+
+- 一套外观状态:`src/renderer/src/lib/use-appearance.ts`
+- 两类结构分支:现代结构、未来复古结构
+- 三套视觉结果:未来复古、现代、未来
+
+`modern` 是默认地基。基础 UI 组件应保持现代风格,不要直接写成复古样式。
+
+`future` 复用现代结构,只额外开启液态玻璃层和透明 token。
+
+`future-retro` 使用复古结构分支,并通过 `.retro-*` 类和根节点 data attribute 追加纸质、硬边框、切角和复古图标表现。
+
+## 架构图
+
+```mermaid
+flowchart TD
+ Settings[设置页:外观选项] --> Appearance[useAppearance]
+ Appearance --> Storage[localStorage
maibot-appearance]
+ Appearance --> Apply[applyAppearance]
+
+ Apply --> RootAttrs[document.documentElement
data-appearance-mode
data-accent / data-font / data-scale
data-retro-paper-texture]
+ Apply --> GlassClass{mode === future?}
+ GlassClass -->|是| LiquidClass[添加 .liquid-glass]
+ GlassClass -->|否| NoGlass[移除 .liquid-glass]
+
+ Appearance --> DesktopShell[DesktopShell]
+ Appearance --> HomePanel[HomePanel]
+
+ DesktopShell --> ChromeBranch{appearance.mode}
+ ChromeBranch -->|future-retro| RetroChrome[复古顶部栏
retro-tabs / retro-top-action
retro-shell]
+ ChromeBranch -->|modern| ModernChrome[旧版现代顶部栏
紧凑 Tabs / 启动下拉 / 停止按钮]
+ ChromeBranch -->|future| FutureChrome[现代顶部栏结构
+ LiquidGlassLayer]
+
+ HomePanel --> HomeBranch{appearance.mode}
+ HomeBranch -->|future-retro| RetroHome[复古首页结构
retro-panel / retro-control
宽仪表盘布局]
+ HomeBranch -->|modern| ModernHome[旧版现代首页结构
紧凑卡片 / 右侧 320px]
+ HomeBranch -->|future| FutureHome[现代首页结构
+ 玻璃 token]
+
+ RootAttrs --> CSS[globals.css]
+ LiquidClass --> CSS
+ NoGlass --> CSS
+
+ CSS --> BaseTokens[基础 token
现代默认]
+ CSS --> RetroScope[:root[data-appearance-mode='future-retro']
复古 token 与 .retro-* 覆盖]
+ CSS --> GlassScope[:root.liquid-glass
玻璃透明度 / blur / 背景覆盖]
+
+ BaseTokens --> UIBase[基础 UI 组件
Button / Tabs / Card / Input / Badge / Dialog]
+ RetroScope --> RetroChrome
+ RetroScope --> RetroHome
+ GlassScope --> FutureChrome
+ GlassScope --> FutureHome
+```
+
+## 状态入口
+
+外观状态在 `use-appearance.ts` 中维护:
+
+```ts
+export type AppearanceMode = "future-retro" | "modern" | "future";
+```
+
+`applyAppearance` 会把配置同步到 `document.documentElement`:
+
+- `data-appearance-mode`
+- `data-retro-paper-texture`
+- `data-accent`
+- `data-font`
+- `data-scale`
+- `.liquid-glass`
+
+旧的 `liquidGlass: true` 会迁移为 `mode: "future"`。
+
+## 结构分支
+
+结构差异较大的地方用 React 条件分支,不强行靠 CSS 扭出来。
+
+主要位置:
+
+- `src/renderer/src/components/app/DesktopShell.tsx`
+ - `useRetroChrome = appearance.mode === "future-retro"`
+ - 控制顶部 tab、右上操作按钮、外壳 `.retro-shell`
+- `src/renderer/src/components/app/HomePanel.tsx`
+ - `useRetroHome = appearance.mode === "future-retro"`
+ - 控制首页布局、卡片结构、快捷操作卡片、弹窗内局部控件
+
+现代和未来模式应尽量走 `2c788736c75aee80379efe6a9ac2d253d972177c` 前的现代结构。
+
+## 样式分层
+
+基础组件保持现代默认:
+
+- `src/renderer/src/components/ui/button.tsx`
+- `src/renderer/src/components/ui/tabs.tsx`
+- `src/renderer/src/components/ui/card.tsx`
+- `src/renderer/src/components/ui/input.tsx`
+- `src/renderer/src/components/ui/badge.tsx`
+- `src/renderer/src/components/ui/dialog.tsx`
+
+复古样式集中在 `src/renderer/src/styles/globals.css`:
+
+- `.retro-shell`
+- `.retro-panel`
+- `.retro-control`
+- `.retro-title`
+- `.retro-value`
+- `.retro-tabs`
+- `.retro-top-action`
+
+复古覆盖尽量写成:
+
+```css
+:root[data-appearance-mode="future-retro"] .retro-panel {
+ /* retro only */
+}
+```
+
+这样现代模式不会被复古类名的默认样式污染。
+
+## 三种模式的职责
+
+### 未来复古
+
+- 使用 `.retro-shell`
+- 首页走复古卡片和更宽的仪表盘布局
+- 顶部 tab 使用硬边分割和选中镂空/高对比图标
+- 可配置纸张纹理、窗口圆角、界面密度
+
+### 现代
+
+- 使用基础组件默认样式
+- 首页走旧版紧凑布局
+- 顶部 tab 和右上按钮保持旧版紧凑交互
+- 可配置主题色、字体、界面密度、窗口圆角
+
+### 未来
+
+- 结构继承现代
+- 开启 `.liquid-glass` 和 `LiquidGlassLayer`
+- 可配置玻璃透度、主题色、界面密度、窗口圆角
+
+## 维护规则
+
+- 改基础组件时,先确认现代模式是否仍像旧版。
+- 复古专属视觉不要直接写进基础组件,放到 `.retro-*` 或 `data-appearance-mode="future-retro"` 作用域。
+- 如果一个差异影响 DOM 结构、尺寸、按钮数量或布局轨道,优先在 React 里用模式分支。
+- 如果只是颜色、纹理、边框、字体、图标 stroke,优先用 CSS token 或复古作用域。
+- 新增外观配置项时,先扩展 `AppearancePreference`,再在设置页按模式展示对应配置。
+- `future` 不要复制复古结构;它应该是现代结构上的玻璃视觉层。
+
+## 验证
+
+涉及外观架构调整后至少运行:
+
+```bash
+bun run typecheck
+bun run build
+```
+
+视觉改动建议手动切换三种模式,重点看:
+
+- 顶部 tab 和右上操作按钮
+- 首页主卡片和右侧统计/快捷操作
+- 设置弹窗是否正常居中显示
+- 未来模式下玻璃层是否仍透明且可读
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a72ad4a..9edaeb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,116 @@
# Changelog
+## 0.4.4 - 2026-05-27
+
+本版本主要继续打磨 0.4.x 的日常使用体验:启动器更新说明更完整,WebUI 与随便聊聊的等待/断线状态更清楚,插件市场交互更顺手,设置中心也更像一个集中管理面板。
+
+### 启动器更新
+- 首页的一键包更新入口新增详情弹窗,可查看当前版本、最新版本、安装包名称、大小、来源和发布说明。
+- 更新入口继续保留黄点提示,并可直接重新检查、打开发布页面或下载安装包。
+
+### MaiBot WebUI 与随便聊聊
+- MaiBot Core 启动后 WebUI 还没就绪时,不再只留下空白页,会显示“等待 WebUI 启动 / 暂不可访问”的状态页,并提供启动入口。
+- WebUI 在服务真正就绪后会自动重新加载,减少“进程已经启动但页面没刷出来”的情况。
+- WebUI 工具栏会跟踪当前实际地址,刷新或在浏览器打开时使用当前页面地址,而不是固定使用初始地址。
+- 随便聊聊在未连接且没有消息时会显示清晰的离线状态,可直接启动 MaiBot Core 或重新连接聊天服务。
+
+### 插件市场与插件编写
+- 插件市场卡片现在可以直接点击打开详情;卡片内的安装、启用、评分、评论等按钮不会误触打开详情。
+- 插件市场会批量加载当前用户的点赞、点踩、评分和评论状态,列表打开后的状态同步更快。
+- 评分和评论可分开提交;只想补一句评论或只改评分时,不必重复填写另一项。
+- 插件更新支持处理非 Git 方式安装的插件:会先备份旧目录、克隆新版并保留原有 `config.toml` / `config_back`,失败时自动恢复旧插件。
+
+### 设置、窗口与外观
+- 设置中心新增更集中的通用体验:关闭行为、终端模式、终端字号、插件编写器、主题、字体、圆角和 QQ 后端都能在同一处调整。
+- 未来复古外观继续打磨:默认保持浅色呈现,滚动条、顶部标签分隔线、徽标和按钮视觉更统一。
+- 窗口圆角改为按外观模式分别记忆,未来复古模式支持调到更方正的 0px 圆角。
+- 移除未稳定的液态玻璃层,旧的 `future` / 液态玻璃设置会迁移到现代外观,减少透明窗口带来的阅读和兼容问题。
+- 无边框窗口的最大化 / 还原逻辑更稳定,最大化会贴合当前屏幕工作区,还原时会回到合理尺寸与位置。
+- 悬浮模式拖动和展开收起更稳,和主窗口最大化状态之间的切换更少出现尺寸错乱。
+
+### 初始化、更新与打包
+- 首次启动引导中,如果依赖下载卡住或需要重来,可以直接重新发起依赖下载 / 重启 MaiBot Core,不必退出引导重开。
+- 重启 MaiBot Core 时会等待正在进行的 Python 依赖安装收尾,减少依赖安装和服务重启互相打架。
+- MaiBot 模块更新选择指定分支或 tag 时会明确切到目标版本;远端拉取或子模块更新失败时,会尽量恢复到更新前的分支、提交和远端配置。
+- MaiBot WebUI 端口读取更稳,只读取配置中的 `[webui]` 段,降低其他 TOML 内容影响 WebUI 地址识别的概率。
+- NapCat WebUI 快捷入口会使用新的登录地址并带上 token,减少打开后还要重新找登录入口的情况。
+- Windows 打包进一步清理内置模块内容:用户插件目录整体从主模块复制中排除,NapCat / SnowLuma 适配器作为干净快照单独打包,避免把配置、数据、日志、数据库或缓存带进安装包。
+
+### 终端与细节修复
+- 终端页顶部改为更清晰的标签栏,可看到服务终端和手动 Shell 的状态,并可关闭用户打开的 Shell。
+- 终端、设置页和未来复古界面的滚动条样式更一致,减少圆角 / 主题混用导致的突兀感。
+- 插件市场、更新弹窗等 Markdown 内容支持分隔线,发布说明和 README 阅读更清楚。
+
+## 0.4.3 - 2026-05-27
+
+### 外观与主题
+- 重构外观模式与首页设置体验,统一外观设置入口、主题状态和页面呈现。
+- 新增未来复古外观风格,并移除旧应用图标选项,减少过时配置入口。
+- 补充外观架构维护说明,方便后续扩展主题、透明效果和窗口样式。
+
+### 首页与界面
+- 优化首页、设置中心和插件市场中的冗余信息展示,让常用操作更集中。
+- 调整首页后端服务控制入口,启动、停止和重启操作更贴近当前服务卡片。
+- 优化部分按钮、徽标和页面层次,提升不同外观模式下的可读性。
+
+## 0.4.1 - 2026-05-25
+
+### 随便聊聊
+- 支持解析 MaiBot WebUI 富文本消息段,机器人发送的图片、表情、语音和文件不再只显示为占位文本。
+- 会读取 MaiBot 配置中的 WebUI port,适配非默认端口启动webui。
+
+### MaiBot 更新
+- 首页 MaiBot 更新入口支持选择正式版、测试版,以及自定义分支或 tag。
+- 首页更新按钮改为上箭头图标,有可用新版本时会显示黄点提示。
+
+### 首页
+- 移除顶部 QQ 后端独立 tab,将启动、停止和重启按钮移动到首页后端卡片。
+- 添加首页角落形象介绍彩蛋。
+
+## 0.4.0 - 2026-05-24
+
+### 插件编写器
+- 内置了opencode,并提供插件编写指导,你可以直接编写插件
+
+### 插件市场
+- 完善插件市场互动体验,强化插件详情、用户状态、评分、点赞/点踩与下载记录等交互入口。
+
+### 桌面外观与首页
+- 添加多种图标可选
+- 添加不好看的apple液态玻璃样式
+- 优化桌面外观、标题栏和首页体验。
+- 设置中心扩展外观选项,支持更细的界面风格、透明度和窗口圆角调整。
+
+### 启动器更新
+- 新增启动器更新检查和下载安装入口,可在首页发现新版本并触发安装流程。
+- MaiBot 更新不再单列“最新旧版”,非预发布 tag 统一归入正式版。
+
+### 运行状态
+- 修正运行时文案、依赖安装提示和服务状态显示,让初始化、终端和托管服务反馈更准确。
+
+
+## 0.3.4 - 2026-05-20
+
+### SnowLuma
+- 更新版本到1.9.0,更新适配器版本到1.2.0,支持表情包正确识别,修复at重复识别,支持转发消息部分正确识别
+
+### 插件管理
+- 插件配置在 MaiBot Core 未启动时也会尝试读取本地 `config.toml` 和插件配置声明,提前显示中文配置名与字段说明。
+- MaiBot Core 仍在加载插件系统时,插件状态显示为“加载中”,不再误判为加载失败。
+
+### 启动器设置
+- 设置中心新增简化的本机网络代理开关,可配置 `127.0.0.1:<端口>` 以对接 Clash / Mihomo 等代理软件。
+- 网络代理会应用到启动器网络请求、Git / pip 更新,以及之后启动的托管服务环境变量。
+
+### 初始化与终端
+- 初始化与修复 MaiBot 配置时不再写入旧的 `onekey-local-chat` 本地聊天平台账号。
+- 稳定内嵌 PTY 终端尺寸同步,减少启动和切换时的尺寸错位。
+
+### 窗口
+- 修复无边框透明窗口无法拖动边缘缩放的问题。
+- 退出悬浮模式时会恢复普通窗口的可缩放状态,避免窗口卡在不可缩放。
+
+
## 0.3.3 - 2026-05-19
### 内置模块
@@ -98,7 +209,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
diff --git a/README.md b/README.md
index 156dd8b..596e372 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,31 @@ bun install
bun run dev
```
+本地预览默认使用 `bun run dev`。除非特别说明要验证 `out/` 构建产物或发布形态,不要优先使用 `bun run preview`。
+
+### Electron dev 启动排障
+
+如果 `bun run dev` 在 `start electron app...` 后立刻退出,并在日志里看到类似下面的错误:
+
+```text
+TypeError: Cannot read properties of undefined (reading 'isPackaged')
+```
+
+通常是当前 shell 环境里设置了 `ELECTRON_RUN_AS_NODE=1`。这个变量会强制 Electron 以普通 Node 模式运行,导致主进程里的 `electron.app` 为空。
+
+PowerShell 下先清掉该变量,再启动开发版:
+
+```powershell
+Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue
+bun run dev
+```
+
+如果只是想临时确认当前 shell 是否带了这个变量:
+
+```powershell
+$env:ELECTRON_RUN_AS_NODE
+```
+
常用检查:
```bash
@@ -24,7 +49,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/
@@ -35,17 +62,22 @@ runtime/
Scripts/pip.exe
git/
bin/git.exe
+ opencode/
+ opencode.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
-```
+编写器里的 OpenCode 入口依赖内置 CLI sidecar:打包前需要把 Windows x64 版 `opencode.exe` 放到 `runtime/opencode/opencode.exe`。当前接入按 `opencode-windows-x64` release binary 设计,`runtime/` 已被 `.gitignore` 忽略,所以该二进制不会进入源码提交;`bun run release:check` 会校验它是否存在。
+
+OpenCode 默认启用内置插件编写说明:源码里的 `resources/opencode/plugin_code.md` 会在打包时复制到安装包资源目录的 `runtime/opencode/plugin_code.md`,启动 OpenCode 时通过 `OPENCODE_CONFIG_CONTENT.instructions` 自动指向它,并用 `OPENCODE_DISABLE_PROJECT_CONFIG=true` 跳过 MaiBot 自带 `AGENTS.md`。设置中心可以关闭这个行为,关闭后 OpenCode 会恢复按项目默认规则读取说明文件。
发布前检查:
@@ -53,17 +85,31 @@ bun run release:win:lite
bun run release:check
```
-生成两个安装包:
+构建 Windows 安装包:
+
+```bash
+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/`,其中 `runtime/opencode/opencode.exe` 用于编写器内置 OpenCode。
更多发布细节见 [docs/release.md](docs/release.md)。
diff --git a/bun.lock b/bun.lock
index 0794153..54b7e84 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,6 +9,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-virtual": "^3.13.24",
"@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-unicode11": "^0.9.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -435,6 +436,8 @@
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
+ "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="],
+
"@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"abbrev": ["abbrev@4.0.0", "https://registry.npmmirror.com/abbrev/-/abbrev-4.0.0.tgz", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
diff --git a/docs/release.md b/docs/release.md
index 59017c4..3b263d8 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
@@ -18,10 +18,19 @@
runtime/python/Lib/
runtime/python/Scripts/pip.exe
runtime/git/bin/git.exe
+ runtime/opencode/opencode.exe
+ resources/opencode/plugin_code.md
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` 不会进入安装包。
+
+ `resources/opencode/plugin_code.md` 会在打包时复制到安装包资源目录的 `runtime/opencode/plugin_code.md`,用于编写器内置 OpenCode 的默认插件编写说明。
+
3. 执行发布检查:
```bash
@@ -34,12 +43,12 @@
bun run release:win
```
-安装包会输出到 `release/`,默认同时生成 `full` 完整包和 `lite` 精简包。`lite` 不包含 `runtime/python/` 与 `runtime/git/`,运行时会寻找系统 Python 3.12+ 与系统 Git,并在缺失时给出下载入口。
+安装包会输出到 `release/`:
-只构建精简包时,payload 可以省略 `runtime/python/` 与 `runtime/git/`:
-
-```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/electron.vite.config.ts b/electron.vite.config.ts
index d3d0b3b..adfc507 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -5,9 +5,27 @@ import { defineConfig, externalizeDepsPlugin } from "electron-vite";
export default defineConfig({
main: {
+ build: {
+ rollupOptions: {
+ output: {
+ chunkFileNames: "chunks/[name]-[hash].cjs",
+ entryFileNames: "[name].cjs",
+ format: "cjs",
+ },
+ },
+ },
plugins: [externalizeDepsPlugin()],
},
preload: {
+ build: {
+ rollupOptions: {
+ output: {
+ chunkFileNames: "chunks/[name]-[hash].cjs",
+ entryFileNames: "[name].cjs",
+ format: "cjs",
+ },
+ },
+ },
plugins: [externalizeDepsPlugin()],
},
renderer: {
diff --git a/notes.md b/notes.md
new file mode 100644
index 0000000..09e75ad
--- /dev/null
+++ b/notes.md
@@ -0,0 +1,7 @@
+# Notes
+
+## 设置页滚动条圆角
+
+- 在 `future-retro` 外观下,设置页滚动条圆角不是单纯由 `::-webkit-scrollbar-thumb` 控制。
+- 关键原因是主题作用域里设置了标准滚动条属性 `scrollbar-color` / `scrollbar-width`,Chromium/Electron 会优先走标准滚动条绘制路径,导致 WebKit 伪元素里的 `border-radius: 0` 看起来不生效。
+- 处理方式:在设置页作用域 `.settings-scroll-scope` 内先将 `scrollbar-color` 和 `scrollbar-width` 重置为 `auto !important`,再用 `::-webkit-scrollbar-thumb` 设置 `border-radius: 0 !important` 和 `border: 0 !important`。
diff --git a/package.json b/package.json
index f2ef1b5..81a3198 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,11 @@
{
"name": "maibot-onekey-desktop",
- "version": "0.3.3",
+ "version": "0.4.4",
"description": "Electron desktop shell for MaiBot OneKey.",
- "author": "MotricSeven",
+ "author": "otricSeven",
"license": "GPL-3.0-only",
"type": "module",
- "main": "out/main/index.js",
+ "main": "out/main/index.cjs",
"scripts": {
"dev": "electron-vite dev",
"dev:web": "vite --config vite.renderer.config.ts",
@@ -23,6 +23,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-virtual": "^3.13.24",
"@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-unicode11": "^0.9.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -78,6 +79,10 @@
"from": "resources/icon.png",
"to": "icon.png"
},
+ {
+ "from": "resources/app-icons",
+ "to": "app-icons"
+ },
{
"from": "runtime",
"to": "runtime",
@@ -90,37 +95,71 @@
]
},
{
- "from": "release-assets/python-overrides",
- "to": "python-overrides",
- "filter": [
- "**/*",
- "!**/__pycache__/**",
- "!**/*.pyc"
- ]
+ "from": "resources/opencode/plugin_code.md",
+ "to": "runtime/opencode/plugin_code.md"
},
{
"from": "modules",
"to": "modules",
"filter": [
"**/*",
+ "!**/.git/**",
"!**/__pycache__/**",
"!**/*.pyc",
- "!**/.git/index.lock",
- "!**/.git/objects/pack/.tmp-*",
"!MaiBot/config/**",
"!MaiBot/data/**",
+ "!MaiBot/dashboard/.vite/**",
+ "!MaiBot/dashboard/node_modules/**",
"!MaiBot/maibot_statistics.html",
- "!MaiBot/plugins/sengokucola_better-image/**",
- "!MaiBot/plugins/__init__.py",
- "!MaiBot/plugins/napcat-adapter/.git/**",
- "!MaiBot/plugins/snowluma-adapter/.git/**",
+ "!MaiBot/plugins/**",
"!napcat/napcat/config/webui.json",
"!napcat/**/*linux*",
"!napcat/**/*darwin*",
"!napcat/**/*arm64*",
"!napcat/versions/**/resources/app/napcat/config/webui.json",
- "!napcatframework/versions/**/resources/app/LiteLoader/plugins/NapCat/config/webui.json",
- "!napcat/.git/**"
+ "!napcatframework/versions/**/resources/app/LiteLoader/plugins/NapCat/config/webui.json"
+ ]
+ },
+ {
+ "from": "modules/MaiBot/plugins/napcat-adapter",
+ "to": "modules/MaiBot/plugins/napcat-adapter",
+ "filter": [
+ "**/*",
+ "!**/.git/**",
+ "!**/__pycache__/**",
+ "!**/*.pyc",
+ "!**/config.toml",
+ "!**/config.toml.*",
+ "!**/config_back/**",
+ "!**/data/**",
+ "!**/logs/**",
+ "!**/*.bak",
+ "!**/*.backup*",
+ "!**/*.db",
+ "!**/*.sqlite",
+ "!**/*.sqlite3",
+ "!**/*.log"
+ ]
+ },
+ {
+ "from": "modules/MaiBot/plugins/snowluma-adapter",
+ "to": "modules/MaiBot/plugins/snowluma-adapter",
+ "filter": [
+ "**/*",
+ "!**/.git/**",
+ "!**/__pycache__/**",
+ "!**/*.pyc",
+ "!**/config.toml",
+ "!**/config.toml.*",
+ "!**/config_back/**",
+ "!**/data/**",
+ "!**/logs/**",
+ "!**/*.bak",
+ "!**/*.backup*",
+ "!**/*.db",
+ "!**/*.sqlite",
+ "!**/*.sqlite3",
+ "!**/*.log"
]
}
],
diff --git a/resources/app-icons/bean.png b/resources/app-icons/bean.png
new file mode 100644
index 0000000..9d77d31
Binary files /dev/null and b/resources/app-icons/bean.png differ
diff --git a/resources/app-icons/soft.png b/resources/app-icons/soft.png
new file mode 100644
index 0000000..48e86fb
Binary files /dev/null and b/resources/app-icons/soft.png differ
diff --git a/resources/app-icons/sprout.png b/resources/app-icons/sprout.png
new file mode 100644
index 0000000..acbe3ae
Binary files /dev/null and b/resources/app-icons/sprout.png differ
diff --git a/resources/icon.ico b/resources/icon.ico
index 796f5d1..4770622 100644
Binary files a/resources/icon.ico and b/resources/icon.ico differ
diff --git a/resources/icon.png b/resources/icon.png
index 3c0e9a7..acbe3ae 100644
Binary files a/resources/icon.png and b/resources/icon.png differ
diff --git a/resources/opencode/plugin_code.md b/resources/opencode/plugin_code.md
new file mode 100644
index 0000000..9f55a2f
--- /dev/null
+++ b/resources/opencode/plugin_code.md
@@ -0,0 +1,90 @@
+# MaiBot 插件 Coding Agent 指南
+
+你正在 MaiBot 根目录中工作,目标通常是编写、修改或排查 `plugins/` 下的插件。除非用户明确授权,不要修改 MaiBot 核心代码、启动脚本、全局配置模板或根目录的 `.gitignore`。
+
+## 工作边界
+
+- 插件代码放在 `plugins//` 独立目录中;新插件应包含 `_manifest.json`、`plugin.py`,需要可配置项时再添加 `config.toml`。
+- 如果需求必须改动主程序、SDK 或 `src/` 下核心模块,先向用户说明原因、影响范围和替代方案,再请求确认。
+- 不要把实验数据、日志、缓存、数据库、临时下载文件提交进插件目录。
+- 项目首选语言是简体中文,面向用户的说明、日志、配置描述和 WebUI 文案优先使用简体中文。
+
+## 优先参考
+
+- 官方插件 SDK 文档:https://github.com/Mai-with-u/maibot-plugin-sdk/blob/main/docs/guide.md
+- 插件提交规范:https://github.com/Mai-with-u/plugin-repo/blob/main/CONTRIBUTING.md
+- 本地示例插件:`plugins/hello_world_plugin/`
+- 如果当前插件已有 README、注释或配置说明,以插件内现有约定为准。
+
+## 开发流程
+
+1. 先阅读目标插件的 `_manifest.json`、`plugin.py`、`config.toml` 和 README,确认插件 ID、权限、SDK 版本和已有组件。
+2. 根据需求选择最小组件:能用 Command 解决的不要做常驻 EventHandler,能用插件内部状态解决的不要改全局状态。
+3. 修改后检查 manifest 的 `capabilities` 是否覆盖实际调用的能力,例如发送文本、图片、转发、表情、配置读取等。
+4. 涉及配置字段时,同步更新 `config_model`、`config.toml`、默认值、字段描述和配置版本。
+5. 完成后给出改动文件、关键行为和建议的验证命令。
+
+## SDK 组件速查
+
+- `Command`:用户显式发送命令时触发,适合 `/help`、`/time`、`/xxx 参数` 这类确定入口。
+- `Tool`:供模型按需调用,适合查询、计算、格式转换、轻量业务动作;返回内容应短、明确、可被模型继续使用。
+- `Action`:让 MaiBot 在对话规划中主动执行动作,适合问候、提醒、发送上下文相关内容;要写清触发条件和适用消息类型。
+- `EventHandler`:监听消息或生命周期事件,适合日志、统计、转发、自动处理;默认应保持轻量,避免每条消息都做高成本操作。
+- `MaiBotPlugin`:插件主类,通常声明 `config_model`,并按需要实现 `on_load`、`on_unload`、`on_config_update`。
+- `PluginConfigBase` 和 `Field`:定义可配置项。字段描述会影响用户理解,保持短句、明确默认行为。
+
+## 推荐文件结构
+
+```text
+plugins//
+ _manifest.json
+ plugin.py
+ config.toml
+ README.md
+```
+
+`_manifest.json` 至少要关注:
+
+- `id`:使用稳定、唯一的插件 ID,例如 `author.plugin-name`。
+- `version`:插件自身版本。
+- `host_application` 和 `sdk`:声明兼容范围,不要随意放宽到未验证版本。
+- `capabilities`:只声明实际需要的能力。
+- `dependencies`:如需第三方包,说明用途,并考虑用户安装成本。
+
+## 常见实现约定
+
+- `plugin.py` 中优先保持导入清晰:标准库、第三方库、SDK、本地模块分组。
+- 异步组件里避免阻塞调用;需要文件或网络操作时考虑超时、异常处理和失败提示。
+- 向聊天流发送消息时优先使用当前传入的 `stream_id`,不要自行计算会话 ID。
+- 不要把用户输入直接拼进正则、路径或命令;路径要限制在插件目录或用户明确指定的位置。
+- 返回给用户的错误信息要说明可操作原因,日志里可以保留更详细的异常。
+- 如果插件维护内存状态,放在插件实例字段上,并在 `on_unload` 中清理定时任务、后台任务和连接。
+
+## 配置辅助
+
+- 配置模型字段名应和 `config.toml` 分组保持一致。
+- 新增配置时提供安全默认值,默认不要开启高风险或高频行为。
+- 修改配置结构时提升 `config_version`,并在 README 或注释中说明迁移点。
+- 除非用户明确要求,不要修改 MaiBot 主配置文件;插件配置只改当前插件目录内的 `config.toml`。
+
+## 调试与验证
+
+- 优先精准定位问题根因,不要为了排查一个插件问题大范围重构。
+- 先看插件目录、manifest、配置和最近日志,再决定是否需要运行命令。
+- 依赖管理优先使用 `uv`。如果修改依赖声明,以 `pyproject.toml` 为准,并同步需要的 requirements 文件。
+- 常见检查命令:
+
+```bash
+uv run python -m compileall plugins/
+uv run python -m pytest tests
+```
+
+如果项目没有对应测试或当前环境缺依赖,要在最终说明里明确未运行的原因。
+
+## 提交前自查
+
+- 插件目录完整,manifest JSON 合法,插件 ID 没有和已有插件冲突。
+- SDK 组件的名称、描述、参数和返回值足够清晰。
+- 权限声明与实际调用一致,没有多声明高权限能力。
+- 配置默认值安全,README 能让用户知道如何启用、如何使用、如何排错。
+- 没有改动 MaiBot 核心代码、根目录 `.gitignore`、无关格式化或 unrelated 文件。
diff --git a/scripts/assets/make-rounded-ico.py b/scripts/assets/make-rounded-ico.py
new file mode 100644
index 0000000..d094385
--- /dev/null
+++ b/scripts/assets/make-rounded-ico.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from PIL import Image, ImageChops, ImageDraw, ImageOps
+
+
+ICO_SIZES = (16, 24, 32, 48, 64, 128, 256)
+
+
+def rounded_square(source: Path, size: int, radius_ratio: float, zoom: float) -> Image.Image:
+ image = Image.open(source).convert("RGBA")
+ fitted = ImageOps.fit(
+ image,
+ (size, size),
+ method=Image.Resampling.LANCZOS,
+ centering=(0.5, 0.5),
+ )
+ if zoom > 1:
+ zoomed_size = round(size * zoom)
+ fitted = fitted.resize((zoomed_size, zoomed_size), Image.Resampling.LANCZOS)
+ left = (zoomed_size - size) // 2
+ top = (zoomed_size - size) // 2
+ fitted = fitted.crop((left, top, left + size, top + size))
+
+ scale = 4
+ mask_size = size * scale
+ radius = int(size * radius_ratio) * scale
+ mask = Image.new("L", (mask_size, mask_size), 0)
+ draw = ImageDraw.Draw(mask)
+ draw.rounded_rectangle((0, 0, mask_size, mask_size), radius=radius, fill=255)
+ mask = mask.resize((size, size), Image.Resampling.LANCZOS)
+
+ fitted.putalpha(ImageChops.multiply(fitted.getchannel("A"), mask))
+ return fitted
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Create a rounded PNG and ICO from an image.")
+ parser.add_argument("source", type=Path, help="Source image path.")
+ parser.add_argument("--png-out", type=Path, default=Path("resources/icon.png"))
+ parser.add_argument("--ico-out", type=Path, default=Path("resources/icon.ico"))
+ parser.add_argument("--variant-out", type=Path, default=Path("resources/app-icons/soft.png"))
+ parser.add_argument("--size", type=int, default=1024)
+ parser.add_argument("--radius-ratio", type=float, default=0.23)
+ parser.add_argument("--zoom", type=float, default=1.0, help="Center zoom before applying rounded corners.")
+ args = parser.parse_args()
+
+ icon = rounded_square(args.source, args.size, args.radius_ratio, max(args.zoom, 1.0))
+
+ for output in (args.png_out, args.ico_out, args.variant_out):
+ output.parent.mkdir(parents=True, exist_ok=True)
+
+ icon.save(args.png_out)
+ icon.save(args.ico_out, sizes=[(size, size) for size in ICO_SIZES])
+ icon.save(args.variant_out)
+
+ print(f"Wrote {args.png_out}")
+ print(f"Wrote {args.ico_out}")
+ print(f"Wrote {args.variant_out}")
+ print(f"ICO sizes: {', '.join(f'{size}x{size}' for size in ICO_SIZES)}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/release/build-windows-variants.ts b/scripts/release/build-windows-variants.ts
index 9f1d303..ac2d6c3 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();
diff --git a/scripts/release/check-windows-payload.ts b/scripts/release/check-windows-payload.ts
index 9091aa3..df3eca8 100644
--- a/scripts/release/check-windows-payload.ts
+++ b/scripts/release/check-windows-payload.ts
@@ -93,6 +93,16 @@ const requirements: Requirement[] = [
required: true,
candidates: [file("runtime/git/bin/git.exe"), file("runtime/git/cmd/git.exe"), file("runtime/git/git.exe")],
},
+ {
+ label: "OpenCode CLI executable",
+ required: true,
+ candidates: [file("runtime/opencode/opencode.exe")],
+ },
+ {
+ label: "OpenCode plugin instruction resource",
+ required: true,
+ candidates: [file("resources/opencode/plugin_code.md")],
+ },
{
label: "modules directory",
required: true,
diff --git a/src/main/index.ts b/src/main/index.ts
index 3922c4c..76c4e37 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -1,16 +1,23 @@
-import { app, BrowserWindow, Menu, nativeImage, shell, Tray } from "electron";
-import { join } from "node:path";
+import { app, BrowserWindow, Menu, nativeImage, net, protocol, session, shell, Tray } from "electron";
+import { stat } from "node:fs/promises";
+import { isAbsolute, join, relative, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import type { AppIconId, RuntimePaths } from "../shared/contracts";
import { registerAppIpc } from "./ipc/app";
import { registerPtyIpc } from "./ipc/pty";
import { PtySessionManager } from "./pty/pty-session-manager";
import { InitManager } from "./services/init-manager";
import { acquireInstallInstanceLock } from "./services/instance-lock";
+import { AppIconManager } from "./services/app-icon-manager";
import { LogStore } from "./services/log-store";
import { ModuleUpdater } from "./services/module-updater";
+import { NetworkProxyManager } from "./services/network-proxy-manager";
+import { OpenCodeSettingsManager } from "./services/opencode-settings-manager";
import { configureRuntimePaths } from "./services/paths";
import { PythonDependencyManager } from "./services/python-dependency-manager";
import { ResourceLocationManager } from "./services/resource-location-manager";
import { ServiceManager } from "./services/service-manager";
+import { isWindowVisuallyMaximized } from "./window-state";
const runtimePaths = configureRuntimePaths();
const instanceLock = acquireInstallInstanceLock(runtimePaths);
@@ -20,10 +27,13 @@ const resourceLock = instanceLock.acquired
: { acquired: true };
const logStore = new LogStore(runtimePaths);
const initManager = new InitManager(runtimePaths);
+const networkProxyManager = new NetworkProxyManager(runtimePaths);
+const openCodeSettingsManager = new OpenCodeSettingsManager(runtimePaths);
const moduleUpdater = new ModuleUpdater(runtimePaths, initManager);
const pythonDependencyManager = new PythonDependencyManager(runtimePaths, initManager);
const ptySessionManager = new PtySessionManager();
const serviceManager = new ServiceManager(runtimePaths, initManager, logStore, ptySessionManager, pythonDependencyManager);
+const appIconManager = new AppIconManager(runtimePaths, app.isPackaged);
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
@@ -31,6 +41,96 @@ let appIpcDisposables: ReturnType | null = null;
let allowQuit = false;
let quitRequested = false;
+const LIVE2D_ASSET_SCHEME = "maibot-live2d";
+const LIVE2D_WEBVIEW_PARTITION = "maibot-live2d";
+const APP_ICON_ASSET_SCHEME = "maibot-app-icon";
+
+protocol.registerSchemesAsPrivileged([
+ {
+ scheme: LIVE2D_ASSET_SCHEME,
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: true,
+ corsEnabled: true,
+ },
+ },
+ {
+ scheme: APP_ICON_ASSET_SCHEME,
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: true,
+ corsEnabled: true,
+ },
+ },
+]);
+
+function isPathInside(root: string, target: string): boolean {
+ const relativePath = relative(resolve(root), resolve(target));
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
+}
+
+function resolveLive2dAssetPath(paths: RuntimePaths, url: string): string | null {
+ const parsed = new URL(url);
+ if (parsed.protocol !== `${LIVE2D_ASSET_SCHEME}:` || parsed.hostname !== "assets") {
+ return null;
+ }
+
+ const relativePath = decodeURIComponent(parsed.pathname).replace(/^\/+/u, "");
+ const target = resolve(paths.live2dRoot, relativePath);
+ return isPathInside(paths.live2dRoot, target) ? target : null;
+}
+
+function registerLive2dResourceProtocol(paths: RuntimePaths): void {
+ const handler = async (request: Request): Promise => {
+ const target = resolveLive2dAssetPath(paths, request.url);
+ if (!target) {
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ try {
+ const fileStat = await stat(target);
+ if (!fileStat.isFile()) {
+ return new Response("Not found", { status: 404 });
+ }
+ return net.fetch(pathToFileURL(target).toString());
+ } catch {
+ return new Response("Not found", { status: 404 });
+ }
+ };
+
+ protocol.handle(LIVE2D_ASSET_SCHEME, handler);
+ session.fromPartition(LIVE2D_WEBVIEW_PARTITION).protocol.handle(LIVE2D_ASSET_SCHEME, handler);
+}
+
+function resolveAppIconAssetPath(url: string): string | null {
+ const parsed = new URL(url);
+ if (parsed.protocol !== `${APP_ICON_ASSET_SCHEME}:`) {
+ return null;
+ }
+ return appIconManager.getIconPath(parsed.hostname as AppIconId);
+}
+
+function registerAppIconResourceProtocol(): void {
+ protocol.handle(APP_ICON_ASSET_SCHEME, async (request: Request): Promise => {
+ const target = resolveAppIconAssetPath(request.url);
+ if (!target) {
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ try {
+ const fileStat = await stat(target);
+ if (!fileStat.isFile()) {
+ return new Response("Not found", { status: 404 });
+ }
+ return net.fetch(pathToFileURL(target).toString());
+ } catch {
+ return new Response("Not found", { status: 404 });
+ }
+ });
+}
+
function createFallbackIcon(): Electron.NativeImage {
return nativeImage.createFromDataURL(
"data:image/svg+xml;utf8," +
@@ -46,20 +146,23 @@ function createFallbackIcon(): Electron.NativeImage {
}
function createAppIcon(): Electron.NativeImage {
- const iconPath = app.isPackaged
- ? join(process.resourcesPath, "icon.png")
- : join(process.cwd(), "resources", "icon.png");
- const icon = nativeImage.createFromPath(iconPath);
+ const icon = appIconManager.createIcon();
return icon.isEmpty() ? createFallbackIcon() : icon;
}
+function applyAppIcon(): void {
+ const icon = createAppIcon();
+ mainWindow?.setIcon(icon);
+ tray?.setImage(icon.resize({ width: 32, height: 32, quality: "best" }));
+}
+
function broadcastWindowState(window: BrowserWindow): void {
if (window.isDestroyed()) {
return;
}
window.webContents.send("desktop:window-state", {
- isMaximized: window.isMaximized(),
+ isMaximized: isWindowVisuallyMaximized(window),
isFullScreen: window.isFullScreen(),
isFocused: window.isFocused(),
});
@@ -72,6 +175,7 @@ function createMainWindow(): BrowserWindow {
height: 820,
minWidth: 1080,
minHeight: 720,
+ resizable: false,
show: false,
backgroundColor: "#00000000",
transparent: true,
@@ -80,7 +184,7 @@ function createMainWindow(): BrowserWindow {
titleBarStyle: process.platform === "darwin" ? "hidden" : "default",
trafficLightPosition: process.platform === "darwin" ? { x: -100, y: -100 } : undefined,
webPreferences: {
- preload: join(__dirname, "../preload/index.mjs"),
+ preload: join(__dirname, "../preload/index.cjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
@@ -202,7 +306,12 @@ if (!instanceLock.acquired || !resourceLock.acquired) {
ptySessionManager.dispose();
app.quit();
} else {
- app.whenReady().then(() => {
+ app.whenReady().then(async () => {
+ registerLive2dResourceProtocol(runtimePaths);
+ registerAppIconResourceProtocol();
+ await networkProxyManager.applyStoredSettings().catch((error: unknown) => {
+ logStore.append("desktop", "system", `network proxy apply failed: ${String(error)}`);
+ });
mainWindow = createMainWindow();
tray = createTray();
@@ -210,10 +319,14 @@ if (!instanceLock.acquired || !resourceLock.acquired) {
paths: runtimePaths,
initManager,
moduleUpdater,
+ networkProxyManager,
+ openCodeSettingsManager,
pythonDependencyManager,
resourceLocationManager,
serviceManager,
logStore,
+ appIconManager,
+ applyAppIcon,
getMainWindow: () => mainWindow,
requestQuit,
showMainWindow,
diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts
index 457a14f..1210bdf 100644
--- a/src/main/ipc/app.ts
+++ b/src/main/ipc/app.ts
@@ -1,15 +1,20 @@
import { app, BrowserWindow, dialog, ipcMain, screen, shell } from "electron";
-import { execFile } from "node:child_process";
+import { execFile, spawn } 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,
+ AppIconId,
+ AppIconSettings,
DesktopSnapshot,
InitRepairResult,
InitState,
+ LauncherUpdateApplyResult,
+ LauncherUpdateInfo,
LauncherResetResult,
LogEntry,
+ Live2dModelImportResult,
LocalChatConnectionState,
LocalChatConnectRequest,
LocalChatMessageEvent,
@@ -19,22 +24,43 @@ 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,
+ MaiBotPluginUserStates,
+ MaiBotPluginVoteResult,
MaiBotStatisticSummary,
ManagedPythonPackageName,
+ ModuleBranchOption,
ModuleRuntimeVersions,
ModuleUpdateResult,
+ NetworkProxySettings,
+ OpenCodeSettings,
ModuleSourceConfig,
ModuleSourceUpdate,
ModuleTagOption,
+ ModuleUpdateTarget,
PythonOverridesState,
PythonPackageSourcePreset,
PythonRuntimeCandidate,
@@ -56,16 +82,27 @@ import type {
StartupAgreementConfirmResult,
StartupAgreementState,
TerminalSettings,
+ WindowResizeEdge,
WindowState,
} from "../../shared/contracts";
+import {
+ buildMaiBotPluginBlueprintFiles,
+ defaultMaiBotPluginFolderName,
+ validateMaiBotPluginBlueprint,
+} from "../../shared/plugin-blueprint";
import { InitManager } from "../services/init-manager";
+import type { AppIconManager } from "../services/app-icon-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 { OpenCodeSettingsManager } from "../services/opencode-settings-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";
+import { getWindowWorkAreaBounds, isWindowVisuallyMaximized } from "../window-state";
const LAUNCHER_SETTING_FILES = [
"resource-paths.json",
@@ -77,29 +114,80 @@ const LAUNCHER_SETTING_FILES = [
"message-platform.json",
"module-sources.json",
"python-dependency-source.json",
+ "network-proxy.json",
+ "opencode-settings.json",
+ "app-icon-settings.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 };
+const NORMAL_DEFAULT_SIZE = { width: 1280, height: 820 };
+const NORMAL_RESTORE_MARGIN = 48;
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";
+const ONEKEY_LATEST_RELEASE_API_URL = "https://api.github.com/repos/DrSmoothl/MaiBotOneKey/releases/latest";
+const ONEKEY_RELEASES_API_URL = "https://api.github.com/repos/DrSmoothl/MaiBotOneKey/releases?per_page=100";
+const ONEKEY_RELEASE_SOURCE = "DrSmoothl/MaiBotOneKey";
+
+interface GitHubReleaseAsset {
+ name?: unknown;
+ size?: unknown;
+ browser_download_url?: unknown;
+}
+
+interface GitHubReleasePayload {
+ tag_name?: unknown;
+ name?: unknown;
+ html_url?: unknown;
+ body?: unknown;
+ prerelease?: unknown;
+ draft?: unknown;
+ assets?: unknown;
+}
+
+interface LauncherUpdateInternalInfo extends LauncherUpdateInfo {
+ downloadUrl?: string;
+}
interface RegisterAppIpcOptions {
paths: RuntimePaths;
initManager: InitManager;
moduleUpdater: ModuleUpdater;
+ networkProxyManager: NetworkProxyManager;
+ openCodeSettingsManager: OpenCodeSettingsManager;
pythonDependencyManager: PythonDependencyManager;
resourceLocationManager: ResourceLocationManager;
serviceManager: ServiceManager;
logStore: LogStore;
+ appIconManager: AppIconManager;
+ applyAppIcon: () => void;
getMainWindow: () => BrowserWindow | null;
requestQuit: () => void;
showMainWindow: () => void;
}
+interface WindowResizeState {
+ edge: WindowResizeEdge;
+ startScreenX: number;
+ startScreenY: number;
+ bounds: Electron.Rectangle;
+}
+
export interface RegisteredAppIpcDisposables {
localChatAdapter: LocalChatAdapter;
dispose: () => void;
@@ -109,6 +197,7 @@ function readWindowState(
window: BrowserWindow | null,
isFloating = false,
floatingEdgeSide: "left" | "right" | null = null,
+ isShellMaximized = false,
): WindowState {
if (!window || window.isDestroyed()) {
return {
@@ -122,7 +211,7 @@ function readWindowState(
}
return {
- isMaximized: window.isMaximized(),
+ isMaximized: isShellMaximized || isWindowVisuallyMaximized(window),
isFullScreen: window.isFullScreen(),
isFocused: window.isFocused(),
isFloating,
@@ -260,6 +349,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[];
@@ -355,32 +518,184 @@ function compareParsedTags(left: ParsedVersionTag, right: ParsedVersionTag): num
return left.tag.localeCompare(right.tag, "en-US", { numeric: true, sensitivity: "base" });
}
-function isAtLeastVersion(tag: ParsedVersionTag, minimum: number[]): boolean {
- const length = Math.max(tag.parts.length, minimum.length);
- for (let index = 0; index < length; index++) {
- const diff = (tag.parts[index] ?? 0) - (minimum[index] ?? 0);
- if (diff !== 0) {
- return diff > 0;
- }
- }
- return true;
-}
-
function pickLatestTags(
rawTags: string[],
-): Pick {
+): Pick {
const parsed = rawTags.map(parseVersionTag).filter((tag): tag is ParsedVersionTag => Boolean(tag));
- const standard = parsed.filter((tag) => isAtLeastVersion(tag, [1, 0, 0]));
- const stable = standard.filter((tag) => !tag.prerelease).sort(compareParsedTags).at(-1)?.tag;
- const prerelease = standard.filter((tag) => tag.prerelease).sort(compareParsedTags).at(-1)?.tag;
- const legacy = parsed.filter((tag) => !isAtLeastVersion(tag, [1, 0, 0])).sort(compareParsedTags).at(-1)?.tag;
+ const stable = parsed.filter((tag) => !tag.prerelease).sort(compareParsedTags).at(-1)?.tag;
+ const prerelease = parsed.filter((tag) => tag.prerelease).sort(compareParsedTags).at(-1)?.tag;
return {
maibotLatestStableTag: stable,
maibotLatestPrereleaseTag: prerelease,
- maibotLatestLegacyTag: legacy,
};
}
+function pickLatestVersionTag(rawTags: string[]): string | undefined {
+ return rawTags
+ .map(parseVersionTag)
+ .filter((tag): tag is ParsedVersionTag => Boolean(tag))
+ .sort(compareParsedTags)
+ .at(-1)?.tag;
+}
+
+function compareVersionTags(left: string | undefined, right: string | undefined): number {
+ const parsedLeft = left ? parseVersionTag(left) : undefined;
+ const parsedRight = right ? parseVersionTag(right) : undefined;
+ if (parsedLeft && parsedRight) {
+ return compareParsedTags(parsedLeft, parsedRight);
+ }
+ return (left ?? "").localeCompare(right ?? "", "en-US", { numeric: true, sensitivity: "base" });
+}
+
+function releaseTagToVersion(tag: string | undefined): string | undefined {
+ return tag?.trim().replace(/^v/iu, "") || undefined;
+}
+
+function sanitizeDownloadFileName(value: string): string {
+ const sanitized = basename(value)
+ .replace(/[<>:"/\\|?*\u0000-\u001F]/gu, "-")
+ .replace(/\s+/gu, " ")
+ .trim()
+ .replace(/[. ]+$/u, "");
+ return sanitized || "MaiBot-OneKey-update.exe";
+}
+
+function selectLauncherUpdateAsset(rawAssets: unknown): GitHubReleaseAsset | undefined {
+ const assets = Array.isArray(rawAssets) ? rawAssets.filter((item): item is GitHubReleaseAsset => {
+ return Boolean(item && typeof item === "object");
+ }) : [];
+ const executableAssets = assets.filter((asset) => {
+ const name = typeof asset.name === "string" ? asset.name.toLowerCase() : "";
+ const url = typeof asset.browser_download_url === "string" ? asset.browser_download_url : "";
+ return name.endsWith(".exe") && !name.includes("uninstaller") && Boolean(url);
+ });
+ return executableAssets.find((asset) => String(asset.name ?? "").toLowerCase().includes("win"))
+ ?? executableAssets[0];
+}
+
+async function fetchLauncherReleaseNotesInRange(currentTag: string, latestTag: string): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 15_000);
+ try {
+ const response = await fetch(ONEKEY_RELEASES_API_URL, {
+ headers: { Accept: "application/vnd.github+json" },
+ signal: controller.signal,
+ });
+ if (!response.ok) {
+ throw new Error(`GitHub Releases returned HTTP ${response.status}`);
+ }
+
+ const releases = (await response.json()) as unknown;
+ if (!Array.isArray(releases)) {
+ return undefined;
+ }
+
+ const notes = releases
+ .filter((release): release is GitHubReleasePayload => {
+ if (!release || typeof release !== "object") {
+ return false;
+ }
+ const tag = release.tag_name;
+ return (
+ release.draft !== true
+ && typeof tag === "string"
+ && compareVersionTags(tag, currentTag) > 0
+ && compareVersionTags(tag, latestTag) <= 0
+ );
+ })
+ .sort((left, right) => compareVersionTags(String(right.tag_name), String(left.tag_name)))
+ .map((release) => {
+ const tag = String(release.tag_name);
+ const title = typeof release.name === "string" && release.name.trim() ? release.name.trim() : tag;
+ const body = typeof release.body === "string" && release.body.trim()
+ ? release.body.trim()
+ : "此版本没有填写更新说明。";
+ return `## ${title}\n\n${body}`;
+ });
+
+ return notes.length > 1 ? notes.join("\n\n---\n\n") : undefined;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+async function fetchLauncherUpdateInfo(currentVersion: string): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 15_000);
+ try {
+ const response = await fetch(ONEKEY_LATEST_RELEASE_API_URL, {
+ headers: { Accept: "application/vnd.github+json" },
+ signal: controller.signal,
+ });
+ if (!response.ok) {
+ throw new Error(`GitHub Releases returned HTTP ${response.status}`);
+ }
+
+ const release = (await response.json()) as GitHubReleasePayload;
+ if (release.draft === true || typeof release.tag_name !== "string") {
+ throw new Error("Latest release metadata is incomplete");
+ }
+
+ const asset = selectLauncherUpdateAsset(release.assets);
+ const latestTag = release.tag_name;
+ const currentTag = `v${currentVersion}`;
+ const available = compareVersionTags(latestTag, currentTag) > 0;
+ const latestReleaseNotes = typeof release.body === "string" ? release.body : undefined;
+ const releaseNotes = available
+ ? await fetchLauncherReleaseNotesInRange(currentTag, latestTag).catch(() => latestReleaseNotes)
+ : latestReleaseNotes;
+ return {
+ currentVersion,
+ latestTag,
+ latestVersion: releaseTagToVersion(latestTag),
+ releaseName: typeof release.name === "string" ? release.name : latestTag,
+ releaseUrl: typeof release.html_url === "string" ? release.html_url : "https://github.com/DrSmoothl/MaiBotOneKey/releases",
+ releaseNotes,
+ assetName: typeof asset?.name === "string" ? asset.name : undefined,
+ assetSize: typeof asset?.size === "number" ? asset.size : undefined,
+ available,
+ checkedAt: Date.now(),
+ source: ONEKEY_RELEASE_SOURCE,
+ downloadUrl: typeof asset?.browser_download_url === "string" ? asset.browser_download_url : undefined,
+ };
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+function publicLauncherUpdateInfo(update: LauncherUpdateInternalInfo): LauncherUpdateInfo {
+ const { downloadUrl: _downloadUrl, ...publicUpdate } = update;
+ return publicUpdate;
+}
+
+async function downloadLauncherUpdate(paths: RuntimePaths, update: LauncherUpdateInternalInfo): Promise {
+ if (!update.downloadUrl || !update.assetName) {
+ throw new Error("最新版本没有可下载的 Windows 安装包");
+ }
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 120_000);
+ try {
+ const response = await fetch(update.downloadUrl, { signal: controller.signal });
+ if (!response.ok) {
+ throw new Error(`安装包下载失败: HTTP ${response.status}`);
+ }
+
+ const buffer = Buffer.from(await response.arrayBuffer());
+ if (update.assetSize && buffer.length !== update.assetSize) {
+ throw new Error(`安装包大小校验失败: ${buffer.length} / ${update.assetSize}`);
+ }
+
+ const updatesRoot = join(paths.userDataRoot, "updates");
+ await mkdir(updatesRoot, { recursive: true });
+ const installerPath = join(updatesRoot, sanitizeDownloadFileName(update.assetName));
+ await writeFile(installerPath, buffer);
+ return installerPath;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
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);
@@ -431,7 +746,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, ">")
@@ -444,7 +759,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;
}
@@ -452,7 +767,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 {
@@ -461,26 +776,69 @@ 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 isChatStatisticHeading(line: string): boolean {
+ const normalized = line.trim().toLowerCase();
+ return (
+ line.includes("\u804A\u5929\u6D88\u606F\u7EDF\u8BA1") ||
+ /^(?:chat\s+)?message\s+statistics$/iu.test(normalized) ||
+ /^chat\s+statistics$/iu.test(normalized)
+ );
+}
+
+function isChatStatisticBoundary(line: string): boolean {
+ const normalized = line.trim().toLowerCase();
+ return (
+ line.includes("\u7EDF\u8BA1\u65F6\u6BB5") ||
+ line.includes("\u6309\u6A21\u578B\u5206\u7C7B\u7EDF\u8BA1") ||
+ line.includes("\u6309\u6A21\u5757\u5206\u7C7B\u7EDF\u8BA1") ||
+ line.includes("\u6309\u8BF7\u6C42\u7C7B\u578B\u5206\u7C7B\u7EDF\u8BA1") ||
+ line.includes("\u6570\u636E\u5206\u5E03\u56FE\u8868") ||
+ line.includes("\u6307\u6807\u8D8B\u52BF") ||
+ /^(?:statistics\s+period|model\s+statistics|module\s+statistics|request\s+type\s+statistics|data\s+distribution|metrics\s+trend|charts?)$/iu.test(normalized)
+ );
+}
+
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(isChatStatisticHeading);
if (startIndex < 0) {
return [];
}
const stats: MaiBotStatisticSummary["chatStats"] = [];
for (const line of lines.slice(startIndex + 1)) {
- if (line.startsWith("-") || line.includes("Token/") || line.includes("花费/")) {
+ if (isStatisticStyleLine(line)) {
+ continue;
+ }
+ if (isChatStatisticBoundary(line)) {
break;
}
- if (line.includes("联系人") || line.includes("群组名称") || line.includes("消息数量")) {
+ if (line.startsWith("-") || line.includes("Token/") || line.toLowerCase().includes("cost")) {
+ break;
+ }
+ 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);
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;
}
@@ -498,8 +856,8 @@ async function readMaiBotStatistics(paths: RuntimePaths): Promise = {};
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 shellMaximized = false;
+ let shellRestoreBounds: Electron.Rectangle | null = null;
+ let resizeState: WindowResizeState | null = null;
+
+ const readManagedWindowState = (window: BrowserWindow | null): WindowState =>
+ readWindowState(window, floatingMode, floatingEdgeSide, shellMaximized);
const sendWindowState = (window: BrowserWindow | null): WindowState => {
- const state = readWindowState(window, floatingMode, floatingEdgeSide);
+ const state = readManagedWindowState(window);
window?.webContents.send("desktop:window-state", state);
return state;
};
@@ -559,7 +928,10 @@ export function registerAppIpc({
};
};
- const floatingBounds = (window: BrowserWindow, size: { width: number; height: number }): Electron.Rectangle => {
+ 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),
@@ -569,21 +941,189 @@ 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(false);
+ window.setMaximizable(true);
+ window.setMinimumSize(NORMAL_MINIMUM_SIZE.width, NORMAL_MINIMUM_SIZE.height);
+ };
+
+ const clampNormalBounds = (bounds: Electron.Rectangle): Electron.Rectangle => {
+ const workArea = screen.getDisplayMatching(bounds).workArea;
+ const width = Math.min(Math.max(bounds.width, NORMAL_MINIMUM_SIZE.width), workArea.width);
+ const height = Math.min(Math.max(bounds.height, NORMAL_MINIMUM_SIZE.height), workArea.height);
+ return {
+ x: Math.round(Math.min(Math.max(bounds.x, workArea.x), workArea.x + workArea.width - width)),
+ y: Math.round(Math.min(Math.max(bounds.y, workArea.y), workArea.y + workArea.height - height)),
+ width: Math.round(width),
+ height: Math.round(height),
+ };
+ };
+
+ const fallbackNormalBounds = (window: BrowserWindow): Electron.Rectangle => {
+ const workArea = getWindowWorkAreaBounds(window);
+ const width = Math.min(
+ Math.max(NORMAL_MINIMUM_SIZE.width, Math.min(NORMAL_DEFAULT_SIZE.width, workArea.width - NORMAL_RESTORE_MARGIN * 2)),
+ workArea.width,
+ );
+ const height = Math.min(
+ Math.max(NORMAL_MINIMUM_SIZE.height, Math.min(NORMAL_DEFAULT_SIZE.height, workArea.height - NORMAL_RESTORE_MARGIN * 2)),
+ workArea.height,
+ );
+ return {
+ x: Math.round(workArea.x + (workArea.width - width) / 2),
+ y: Math.round(workArea.y + (workArea.height - height) / 2),
+ width: Math.round(width),
+ height: Math.round(height),
+ };
+ };
+
+ const isShellWindowMaximized = (window: BrowserWindow): boolean =>
+ shellMaximized || isWindowVisuallyMaximized(window);
+
+ const rememberShellRestoreBounds = (window: BrowserWindow): void => {
+ if (!isShellWindowMaximized(window)) {
+ shellRestoreBounds = clampNormalBounds(window.getBounds());
+ }
+ };
+
+ const getShellRestoreBounds = (window: BrowserWindow): Electron.Rectangle => {
+ if (shellRestoreBounds) {
+ return clampNormalBounds(shellRestoreBounds);
+ }
+ if (window.isMaximized()) {
+ return clampNormalBounds(window.getNormalBounds());
+ }
+ return fallbackNormalBounds(window);
+ };
+
+ const maximizeShellWindow = (window: BrowserWindow): WindowState => {
+ resizeState = null;
+ rememberShellRestoreBounds(window);
+ restoreNormalWindowChrome(window);
+ shellMaximized = true;
+ window.setBounds(getWindowWorkAreaBounds(window), true);
+ window.show();
+ return sendWindowState(window);
+ };
+
+ const restoreShellWindow = (window: BrowserWindow): WindowState => {
+ resizeState = null;
+ const restoreBounds = getShellRestoreBounds(window);
+ shellMaximized = false;
+ shellRestoreBounds = null;
+ if (window.isMaximized()) {
+ window.unmaximize();
+ }
+ restoreNormalWindowChrome(window);
+ window.setBounds(restoreBounds, true);
+ window.show();
+ return sendWindowState(window);
+ };
+
+ const startWindowResize = (
+ edge: WindowResizeEdge,
+ screenX: number,
+ screenY: number,
+ ): WindowState => {
+ const window = getMainWindow();
+ if (!window || window.isDestroyed()) {
+ return readManagedWindowState(window);
+ }
+ if (floatingMode || isShellWindowMaximized(window) || 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 readManagedWindowState(window);
+ }
+ if (!resizeState || floatingMode || isShellWindowMaximized(window) || 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()) {
- return readWindowState(window, floatingMode, floatingEdgeSide);
+ return readManagedWindowState(window);
}
if (enabled && !floatingMode) {
- normalBounds = window.getBounds();
+ resizeState = null;
+ normalBounds = isShellWindowMaximized(window)
+ ? getShellRestoreBounds(window)
+ : window.getBounds();
+ shellMaximized = false;
+ shellRestoreBounds = null;
if (window.isMaximized()) {
window.unmaximize();
}
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 +1132,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();
@@ -612,11 +1155,14 @@ export function registerAppIpc({
const applyFloatingPanelExpanded = (expanded: boolean): WindowState => {
const window = getMainWindow();
if (!window || window.isDestroyed()) {
- return readWindowState(window, floatingMode, floatingEdgeSide);
+ return readManagedWindowState(window);
}
if (!floatingMode) {
return sendWindowState(window);
}
+ if (floatingPanelExpanded === expanded && (expanded || !floatingEdgeSide)) {
+ return sendWindowState(window);
+ }
floatingPanelExpanded = expanded;
floatingEdgeSide = null;
const currentBounds = window.getBounds();
@@ -636,7 +1182,7 @@ export function registerAppIpc({
const moveFloatingBy = (deltaX: number, deltaY: number): WindowState => {
const window = getMainWindow();
if (!window || window.isDestroyed()) {
- return readWindowState(window, floatingMode, floatingEdgeSide);
+ return readManagedWindowState(window);
}
if (!floatingMode) {
return sendWindowState(window);
@@ -658,19 +1204,20 @@ 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,
);
return sendWindowState(window);
};
- const moveFloatingTo = (screenX: number, screenY: number, offsetX: number, offsetY: number): WindowState => {
+ const moveFloatingTo = (offsetX: number, offsetY: number): WindowState => {
const window = getMainWindow();
if (!window || window.isDestroyed()) {
- return readWindowState(window, floatingMode, floatingEdgeSide);
+ return readManagedWindowState(window);
}
if (!floatingMode) {
return sendWindowState(window);
@@ -692,18 +1239,20 @@ 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,
);
@@ -713,13 +1262,13 @@ export function registerAppIpc({
const finishFloatingDrag = (): WindowState => {
const window = getMainWindow();
if (!window || window.isDestroyed()) {
- return readWindowState(window, floatingMode, floatingEdgeSide);
+ return readManagedWindowState(window);
}
if (!floatingMode) {
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 +1293,7 @@ export function registerAppIpc({
}
floatingEdgeSide = null;
- window.setBounds(clampFloatingBounds(currentBounds), true);
+ window.setBounds(clampFloatingBounds(withActiveFloatingSize(currentBounds)), true);
return sendWindowState(window);
};
@@ -806,11 +1355,68 @@ 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: ONEKEY_RELEASE_SOURCE,
+ };
+ }
+ }
+
+ 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: ONEKEY_RELEASE_SOURCE,
+ }
+ : {};
+ } catch {
+ return {};
+ } finally {
+ clearTimeout(timeout);
+ }
+ };
+
const readModuleVersions = async (): Promise => ({
...remoteModuleVersionsCache,
...(await readLocalModuleVersions()),
});
+ const checkLauncherUpdate = async (): Promise => {
+ const update = await fetchLauncherUpdateInfo(app.getVersion());
+ remoteAppVersionCache = update.latestTag
+ ? {
+ appLatestTag: update.latestTag,
+ appLatestSource: update.source,
+ }
+ : {};
+ await broadcastSnapshot();
+ return update;
+ };
+
const buildSnapshot = async (options: { refreshDependencies?: boolean } = {}): Promise => ({
paths,
services: serviceManager.snapshot(),
@@ -818,10 +1424,15 @@ export function registerAppIpc({
runtimePathConfigs: serviceManager.getRuntimePathConfigs(),
runtimeResourcePathConfigs: resourceLocationManager.getPathConfigs(),
terminalSettings: serviceManager.getTerminalSettings(),
+ openCodeSettings: openCodeSettingsManager.getSettings(),
+ appIconSettings: appIconManager.getSettings(),
+ networkProxySettings: networkProxyManager.getSettings(),
appVersion: app.getVersion(),
+ appLatestTag: remoteAppVersionCache.appLatestTag,
+ appLatestSource: remoteAppVersionCache.appLatestSource,
moduleVersions: await readModuleVersions(),
platform: process.platform,
- windowState: readWindowState(getMainWindow(), floatingMode, floatingEdgeSide),
+ windowState: readManagedWindowState(getMainWindow()),
initState: await initManager.getState({ refreshDependencies: options.refreshDependencies ?? false }),
startupAgreement: await initManager.getAgreementState(),
recentLogs: logStore.list(),
@@ -839,9 +1450,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) => {
@@ -877,7 +1489,8 @@ export function registerAppIpc({
getModuleSourceConfig: () => moduleUpdater.getSourceConfig(),
});
let maibotPluginClient = createMaibotPluginClient();
- const localChatAdapter = new LocalChatAdapter(paths);
+ const pluginBuilderLibrary = new PluginBuilderLibrary(paths.pluginBuilderRoot);
+ const localChatAdapter = new LocalChatAdapter(paths, initManager);
const assertServicesStoppedForResourceMove = (): void => {
const active = serviceManager
@@ -890,7 +1503,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(", ")}`);
}
};
@@ -916,6 +1529,10 @@ export function registerAppIpc({
await serviceManager.resetRuntimePathConfig("python");
await serviceManager.resetRuntimePathConfig("git");
await serviceManager.saveTerminalSettings({ ...serviceManager.getTerminalSettings(), useEmbeddedTerminal: true });
+ await networkProxyManager.resetSettings();
+ await openCodeSettingsManager.resetSettings();
+ appIconManager.reset();
+ applyAppIcon();
for (const key of ["maibot", "napcat"] as const) {
const config = resourceLocationManager.getPathConfigs().find((item) => item.key === key);
@@ -940,7 +1557,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",
@@ -954,10 +1571,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();
@@ -1009,13 +1626,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;
});
@@ -1023,7 +1683,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();
@@ -1036,10 +1696,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();
@@ -1058,7 +1718,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,
@@ -1079,28 +1739,32 @@ 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;
});
- ipcMain.handle("modules:updateMaibot", async (_event, tag?: string): Promise => {
+ ipcMain.handle("modules:updateMaibot", async (_event, target?: ModuleUpdateTarget): 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 强制拉取远端代码");
- const result = await moduleUpdater.updateMaiBot(tag);
+ logStore.append("desktop", "system", "Updating MaiBot module from Git.");
+ const result = await moduleUpdater.updateMaiBot(target);
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;
});
+ ipcMain.handle("modules:listMaibotBranches", async (): Promise => {
+ return moduleUpdater.listMaiBotBranches();
+ });
+
ipcMain.handle("modules:listMaibotTags", async (): Promise => {
return moduleUpdater.listMaiBotTags();
});
@@ -1111,7 +1775,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;
});
@@ -1119,16 +1783,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
@@ -1142,7 +1806,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;
@@ -1158,20 +1822,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
@@ -1185,7 +1849,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;
@@ -1195,14 +1859,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;
@@ -1216,6 +1880,71 @@ 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(
+ "launcher:saveOpenCodeSettings",
+ async (_event, settings: OpenCodeSettings): Promise => {
+ const result = await openCodeSettingsManager.saveSettings(settings);
+ logStore.append(
+ "desktop",
+ "system",
+ result.useBundledPluginInstructions
+ ? "OpenCode 已启用内置插件编写说明"
+ : "OpenCode 已恢复项目默认说明",
+ );
+ await broadcastSnapshot();
+ return result;
+ },
+ );
+
+ ipcMain.handle("launcher:selectAppIcon", async (_event, iconId: AppIconId): Promise => {
+ const result = await appIconManager.select(iconId);
+ applyAppIcon();
+ return result;
+ });
+
+ ipcMain.handle("launcher:checkUpdate", async (): Promise => {
+ return publicLauncherUpdateInfo(await checkLauncherUpdate());
+ });
+
+ ipcMain.handle("launcher:downloadAndInstallUpdate", async (): Promise => {
+ const update = await checkLauncherUpdate();
+ if (!update.available) {
+ throw new Error("当前启动器已经是最新版本");
+ }
+
+ const installerPath = await downloadLauncherUpdate(paths, update);
+ const child = spawn(installerPath, [], {
+ detached: true,
+ stdio: "ignore",
+ windowsHide: false,
+ });
+ child.unref();
+ logStore.append("desktop", "system", `启动器更新安装器已启动: ${installerPath}`);
+ setTimeout(() => requestQuit(), 800);
+ return {
+ update: publicLauncherUpdateInfo(update),
+ installerPath,
+ started: true,
+ willQuit: true,
+ };
+ });
+
ipcMain.handle("plugins:listMarket", async (
_event,
serviceUrl?: string,
@@ -1269,6 +1998,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);
});
@@ -1296,6 +2191,56 @@ 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:getUserStates", async (
+ _event,
+ userId: string,
+ ): Promise => {
+ return maibotPluginClient.getUserStates(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 | null | undefined,
+ comment: string | null | 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);
});
@@ -1317,15 +2262,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;
@@ -1398,7 +2343,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"] : ["*"] },
@@ -1433,7 +2378,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;
}
@@ -1447,7 +2392,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;
}
@@ -1517,14 +2462,12 @@ export function registerAppIpc({
getMainWindow()?.minimize();
});
- ipcMain.handle("desktop:window:toggleMaximize", (): void => {
+ ipcMain.handle("desktop:window:toggleMaximize", (): WindowState | void => {
const window = getMainWindow();
if (!window) return;
- if (window.isMaximized()) {
- window.unmaximize();
- } else {
- window.maximize();
- }
+ return isShellWindowMaximized(window)
+ ? restoreShellWindow(window)
+ : maximizeShellWindow(window);
});
ipcMain.handle("desktop:window:close", (): void => {
@@ -1542,14 +2485,26 @@ export function registerAppIpc({
);
ipcMain.handle(
"desktop:window:moveFloatingTo",
- (_event, screenX: number, screenY: number, offsetX: number, offsetY: number): WindowState =>
- moveFloatingTo(screenX, screenY, offsetX, offsetY),
+ (_event, offsetX: number, offsetY: number): WindowState =>
+ moveFloatingTo(offsetX, offsetY),
);
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),
+ readManagedWindowState(getMainWindow()),
);
return {
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index c05f9b1..caeeb65 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -59,6 +59,10 @@ export function registerPtyIpc({ manager, getMainWindow }: RegisterPtyIpcOptions
manager.kill(sessionId);
});
+ ipcMain.handle("pty:close", (_event, sessionId: string): void => {
+ manager.close(sessionId);
+ });
+
ipcMain.handle("pty:clear", (_event, sessionId: string): void => {
manager.clear(sessionId);
});
diff --git a/src/main/pty/pty-session-manager.ts b/src/main/pty/pty-session-manager.ts
index 3dd04e6..9182fab 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,
@@ -493,6 +496,12 @@ export class PtySessionManager extends EventEmitter {
this.getRequired(sessionId).kill();
}
+ close(sessionId: string): void {
+ const session = this.getRequired(sessionId);
+ session.kill();
+ this.sessions.delete(sessionId);
+ }
+
clear(sessionId: string): void {
this.getRequired(sessionId).clear();
}
diff --git a/src/main/services/app-icon-manager.ts b/src/main/services/app-icon-manager.ts
new file mode 100644
index 0000000..dc58c8d
--- /dev/null
+++ b/src/main/services/app-icon-manager.ts
@@ -0,0 +1,117 @@
+import { nativeImage } from "electron";
+import { existsSync, readFileSync } from "node:fs";
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import type { AppIconId, AppIconOption, AppIconSettings, RuntimePaths } from "../../shared/contracts";
+
+const APP_ICON_SETTINGS_FILE = "app-icon-settings.json";
+const DEFAULT_APP_ICON_ID: AppIconId = "sprout";
+
+const APP_ICON_OPTIONS = [
+ {
+ id: "soft",
+ label: "圆角头像",
+ description: "使用新的柔和圆角麦麦头像。",
+ fileName: "soft.png",
+ },
+ {
+ id: "sprout",
+ label: "小芽头像",
+ description: "使用带小芽和圆滚滚脸型的麦麦头像。",
+ fileName: "sprout.png",
+ },
+ {
+ id: "bean",
+ label: "橙团小芽",
+ description: "使用更近景、更圆润的橙团小芽头像。",
+ fileName: "bean.png",
+ },
+] as const satisfies readonly (AppIconOption & { fileName: string })[];
+
+interface StoredAppIconSettingsFile {
+ version: 1;
+ selectedIconId?: AppIconId;
+}
+
+function normalizeAppIconId(value: unknown): AppIconId {
+ return APP_ICON_OPTIONS.some((option) => option.id === value) ? (value as AppIconId) : DEFAULT_APP_ICON_ID;
+}
+
+export class AppIconManager {
+ private selectedIconId: AppIconId;
+ private readonly settingsPath: string;
+ private readonly iconRoot: string;
+ private readonly fallbackIconPath: string;
+
+ constructor(paths: RuntimePaths, packaged: boolean) {
+ this.settingsPath = join(paths.userDataRoot, APP_ICON_SETTINGS_FILE);
+ this.iconRoot = packaged ? join(process.resourcesPath, "app-icons") : join(paths.installRoot, "resources", "app-icons");
+ this.fallbackIconPath = packaged ? join(process.resourcesPath, "icon.png") : join(paths.installRoot, "resources", "icon.png");
+ this.selectedIconId = this.readSelectedIconId();
+ }
+
+ getSettings(): AppIconSettings {
+ return {
+ selectedIconId: this.selectedIconId,
+ options: APP_ICON_OPTIONS.map((option) => ({
+ id: option.id,
+ label: option.label,
+ description: option.description,
+ previewUrl: this.previewUrl(option.id),
+ })),
+ };
+ }
+
+ createIcon(): Electron.NativeImage {
+ const icon = nativeImage.createFromPath(this.iconPath(this.selectedIconId));
+ return icon.isEmpty() ? nativeImage.createFromPath(this.fallbackIconPath) : icon;
+ }
+
+ async select(iconId: AppIconId): Promise {
+ this.selectedIconId = normalizeAppIconId(iconId);
+ await mkdir(dirname(this.settingsPath), { recursive: true });
+ await writeFile(
+ this.settingsPath,
+ `${JSON.stringify({ version: 1, selectedIconId: this.selectedIconId } satisfies StoredAppIconSettingsFile, null, 2)}\n`,
+ "utf8",
+ );
+ return this.getSettings();
+ }
+
+ reset(): AppIconSettings {
+ this.selectedIconId = DEFAULT_APP_ICON_ID;
+ return this.getSettings();
+ }
+
+ getIconPath(iconId: AppIconId): string {
+ return this.iconPath(normalizeAppIconId(iconId));
+ }
+
+ private readSelectedIconId(): AppIconId {
+ if (!existsSync(this.settingsPath)) {
+ return DEFAULT_APP_ICON_ID;
+ }
+
+ try {
+ const raw = JSON.parse(readFileSync(this.settingsPath, "utf8")) as StoredAppIconSettingsFile;
+ return normalizeAppIconId(raw.selectedIconId);
+ } catch {
+ return DEFAULT_APP_ICON_ID;
+ }
+ }
+
+ private iconPath(iconId: AppIconId): string {
+ const option = APP_ICON_OPTIONS.find((candidate) => candidate.id === iconId) ?? APP_ICON_OPTIONS[0];
+ const optionPath = join(this.iconRoot, option.fileName);
+ return existsSync(optionPath) ? optionPath : this.fallbackIconPath;
+ }
+
+ private previewUrl(iconId: AppIconId): string | undefined {
+ try {
+ return `data:image/png;base64,${readFileSync(this.iconPath(iconId)).toString("base64")}`;
+ } catch {
+ return undefined;
+ }
+ }
+
+}
diff --git a/src/main/services/init-manager.ts b/src/main/services/init-manager.ts
index 919454b..ef24a35 100644
--- a/src/main/services/init-manager.ts
+++ b/src/main/services/init-manager.ts
@@ -35,6 +35,8 @@ const PYTHON_DOWNLOAD_URL = "https://www.python.org/downloads/windows/";
const GIT_DOWNLOAD_URL = "https://git-scm.com/download/win";
const NAPCAT_FALLBACK_VERSION = "9.9.26-44498";
const MAIBOT_FALLBACK_CONFIG_VERSION = "8.10.22";
+const MAIBOT_WEBUI_FALLBACK_HOST = "127.0.0.1";
+const MAIBOT_WEBUI_FALLBACK_PORT = 8001;
const QQ_BACKEND_FILE = "qq-backend.json";
const MESSAGE_PLATFORM_FILE = "message-platform.json";
const PYTHON_OVERRIDES_IGNORED_ENTRIES = new Set([".keep", "resource.lock"]);
@@ -233,8 +235,8 @@ const AGREEMENT_FILES: Array<{ id: AgreementDocumentId; title: string; fileName:
const AGREEMENT_STORE_FILE = "agreement.json";
/**
- * NapCat 鍚姩鍖呰 .cmd锛氬湪鍚姩 exe 鍓嶅厛鍒囨帶鍒跺彴鍒?UTF-8锛岄伩鍏嶄腑鏂囦贡鐮併€?
- * 鍐呭鏄浐瀹氱殑銆佷笉渚濊禆浠讳綍杩愯鏃舵嫾鎺ョ殑鍙橀噺锛氫笉浼氶亣鍒?cmd 寮曞彿瑙f瀽闂銆?
+ * NapCat startup wrapper .cmd: switch the console to UTF-8 before launching the exe.
+ * The content is fixed and does not interpolate runtime variables, avoiding cmd quote parsing issues.
*/
const NAPCAT_LAUNCHER_FILE = "napcat-launch.cmd";
const NAPCAT_LAUNCHER_CONTENT = [
@@ -255,8 +257,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;
@@ -326,6 +326,46 @@ function asPositiveInt(value: unknown, fallback: number): number {
return Math.max(1, Math.floor(num));
}
+function asTcpPort(value: unknown, fallback: number): number {
+ const port = asPositiveInt(value, fallback);
+ return port <= 65535 ? port : fallback;
+}
+
+function localWebUiHost(host: string): string {
+ const normalized = host.trim();
+ if (!normalized || normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]" || normalized === "*") {
+ return MAIBOT_WEBUI_FALLBACK_HOST;
+ }
+ return normalized;
+}
+
+function hostForUrl(host: string): string {
+ const unwrapped = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
+ return unwrapped.includes(":") ? `[${unwrapped}]` : unwrapped;
+}
+
+function buildMaiBotWebUiEndpoint(host = MAIBOT_WEBUI_FALLBACK_HOST, port = MAIBOT_WEBUI_FALLBACK_PORT): {
+ host: string;
+ port: number;
+ url: string;
+} {
+ const resolvedHost = localWebUiHost(host);
+ const resolvedPort = asTcpPort(port, MAIBOT_WEBUI_FALLBACK_PORT);
+ try {
+ return {
+ host: resolvedHost,
+ port: resolvedPort,
+ url: new URL(`http://${hostForUrl(resolvedHost)}:${resolvedPort}`).origin,
+ };
+ } catch {
+ return {
+ host: MAIBOT_WEBUI_FALLBACK_HOST,
+ port: MAIBOT_WEBUI_FALLBACK_PORT,
+ url: `http://${MAIBOT_WEBUI_FALLBACK_HOST}:${MAIBOT_WEBUI_FALLBACK_PORT}`,
+ };
+ }
+}
+
function asListMode(value: unknown, fallback: NapcatChatListMode): NapcatChatListMode {
if (value === "whitelist" || value === "blacklist") return value;
return fallback;
@@ -360,7 +400,7 @@ function normalizeNapcatAdapterConfig(
configVersion: asString(pluginRaw["config_version"], defaults.plugin.configVersion),
},
server: {
- host: asString(serverRaw["host"], defaults.server.host).trim() || defaults.server.host,
+ host: asString(serverRaw["host"] ?? serverRaw["server"], defaults.server.host).trim() || defaults.server.host,
port: asPositiveInt(serverRaw["port"], defaults.server.port),
token: asString(serverRaw["token"] ?? serverRaw["access_token"], defaults.server.token),
heartbeatInterval: asPositiveNumber(
@@ -480,6 +520,15 @@ function applyChatOverrides(
};
}
+function hasUsableWebsocketServerConfig(server: NapcatWebsocketServerConfig | undefined): server is NapcatWebsocketServerConfig {
+ return Boolean(
+ server?.host.trim()
+ && Number.isFinite(server.port)
+ && server.port > 0
+ && server.token.trim(),
+ );
+}
+
interface StoredAgreementFile {
version: 1;
hashes: Partial>;
@@ -502,29 +551,58 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
+function normalizeTomlLineEndings(content: string): string {
+ return content.replace(/\r\n?/gu, "\n");
+}
+
+function readTomlTable(content: string, tableName: string): Record | undefined {
+ const normalized = normalizeTomlLineEndings(content);
+ const tablePattern = new RegExp(
+ `(^|\\n)\\s*\\[${escapeRegExp(tableName)}\\]\\s*(?:#.*)?(?:\\n|$)`,
+ "u",
+ );
+ const tableMatch = tablePattern.exec(normalized);
+ if (!tableMatch) {
+ return undefined;
+ }
+
+ const tableStart = tableMatch.index + tableMatch[0].length;
+ const nextTableOffset = normalized
+ .slice(tableStart)
+ .search(/\n\s*\[\[?[^\]]+\]\]?\s*(?:#.*)?(?:\n|$)/u);
+ const tableBody = nextTableOffset === -1
+ ? normalized.slice(tableStart)
+ : normalized.slice(tableStart, tableStart + nextTableOffset);
+
+ try {
+ const parsed = parseToml(`[${tableName}]\n${tableBody.trimEnd()}\n`);
+ const table = parsed && typeof parsed === "object" && !Array.isArray(parsed)
+ ? (parsed as Record)[tableName]
+ : undefined;
+ return table && typeof table === "object" && !Array.isArray(table)
+ ? table as Record
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
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 +638,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}`;
}
@@ -650,9 +710,9 @@ function createWebsocketToken(): string {
}
function md5Utf8(content: string): string {
- // 涓?Python `open(path, encoding="utf-8").read()` 琛屼负瀵归綈锛?
- // Python 鏂囨湰妯″紡浼氭妸 \r\n / \r 缁熶竴杞垚 \n锛屽啀浜ょ粰 hashlib銆?
- // Node 鐨?readFile(path, 'utf8') 淇濈暀鍘熷 CRLF锛屾墍浠ヨ繖閲屾墜鍔ㄥ綊涓€鍖栦互鍖归厤 MaiBot 鐨勫搱甯岀粨鏋溿€?
+ // Match Python `open(path, encoding="utf-8").read()` behavior.
+ // Python text mode normalizes CRLF / CR to LF before passing content to hashlib.
+ // Node readFile(path, "utf8") preserves original CRLF, so normalize here to match MaiBot hashes.
const normalized = content.replace(/\r\n?/g, "\n");
return createHash("md5").update(normalized, "utf8").digest("hex");
}
@@ -819,7 +879,7 @@ export class InitManager {
const state = await this.getAgreementState();
const missing = state.documents.find((document) => !document.exists);
if (missing) {
- throw new Error(`${missing.title} 鏂囦欢缂哄け: ${missing.sourcePath}`);
+ throw new Error(`${missing.title} 文件缺失: ${missing.sourcePath}`);
}
const hashes: Partial> = {};
@@ -850,9 +910,9 @@ export class InitManager {
}
/**
- * 璁$畻褰撳墠 EULA / PRIVACY 鐨勬渶鏂?MD5锛屼綔涓虹幆澧冨彉閲忓湪姣忔鍚姩 MaiBot 鏃舵敞鍏ャ€?
- * 楹﹂害鐨?bot.py 浼氳鍙?`EULA_AGREE` 涓?`PRIVACY_AGREE`锛岀瓑浜庡綋鍓嶆枃浠?hash 鍗宠涓哄凡鍚屾剰锛?
- * 鍗忚鏈夋洿鏂版椂 hash 鑷姩鍙樺寲锛岄害楹︾浼氳Е鍙戦噸鏂扮‘璁ゆ祦绋嬨€?
+ * Calculate the latest EULA / PRIVACY MD5 and inject it as environment variables on each MaiBot start.
+ * MaiBot bot.py reads `EULA_AGREE` and `PRIVACY_AGREE`; matching the current file hash means accepted.
+ * When agreements change, the hash changes automatically and MaiBot will trigger confirmation again.
*/
async getAgreementEnvVars(): Promise> {
const env: Record = {};
@@ -865,7 +925,7 @@ export class InitManager {
const content = await readFile(sourcePath, "utf8");
env[agreement.envVar] = md5Utf8(content);
} catch {
- // 蹇界暐璇诲彇澶辫触锛岄害楹︿細鍥為€€鍒颁氦浜掑紡纭
+ // Ignore read failures; MaiBot will fall back to interactive confirmation.
}
}
return env;
@@ -880,21 +940,21 @@ export class InitManager {
}
/**
- * 鎶婄敤鎴锋彁渚涚殑 bot_config.toml / model_config.toml 瑕嗙洊鍒?MaiBot/config 涓嬶紝
- * 鑷姩鍑嗗濂藉彲鍐欑殑 MaiBot 妯″潡鐩綍涓?config 瀛愮洰褰曪紝骞跺鍘熸枃浠跺仛鏃堕棿鎴冲浠姐€?
+ * Copy user-provided bot_config.toml / model_config.toml into MaiBot/config.
+ * Prepare the writable MaiBot module config directory and back up original files with timestamps.
*/
async importMaiBotConfig(
fileName: MaiBotConfigFileName,
sourcePath: string,
): Promise {
if (fileName !== "bot_config.toml" && fileName !== "model_config.toml") {
- throw new Error(`涓嶆敮鎸佺殑閰嶇疆鏂囦欢鍚? ${fileName}`);
+ throw new Error(`Unsupported config file name: ${fileName}`);
}
if (!sourcePath) {
- throw new Error("鏈€夋嫨閰嶇疆鏂囦欢");
+ throw new Error("No config file selected");
}
if (!existsSync(sourcePath)) {
- throw new Error(`閰嶇疆鏂囦欢涓嶅瓨鍦? ${sourcePath}`);
+ throw new Error(`Config file does not exist: ${sourcePath}`);
}
const sourceStat = await stat(sourcePath);
if (!sourceStat.isFile()) {
@@ -924,15 +984,15 @@ export class InitManager {
}
/**
- * 鎶婄敤鎴锋彁渚涚殑 MaiBot.db 瑕嗙洊鍒?MaiBot/data/MaiBot.db锛?
- * 鑷姩鍑嗗濂藉彲鍐欑殑 MaiBot 妯″潡鐩綍涓?data 瀛愮洰褰曘€?
+ * Copy user-provided MaiBot.db into MaiBot/data/MaiBot.db.
+ * Prepare the writable MaiBot module data directory.
*/
async importMaiBotDatabase(sourcePath: string): Promise {
if (!sourcePath) {
throw new Error("未选择数据库文件");
}
if (!existsSync(sourcePath)) {
- throw new Error(`鏁版嵁搴撴枃浠朵笉瀛樺湪: ${sourcePath}`);
+ throw new Error(`数据库文件不存在: ${sourcePath}`);
}
const sourceStat = await stat(sourcePath);
if (!sourceStat.isFile()) {
@@ -961,8 +1021,8 @@ export class InitManager {
}
/**
- * 娓呯┖ MaiBot/data 鐩綍涓嬬殑鎵€鏈夊唴瀹癸紙涓嶄細鍒犻櫎 data 鐩綍鏈韩锛夈€?
- * 浠呬綔鐢ㄤ簬鍙啓妯″潡鐩綍锛屽紑鍙戞€佹寚鍚?bundled 妯℃澘鏃朵細鎷掔粷鎵ц銆?
+ * Clear all contents under MaiBot/data without deleting the data directory itself.
+ * Only applies to writable module directories; bundled template mode refuses to run this.
*/
async resetMaiBotData(): Promise {
if (samePath(this.paths.maibotRoot, join(this.paths.bundledModulesRoot, "MaiBot"))) {
@@ -1062,17 +1122,16 @@ export class InitManager {
const existingWebsocketServer = qqBackend === "snowluma"
? await this.readSnowLumaWebsocketServer(qqAccount)
: await this.readNapcatWebsocketServer(qqAccount);
+ const adapterWebsocketServer = await this.readAdapterServerFromConfig(qqBackend);
+ const configuredWebsocketServer = existingWebsocketServer
+ ?? (hasUsableWebsocketServerConfig(adapterWebsocketServer) ? adapterWebsocketServer : undefined);
const resolvedWebsocketServer: NapcatWebsocketServerConfig = {
- ...(existingWebsocketServer ?? {
- host: NAPCAT_ADAPTER_HOST,
- token: websocketToken || createWebsocketToken(),
- }),
- host: NAPCAT_ADAPTER_HOST,
- port: qqBackend === "snowluma" ? SNOWLUMA_ONEBOT_PORT : NAPCAT_ADAPTER_PORT,
- token: existingWebsocketServer?.token || websocketToken || createWebsocketToken(),
+ host: configuredWebsocketServer?.host || NAPCAT_ADAPTER_HOST,
+ port: configuredWebsocketServer?.port || (qqBackend === "snowluma" ? SNOWLUMA_ONEBOT_PORT : NAPCAT_ADAPTER_PORT),
+ token: configuredWebsocketServer?.token || websocketToken || createWebsocketToken(),
};
- const shouldInitializeAdapterConfig = !(await this.isAdapterConfigInitialized(qqBackend));
- let initializedAdapterConfig = false;
+ const adapterConfigReady = await this.isAdapterConfigInitialized(qqBackend);
+ let initializedAdapterConfig = adapterConfigReady;
if (qqBackend === "snowluma") {
await this.createSnowLumaConfigs(qqAccount, resolvedWebsocketServer.token, resolvedWebsocketServer.port);
@@ -1080,7 +1139,7 @@ export class InitManager {
await this.createNapCatConfigs(qqAccount, resolvedWebsocketServer.token, resolvedWebsocketServer.port);
await this.ensureNapCatWebUiConfig();
}
- if (shouldInitializeAdapterConfig) {
+ if (!adapterConfigReady) {
initializedAdapterConfig = await this.writeQqAdapterConfigsForBackend(
qqBackend,
resolvedWebsocketServer,
@@ -1111,8 +1170,8 @@ export class InitManager {
}
/**
- * 璇诲彇鏈€鏂颁竴浠?onebot11_.json 涓凡鍐欏叆鐨?WebSocket Token锛?
- * 鐢ㄤ簬鍦?napcat-adapter 閰嶇疆涓鐢ㄥ悓涓€涓?token锛岄伩鍏嶉害楹︾杩炰笉涓娿€?
+ * Read the latest onebot11_.json that contains a WebSocket Token.
+ * Reuse the same token in napcat-adapter config to avoid failed MaiBot connections.
*/
async readNapcatWebsocketServer(qqAccount?: string): Promise {
try {
@@ -1141,7 +1200,7 @@ export class InitManager {
}
}
} catch {
- // ignore 鈥?fall through to empty token
+ // ignore and fall through to empty token
}
return undefined;
}
@@ -1180,8 +1239,8 @@ export class InitManager {
}
/**
- * 鍒涘缓/鏇存柊 napcat-adapter 鐨?config.toml锛? * token 鐩存帴鏉ヨ嚜褰撳墠 setQqAccount 娴佺▼鐢熸垚鐨?websocket token锛?
- * chat 璁剧疆鍒欏彇鐢ㄦ埛鍦ㄥ紩瀵肩晫闈㈠~鍐欑殑瑕嗙洊鍊硷紙缂虹渷鍗抽粯璁わ級銆?
+ * Create/update napcat-adapter config.toml. The token comes from the current setQqAccount flow.
+ * Chat settings use values entered in the setup UI, falling back to defaults when absent.
*/
private async writeQqAdapterConfigsForBackend(
qqBackend: QqBackend,
@@ -1196,9 +1255,8 @@ export class InitManager {
? selectedWebsocketServer
: await this.resolveSnowLumaAdapterServer(qqAccount);
- const shouldInitializeInactive = !(await this.isAdapterConfigInitialized(
- qqBackend === "snowluma" ? "napcat" : "snowluma",
- ));
+ const inactiveBackend = qqBackend === "snowluma" ? "napcat" : "snowluma";
+ const shouldInitializeInactive = !(await this.isAdapterConfigInitialized(inactiveBackend));
if (qqBackend === "snowluma") {
if (shouldInitializeInactive) {
@@ -1244,8 +1302,8 @@ export class InitManager {
: await this.readNapcatWebsocketServer(qqAccount);
websocketServer = {
- host: NAPCAT_ADAPTER_HOST,
- port: qqBackend === "snowluma" ? SNOWLUMA_ONEBOT_PORT : NAPCAT_ADAPTER_PORT,
+ host: websocketServer?.host || NAPCAT_ADAPTER_HOST,
+ port: websocketServer?.port || (qqBackend === "snowluma" ? SNOWLUMA_ONEBOT_PORT : NAPCAT_ADAPTER_PORT),
token: websocketServer?.token || createWebsocketToken(),
};
if (qqBackend === "snowluma") {
@@ -1284,7 +1342,7 @@ export class InitManager {
existing = normalizeNapcatAdapterConfig(parsed as Record, defaults);
}
} catch {
- // 瑙f瀽澶辫触鍒欑洿鎺ヤ互榛樿鍊艰鐩?
+ // On parse failure, use default values directly.
}
}
@@ -1367,7 +1425,7 @@ export class InitManager {
await mkdir(this.paths.logsRoot, { recursive: true });
if (!existsSync(this.paths.bundledModulesRoot)) {
- throw new Error(`鍐呯疆 modules 妯℃澘缂哄け: ${this.paths.bundledModulesRoot}`);
+ throw new Error(`内置 modules 模板缺失: ${this.paths.bundledModulesRoot}`);
}
if (serviceId === "maibot") {
@@ -1457,9 +1515,9 @@ export class InitManager {
}
/**
- * 鍦?napcat 鐩綍涓嬬敓鎴愪竴涓浐瀹氱殑寮曞 .cmd锛屽惎鍔ㄦ椂鍏?chcp 65001 鍐嶈皟 exe锛?
- * 閬垮厤鍦ㄦ簮鐮侀噷鎷兼帴 `cmd /C` 瀛楃涓插甫鏉ョ殑寮曞彿闂锛屽悓鏃朵繚鐣欐帶鍒跺彴 UTF-8
- * 浠ュ厤涓枃杈撳嚭涔辩爜銆?
+ * Generate a fixed launcher .cmd under the napcat directory; it runs chcp 65001 before the exe.
+ * Avoid building a `cmd /C` command string in source while keeping the console in UTF-8.
+ * This prevents garbled Chinese output.
*/
private async ensureNapCatLauncher(): Promise {
const napcatRoot = this.paths.napcatRoot;
@@ -1477,7 +1535,7 @@ export class InitManager {
return undefined;
}
} catch {
- // 璇讳笉鍒板氨閲嶅啓
+ // 读不到就重写
}
}
@@ -1641,7 +1699,7 @@ export class InitManager {
return [];
}
- throw new Error(`鍐呯疆 ${moduleName} 妯℃澘缂哄け: ${source}`);
+ throw new Error(`内置 ${moduleName} 模板缺失: ${source}`);
}
if (samePath(source, target)) {
@@ -1764,7 +1822,7 @@ export class InitManager {
}
if (existing.exists) {
- throw new Error(existing.error ?? "NapCat WebUI 閰嶇疆瀛樺湪浣嗙己灏?token锛岃鎵嬪姩妫€鏌?webui.json");
+ throw new Error(existing.error ?? "NapCat WebUI config exists but token is missing; please check webui.json manually");
}
const configDirs = await this.findNapCatWebUiConfigDirs();
@@ -1813,19 +1871,45 @@ export class InitManager {
if (typeof raw.token === "string" && raw.token.length > 0) {
return { token: raw.token, exists: true };
}
- firstError ??= `缂哄皯 token: ${candidate}`;
+ firstError ??= `缺少 token: ${candidate}`;
} catch (error) {
- firstError ??= `JSON 鏍煎紡閿欒: ${candidate}: ${toDetail(error)}`;
+ firstError ??= `JSON 格式错误: ${candidate}: ${toDetail(error)}`;
}
}
return { exists: sawExisting, error: firstError };
}
+ readMaiBotWebUiEndpointSync(): { host: string; port: number; url: string } {
+ const fallback = buildMaiBotWebUiEndpoint();
+ const candidates = uniqueExistingPaths([
+ this.botConfigPath(),
+ join(this.paths.bundledModulesRoot, "MaiBot", "config", "bot_config.toml"),
+ ]);
+
+ for (const candidate of candidates) {
+ try {
+ const config = readTomlTable(readFileSync(candidate, "utf8"), "webui");
+ if (!config) {
+ continue;
+ }
+
+ return buildMaiBotWebUiEndpoint(
+ asString(config["host"], fallback.host),
+ asTcpPort(config["port"], fallback.port),
+ );
+ } catch {
+ continue;
+ }
+ }
+
+ return fallback;
+ }
+
/**
- * 璇诲彇 MaiBot Core WebUI 鐨?access_token锛岀敤浜庡湪 WebUI 鍏ュ彛鎷兼帴
- * `?token=` 瀹炵幇鑷姩鐧诲綍銆?
- * 鏂囦欢涓嶅瓨鍦ㄦ垨缂哄瓧娈垫椂杩斿洖绌?token锛岃皟鐢ㄦ柟搴斿洖閫€涓轰笉甯﹀弬鏁扮殑鍦板潃銆?
+ * Read MaiBot Core WebUI access_token for composing the WebUI entry URL.
+ * `?token=` performs automatic login.
+ * If the file or field is missing, return an empty token and let callers use the plain root URL.
*/
async readMaiBotWebUiToken(): Promise<{ token?: string; exists: boolean; error?: string }> {
const candidates = [
@@ -1847,9 +1931,9 @@ export class InitManager {
if (typeof raw.access_token === "string" && raw.access_token.length > 0) {
return { token: raw.access_token, exists: true };
}
- firstError ??= `缂哄皯 access_token: ${candidate}`;
+ firstError ??= `缺少 access_token: ${candidate}`;
} catch (error) {
- firstError ??= `JSON 鏍煎紡閿欒: ${candidate}: ${toDetail(error)}`;
+ firstError ??= `JSON 格式错误: ${candidate}: ${toDetail(error)}`;
}
}
@@ -1890,14 +1974,52 @@ export class InitManager {
}
}
+ private hasAdapterConfigInitializedMarker(backend: QqBackend): boolean {
+ return typeof this.readMessagePlatformStore()?.adapterConfigInitialized?.[backend] === "number";
+ }
+
private async isAdapterConfigInitialized(backend: QqBackend): Promise {
const configPath = backend === "snowluma"
? this.snowlumaAdapterConfigPath()
: this.napcatAdapterConfigPath();
- return (
- typeof this.readMessagePlatformStore()?.adapterConfigInitialized?.[backend] === "number"
- || existsSync(configPath)
+ if (!existsSync(configPath)) {
+ return false;
+ }
+
+ if (this.hasAdapterConfigInitializedMarker(backend)) {
+ return true;
+ }
+
+ return hasUsableWebsocketServerConfig(await this.readAdapterServerFromConfig(backend));
+ }
+
+ private async readAdapterServerFromConfig(backend: QqBackend): Promise {
+ const configPath = backend === "snowluma"
+ ? this.snowlumaAdapterConfigPath()
+ : this.napcatAdapterConfigPath();
+ if (!existsSync(configPath)) {
+ return undefined;
+ }
+
+ const defaults = buildDefaultNapcatAdapterConfig(
+ "",
+ backend === "snowluma" ? SNOWLUMA_ONEBOT_PORT : NAPCAT_ADAPTER_PORT,
);
+ if (backend === "snowluma") {
+ defaults.plugin.configVersion = SNOWLUMA_ADAPTER_CONFIG_VERSION;
+ defaults.server.actionTimeoutSec = 10;
+ }
+
+ try {
+ const parsed = parseToml(await readFile(configPath, "utf8"));
+ if (parsed && typeof parsed === "object") {
+ return normalizeNapcatAdapterConfig(parsed as Record, defaults).server;
+ }
+ } catch {
+ return undefined;
+ }
+
+ return undefined;
}
private async markMessagePlatformConfigured(
@@ -1963,7 +2085,7 @@ export class InitManager {
hash: "",
exists: false,
confirmed: false,
- error: `${fileName} 鏂囦欢缂哄け`,
+ error: `${fileName} 文件缺失`,
};
}
@@ -2002,11 +2124,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 +2138,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;
}
@@ -2223,7 +2343,7 @@ export class InitManager {
id: "napcat-webui-token",
label: "NapCat WebUI token",
status: "ok",
- detail: "宸叉壘鍒?token",
+ detail: "token found",
};
}
diff --git a/src/main/services/local-chat-adapter.ts b/src/main/services/local-chat-adapter.ts
index c72242c..636a482 100644
--- a/src/main/services/local-chat-adapter.ts
+++ b/src/main/services/local-chat-adapter.ts
@@ -16,8 +16,8 @@ import type {
LocalChatVoiceAttachment,
RuntimePaths,
} from "../../shared/contracts";
+import type { InitManager } from "./init-manager";
-const DEFAULT_WEBUI_ORIGIN = "http://127.0.0.1:8001";
const DEFAULT_USER_ID = "onekey-local-user";
const DEFAULT_WEBUI_USER_ID = `webui_user_${DEFAULT_USER_ID}`;
const DEFAULT_USER_NAME = "本地用户";
@@ -60,6 +60,31 @@ function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
+function dataUrlMimeType(dataUrl: string, fallback: string): string {
+ const match = /^data:([^;,]+)[;,]/iu.exec(dataUrl);
+ return match?.[1] ?? fallback;
+}
+
+function normalizeBase64Text(value: unknown): string | undefined {
+ const text = asString(value);
+ if (!text) {
+ return undefined;
+ }
+ const compact = text.replace(/\s+/gu, "");
+ if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(compact)) {
+ return undefined;
+ }
+ try {
+ const decoded = Buffer.from(compact, "base64");
+ if (decoded.length === 0) {
+ return undefined;
+ }
+ return compact;
+ } catch {
+ return undefined;
+ }
+}
+
function parseSocketPayload(data: WebSocket.RawData): unknown | undefined {
try {
const text = Array.isArray(data)
@@ -215,6 +240,213 @@ function fileAttachments(value: unknown): LocalChatFileAttachment[] {
});
}
+interface RichSegmentContent {
+ content: string;
+ emojis: LocalChatImageAttachment[];
+ files: LocalChatFileAttachment[];
+ hasSegments: boolean;
+ images: LocalChatImageAttachment[];
+ voices: LocalChatVoiceAttachment[];
+}
+
+function uniqueImages(images: LocalChatImageAttachment[]): LocalChatImageAttachment[] {
+ const seen = new Set();
+ return images.filter((image) => {
+ const key = image.dataUrl ?? `${image.mimeType}:${image.base64}`;
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+ return true;
+ });
+}
+
+function uniqueFiles(files: LocalChatFileAttachment[]): LocalChatFileAttachment[] {
+ const seen = new Set();
+ return files.filter((file) => {
+ const key = `${file.name}:${file.size}:${file.base64}`;
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+ return true;
+ });
+}
+
+function uniqueVoices(voices: LocalChatVoiceAttachment[]): LocalChatVoiceAttachment[] {
+ const seen = new Set();
+ return voices.filter((voice) => {
+ const key = voice.dataUrl ?? `${voice.mimeType}:${voice.base64}`;
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+ return true;
+ });
+}
+
+function segmentText(segment: Record): string {
+ const data = segment.data;
+ if (typeof data === "string") {
+ return data;
+ }
+ const record = asRecord(data);
+ return asString(record?.text) ?? asString(record?.content) ?? asString(record?.message) ?? "";
+}
+
+function segmentBinaryRecord(
+ segment: Record,
+ fallbackMimeType: string,
+): Record | undefined {
+ const data = segment.data;
+ const dataRecord = asRecord(data);
+ const mimeType = asString(segment.mimeType) ?? asString(segment.mime_type) ?? fallbackMimeType;
+
+ if (dataRecord) {
+ return {
+ ...dataRecord,
+ base64: dataRecord.base64
+ ?? dataRecord.binary_data_base64
+ ?? dataRecord.image_base64
+ ?? dataRecord.emoji_base64
+ ?? dataRecord.voice_base64,
+ data_url: dataRecord.data_url ?? dataRecord.dataUrl,
+ mime_type: dataRecord.mime_type ?? dataRecord.mimeType ?? mimeType,
+ name: dataRecord.name ?? segment.name,
+ size: dataRecord.size ?? segment.size,
+ };
+ }
+
+ const directBase64 = normalizeBase64Text(
+ segment.binary_data_base64
+ ?? segment.image_base64
+ ?? segment.emoji_base64
+ ?? segment.voice_base64,
+ );
+ if (directBase64) {
+ return {
+ base64: directBase64,
+ mime_type: mimeType,
+ name: asString(segment.name),
+ size: asNumber(segment.size),
+ };
+ }
+
+ const dataText = asString(data);
+ if (!dataText) {
+ return undefined;
+ }
+ if (dataText.startsWith("data:") && dataText.includes(",")) {
+ return {
+ data_url: dataText,
+ mime_type: dataUrlMimeType(dataText, mimeType),
+ name: asString(segment.name),
+ size: asNumber(segment.size),
+ };
+ }
+
+ const base64 = normalizeBase64Text(dataText);
+ if (!base64) {
+ return undefined;
+ }
+ return {
+ base64,
+ mime_type: mimeType,
+ name: asString(segment.name),
+ size: asNumber(segment.size),
+ };
+}
+
+function richSegmentContent(value: unknown): RichSegmentContent {
+ const empty: RichSegmentContent = {
+ content: "",
+ emojis: [],
+ files: [],
+ hasSegments: false,
+ images: [],
+ voices: [],
+ };
+ if (!Array.isArray(value) || value.length === 0) {
+ return empty;
+ }
+
+ const textParts: string[] = [];
+ const images: LocalChatImageAttachment[] = [];
+ const emojis: LocalChatImageAttachment[] = [];
+ const files: LocalChatFileAttachment[] = [];
+ const voices: LocalChatVoiceAttachment[] = [];
+
+ for (const item of value) {
+ const segment = asRecord(item);
+ const type = asString(segment?.type)?.toLowerCase();
+ if (!segment || !type) {
+ continue;
+ }
+
+ if (type === "text") {
+ const text = segmentText(segment);
+ if (text) {
+ textParts.push(text);
+ }
+ continue;
+ }
+
+ if (type === "image") {
+ const image = segmentBinaryRecord(segment, "image/png");
+ images.push(...imageAttachments(image ? [image] : []));
+ continue;
+ }
+
+ if (type === "emoji" || type === "face") {
+ const emoji = segmentBinaryRecord(segment, "image/gif");
+ emojis.push(...imageAttachments(emoji ? [emoji] : []));
+ continue;
+ }
+
+ if (type === "voice") {
+ const voice = segmentBinaryRecord(segment, "audio/wav");
+ voices.push(...voiceAttachments(voice ? [voice] : []));
+ continue;
+ }
+
+ if (type === "file") {
+ const dataRecord = asRecord(segment.data);
+ files.push(...fileAttachments(dataRecord ? [{
+ ...dataRecord,
+ mime_type: dataRecord.mime_type ?? dataRecord.mimeType ?? segment.mime_type ?? segment.mimeType,
+ name: dataRecord.name ?? segment.name,
+ size: dataRecord.size ?? segment.size,
+ }] : []));
+ continue;
+ }
+
+ if (type === "at") {
+ const record = asRecord(segment.data);
+ const name = asString(record?.target_user_nickname)
+ ?? asString(record?.target_user_cardname)
+ ?? asString(record?.target_user_id);
+ if (name) {
+ textParts.push(`@${name}`);
+ }
+ }
+ }
+
+ const hasParsedSegments = textParts.length > 0
+ || images.length > 0
+ || emojis.length > 0
+ || files.length > 0
+ || voices.length > 0;
+
+ return {
+ content: textParts.join("").trim(),
+ emojis: uniqueImages(emojis),
+ files: uniqueFiles(files),
+ hasSegments: hasParsedSegments,
+ images: uniqueImages(images),
+ voices: uniqueVoices(voices),
+ };
+}
+
function plannerContent(data: Record): string {
const planner = asRecord(data.planner);
const content = asString(data.content) ?? asString(planner?.content);
@@ -370,19 +602,21 @@ function splitReplyMessage(content: string): { content: string; hasReplyPrefix:
}
function historyMessageToLocal(message: Record): LocalChatMessageEvent | undefined {
- const rawContent = asString(message.content);
- const images = imageAttachments(message.images);
- const emojis = imageAttachments(message.emojis);
- const files = fileAttachments(message.files);
- const voices = voiceAttachments(message.voices);
+ const rich = richSegmentContent(message.segments ?? message.message_segments);
+ const rawContent = rich.hasSegments ? rich.content : asString(message.content);
+ const images = uniqueImages([...imageAttachments(message.images), ...rich.images]);
+ const emojis = uniqueImages([...imageAttachments(message.emojis), ...rich.emojis]);
+ const files = uniqueFiles([...fileAttachments(message.files), ...rich.files]);
+ const voices = uniqueVoices([...voiceAttachments(message.voices), ...rich.voices]);
const fallbackContent = [imagePlaceholder(images), emojiPlaceholder(emojis), voicePlaceholder(voices), filePlaceholder(files)]
.filter(Boolean)
.join("\n");
- if (!rawContent && !fallbackContent) {
+ const displayContent = rich.hasSegments ? rich.content : (rawContent ?? fallbackContent);
+ if (!displayContent && !fallbackContent) {
return undefined;
}
- const parsed = splitReplyMessage(rawContent ?? fallbackContent);
+ const parsed = splitReplyMessage(displayContent);
const type = asString(message.type);
const isBot = message.is_bot === true || type === "bot";
return {
@@ -411,7 +645,10 @@ export class LocalChatAdapter extends EventEmitter {
private runtimeSessionId: string | null = null;
private monitorSessionId: string | null = null;
- constructor(private readonly paths: RuntimePaths) {
+ constructor(
+ private readonly paths: RuntimePaths,
+ private readonly initManager: InitManager,
+ ) {
super();
}
@@ -588,22 +825,7 @@ export class LocalChatAdapter extends EventEmitter {
}
private async readWebUiOrigin(): Promise {
- const candidates = [
- join(this.paths.maibotRoot, "data", "webui.json"),
- join(this.paths.bundledModulesRoot, "MaiBot", "data", "webui.json"),
- ];
- for (const configPath of candidates) {
- try {
- const raw = JSON.parse(await readFile(configPath, "utf8")) as Record;
- const port = asNumber(raw.webui_port) ?? asNumber(raw.port);
- if (port) {
- return `http://127.0.0.1:${port}`;
- }
- } catch {
- // Try the next known location.
- }
- }
- return DEFAULT_WEBUI_ORIGIN;
+ return this.initManager.readMaiBotWebUiEndpointSync().url;
}
private async readWebUiToken(): Promise {
@@ -742,22 +964,24 @@ export class LocalChatAdapter extends EventEmitter {
return;
}
- const images = imageAttachments(data.images);
- const emojis = imageAttachments(data.emojis);
- const files = fileAttachments(data.files);
- const voices = voiceAttachments(data.voices);
- const rawContent = asString(data.content);
+ const rich = richSegmentContent(data.segments ?? data.message_segments);
+ const images = uniqueImages([...imageAttachments(data.images), ...rich.images]);
+ const emojis = uniqueImages([...imageAttachments(data.emojis), ...rich.emojis]);
+ const files = uniqueFiles([...fileAttachments(data.files), ...rich.files]);
+ const voices = uniqueVoices([...voiceAttachments(data.voices), ...rich.voices]);
+ const rawContent = rich.hasSegments ? rich.content : asString(data.content);
const fallbackContent = [imagePlaceholder(images), emojiPlaceholder(emojis), voicePlaceholder(voices), filePlaceholder(files)]
.filter(Boolean)
.join("\n");
- if (!rawContent && !fallbackContent) {
+ const displayContent = rich.hasSegments ? rich.content : (rawContent ?? fallbackContent);
+ if (!displayContent && !fallbackContent) {
return;
}
const sender = asRecord(data.sender);
const isUser = eventName === "user_message" || sender?.is_bot === false;
const role = eventName === "error" ? "error" : isUser ? "user" : eventName === "system" ? "system" : "bot";
- const parsed = splitReplyMessage(rawContent ?? fallbackContent);
+ const parsed = splitReplyMessage(displayContent);
const content = parsed.content;
if (
role === "user"
diff --git a/src/main/services/log-store.ts b/src/main/services/log-store.ts
index ee0f107..3e333f3 100644
--- a/src/main/services/log-store.ts
+++ b/src/main/services/log-store.ts
@@ -1,9 +1,11 @@
import { EventEmitter } from "node:events";
-import { mkdir, appendFile } from "node:fs/promises";
+import { appendFile, mkdir, rename, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import type { LogEntry, LogSource, LogStream, RuntimePaths } from "../../shared/contracts";
const MAX_BUFFERED_LOGS = 1000;
+const MAX_LOG_FILE_BYTES = 10 * 1024 * 1024;
+const MAX_LOG_FILE_BACKUPS = 5;
function formatLogLine(entry: LogEntry): string {
const timestamp = new Date(entry.timestamp).toISOString();
@@ -12,6 +14,7 @@ function formatLogLine(entry: LogEntry): string {
export class LogStore extends EventEmitter {
private readonly entries: LogEntry[] = [];
+ private writeQueue = Promise.resolve();
constructor(private readonly paths: RuntimePaths) {
super();
@@ -54,8 +57,47 @@ export class LogStore extends EventEmitter {
}
private writeEntry(entry: LogEntry): void {
- void mkdir(this.paths.logsRoot, { recursive: true })
- .then(() => appendFile(this.getServiceLogPath(entry.source), formatLogLine(entry), "utf8"))
+ const line = formatLogLine(entry);
+ this.writeQueue = this.writeQueue
+ .catch(() => undefined)
+ .then(() => this.writeLine(entry.source, line))
.catch(() => undefined);
}
+
+ private async writeLine(source: LogSource, line: string): Promise {
+ await mkdir(this.paths.logsRoot, { recursive: true });
+
+ const logPath = this.getServiceLogPath(source);
+ await this.rotateLogFileIfNeeded(logPath, Buffer.byteLength(line, "utf8"));
+ await appendFile(logPath, line, "utf8");
+ }
+
+ private async rotateLogFileIfNeeded(logPath: string, incomingBytes: number): Promise {
+ const currentSize = await this.getFileSize(logPath);
+ if (currentSize <= 0 || currentSize + incomingBytes <= MAX_LOG_FILE_BYTES) {
+ return;
+ }
+
+ await rm(`${logPath}.${MAX_LOG_FILE_BACKUPS}`, { force: true });
+ for (let index = MAX_LOG_FILE_BACKUPS - 1; index >= 1; index -= 1) {
+ await this.renameIfExists(`${logPath}.${index}`, `${logPath}.${index + 1}`);
+ }
+ await this.renameIfExists(logPath, `${logPath}.1`);
+ }
+
+ private async getFileSize(path: string): Promise {
+ try {
+ return (await stat(path)).size;
+ } catch {
+ return 0;
+ }
+ }
+
+ private async renameIfExists(from: string, to: string): Promise {
+ try {
+ await rename(from, to);
+ } catch {
+ // Missing or locked old log files should not block new logs from being written.
+ }
+ }
}
diff --git a/src/main/services/maibot-plugin-client.ts b/src/main/services/maibot-plugin-client.ts
index ddf5172..a16fc8d 100644
--- a/src/main/services/maibot-plugin-client.ts
+++ b/src/main/services/maibot-plugin-client.ts
@@ -1,21 +1,41 @@
import { execFile } from "node:child_process";
-import { copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
+import { copyFile, cp, mkdir, readFile, readdir, rename, 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,
+ MaiBotPluginConfigSection,
MaiBotPluginConfigState,
MaiBotPluginConfigValue,
MaiBotPluginConfigLocalizedText,
MaiBotInstalledPlugin,
+ MaiBotPluginDownloadResult,
MaiBotPluginListOptions,
MaiBotMarketPlugin,
MaiBotPluginListResult,
MaiBotPluginManifest,
MaiBotPluginOperationResult,
+ MaiBotPluginRatingResult,
MaiBotPluginReadmeResult,
MaiBotPluginStats,
+ MaiBotPluginUserState,
+ MaiBotPluginUserStates,
+ MaiBotPluginVoteResult,
ModuleSourceConfig,
} from "../../shared/contracts";
@@ -32,6 +52,9 @@ const MARKET_TIMEOUT_MS = 10_000;
const MAIBOT_API_TIMEOUT_MS = 3_000;
const PLUGIN_MARKET_CACHE_TTL_MS = 5 * 60 * 1000;
const PLUGIN_CONFIG_FILE = "config.toml";
+const PLUGIN_CONFIG_BACKUP_DIR = "config_back";
+const PLUGIN_UPDATE_BACKUP_DIR = ".update_backups";
+const PLUGIN_UPDATE_TMP_DIR = ".update_tmp";
export interface MaiBotPluginClientOptions {
maibotRoot: string;
@@ -49,6 +72,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;
@@ -136,6 +199,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,
};
});
@@ -145,14 +209,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),
@@ -167,21 +231,26 @@ 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 resolvedRepositoryUrl = await this.resolveSourceUrl(repositoryUrl);
+ if (!(await isDirectory(join(pluginPath, ".git")))) {
+ return this.replaceNonGitPlugin(pluginId, pluginPath, resolvedRepositoryUrl, branch, oldVersion);
}
+
const beforeCommit = await this.currentGitCommit(pluginPath);
if (!beforeCommit) {
- throw new Error("插件目录不是可更新的 Git 仓库,无法执行强制 pull");
+ throw new Error("Plugin Git repository cannot be read; update cannot continue");
}
try {
- await this.forcePullRepository(pluginPath, await this.resolveSourceUrl(repositoryUrl), branch);
+ await this.forcePullRepository(pluginPath, resolvedRepositoryUrl, branch);
const newManifest = await this.validateInstalledManifest(pluginPath, pluginId, false);
return {
success: true,
@@ -207,17 +276,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);
@@ -228,6 +405,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 +414,7 @@ export class MaiBotPluginClient {
configPath,
exists,
config,
- schema: buildPluginConfigSchema(config, "local"),
+ schema,
raw,
};
}
@@ -248,7 +427,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);
@@ -272,7 +451,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(),
@@ -297,7 +477,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"]) {
@@ -306,7 +486,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 {
@@ -318,6 +498,88 @@ 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 getUserStates(userId: string): Promise {
+ const query = new URLSearchParams({ user_id: userId });
+ const data = await requestPluginStatsService("GET", `/stats/user-states?${query.toString()}`);
+ if (isUnknownRecord(data) && data.success === false) {
+ throw new Error(typeof data.error === "string" ? data.error : "Plugin user states request failed");
+ }
+ return normalizePluginUserStates(data);
+ }
+
+ 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 | null | undefined,
+ comment: string | null | undefined,
+ userId: string,
+ ): Promise {
+ if (rating !== undefined && rating !== null && (rating < 1 || rating > 5)) {
+ return { success: false, error: "评分必须在 1-5 之间" };
+ }
+ if (rating === undefined && comment === undefined) {
+ return { success: false, error: "评分和评论至少需要提交一项" };
+ }
+
+ const payload: Record = {
+ plugin_id: pluginId,
+ user_id: userId,
+ };
+ if (rating !== undefined) {
+ payload.rating = rating;
+ }
+ if (comment !== undefined) {
+ payload.comment = comment;
+ }
+
+ const data = await requestPluginStatsService("POST", "/stats/rate", payload);
+ 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);
}
@@ -391,7 +653,7 @@ export class MaiBotPluginClient {
private async getPluginStatsSummary(options: MaiBotPluginListOptions): Promise> {
const cached = await this.readCache(
- "onekey-plugin-market-stats-cache.json",
+ "onekey-plugin-market-stats-cache-v2.json",
this.statsCache,
isPluginStatsMap,
);
@@ -405,7 +667,7 @@ export class MaiBotPluginClient {
.then(async (stats) => {
const nextCache = { timestamp: Date.now(), data: stats };
this.statsCache = nextCache;
- await this.writeCache("onekey-plugin-market-stats-cache.json", nextCache);
+ await this.writeCache("onekey-plugin-market-stats-cache-v2.json", nextCache);
return stats;
})
.catch((error) => {
@@ -422,6 +684,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;
@@ -464,7 +756,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;
}
@@ -475,7 +767,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 || "克隆仓库失败");
}
}
@@ -487,6 +779,93 @@ export class MaiBotPluginClient {
return result.exitCode === 0 && result.output.trim() ? result.output.trim().split(/\s+/u)[0] : null;
}
+ private async replaceNonGitPlugin(
+ pluginId: string,
+ pluginPath: string,
+ repositoryUrl: string,
+ branch: string,
+ oldVersion: string,
+ ): Promise {
+ const { backupPath, tempPath } = this.createPluginUpdateWorkspace(pluginId);
+
+ await mkdir(dirname(backupPath), { recursive: true });
+ await mkdir(dirname(tempPath), { recursive: true });
+ await rename(pluginPath, backupPath);
+
+ try {
+ await this.cloneRepository(repositoryUrl, tempPath, branch);
+ const newManifest = await this.validateReplacementManifest(tempPath, pluginId);
+ await this.restoreOfficialPluginConfig(backupPath, tempPath);
+ await rename(tempPath, pluginPath);
+
+ return {
+ success: true,
+ message: "Plugin updated successfully",
+ plugin_id: pluginId,
+ plugin_name: pluginName({ id: pluginId, manifest: newManifest }),
+ old_version: oldVersion,
+ new_version: pluginVersion(newManifest),
+ };
+ } catch (error) {
+ await rm(tempPath, { recursive: true, force: true }).catch(() => undefined);
+ await this.restoreUpdateBackup(backupPath, pluginPath);
+ throw error;
+ }
+ }
+
+ private createPluginUpdateWorkspace(pluginId: string): { backupPath: string; tempPath: string } {
+ const timestamp = new Date().toISOString().replace(/[:.]/gu, "-");
+ const safeId = sanitizeUpdateWorkspaceName(validatePluginId(pluginId));
+ const folderName = `${safeId}.${timestamp}.${process.pid}`;
+ const backupPath = resolve(this.pluginsRoot, PLUGIN_UPDATE_BACKUP_DIR, folderName);
+ const tempPath = resolve(this.pluginsRoot, PLUGIN_UPDATE_TMP_DIR, folderName);
+
+ if (!isPathInside(this.pluginsRoot, backupPath) || !isPathInside(this.pluginsRoot, tempPath)) {
+ throw new Error("Plugin update workspace path is outside the allowed range");
+ }
+ return { backupPath, tempPath };
+ }
+
+ private async restoreUpdateBackup(backupPath: string, pluginPath: string): Promise {
+ if (!(await pathExists(backupPath))) {
+ return;
+ }
+
+ if (await pathExists(pluginPath)) {
+ await rm(pluginPath, { recursive: true, force: true });
+ }
+ await rename(backupPath, pluginPath);
+ }
+
+ private async restoreOfficialPluginConfig(backupPath: string, pluginPath: string): Promise {
+ await this.restorePluginConfigEntry(backupPath, pluginPath, PLUGIN_CONFIG_FILE);
+ await this.restorePluginConfigEntry(backupPath, pluginPath, PLUGIN_CONFIG_BACKUP_DIR);
+ }
+
+ private async restorePluginConfigEntry(
+ backupPath: string,
+ pluginPath: string,
+ entryName: string,
+ ): Promise {
+ const sourcePath = resolve(backupPath, entryName);
+ const targetPath = resolve(pluginPath, entryName);
+ if (!isPathInside(backupPath, sourcePath) || !isPathInside(pluginPath, targetPath)) {
+ throw new Error("Plugin config restore path is outside the allowed range");
+ }
+ if (!(await pathExists(sourcePath))) {
+ return;
+ }
+
+ const sourceStat = await stat(sourcePath);
+ await rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
+ if (sourceStat.isDirectory()) {
+ await cp(sourcePath, targetPath, { recursive: true, force: true });
+ } else if (sourceStat.isFile()) {
+ await mkdir(dirname(targetPath), { recursive: true });
+ await copyFile(sourcePath, targetPath);
+ }
+ }
+
private async forcePullRepository(pluginPath: string, repositoryUrl: string, branch: string): Promise {
const remote = repositoryUrl.trim();
const targetBranch = branch || "main";
@@ -508,13 +887,39 @@ export class MaiBotPluginClient {
}
}
+ private async validateReplacementManifest(pluginPath: string, pluginId: string): Promise {
+ const manifest = await this.readManifest(pluginPath);
+ if (!manifest) {
+ throw new Error("Invalid plugin: missing _manifest.json");
+ }
+
+ const expectedId = validatePluginId(pluginId);
+ const manifestId = manifest.id?.trim();
+ if (manifestId !== expectedId) {
+ throw new Error(`Invalid _manifest.json: plugin id must be ${expectedId}`);
+ }
+ if (!manifest.name?.trim()) {
+ throw new Error("Invalid _manifest.json: missing required field name");
+ }
+ if (!manifest.version?.trim()) {
+ throw new Error("Invalid _manifest.json: missing required field version");
+ }
+
+ const authorName = typeof manifest.author === "string" ? manifest.author.trim() : manifest.author?.name?.trim();
+ if (!authorName) {
+ throw new Error("Invalid _manifest.json: missing required field author");
+ }
+
+ return { ...manifest, id: manifestId };
+ }
+
private async validateInstalledManifest(pluginPath: string, pluginId: string, removeOnFailure = true): Promise {
const manifest = await this.readManifest(pluginPath);
if (!manifest) {
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"]) {
@@ -522,7 +927,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}`);
}
}
@@ -707,12 +1112,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("拒绝操作插件根目录");
@@ -721,6 +1126,299 @@ 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", "detailed_description"]) || name,
+ 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;
@@ -733,6 +1431,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();
@@ -747,6 +1447,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),
};
}
@@ -760,14 +1461,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,
};
}
@@ -819,6 +1528,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
+ ?? 0;
return [
pluginId,
{
@@ -827,8 +1542,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,
},
];
}
@@ -849,31 +1565,127 @@ function normalizePluginRatings(rawRatings: unknown): MaiBotPluginStats["recent_
return rawRatings
.filter(isUnknownRecord)
.map((rating) => ({
- user_id: String(rating.user_id ?? "鍖垮悕鐢ㄦ埛"),
- rating: normalizeStatsNumber(rating.rating) ?? 0,
+ id: typeof rating.id === "string" ? rating.id : undefined,
+ user_id: String(rating.user_id ?? "匿名用户"),
+ rating: rating.rating === null ? null : normalizeStatsNumber(rating.rating),
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);
+ .filter((rating) => (typeof rating.rating === "number" && rating.rating > 0) || rating.comment);
}
-function githubRawReadmeUrl(repositoryUrl: string): ((branch: string) => string) | undefined {
- const match = repositoryUrl.match(/github\.com[/:]([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[/?#]|$)/iu);
- if (!match) {
- return undefined;
- }
- const [, owner, repo] = match;
- return (branch: string) => `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/README.md`;
+function createEmptyPluginStats(pluginId: string): MaiBotPluginStats {
+ return {
+ plugin_id: pluginId,
+ likes: 0,
+ dislikes: 0,
+ downloads: 0,
+ rating: 0,
+ rating_count: 0,
+ comment_count: 0,
+ };
}
-function rewriteGithubUrl(url: string, sourceMaibotUrl: string): string {
- const sourcePrefix = githubSourcePrefix(sourceMaibotUrl);
- if (!sourcePrefix) {
- return url;
+function normalizePluginUserState(rawData: unknown): MaiBotPluginUserState | null {
+ if (!isUnknownRecord(rawData) || rawData.success === false) {
+ return null;
}
- const normalized = normalizeGithubUrl(url);
- return normalized ? `${sourcePrefix}${normalized}` : url;
+ return {
+ liked: rawData.liked === true,
+ disliked: rawData.disliked === true,
+ rating: rawData.rating === null ? null : normalizeStatsNumber(rawData.rating) ?? 0,
+ comment: typeof rawData.comment === "string" ? rawData.comment : "",
+ };
+}
+
+function normalizePluginUserStates(rawData: unknown): MaiBotPluginUserStates {
+ if (!isUnknownRecord(rawData) || rawData.success === false || !isUnknownRecord(rawData.states)) {
+ return {};
+ }
+
+ return Object.fromEntries(
+ Object.entries(rawData.states)
+ .map(([pluginId, rawState]) => {
+ const state = normalizePluginUserState(rawState);
+ if (!state) {
+ return null;
+ }
+ const normalizedPluginId = isUnknownRecord(rawState) && typeof rawState.plugin_id === "string"
+ ? rawState.plugin_id
+ : pluginId;
+ return [normalizedPluginId, state] as const;
+ })
+ .filter((entry): entry is readonly [string, MaiBotPluginUserState] => entry !== null),
+ );
+}
+
+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: rawData.user_rating === null ? null : normalizeStatsNumber(rawData.user_rating),
+ rating: normalizeStatsNumber(rawData.rating),
+ rating_count: normalizeStatsNumber(rawData.rating_count),
+ comment_count: normalizeStatsNumber(rawData.comment_count) ?? normalizeStatsNumber(rawData.comments),
+ 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) {
+ return undefined;
+ }
+ const [, owner, repo] = match;
+ return (branch: string) => `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/README.md`;
+}
+
+function rewriteGithubUrl(url: string, sourceMaibotUrl: string): string {
+ const sourcePrefix = githubSourcePrefix(sourceMaibotUrl);
+ if (!sourcePrefix) {
+ return url;
+ }
+
+ const normalized = normalizeGithubUrl(url);
+ return normalized ? `${sourcePrefix}${normalized}` : url;
}
function githubSourcePrefix(sourceMaibotUrl: string): string | undefined {
@@ -939,14 +1751,18 @@ 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;
}
+function sanitizeUpdateWorkspaceName(pluginId: string): string {
+ return pluginId.replace(/[^a-zA-Z0-9._-]/gu, "_").replace(/\.+/gu, ".");
+}
+
function pluginName(plugin: { id: string; manifest: MaiBotPluginManifest }): string {
return plugin.manifest.name?.trim() || plugin.id;
}
@@ -959,7 +1775,7 @@ function parsePluginConfig(raw: string, configPath: string): 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),
+ 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): 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;
@@ -1088,14 +2579,14 @@ function normalizePluginConfigRootForToml(
function normalizePluginConfigValueForToml(value: MaiBotPluginConfigValue, path: string): MaiBotPluginConfigValue {
if (value === null) {
- throw new Error(`TOML 涓嶆敮鎸?null: ${path}`);
+ throw new Error(`TOML does not support null: ${path}`);
}
if (typeof value === "string" || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
- throw new Error(`鏁板瓧閰嶇疆鏃犳晥: ${path}`);
+ throw new Error(`数字配置无效: ${path}`);
}
return value;
}
@@ -1110,7 +2601,7 @@ function normalizePluginConfigValueForToml(value: MaiBotPluginConfigValue, path:
]),
);
}
- throw new Error(`鎻掍欢閰嶇疆鍊间笉鍙楁敮鎸? ${path}`);
+ throw new Error(`Unsupported plugin config value: ${path}`);
}
function buildPluginConfigSchema(
@@ -1134,7 +2625,7 @@ function buildPluginConfigSchema(
if (generalFields.length > 0) {
sections.unshift({
name: "general",
- title: "甯歌",
+ title: "常规",
fields: generalFields,
});
}
@@ -1428,6 +2919,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/module-updater.ts b/src/main/services/module-updater.ts
index b23057e..fa9db27 100644
--- a/src/main/services/module-updater.ts
+++ b/src/main/services/module-updater.ts
@@ -7,7 +7,9 @@ import type {
ModuleSourceOption,
ModuleSourcePreset,
ModuleSourceUpdate,
+ ModuleBranchOption,
ModuleTagOption,
+ ModuleUpdateTarget,
ModuleUpdateResult,
RuntimePaths,
} from "../../shared/contracts";
@@ -53,6 +55,7 @@ interface RepoUpdateSpec {
/** 是否执行 git submodule 更新(仅主仓需要)。 */
runSubmodule: boolean;
targetTag?: string;
+ targetBranch?: string;
}
function isPrereleaseTag(tag: string): boolean {
@@ -123,7 +126,30 @@ export class ModuleUpdater {
.map((name) => ({ name, isPrerelease: isPrereleaseTag(name) }));
}
- async updateMaiBot(targetTag?: string): Promise {
+ async listMaiBotBranches(): Promise {
+ const gitPath = this.initManager.getGitPath();
+ const sourceConfig = await this.getSourceConfig();
+ const result = await this.runGit(
+ gitPath,
+ this.paths.installRoot,
+ ["ls-remote", "--heads", "--refs", sourceConfig.maibotUrl],
+ FETCH_ORIGIN_TIMEOUT_MS,
+ );
+ return result.output
+ .map((line) => line.match(/refs\/heads\/(.+)$/u)?.[1])
+ .filter((branch): branch is string => Boolean(branch))
+ .sort((left, right) => {
+ if (left === "main") return -1;
+ if (right === "main") return 1;
+ if (left === "dev") return -1;
+ if (right === "dev") return 1;
+ return left.localeCompare(right, "en-US", { numeric: true, sensitivity: "base" });
+ })
+ .slice(0, 80)
+ .map((name) => ({ name }));
+ }
+
+ async updateMaiBot(target?: ModuleUpdateTarget): Promise {
const gitPath = this.initManager.getGitPath();
if (!existsSync(gitPath)) {
throw new Error(`未找到可用 Git: ${gitPath}`);
@@ -141,7 +167,8 @@ export class ModuleUpdater {
defaultBranch: "main",
throwOnFailure: true,
runSubmodule: true,
- targetTag: targetTag?.trim() || undefined,
+ targetTag: target?.type === "tag" ? target.name.trim() || undefined : undefined,
+ targetBranch: target?.type === "branch" ? target.name.trim() || undefined : undefined,
});
return mainResult;
}
@@ -150,7 +177,7 @@ export class ModuleUpdater {
* 直接用一键包内置的 napcat-adapter 快照覆盖可写目录里的对应插件,不走任何网络。
* 适用场景:用户因 .gitignore 历史问题导致 plugins/napcat-adapter/runtime/ 缺失,
* 报 `[E_PLUGIN_NOT_FOUND] No module named '_maibot_plugin_maibot_team_napcat_adapter.runtime'`,
- * 又不想等 git fetch 联网。强制清空再整目录复制 bundled,含 .git。
+ * 又不想等 git fetch 联网。强制清空再整目录复制 bundled 快照。
*/
async repairNapcatAdapterFromBundled(): Promise {
const moduleId: ModuleUpdateResult["moduleId"] = "napcat-adapter";
@@ -182,7 +209,7 @@ export class ModuleUpdater {
await rm(cwd, { recursive: true, force: true });
}
- output.push(`[${moduleName}] 复制内置快照(含 .git)...`);
+ output.push(`[${moduleName}] 复制内置快照...`);
await cp(bundled, cwd, {
recursive: true,
force: true,
@@ -298,7 +325,25 @@ export class ModuleUpdater {
)
).output,
);
- upstream = spec.targetTag ? `refs/tags/${spec.targetTag}` : await this.resolveUpstream(gitPath, cwd, branch ?? defaultBranch);
+ if (spec.targetBranch) {
+ upstream = `origin/${spec.targetBranch}`;
+ append(
+ `[${moduleName}] checkout --force -B ${spec.targetBranch} ${upstream}`,
+ (await this.runGit(gitPath, cwd, ["checkout", "--force", "-B", spec.targetBranch, upstream])).output,
+ );
+ append(
+ `[${moduleName}] branch --set-upstream-to ${upstream} ${spec.targetBranch}`,
+ (await this.runGit(gitPath, cwd, ["branch", "--set-upstream-to", upstream, spec.targetBranch])).output,
+ );
+ } else if (spec.targetTag) {
+ upstream = `refs/tags/${spec.targetTag}`;
+ append(
+ `[${moduleName}] checkout --force --detach ${upstream}`,
+ (await this.runGit(gitPath, cwd, ["checkout", "--force", "--detach", upstream])).output,
+ );
+ } else {
+ upstream = await this.resolveUpstream(gitPath, cwd, branch ?? defaultBranch);
+ }
append(
`[${moduleName}] reset --hard ${upstream}`,
(await this.runGit(gitPath, cwd, ["reset", "--hard", upstream])).output,
@@ -306,9 +351,20 @@ export class ModuleUpdater {
} catch (originErr) {
remoteError = toDetail(originErr);
output.push(`[${moduleName}] 远端拉取或更新失败: ${remoteError}`);
- await this.restoreRepositoryBeforeUpdate(gitPath, cwd, moduleName, before, originalRemote, hadOriginRemote, output);
+ await this.restoreRepositoryBeforeUpdate(
+ gitPath,
+ cwd,
+ moduleName,
+ before,
+ branch,
+ originalRemote,
+ hadOriginRemote,
+ output,
+ );
const failure = spec.targetTag
? `无法拉取远端 tag ${spec.targetTag},已恢复到更新前状态: ${remoteError}`
+ : spec.targetBranch
+ ? `无法拉取远端分支 ${spec.targetBranch},已恢复到更新前状态: ${remoteError}`
: `远端更新失败,已恢复到更新前状态: ${remoteError}`;
if (spec.throwOnFailure) {
throw new Error(failure);
@@ -340,7 +396,16 @@ export class ModuleUpdater {
if (spec.throwOnFailure) {
const remoteError = toDetail(subErr);
output.push(`[${moduleName}] 子模块更新失败: ${remoteError}`);
- await this.restoreRepositoryBeforeUpdate(gitPath, cwd, moduleName, before, originalRemote, hadOriginRemote, output);
+ await this.restoreRepositoryBeforeUpdate(
+ gitPath,
+ cwd,
+ moduleName,
+ before,
+ branch,
+ originalRemote,
+ hadOriginRemote,
+ output,
+ );
throw new Error(`子模块更新失败,已恢复到更新前状态: ${remoteError}`);
} else {
output.push(`[${moduleName}] 子模块更新失败(已忽略): ${toDetail(subErr)}`);
@@ -349,6 +414,7 @@ export class ModuleUpdater {
}
const after = await this.readGitValue(gitPath, cwd, ["rev-parse", "--short", "HEAD"]);
+ const afterBranch = await this.readGitValue(gitPath, cwd, ["branch", "--show-current"]);
return {
moduleId,
@@ -356,11 +422,11 @@ export class ModuleUpdater {
cwd,
gitPath,
remote,
- branch,
+ branch: afterBranch,
upstream,
before,
after,
- changed: before ? Boolean(after && before !== after) : Boolean(after),
+ changed: before ? Boolean(after && (before !== after || branch !== afterBranch)) : Boolean(after),
output,
updatedAt: Date.now(),
source: "remote",
@@ -402,10 +468,23 @@ export class ModuleUpdater {
cwd: string,
moduleName: string,
before: string | undefined,
+ branch: string | undefined,
originalRemote: string | undefined,
hadOriginRemote: boolean,
output: string[],
): Promise {
+ try {
+ if (branch) {
+ output.push(`[${moduleName}] 恢复到更新前分支 ${branch} ...`);
+ output.push(...(await this.runGit(gitPath, cwd, ["checkout", "--force", branch], 60_000)).output);
+ } else if (before) {
+ output.push(`[${moduleName}] 恢复到更新前 detached HEAD ${before} ...`);
+ output.push(...(await this.runGit(gitPath, cwd, ["checkout", "--force", "--detach", before], 60_000)).output);
+ }
+ } catch (restoreError) {
+ output.push(`[${moduleName}] 恢复分支失败: ${toDetail(restoreError)}`);
+ }
+
try {
if (before) {
output.push(`[${moduleName}] 恢复到更新前提交 ${before} ...`);
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/main/services/opencode-settings-manager.ts b/src/main/services/opencode-settings-manager.ts
new file mode 100644
index 0000000..65741ae
--- /dev/null
+++ b/src/main/services/opencode-settings-manager.ts
@@ -0,0 +1,54 @@
+import { readFileSync } from "node:fs";
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import type { OpenCodeSettings, RuntimePaths } from "../../shared/contracts";
+
+const OPENCODE_SETTINGS_FILE = "opencode-settings.json";
+
+export class OpenCodeSettingsManager {
+ private readonly settingsPath: string;
+ private cache: OpenCodeSettings;
+
+ constructor(paths: RuntimePaths) {
+ this.settingsPath = join(paths.userDataRoot, OPENCODE_SETTINGS_FILE);
+ this.cache = this.read();
+ }
+
+ getSettings(): OpenCodeSettings {
+ return { ...this.cache };
+ }
+
+ async saveSettings(settings: OpenCodeSettings): Promise {
+ const normalized = normalizeOpenCodeSettings(settings);
+ await mkdir(dirname(this.settingsPath), { recursive: true });
+ await writeFile(this.settingsPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
+ this.cache = normalized;
+ return this.getSettings();
+ }
+
+ async resetSettings(): Promise {
+ this.cache = defaultOpenCodeSettings();
+ return this.getSettings();
+ }
+
+ private read(): OpenCodeSettings {
+ try {
+ const raw = JSON.parse(readFileSync(this.settingsPath, "utf8")) as Partial;
+ return normalizeOpenCodeSettings(raw);
+ } catch {
+ return defaultOpenCodeSettings();
+ }
+ }
+}
+
+function defaultOpenCodeSettings(): OpenCodeSettings {
+ return {
+ useBundledPluginInstructions: true,
+ };
+}
+
+function normalizeOpenCodeSettings(value: Partial): OpenCodeSettings {
+ return {
+ useBundledPluginInstructions: value.useBundledPluginInstructions !== false,
+ };
+}
diff --git a/src/main/services/paths.ts b/src/main/services/paths.ts
index 1fd7607..52eab90 100644
--- a/src/main/services/paths.ts
+++ b/src/main/services/paths.ts
@@ -113,8 +113,13 @@ export function configureRuntimePaths(): RuntimePaths {
snowlumaRoot: join(defaultResourceRoot, "modules", "SnowLuma"),
bundledModulesRoot,
runtimeRoot: join(payloadRoot, "runtime"),
+ opencodePluginInstructionsPath: app.isPackaged
+ ? join(payloadRoot, "runtime", "opencode", "plugin_code.md")
+ : join(installRoot, "resources", "opencode", "plugin_code.md"),
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..62a6b0b
--- /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, 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/main/services/python-dependency-manager.ts b/src/main/services/python-dependency-manager.ts
index 754f560..75f72ff 100644
--- a/src/main/services/python-dependency-manager.ts
+++ b/src/main/services/python-dependency-manager.ts
@@ -33,6 +33,7 @@ const MANAGED_PACKAGES: ManagedPythonPackage[] = [
const PYTHON_OVERLAY_TARGET_ENV = "MAIBOT_PYTHON_OVERLAY_TARGET";
const REQUEST_TIMEOUT_MS = 60_000;
const PIP_TIMEOUT_MS = 10 * 60 * 1000;
+const STARTUP_UPGRADE_IDLE_TIMEOUT_MS = 10_000;
const SIMPLE_ACCEPT = "application/vnd.pypi.simple.v1+json, application/json;q=0.9, text/html;q=0.8";
interface SimpleProjectFile {
@@ -86,7 +87,7 @@ function toDetail(error: unknown): string {
function assertManagedPackage(packageName: ManagedPythonPackageName): void {
if (!MANAGED_PACKAGES.some((item) => item.name === packageName)) {
- throw new Error(`涓嶆敮鎸佹洿鏂版 Python 渚濊禆: ${packageName}`);
+ throw new Error(`Updating this Python dependency is not supported: ${packageName}`);
}
}
@@ -428,20 +429,20 @@ export class PythonDependencyManager {
}
if (hasMissingUploadTimes(versions)) {
- output.push(`娓呭崕 Simple 绱㈠紩缂哄皯閮ㄥ垎鍙戝竷鏃堕棿锛屽皾璇曚粠 ${PYPI_SIMPLE_INDEX}/${packageName}/ 琛ラ綈鎺掑簭淇℃伅`);
+ output.push(`清华 Simple 索引缺少部分发布时间,尝试从 ${PYPI_SIMPLE_INDEX}/${packageName}/ 补齐排序信息`);
try {
const supplemental = await fetchSimpleVersions(packageName, PYPI_SIMPLE_INDEX);
versions = mergeVersionLists(versions, supplemental);
output.push("发布时间补齐完成,仍以清华源作为安装源");
} catch (metadataError) {
- output.push(`鍙戝竷鏃堕棿琛ラ綈澶辫触锛屽皢鎸夊彲鐢ㄦ椂闂翠笌鐗堟湰鍙锋帓搴? ${toDetail(metadataError)}`);
+ output.push(`Release time backfill failed; sorting by available time and version: ${toDetail(metadataError)}`);
}
}
output.push(
hasMissingUploadTimes(versions)
- ? `鎵惧埌 ${versions.length} 涓増鏈紝宸叉寜鍙敤鍙戝竷鏃堕棿闄嶅簭鎺掑垪锛涚己澶卞彂甯冩椂闂寸殑鐗堟湰鐢ㄧ増鏈彿琛ヤ綅`
- : `鎵惧埌 ${versions.length} 涓増鏈紝宸叉寜鍙戝竷鏃堕棿闄嶅簭鎺掑垪`,
+ ? `Found ${versions.length} versions, sorted by available release time descending; versions without release time use version number as fallback`
+ : `Found ${versions.length} versions, sorted by available release time descending`,
);
return {
packageName,
@@ -451,7 +452,7 @@ export class PythonDependencyManager {
fetchedAt: Date.now(),
};
} catch (error) {
- output.push(`璇诲彇鐗堟湰鍒楄〃澶辫触: ${toDetail(error)}`);
+ output.push(`读取版本列表失败: ${toDetail(error)}`);
throw new Error(output.join("\n"));
}
}
@@ -459,7 +460,7 @@ export class PythonDependencyManager {
async installVersion(request: PythonPackageInstallRequest): Promise {
assertManagedPackage(request.packageName);
if (!request.version.trim()) {
- throw new Error("璇烽€夋嫨瑕佸畨瑁呯殑鐗堟湰");
+ throw new Error("Please select a version to install");
}
const targetDir = this.getOverridesRoot();
@@ -526,6 +527,29 @@ export class PythonDependencyManager {
return true;
}
+ async waitForStartupUpgradeIdle(timeoutMs = STARTUP_UPGRADE_IDLE_TIMEOUT_MS): Promise {
+ const currentUpgrade = this.startupUpgradePromise;
+ if (!currentUpgrade) {
+ return;
+ }
+
+ let timeout: NodeJS.Timeout | undefined;
+ try {
+ await Promise.race([
+ currentUpgrade.catch(() => undefined).then(() => undefined),
+ new Promise((_resolve, reject) => {
+ timeout = setTimeout(() => {
+ reject(new Error("Timed out waiting for Python dependency install to stop"));
+ }, timeoutMs);
+ }),
+ ]);
+ } finally {
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ }
+ }
+
private async installProjectDeclaredDependencies(
signal?: AbortSignal,
onOutput?: PythonOutputHandler,
@@ -545,10 +569,10 @@ export class PythonDependencyManager {
: undefined;
if (!sourceFile) {
- throw new Error(`鏈壘鍒?MaiBot 渚濊禆澹版槑鏂囦欢: ${requirementsPath} 鎴?${pyprojectPath}`);
+ throw new Error(`MaiBot dependency declaration not found: ${requirementsPath} or ${pyprojectPath}`);
}
if (sourceFile === pyprojectPath && pyprojectDependencies.length === 0) {
- throw new Error(`MaiBot pyproject.toml 娌℃湁鍙敤鐨?[project.dependencies]: ${pyprojectPath}`);
+ throw new Error(`MaiBot pyproject.toml has no usable [project.dependencies]: ${pyprojectPath}`);
}
if (pyprojectDependencies.length > 0) {
diff --git a/src/main/services/service-manager.ts b/src/main/services/service-manager.ts
index b2e6df9..4d52ccb 100644
--- a/src/main/services/service-manager.ts
+++ b/src/main/services/service-manager.ts
@@ -104,8 +104,9 @@ 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 LOCAL_DASHBOARD_ENV_NAME = "MAIBOT_WEBUI_USE_LOCAL_DASHBOARD";
const COMMAND_CONFIG_FILE = "service-commands.json";
const RUNTIME_PATH_CONFIG_FILE = "runtime-paths.json";
const TERMINAL_SETTINGS_FILE = "terminal-settings.json";
@@ -265,6 +266,24 @@ function createServiceEnv(extraEnv: Record | undefined): NodeJS.
return env;
}
+function isDevRuntime(): boolean {
+ return (
+ process.env.NODE_ENV === "development" ||
+ Boolean(process.env.ELECTRON_RENDERER_URL) ||
+ Boolean(process.env.VITE_DEV_SERVER_URL)
+ );
+}
+
+function createServiceSpecificEnv(serviceId: ServiceId): Record {
+ if (serviceId === "maibot" && isDevRuntime()) {
+ return {
+ [LOCAL_DASHBOARD_ENV_NAME]: "1",
+ };
+ }
+
+ return {};
+}
+
function killWindowsProcessTree(pid: number, force: boolean): Promise {
const args = force ? ["/F", "/T", "/PID", String(pid)] : ["/T", "/PID", String(pid)];
return new Promise((resolve, reject) => {
@@ -556,10 +575,14 @@ export class ServiceManager extends EventEmitter {
async restart(serviceId: ServiceId): Promise {
await this.stop(serviceId);
+ if (serviceId === "maibot") {
+ await this.pythonDependencyManager?.waitForStartupUpgradeIdle();
+ }
return this.start(serviceId);
}
async start(serviceId: ServiceId, resetRestartAttempts = true): Promise {
+ this.definitions = this.createDefinitions();
const definition = this.getDefinition(serviceId);
const state = this.getState(serviceId);
const sessionId = serviceSessionId(serviceId);
@@ -641,7 +664,11 @@ export class ServiceManager extends EventEmitter {
const agreementEnv = await this.initManager.getAgreementEnvVars();
const usePythonOverlay = definition.id === "maibot" && !this.isCustomPythonRuntimeEnabled();
const baseEnv = usePythonOverlay ? this.pythonDependencyManager?.buildPythonPathEnv() : undefined;
- const mergedEnv: Record = { ...(baseEnv ?? {}), ...agreementEnv };
+ const serviceEnv = createServiceSpecificEnv(definition.id);
+ const mergedEnv: Record = { ...(baseEnv ?? {}), ...agreementEnv, ...serviceEnv };
+ if (serviceEnv[LOCAL_DASHBOARD_ENV_NAME]) {
+ this.logs.append("maibot", "system", `dev local dashboard enabled: ${LOCAL_DASHBOARD_ENV_NAME}=1`);
+ }
if (usePythonOverlay && this.pythonDependencyManager) {
const syncedPythonOverrides = await this.initManager.ensureBundledPythonOverrides();
if (syncedPythonOverrides.length > 0) {
@@ -870,6 +897,7 @@ export class ServiceManager extends EventEmitter {
}
async refresh(): Promise {
+ this.definitions = this.createDefinitions();
this.attachLivePtySessions();
this.reconcileExitedPtySessions();
@@ -985,6 +1013,7 @@ export class ServiceManager extends EventEmitter {
private createDefinitions(): ServiceDefinition[] {
const python = this.getRuntimePath("python");
const maibotRoot = this.paths.maibotRoot;
+ const maibotWebUi = this.initManager.readMaiBotWebUiEndpointSync();
const napcatRoot = this.paths.napcatRoot;
const qqBackend = this.initManager.getQqBackendSync();
const snowlumaRoot = this.paths.snowlumaRoot;
@@ -1001,13 +1030,13 @@ export class ServiceManager extends EventEmitter {
{
id: "maibot",
name: "MaiBot Core",
- port: 8001,
- ports: [8001],
- url: "http://127.0.0.1:8001",
+ port: maibotWebUi.port,
+ ports: [maibotWebUi.port],
+ url: maibotWebUi.url,
cwd: maibotRoot,
defaultRequiredPaths: [python, maibotRoot, join(maibotRoot, "bot.py")],
- conflictPorts: [8001],
- readyPorts: [8001],
+ conflictPorts: [maibotWebUi.port],
+ readyPorts: [maibotWebUi.port],
buildDefaultCommand: async () => [python, "bot.py"],
buildDefaultCommandLine: async () => `${quoteCommandPart(python)} bot.py`,
},
@@ -1042,8 +1071,8 @@ export class ServiceManager extends EventEmitter {
return qq ? [napcatNode, napcatNodeEntry, "-q", qq] : [napcatNode, napcatNodeEntry];
}
if (process.platform === "win32" && existsSync(napcatLauncherPath)) {
- // 閫氳繃 cmd.exe 璋冪敤纾佺洏涓婄殑 napcat-launch.cmd锛堝凡鍥哄畾 chcp 65001锛夛紝
- // argv 鍚勫厓绱犵嫭绔嬩紶閫掞紝涓嶄細瑙﹀彂 cmd /C 瀛楃涓叉嫾鎺ョ殑寮曞彿姝т箟銆?
+ // 通过 cmd.exe 调用磁盘上的 napcat-launch.cmd(已固定 chcp 65001),
+ // argv entries are passed independently and do not trigger cmd /C string quote issues.
const args = ["/D", "/S", "/C", napcatLauncherName];
if (qq) {
args.push("-q", qq);
@@ -1317,7 +1346,7 @@ export class ServiceManager extends EventEmitter {
private getRuntimePathDefinition(key: RuntimePathKey): RuntimePathDefinition {
const definition = this.getRuntimePathDefinitions().find((item) => item.key === key);
if (!definition) {
- throw new Error(`鏈煡璺緞閰嶇疆: ${key}`);
+ throw new Error(`Unknown path config: ${key}`);
}
return definition;
}
@@ -1372,16 +1401,23 @@ export class ServiceManager extends EventEmitter {
private async resolveNapCatUrl(fallback: string): Promise {
try {
const { token } = await this.initManager.readNapCatWebUiToken();
- return token ? `http://127.0.0.1:6099/webui?token=${encodeURIComponent(token)}` : fallback;
+ if (!token) {
+ return fallback;
+ }
+ const target = new URL(fallback);
+ target.pathname = "/webui/web_login";
+ target.search = "";
+ target.searchParams.set("token", token);
+ return target.toString();
} catch {
- // 浠讳綍璇诲彇寮傚父閮界洿鎺ュ洖閫€鍒版櫘閫氱櫥褰曢〉锛岄伩鍏嶉樆濉炰富闈㈡澘銆?
+ // Any read error falls back to the normal login page, avoiding a blocked main panel.
return fallback;
}
}
/**
- * MaiBot Core WebUI 鏀寔 `/auth?token=` 鐩存帴鐧诲綍锛?
- * webui.json 杩樻湭鐢熸垚鎴栧瓧娈电己澶辨椂鐩存帴鍥為€€涓烘牴鍦板潃锛岀敱鐢ㄦ埛璧版櫘閫氱櫥褰曟祦绋嬨€?
+ * MaiBot Core WebUI supports direct login through `/auth?token=`.
+ * If webui.json has not been generated or fields are missing, return the root URL for normal login.
*/
private async resolveMaiBotUrl(fallback: string): Promise {
try {
@@ -1507,7 +1543,7 @@ export class ServiceManager extends EventEmitter {
: shouldRestart
? `进程退出,准备自动重启: ${event.exitCode}`
: `进程异常退出: ${event.exitCode}`,
- error: stoppedByRequest ? undefined : shouldRestart ? undefined : `杩涚▼寮傚父閫€鍑? ${event.exitCode}`,
+ error: stoppedByRequest ? undefined : shouldRestart ? undefined : `Process exited unexpectedly: ${event.exitCode}`,
stoppedAt: Date.now(),
});
@@ -1646,7 +1682,7 @@ export class ServiceManager extends EventEmitter {
private getDefinition(serviceId: ServiceId): ServiceDefinition {
const definition = this.definitions.find((item) => item.id === serviceId);
if (!definition) {
- throw new Error(`鏈煡鏈嶅姟: ${serviceId}`);
+ throw new Error(`Unknown service: ${serviceId}`);
}
return definition;
}
@@ -1654,7 +1690,7 @@ export class ServiceManager extends EventEmitter {
private getState(serviceId: ServiceId): ServiceState {
const state = this.states.get(serviceId);
if (!state) {
- throw new Error(`鏈煡鏈嶅姟鐘舵€? ${serviceId}`);
+ throw new Error(`Unknown service status: ${serviceId}`);
}
return state;
}
diff --git a/src/main/window-state.ts b/src/main/window-state.ts
new file mode 100644
index 0000000..58ee18d
--- /dev/null
+++ b/src/main/window-state.ts
@@ -0,0 +1,35 @@
+import { screen, type BrowserWindow, type Rectangle } from "electron";
+
+const MAXIMIZED_BOUNDS_TOLERANCE = 2;
+
+function nearlyEqual(a: number, b: number): boolean {
+ return Math.abs(a - b) <= MAXIMIZED_BOUNDS_TOLERANCE;
+}
+
+export function getWindowWorkAreaBounds(window: BrowserWindow): Rectangle {
+ const workArea = screen.getDisplayMatching(window.getBounds()).workArea;
+ return {
+ x: workArea.x,
+ y: workArea.y,
+ width: workArea.width,
+ height: workArea.height,
+ };
+}
+
+export function isWindowVisuallyMaximized(window: BrowserWindow): boolean {
+ if (window.isDestroyed() || window.isFullScreen()) {
+ return false;
+ }
+ if (window.isMaximized()) {
+ return true;
+ }
+
+ const bounds = window.getBounds();
+ const workArea = getWindowWorkAreaBounds(window);
+ return (
+ nearlyEqual(bounds.x, workArea.x) &&
+ nearlyEqual(bounds.y, workArea.y) &&
+ nearlyEqual(bounds.width, workArea.width) &&
+ nearlyEqual(bounds.height, workArea.height)
+ );
+}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index bf72726..f6e05ac 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1,11 +1,16 @@
import { contextBridge, ipcRenderer } from "electron";
import type {
CloseAction,
+ AppIconId,
+ AppIconSettings,
DesktopBridge,
DesktopSnapshot,
InitRepairResult,
InitState,
+ LauncherUpdateApplyResult,
+ LauncherUpdateInfo,
LogEntry,
+ Live2dModelImportResult,
LocalChatConnectionState,
LocalChatConnectRequest,
LocalChatEvent,
@@ -17,18 +22,38 @@ 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,
+ MaiBotPluginUserStates,
+ MaiBotPluginVoteResult,
MaiBotStatisticSummary,
ManagedPythonPackageName,
+ ModuleBranchOption,
+ ModuleUpdateTarget,
ModuleUpdateResult,
+ NetworkProxySettings,
+ OpenCodeSettings,
ModuleSourceConfig,
ModuleSourceUpdate,
ModuleTagOption,
@@ -61,6 +86,7 @@ import type {
StartupAgreementConfirmResult,
StartupAgreementState,
TerminalSettings,
+ WindowResizeEdge,
WindowState,
} from "../shared/contracts";
@@ -92,7 +118,7 @@ const desktopBridge: DesktopBridge = {
onIpc("desktop:snapshot", callback),
window: {
minimize: () => ipcRenderer.invoke("desktop:window:minimize") as Promise,
- toggleMaximize: () => ipcRenderer.invoke("desktop:window:toggleMaximize") as Promise,
+ toggleMaximize: () => ipcRenderer.invoke("desktop:window:toggleMaximize") as Promise,
close: () => ipcRenderer.invoke("desktop:window:close") as Promise,
setFloatingMode: (enabled: boolean) =>
ipcRenderer.invoke("desktop:window:setFloatingMode", enabled) as Promise,
@@ -100,10 +126,16 @@ const desktopBridge: DesktopBridge = {
ipcRenderer.invoke("desktop:window:setFloatingPanelExpanded", expanded) as Promise,
moveFloatingBy: (deltaX: number, deltaY: number) =>
ipcRenderer.invoke("desktop:window:moveFloatingBy", deltaX, deltaY) as Promise,
- moveFloatingTo: (screenX: number, screenY: number, offsetX: number, offsetY: number) =>
- ipcRenderer.invoke("desktop:window:moveFloatingTo", screenX, screenY, offsetX, offsetY) as Promise,
+ moveFloatingTo: (offsetX: number, offsetY: number) =>
+ ipcRenderer.invoke("desktop:window:moveFloatingTo", 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),
},
@@ -122,7 +154,9 @@ const desktopBridge: DesktopBridge = {
confirm: () => ipcRenderer.invoke("agreements:confirm") as Promise,
},
modules: {
- updateMaiBot: (tag?: string) => ipcRenderer.invoke("modules:updateMaibot", tag) as Promise,
+ updateMaiBot: (target?: ModuleUpdateTarget) =>
+ ipcRenderer.invoke("modules:updateMaibot", target) as Promise,
+ listMaiBotBranches: () => ipcRenderer.invoke("modules:listMaibotBranches") as Promise,
listMaiBotTags: () => ipcRenderer.invoke("modules:listMaibotTags") as Promise,
getSourceConfig: () => ipcRenderer.invoke("modules:getSourceConfig") as Promise,
saveSourceConfig: (config: ModuleSourceUpdate) =>
@@ -140,11 +174,27 @@ const desktopBridge: DesktopBridge = {
ipcRenderer.invoke("data:resetMaibotData") as Promise,
},
launcher: {
+ saveNetworkProxySettings: (settings: NetworkProxySettings) =>
+ ipcRenderer.invoke("launcher:saveNetworkProxySettings", settings) as Promise,
+ saveOpenCodeSettings: (settings: OpenCodeSettings) =>
+ ipcRenderer.invoke("launcher:saveOpenCodeSettings", settings) as Promise,
+ selectAppIcon: (iconId: AppIconId) =>
+ ipcRenderer.invoke("launcher:selectAppIcon", iconId) as Promise,
+ checkUpdate: () =>
+ ipcRenderer.invoke("launcher:checkUpdate") as Promise,
+ downloadAndInstallUpdate: () =>
+ ipcRenderer.invoke("launcher:downloadAndInstallUpdate") as Promise,
resetSettings: () =>
ipcRenderer.invoke("launcher:resetSettings") as Promise,
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,
@@ -156,6 +206,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) =>
@@ -164,6 +232,18 @@ 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,
+ getUserStates: (userId: string) =>
+ ipcRenderer.invoke("plugins:getUserStates", 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 | null | undefined, comment: string | null | 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: () =>
@@ -235,6 +315,7 @@ const desktopBridge: DesktopBridge = {
ipcRenderer.invoke("pty:start", request) as Promise,
stop: (request: PtyStopRequest) => ipcRenderer.invoke("pty:stop", request) as Promise,
kill: (sessionId: string) => ipcRenderer.invoke("pty:kill", sessionId) as Promise