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": "![1779700604525](image/package/1779700604525.png)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, + close: (sessionId: string) => ipcRenderer.invoke("pty:close", sessionId) as Promise, input: (request: PtyInputRequest) => ipcRenderer.invoke("pty:input", request) as Promise, resize: (request: PtyResizeRequest) => ipcRenderer.invoke("pty:resize", request) as Promise, diff --git a/src/renderer/index.html b/src/renderer/index.html index 7cdda39..a48f0ee 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,7 +4,7 @@ MaiBot OneKey diff --git a/emoji2.png b/src/renderer/src/assets/home-drops/emoji2.png similarity index 100% rename from emoji2.png rename to src/renderer/src/assets/home-drops/emoji2.png diff --git a/mai.png b/src/renderer/src/assets/home-drops/mai.png similarity index 100% rename from mai.png rename to src/renderer/src/assets/home-drops/mai.png diff --git a/mai2.png b/src/renderer/src/assets/home-drops/mai2.png similarity index 100% rename from mai2.png rename to src/renderer/src/assets/home-drops/mai2.png diff --git a/src/renderer/src/components/app/AppErrorBoundary.tsx b/src/renderer/src/components/app/AppErrorBoundary.tsx index cc20be2..c0e66ce 100644 --- a/src/renderer/src/components/app/AppErrorBoundary.tsx +++ b/src/renderer/src/components/app/AppErrorBoundary.tsx @@ -58,14 +58,14 @@ export class AppErrorBoundary extends Component -
+
- +
-

桌面界面加载失败

+

桌面界面加载失败

renderer

@@ -78,7 +78,7 @@ export class AppErrorBoundary extends Component {error.message}

-
+            
               {error.stack ?? error.message}
             
diff --git a/src/renderer/src/components/app/DesktopShell.tsx b/src/renderer/src/components/app/DesktopShell.tsx index 0331682..42bd137 100644 --- a/src/renderer/src/components/app/DesktopShell.tsx +++ b/src/renderer/src/components/app/DesktopShell.tsx @@ -1,7 +1,10 @@ import { - FolderOpen, + ChevronDown, + Code2, GripHorizontal, Home, + Info, + ListTree, Loader2, MessageSquare, Play, @@ -12,14 +15,20 @@ Square, TerminalSquare, } from "lucide-react"; -import { type PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type FocusEvent, type MouseEvent, type PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; +import { toast } from "sonner"; import type { DesktopSnapshot, + PluginBuilderMode, ServiceDescriptor, ServiceId, ServiceStatus, + WindowState, + WindowResizeEdge, } from "@shared/contracts"; import { getDesktopSnapshot, normalizeDesktopSnapshot } from "@/lib/desktop-api"; +import { useAppearance } from "@/lib/use-appearance"; import { useShortcut } from "@/lib/use-shortcut"; import { useTheme } from "@/lib/use-theme"; import { cn } from "@/lib/utils"; @@ -52,30 +61,150 @@ const statusText: Record = { error: "异常", }; -const statusDotColor: Record = { - stopped: "bg-muted-foreground/40", - starting: "bg-warning", - running: "bg-success", - stopping: "bg-warning", - error: "bg-destructive", +const statusColor: Record = { + stopped: "var(--retro-ink, var(--muted-foreground))", + starting: "var(--warning)", + running: "var(--retro-rust, var(--success))", + stopping: "var(--warning)", + error: "var(--destructive)", }; +const PLUGIN_BUILDER_MODE_STORAGE_KEY = "maibot-onekey.plugin-builder-mode"; +const OPENCODE_TERMINAL_SESSION_PREFIX = "user-terminal:opencode:"; +const toolbarMenuItemClassName = + "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"; +const retroTopActionIconClassName = + "[&_svg]:!size-6 [&_svg]:fill-none [&_svg]:stroke-[3] [&_svg]:[stroke-linecap:square] [&_svg]:[stroke-linejoin:miter]"; + +function createOpenCodeSessionId(): string { + const randomId = + globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + return `${OPENCODE_TERMINAL_SESSION_PREFIX}${randomId}`; +} + +function joinDesktopPath(platform: NodeJS.Platform | undefined, root: string, ...segments: string[]): string { + const separator = platform === "win32" || root.includes("\\") ? "\\" : "/"; + const cleanRoot = root.replace(/[\\/]+$/u, ""); + const cleanSegments = segments.map((segment) => segment.replace(/^[\\/]+|[\\/]+$/gu, "")); + return [cleanRoot, ...cleanSegments].filter(Boolean).join(separator); +} + +function opencodeExecutablePath(snapshot: DesktopSnapshot | null): string { + const platform = snapshot?.platform; + const filename = platform === "win32" ? "opencode.exe" : "opencode"; + return joinDesktopPath(platform, snapshot?.paths.runtimeRoot ?? "runtime", "opencode", filename); +} + +function opencodeLaunchEnv(snapshot: DesktopSnapshot): Record { + const useBundledPluginInstructions = snapshot.openCodeSettings.useBundledPluginInstructions !== false; + const config: { autoupdate: false; instructions?: string[] } = { autoupdate: false }; + const env: Record = {}; + + if (useBundledPluginInstructions) { + config.instructions = [snapshot.paths.opencodePluginInstructionsPath]; + env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; + } + + env.OPENCODE_CONFIG_CONTENT = JSON.stringify(config); + return env; +} + +function readPluginBuilderMode(): PluginBuilderMode { + if (typeof window === "undefined") { + return "agent"; + } + const mode = window.localStorage.getItem(PLUGIN_BUILDER_MODE_STORAGE_KEY); + return mode === "disabled" ? "disabled" : "agent"; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function ServiceChip({ +const resizeHandles: Array<{ edge: WindowResizeEdge; className: string }> = [ + { edge: "top", className: "inset-x-3 top-0 h-1.5 cursor-ns-resize" }, + { edge: "right", className: "inset-y-3 right-0 w-1.5 cursor-ew-resize" }, + { edge: "bottom", className: "inset-x-3 bottom-0 h-1.5 cursor-ns-resize" }, + { edge: "left", className: "inset-y-3 left-0 w-1.5 cursor-ew-resize" }, + { edge: "top-left", className: "left-0 top-0 size-3 cursor-nwse-resize" }, + { edge: "top-right", className: "right-0 top-0 size-3 cursor-nesw-resize" }, + { edge: "bottom-right", className: "bottom-0 right-0 size-3 cursor-nwse-resize" }, + { edge: "bottom-left", className: "bottom-0 left-0 size-3 cursor-nesw-resize" }, +]; + +function WindowResizeHandles(): React.JSX.Element { + const resizingRef = useRef(false); + const pointerIdRef = useRef(null); + + const startResize = useCallback((edge: WindowResizeEdge, event: PointerEvent) => { + const bridge = window.maibotDesktop?.window; + if (!bridge) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + resizingRef.current = true; + pointerIdRef.current = event.pointerId; + event.currentTarget.setPointerCapture(event.pointerId); + void bridge.startResize(edge, event.screenX, event.screenY); + }, []); + + const resize = useCallback((event: PointerEvent) => { + if (!resizingRef.current || pointerIdRef.current !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + void window.maibotDesktop?.window.resizeTo(event.screenX, event.screenY); + }, []); + + const finishResize = useCallback((event: PointerEvent) => { + if (!resizingRef.current || pointerIdRef.current !== event.pointerId) { + return; + } + + resizingRef.current = false; + pointerIdRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + event.preventDefault(); + event.stopPropagation(); + void window.maibotDesktop?.window.finishResize(); + }, []); + + return ( +
+ {resizeHandles.map((handle) => ( +
startResize(handle.edge, event)} + onPointerMove={resize} + onPointerUp={finishResize} + /> + ))} +
+ ); +} + +function ServiceControlButtons({ service, busy, onStart, onStop, onRestart, + className, }: { service: ServiceDescriptor; busy: boolean; onStart: (id: ServiceId) => void; onStop: (id: ServiceId) => void; onRestart: (id: ServiceId) => void; + className?: string; }): React.JSX.Element { const isTransitioning = service.status === "starting" || service.status === "stopping" || busy; @@ -88,26 +217,22 @@ function ServiceChip({ const stopDisabled = !canStop || (busy && !isStarting) || service.status === "stopping"; return ( -
- - {service.name} - - {statusText[service.status]} - -
+
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + {canStart ? ( - - 停止 - - - - - - 重启 - -
+ ) : ( + <> + + + + + 停止 + + + + + + 重启 + + + )} +
+ ); +} + +function ServiceTabControls({ + service, + busy, + retro, + onStart, + onStop, + onRestart, +}: { + service: ServiceDescriptor | undefined; + busy: boolean; + retro: boolean; + onStart: (id: ServiceId) => void; + onStop: (id: ServiceId) => void; + onRestart: (id: ServiceId) => void; +}): React.JSX.Element | null { + if (!service) { + return null; + } + + return ( +
+
); } +function MaiBotOfflineIllustration({ waiting }: { waiting: boolean }): React.JSX.Element { + return ( + + + + + + + + + + {waiting ? ( + + ) : null} + + + + {[60, 78, 96, 114].map((x) => ( + + ))} + {[60, 78, 96, 114].map((x) => ( + + ))} + + + + + ); +} + +function MaiBotWebuiStatusPanel({ + service, + busy, + onStart, + retro, +}: { + service: ServiceDescriptor | undefined; + busy: boolean; + onStart: (id: ServiceId) => void; + retro: boolean; +}): React.JSX.Element { + const status = service?.status ?? "stopped"; + const health = service?.health ?? "unknown"; + const [showWebUiUnavailable, setShowWebUiUnavailable] = useState(false); + const webUiUnreachable = status === "running" && health === "unreachable"; + + useEffect(() => { + if (!webUiUnreachable) { + setShowWebUiUnavailable(false); + return undefined; + } + + const timer = window.setTimeout(() => setShowWebUiUnavailable(true), 10_000); + return () => window.clearTimeout(timer); + }, [webUiUnreachable]); + + const canStart = service && (status === "stopped" || status === "error"); + const waiting = + busy + || status === "starting" + || status === "stopping" + || (status === "running" && (health !== "unreachable" || !showWebUiUnavailable)); + const title = !service + ? "MAIBOT 未发现" + : status === "stopped" + ? "MAIBOT 尚未启动" + : status === "starting" + ? "MAIBOT 正在启动" + : status === "running" + ? showWebUiUnavailable + ? "MAIBOT WEBUI 暂不可访问" + : "等待 WebUI 启动" + : status === "stopping" + ? "MAIBOT 正在停止" + : "MAIBOT 启动异常"; + const description = !service || status === "stopped" + ? "当前没有运行中的 Maibot 实例,信号连接未建立。" + : status === "starting" + ? "正在启动 Maibot Core,请稍等片刻。" + : status === "running" + ? showWebUiUnavailable + ? "服务进程已启动,但 WebUI 端口暂不可访问。" + : "WebUI 正在加载,完成后会自动打开。" + : status === "stopping" + ? "正在停止 Maibot Core。" + : "启动过程中发生异常,请查看终端日志。"; + + return ( +
+
+ {retro ? : null} + +

+ {title} +

+

+ {description} +

+ +
+ + + +
+ +
+
+ + + +
+

提示

+
+ {canStart ? ( + <> + 点击 + + 建立连接并开始使用。 + + ) : ( + {waiting ? "正在等待 Maibot 建立连接。" : "请先在服务状态中确认 Maibot 配置。"} + )} +
+
+
+
+
+
+ ); +} + function FloatingShell({ expanded, edge, @@ -156,6 +503,7 @@ function FloatingShell({ onExpand, onCollapse, onRestore, + onWindowState, }: { expanded: boolean; edge: "left" | "right" | null; @@ -163,6 +511,7 @@ function FloatingShell({ onExpand: () => void; onCollapse: () => void; onRestore: () => void; + onWindowState: (state: WindowState) => void; }): React.JSX.Element { const dragRef = useRef<{ offsetX: number; @@ -172,8 +521,68 @@ function FloatingShell({ moved: boolean; pointerId: number; } | null>(null); + const dragRequestPendingRef = useRef(false); + const dragPointRef = useRef<{ + clientX: number; + clientY: number; + } | null>(null); + const dragFrameRef = useRef(null); + const suppressNextClickRef = useRef(false); + + const updateFloatingState = useCallback((state?: WindowState) => { + if (state) { + onWindowState(state); + } + }, [onWindowState]); + + const suppressNextClickBriefly = useCallback(() => { + suppressNextClickRef.current = true; + window.setTimeout(() => { + suppressNextClickRef.current = false; + }, 250); + }, []); - const updateFloatingState = useCallback(() => undefined, []); + const expandFromClick = useCallback((event: MouseEvent) => { + if (suppressNextClickRef.current) { + event.preventDefault(); + event.stopPropagation(); + return; + } + onExpand(); + }, [onExpand]); + + const flushDragMove = useCallback(() => { + if (dragFrameRef.current !== null) { + window.cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + } + if (dragRequestPendingRef.current) { + return; + } + const point = dragPointRef.current; + if (!point) { + return; + } + dragPointRef.current = null; + dragRequestPendingRef.current = true; + void window.maibotDesktop?.window + .moveFloatingTo(point.clientX, point.clientY) + .then(updateFloatingState) + .finally(() => { + dragRequestPendingRef.current = false; + flushDragMove(); + }); + }, [updateFloatingState]); + + const scheduleDragMove = useCallback(() => { + if (dragFrameRef.current !== null) { + return; + } + dragFrameRef.current = window.requestAnimationFrame(() => { + dragFrameRef.current = null; + flushDragMove(); + }); + }, [flushDragMove]); const startDrag = useCallback((event: PointerEvent) => { if (!event.isPrimary || (event.pointerType === "mouse" && event.button !== 0)) { @@ -192,6 +601,7 @@ function FloatingShell({ moved: false, pointerId: event.pointerId, }; + dragPointRef.current = null; }, []); const cancelDrag = useCallback((event: PointerEvent) => { @@ -200,6 +610,11 @@ function FloatingShell({ return; } dragRef.current = null; + dragPointRef.current = null; + if (dragFrameRef.current !== null) { + window.cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + } if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } @@ -218,27 +633,36 @@ function FloatingShell({ if (movedDistance < 4) { return; } + if (event.screenX === current.startScreenX && event.screenY === current.startScreenY) { + return; + } current.moved = true; - void window.maibotDesktop?.window - .moveFloatingTo(event.screenX, event.screenY, current.offsetX, current.offsetY) - .then(updateFloatingState); - }, [cancelDrag, updateFloatingState]); + dragPointRef.current = { + clientX: current.offsetX, + clientY: current.offsetY, + }; + scheduleDragMove(); + }, [cancelDrag, scheduleDragMove]); - const finishDrag = useCallback((event: PointerEvent, clickAction?: () => void) => { + const finishDrag = useCallback((event: PointerEvent) => { const current = dragRef.current; if (!current || current.pointerId !== event.pointerId) { return; } dragRef.current = null; + dragPointRef.current = null; + if (dragFrameRef.current !== null) { + window.cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + } if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } if (current.moved) { + suppressNextClickBriefly(); void window.maibotDesktop?.window.finishFloatingDrag().then(updateFloatingState); - return; } - clickAction?.(); - }, [updateFloatingState]); + }, [suppressNextClickBriefly, updateFloatingState]); if (!expanded) { if (edge) { @@ -249,13 +673,16 @@ function FloatingShell({ edge === "left" ? "pl-0.5" : "pr-0.5", )} data-floating-shell="true" + onClick={expandFromClick} onPointerCancel={(event) => finishDrag(event)} onPointerDown={startDrag} onPointerMove={drag} - onPointerUp={(event) => finishDrag(event, onExpand)} + onPointerUp={(event) => finishDrag(event)} title="拖动悬浮条,点击展开" > -
+
); } return ( -
+
void; + retro: boolean; +}): React.JSX.Element { + return ( +
+
+
+
+
+ + + +
+

内置 Coding Agent

+

+ 当前编写器默认使用代码代理模式,适合直接用自然语言描述插件需求。 +

+
+
+
+
+ +
+
+ +

Coding Agent 工作区

+

+ OpenCode 会在 MaiBot 目录中启动,终端页会自动切到对应会话。 +

+ +
+
+
+
+ ); +} + +const RETRO_TAB_ITEM_SELECTOR = "[data-retro-tab-item='true']"; + +function retroTabItemForValue(list: HTMLElement, value: string): HTMLElement | null { + return Array.from(list.querySelectorAll(RETRO_TAB_ITEM_SELECTOR)).find( + (item) => item.dataset.retroTabValue === value, + ) ?? null; +} + +function formatCssPixel(value: number): string { + return `${Number(value.toFixed(3))}px`; +} + +function syncRetroTabDividers(list: HTMLElement | null): void { + if (!list) { + return; + } + + const items = Array.from(list.querySelectorAll(RETRO_TAB_ITEM_SELECTOR)); + if (items.length < 2) { + list.style.setProperty("--retro-tab-divider-background", "none"); + return; + } + + const pixelRatio = Math.max(window.devicePixelRatio || 1, 1); + const lineWidth = 2 / pixelRatio; + const listLeft = list.getBoundingClientRect().left; + const layers = items.slice(0, -1).map((item) => { + const itemRight = item.getBoundingClientRect().right - listLeft; + const x = Math.round(itemRight * pixelRatio) / pixelRatio; + const from = formatCssPixel(x); + const to = formatCssPixel(x + lineWidth); + return `linear-gradient(to right, transparent 0 ${from}, var(--retro-tab-divider-color) ${from} ${to}, transparent ${to} 100%)`; + }); + + list.style.setProperty("--retro-tab-divider-background", layers.join(", ")); +} + +function moveRetroTabIndicator(list: HTMLElement | null, item: HTMLElement | null): void { + if (!list || !item) { + list?.style.setProperty("--retro-tab-indicator-opacity", "0"); + return; + } + + const listRect = list.getBoundingClientRect(); + const itemRect = item.getBoundingClientRect(); + list.style.setProperty("--retro-tab-indicator-x", `${itemRect.left - listRect.left}px`); + list.style.setProperty("--retro-tab-indicator-width", `${itemRect.width}px`); + list.style.setProperty("--retro-tab-indicator-opacity", "1"); +} + export function DesktopShell(): React.JSX.Element { const [snapshot, setSnapshot] = useState(null); const [activeTab, setActiveTab] = useState("home"); + const [webviewToolbarHost, setWebviewToolbarHost] = useState(null); const [pluginMode, setPluginMode] = useState<"market" | "manage">("manage"); + const [pluginBuilderMode, setPluginBuilderModeState] = useState(() => readPluginBuilderMode()); + const [isStartingOpenCode, setIsStartingOpenCode] = useState(false); + const [terminalFocusSessionId, setTerminalFocusSessionId] = useState(null); const [requestedConfigPluginId, setRequestedConfigPluginId] = useState(null); const [actionBusy, setActionBusy] = useState(null); const [actionError, setActionError] = useState(null); const [floatingMode, setFloatingMode] = useState(false); const [floatingExpanded, setFloatingExpanded] = useState(false); const [floatingEdge, setFloatingEdge] = useState<"left" | "right" | null>(null); + const retroTabsRef = useRef(null); + const appearance = useAppearance(); const theme = useTheme(); + const useRetroChrome = appearance.mode === "future-retro"; + + const setPluginBuilderMode = useCallback((mode: PluginBuilderMode) => { + setPluginBuilderModeState(mode); + window.localStorage.setItem(PLUGIN_BUILDER_MODE_STORAGE_KEY, mode); + }, []); const refreshSnapshot = useCallback(async () => { const next = await getDesktopSnapshot(); @@ -381,18 +923,21 @@ export function DesktopShell(): React.JSX.Element { }, [refreshSnapshot]); const services = snapshot?.services ?? []; - const messagePlatformReady = - snapshot?.initState.messagePlatformConfigured === true && - Boolean(snapshot.initState.qqAccount?.trim()); - const visibleServiceChips = services.filter( - (service) => service.id === "maibot" || messagePlatformReady, - ); const serviceById = useMemo( () => new Map(services.map((s) => [s.id, s])), [services], ); const maibotService = serviceById.get("maibot"); + const maibotWebviewReady = maibotService?.status === "running" && maibotService.health === "ready"; + const maibotWebviewReloadTrigger = + maibotWebviewReady + ? maibotService.url + : null; + const qqBackendService = serviceById.get("napcat"); + const qqBackendName = + qqBackendService?.name ?? (snapshot?.initState.qqBackend === "snowluma" ? "SnowLuma" : "NapCat"); const showTerminalTab = snapshot?.terminalSettings.useEmbeddedTerminal === true; + const openCodePath = useMemo(() => opencodeExecutablePath(snapshot), [snapshot]); const canInterruptStartup = actionBusy === "all:start" || services.some((service) => service.status === "starting"); @@ -442,12 +987,6 @@ export function DesktopShell(): React.JSX.Element { async () => window.maibotDesktop?.services.stopAll() ?? [], ); }, [runServiceAction]); - const refreshServices = useCallback(() => { - void runServiceAction( - "all:refresh", - async () => window.maibotDesktop?.services.refresh() ?? [], - ); - }, [runServiceAction]); const startService = useCallback( (id: ServiceId) => void runServiceAction(`${id}:start`, async () => { @@ -501,6 +1040,11 @@ export function DesktopShell(): React.JSX.Element { }); }, []); + const syncWindowState = useCallback((state: WindowState) => { + setFloatingMode(state.isFloating === true); + setFloatingEdge(state.floatingEdge ?? null); + }, []); + const selectTab = useCallback((value: string) => { if (value === "terminal" && !showTerminalTab) { setActiveTab("home"); @@ -516,8 +1060,68 @@ export function DesktopShell(): React.JSX.Element { setActiveTab("plugins"); return; } + if (value === "pluginbuilder") { + if (pluginBuilderMode === "disabled") { + setActiveTab("home"); + return; + } + setActiveTab("pluginbuilder"); + return; + } setActiveTab(value); - }, [showTerminalTab]); + }, [pluginBuilderMode, showTerminalTab]); + + const syncRetroTabIndicator = useCallback((value: string) => { + const list = retroTabsRef.current; + if (!list) { + return; + } + moveRetroTabIndicator(list, retroTabItemForValue(list, value)); + }, []); + + const handleRetroTabsPointerOver = useCallback((event: PointerEvent) => { + if (!useRetroChrome) { + return; + } + + const item = (event.target as HTMLElement).closest(RETRO_TAB_ITEM_SELECTOR); + if (!item || !retroTabsRef.current?.contains(item)) { + return; + } + moveRetroTabIndicator(retroTabsRef.current, item); + }, [useRetroChrome]); + + const handleRetroTabsFocus = useCallback((event: FocusEvent) => { + if (!useRetroChrome) { + return; + } + + const item = (event.target as HTMLElement).closest(RETRO_TAB_ITEM_SELECTOR); + if (!item || !retroTabsRef.current?.contains(item)) { + return; + } + moveRetroTabIndicator(retroTabsRef.current, item); + }, [useRetroChrome]); + + const handleRetroTabsPointerDown = useCallback((event: PointerEvent) => { + if (!useRetroChrome || event.button !== 0) { + return; + } + + const list = retroTabsRef.current; + const item = (event.target as HTMLElement).closest(RETRO_TAB_ITEM_SELECTOR); + if (!list || !item || !list.contains(item)) { + return; + } + + list.removeAttribute("data-retro-pressing"); + void list.offsetWidth; + list.setAttribute("data-retro-pressing", "true"); + }, [useRetroChrome]); + + const handleRetroTabsPointerLeave = useCallback(() => { + syncRetroTabIndicator(activeTab); + }, [activeTab, syncRetroTabIndicator]); const openPluginConfig = useCallback((pluginId: string) => { setPluginMode("manage"); @@ -525,20 +1129,114 @@ export function DesktopShell(): React.JSX.Element { setActiveTab("plugins"); }, []); + const openTerminalSession = useCallback((sessionId: string) => { + setTerminalFocusSessionId(sessionId); + setActiveTab("terminal"); + }, []); + + const startOpenCode = useCallback(async () => { + const bridge = window.maibotDesktop; + if (!bridge?.pty) { + toast.error("Electron 终端桥接未连接"); + return; + } + if (!snapshot) { + toast.error("桌面状态还没有准备好"); + return; + } + if (snapshot.terminalSettings.useEmbeddedTerminal !== true) { + toast.error("请先在设置中启用内嵌终端"); + return; + } + + setIsStartingOpenCode(true); + try { + const session = await bridge.pty.start({ + id: createOpenCodeSessionId(), + title: "OpenCode 编写器", + cwd: snapshot.paths.maibotRoot, + command: [openCodePath, snapshot.paths.maibotRoot], + encoding: "utf8", + env: opencodeLaunchEnv(snapshot), + }); + openTerminalSession(session.id); + toast.success("OpenCode 已在终端中启动"); + } catch (error) { + toast.error(`OpenCode 启动失败:${errorMessage(error)}`); + } finally { + setIsStartingOpenCode(false); + } + }, [openCodePath, openTerminalSession, snapshot]); + useEffect(() => { if (activeTab === "terminal" && !showTerminalTab) { setActiveTab("home"); } }, [activeTab, showTerminalTab]); - // Shortcuts - useShortcut("Mod+1", () => selectTab("home")); - useShortcut("Mod+2", () => selectTab("maibot")); - useShortcut("Mod+3", () => selectTab("localchat")); - useShortcut("Mod+4", () => selectTab("terminal"), { enabled: showTerminalTab }); - useShortcut("Mod+5", () => selectTab("pluginmarket")); - useShortcut("Mod+6", () => selectTab("pluginmanage")); - useShortcut("Mod+8", () => selectTab("settings")); + useEffect(() => { + if (activeTab === "pluginbuilder" && pluginBuilderMode === "disabled") { + setActiveTab("home"); + } + }, [activeTab, pluginBuilderMode]); + + useEffect(() => { + if (!useRetroChrome) { + moveRetroTabIndicator(retroTabsRef.current, null); + syncRetroTabDividers(retroTabsRef.current); + return; + } + + let frame = 0; + const syncNow = (): void => { + syncRetroTabDividers(retroTabsRef.current); + syncRetroTabIndicator(activeTab); + }; + const sync = (): void => { + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(syncNow); + }; + + sync(); + const observer = new ResizeObserver(sync); + const list = retroTabsRef.current; + if (list) { + observer.observe(list); + for (const item of list.querySelectorAll(RETRO_TAB_ITEM_SELECTOR)) { + observer.observe(item); + } + } + + window.addEventListener("resize", sync); + return () => { + window.cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener("resize", sync); + }; + }, [activeTab, maibotService?.status, pluginBuilderMode, showTerminalTab, syncRetroTabIndicator, useRetroChrome]); + + useEffect(() => { + const list = retroTabsRef.current; + if (!list || !useRetroChrome) { + return; + } + + const clearPressing = (event: AnimationEvent): void => { + if (event.animationName === "retro-tab-indicator-press") { + list.removeAttribute("data-retro-pressing"); + } + }; + + list.addEventListener("animationend", clearPressing); + list.addEventListener("animationcancel", clearPressing); + return () => { + list.removeAttribute("data-retro-pressing"); + list.removeEventListener("animationend", clearPressing); + list.removeEventListener("animationcancel", clearPressing); + }; + }, [useRetroChrome]); + + // Global shortcuts useShortcut("Mod+L", openLogs); useShortcut("Mod+Shift+S", startAll); useShortcut("Mod+Shift+X", stopAll); @@ -554,6 +1252,7 @@ export function DesktopShell(): React.JSX.Element { onCollapse={() => setFloatingPanel(false)} onExpand={() => setFloatingPanel(true)} onRestore={restoreMainWindow} + onWindowState={syncWindowState} /> @@ -562,172 +1261,324 @@ export function DesktopShell(): React.JSX.Element { return ( -
- + +
+
+ - {/* Service strip */} -
-
- {visibleServiceChips.length === 0 ? ( - - 等待服务发现… - - ) : ( - visibleServiceChips.map((service) => ( - - )) - )} -
-
- - - - - - - 设置 - - - - - -
+ ) : null} + + {/* Main */} +
+ +
+ - {actionBusy === "all:start" ? ( - - ) : ( - + + + 首页 + +
+ + + MaiBot + + + {maibotService ? statusText[maibotService.status] : "未发现"} + + + +
+ + + 聊聊 + + {showTerminalTab ? ( + + + 终端 + + ) : null} + + + 插件 + + {pluginBuilderMode !== "disabled" ? ( + + + 编写器 + + ) : null} +
+
- - - - 启动全部服务 - - - - - - - - 刷新服务状态 - -
-
- - {actionError ? ( -
- {actionError} -
- ) : null} - - {/* Main */} -
- -
- - - - 首页 - - - - - MaiBot - - - - - 随便聊聊 - - - {showTerminalTab ? ( - - - 终端 - - + + + + + + 设置 + + + {useRetroChrome ? ( + + + + + + + 启动全部服务 + + + + ) : ( +
+ + + + + + + 启动全部服务 + + + + + + + + + + + + 启动全部服务 + + + startService("maibot")} + > + + 启动 MaiBot + + startService("napcat")} + > + + 启动 {qqBackendName} + + + + +
+ )} + {!useRetroChrome ? ( + + + + + + + 停止全部 + + + ) : null} - - - 插件 - - -
-
@@ -747,6 +1598,10 @@ export function DesktopShell(): React.JSX.Element { onOpenPluginConfig={openPluginConfig} onOpenTab={selectTab} onSnapshot={setSnapshot} + onRestartService={restartService} + onStartService={startService} + onStopService={stopService} + serviceActionBusy={actionBusy} snapshot={snapshot} /> ) : ( @@ -764,12 +1619,24 @@ export function DesktopShell(): React.JSX.Element { value="maibot" className="min-h-0 flex-1 outline-none data-[state=inactive]:hidden" > - + {maibotWebviewReady ? ( + + ) : ( + + )} @@ -792,6 +1662,8 @@ export function DesktopShell(): React.JSX.Element { setRequestedConfigPluginId(null)} + retro={useRetroChrome} requestedConfigPluginId={requestedConfigPluginId} /> + {pluginBuilderMode !== "disabled" ? ( + + + + ) : null} + {snapshot ? ( ) : ( @@ -841,7 +1729,8 @@ export function DesktopShell(): React.JSX.Element { {snapshot ? ( ) : null} - + +
); diff --git a/src/renderer/src/components/app/HomePanel.tsx b/src/renderer/src/components/app/HomePanel.tsx index 0a82aac..a048554 100644 --- a/src/renderer/src/components/app/HomePanel.tsx +++ b/src/renderer/src/components/app/HomePanel.tsx @@ -1,36 +1,44 @@ -import { +import { ArrowRight, + ArrowUp, + ChevronDown, Download, ExternalLink, Loader2, Maximize2, - MessageSquare, PackageCheck, - Puzzle, + Play, Radar, RefreshCw, Server, Settings, Send, - Store, + Sparkles, + Square, Wrench, } from "lucide-react"; import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import emojiDropImage from "../../../../../emoji2.png"; -import maiDropImage from "../../../../../mai.png"; -import mai2DropImage from "../../../../../mai2.png"; +import emojiDropImage from "@/assets/home-drops/emoji2.png"; +import maiDropImage from "@/assets/home-drops/mai.png"; +import mai2DropImage from "@/assets/home-drops/mai2.png"; import maiMascotImage from "@/assets/mai2.png"; import type { DesktopSnapshot, + LauncherUpdateInfo, LocalChatEvent, LocalChatMessageEvent, MaiBotStatisticSummary, + ModuleBranchOption, ModuleSourceConfig, ModuleSourcePreset, + ModuleSourceUpdate, + ModuleTagOption, + ModuleUpdateTarget, QqBackend, ServiceDescriptor, + ServiceId, ServiceStatus, } from "@shared/contracts"; import { Badge } from "@/components/ui/badge"; @@ -44,17 +52,23 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { localChatErrorMessage } from "@/lib/local-chat-error"; +import { useAppearance } from "@/lib/use-appearance"; import { cn } from "@/lib/utils"; import { WebviewPanel } from "./WebviewPanel"; import { QuickActionsPanel } from "./QuickActionsPanel"; +import { MarkdownRenderer } from "./MarkdownRenderer"; -type MaiBotUpdateChannel = "stable" | "test" | "legacy"; +type MaiBotUpdateChannel = "stable" | "test" | "other"; type DashboardUpdateChannel = "stable" | "test"; type CompactChatState = "idle" | "connecting" | "connected" | "error"; const LOCAL_CHAT_USER_NAME_STORAGE_KEY = "maibot.localChat.userName"; const QQ_WEBUI_PORT_STORAGE_PREFIX = "maibot.qqWebuiPort"; const ADAPTER_CONFIG_PROMPTED_STORAGE_PREFIX = "maibot.adapterConfigPrompted"; +const MAIBOT_OFFICIAL_DOCS_URL = "https://docs.mai-mai.org/"; +const MASCOT_INTRO_TRIGGER_CLICKS = 10; + +let mascotIntroShownThisSession = false; export function adapterPluginIdForBackend(backend: QqBackend): string { return backend === "snowluma" ? "maibot-team.snowluma-adapter" : "maibot-team.napcat-adapter"; @@ -118,22 +132,78 @@ const statusText: Record = { error: "异常", }; -const statusVariant: Record["variant"]> = { - stopped: "outline", - starting: "warning", - running: "success", - stopping: "warning", - error: "danger", +const statusColor: Record = { + stopped: "var(--retro-ink, var(--muted-foreground))", + starting: "var(--warning)", + running: "var(--retro-rust, var(--success))", + stopping: "var(--warning)", + error: "var(--destructive)", }; function valueOrFallback(value: string | undefined): string { return value && value.trim().length > 0 ? value : "未读取"; } +function versionAsTag(version: string | undefined): string | undefined { + const trimmed = version?.trim(); + if (!trimmed) { + return undefined; + } + return /^v/iu.test(trimmed) ? trimmed : `v${trimmed}`; +} + +function formatFileSize(bytes: number | undefined): string | undefined { + if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) { + return undefined; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +function ServiceStatusText({ + status, + className, +}: { + status: ServiceStatus; + className?: string; +}): React.JSX.Element { + return ( + + + {statusText[status]} + + ); +} + function formatStatNumber(value: number | undefined): string | undefined { return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString("zh-CN") : undefined; } +function parseVersionParts(version: string | undefined): number[] { + const normalized = version?.trim().replace(/^v/iu, "").split(/[+-]/u, 1)[0] ?? ""; + return normalized + .split(/[._-]/u) + .map((part) => Number(part.match(/^\d+/u)?.[0] ?? 0)); +} + +function compareVersionText(left: string | undefined, right: string | undefined): number { + const leftParts = parseVersionParts(left); + const rightParts = parseVersionParts(right); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index++) { + const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (diff !== 0) { + return diff; + } + } + return (left ?? "").localeCompare(right ?? "", "en-US", { numeric: true, sensitivity: "base" }); +} + function messageFromError(error: unknown): string { return localChatErrorMessage(error); } @@ -159,15 +229,23 @@ function DetailRow({ label, value, className, + retro = false, }: { label: string; value: string | undefined; className?: string; + retro?: boolean; }): React.JSX.Element { return (
{label} - + {valueOrFallback(value)}
@@ -178,15 +256,17 @@ function ChoiceSwitch({ value, options, onChange, + retro = false, }: { value: T; options: Array<{ value: T; label: string; version: string | undefined }>; onChange: (value: T) => void; + retro?: boolean; }): React.JSX.Element { return (
@@ -196,8 +276,9 @@ function ChoiceSwitch({ return (
-
+
{visibleMessages.length > 0 ? ( visibleMessages.map((message) => (

@@ -357,7 +434,7 @@ function LocalChatQuickCard({ )) ) : (

- {error ?? "这里会显示最近几句简单文字。"} + {error ?? "暂无本地聊天消息"}
)}
@@ -375,7 +452,7 @@ function LocalChatQuickCard({ value={draft} /> + + +
+ ); +} + function ServiceSummary({ icon, service, + serviceControls, webuiAction, adapterAction, + retro, }: { icon: React.ReactNode; service: ServiceDescriptor | undefined; + serviceControls?: { + busy: boolean; + onStart: (id: ServiceId) => void; + onStop: (id: ServiceId) => void; + onRestart: (id: ServiceId) => void; + }; webuiAction?: { + title: string; label: string; port: string; portValid: boolean; @@ -405,60 +551,75 @@ function ServiceSummary({ }; adapterAction?: { title: string; - description: string; + description?: string; label: string; onClick: () => void; }; + retro: boolean; }): React.JSX.Element { return ( -
+
- - {icon} - + {icon ? ( + + {icon} + + ) : null}
-

{service?.name ?? "未知服务"}

+

{service?.name ?? "未知服务"}

- {service ? ( - - {statusText[service.status]} - - ) : null} +
+ {service && serviceControls ? ( + + ) : null} + {service ? : null} +
{(webuiAction || adapterAction) ? ( -
+
{webuiAction ? ( -
- - +
+

{webuiAction.title}

+
+ + +
) : null} {adapterAction ? ( -
+
-

{adapterAction.title}

-

- {adapterAction.description} -

+

{adapterAction.title}

+ {adapterAction.description ? ( +

+ {adapterAction.description} +

+ ) : null}
+
+ + ); +} + function MaiBotOverviewCard({ service, localVersion, @@ -515,8 +744,7 @@ function MaiBotOverviewCard({ latestPrerelease, updateBusy, onUpdate, - onOpenPluginStore, - onOpenPluginManager, + retro, }: { service: ServiceDescriptor | undefined; localVersion: string | undefined; @@ -524,113 +752,67 @@ function MaiBotOverviewCard({ latestPrerelease: string | undefined; updateBusy?: boolean; onUpdate: () => void; - onOpenPluginStore: () => void; - onOpenPluginManager: () => void; + retro: boolean; }): React.JSX.Element { - const [activeTab, setActiveTab] = useState<"version" | "plugins">("version"); + const hasNewVersion = + compareVersionText(latestStable, localVersion) > 0 || + compareVersionText(latestPrerelease, localVersion) > 0; return ( -
+
-
- - - +
+ {!retro ? ( + + + + ) : null}
-

{service?.name ?? "MaiBot Core"}

+

+ {service?.name ?? "MaiBot Core"} +

- {service ? ( - - {statusText[service.status]} - - ) : null} + {service ? : null}
-
-
-
- {([ - { value: "version", label: "版本" }, - { value: "plugins", label: "插件" }, - ] as const).map((tab) => ( - - ))} -
-
- - {activeTab === "version" ? ( -
-
-

MaiBot 本地版本

-

- {valueOrFallback(localVersion)} -

-
-
-
- 最新正式版 - - {valueOrFallback(latestStable)} - -
-
- 最新测试版 - - {valueOrFallback(latestPrerelease)} - -
- -
-
- ) : ( -
-
- - - - - 插件 - - 安装或管理 MaiBot Core 插件。 - - -
- - - - -
+
+
+

{retro ? "MAIBOT 版本" : "MaiBot 版本"}

+

+ {valueOrFallback(localVersion)} +

+
+
+ +
); @@ -638,17 +820,14 @@ function MaiBotOverviewCard({ function HomeStatsPanel({ snapshot, - services, onOpenQuickActions, + retro, }: { snapshot: DesktopSnapshot; - services: ServiceDescriptor[]; onOpenQuickActions: () => void; + retro: boolean; }): React.JSX.Element { const [maibotStats, setMaibotStats] = useState(null); - const runningCount = services.filter((service) => service.status === "running").length; - const readyCount = services.filter((service) => service.health === "ready").length; - const qqBackend = snapshot.initState.qqBackend === "snowluma" ? "SnowLuma" : "NapCat"; const topChats = maibotStats?.chatStats.slice(0, 2) ?? []; useEffect(() => { @@ -679,69 +858,90 @@ function HomeStatsPanel({ }, [snapshot.paths.maibotRoot]); return ( -
-
+
+
+
+ + Live2D 形象 +
+ +
+
+
+ + 该功能暂时未开发,默认不会启用。 +
+ +

+ 本地模型会导入到启动器 Live2D 资源库,网络地址和自建展示页仍可直接使用。 +

+ {live2dLibraryRoot ? ( + + {live2dLibraryRoot} + + ) : null} +
+ + + + + + +
+
+
+ + + + + + + ); +} + +function PluginBuilderProjectBar({ + autoSaveStatus, + builderLibrary, + canExport, + canGenerate, + canRedo, + canUndo, + hasGeneratedPlugin, + isFilePreviewOpen, + isStartingOpenCode, + issues, + libraryBusy, + openCodePath, + pluginId, + saving, + saveButtonText, + selectedBuilderPlugin, + selectedBuilderPluginId, + onCreateNew, + onDelete, + onExport, + onGenerate, + onImport, + onOpenDocs, + onOpenLastDirectory, + onStartOpenCode, + onRedo, + onSave, + onSelectBuilderPlugin, + onToggleFilePreview, + onUndo, +}: { + autoSaveStatus: BlueprintAutoSaveStatus; + builderLibrary: MaiBotPluginBuilderLibraryListResult | null; + canExport: boolean; + canGenerate: boolean; + canRedo: boolean; + canUndo: boolean; + hasGeneratedPlugin: boolean; + isFilePreviewOpen: boolean; + isStartingOpenCode: boolean; + issues: BlueprintIssue[]; + libraryBusy: boolean; + openCodePath?: string; + pluginId: string; + saving: boolean; + saveButtonText: string; + selectedBuilderPlugin: MaiBotPluginBuilderLibraryItem | null; + selectedBuilderPluginId: string; + onCreateNew: () => void; + onDelete: () => void; + onExport: () => void; + onGenerate: () => void; + onImport: () => void; + onOpenDocs: () => void; + onOpenLastDirectory: () => void; + onStartOpenCode?: () => void; + onRedo: () => void; + onSave: () => void; + onSelectBuilderPlugin: (pluginId: string) => void; + onToggleFilePreview: () => void; + onUndo: () => void; +}): React.JSX.Element { + const errorCount = issues.filter((issue) => issue.level === "error").length; + + return ( +
+
+
+
+

插件编写器

+

{pluginId}

+
+
+ +
+ +
+ + + + + + + + + +
+ {autoSaveStatus !== "idle" ? ( + + {autoSaveStatus === "saving" ? "自动保存中" : autoSaveStatus === "saved" ? "已自动保存" : "自动保存失败"} + + ) : null} + 0 ? "danger" : issues.length > 0 ? "secondary" : "success"}> + {issues.length === 0 ? "OK" : `${issues.length} issues`} + + + {onStartOpenCode ? ( + + ) : null} + {hasGeneratedPlugin ? ( + + ) : null} + + +
+
+
+ ); +} + +function BuilderProjectSelect({ + builderLibrary, + disabled, + onChange, + selectedPlugin, + value, +}: { + builderLibrary: MaiBotPluginBuilderLibraryListResult | null; + disabled: boolean; + onChange: (pluginId: string) => void; + selectedPlugin: MaiBotPluginBuilderLibraryItem | null; + value: string; +}): React.JSX.Element { + const [open, setOpen] = useState(false); + const [menuStyle, setMenuStyle] = useState({}); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const plugins = builderLibrary?.plugins ?? []; + const selectedLabel = selectedPlugin?.name || selectedPlugin?.pluginId || "未保存的新蓝图"; + const selectedMeta = selectedPlugin?.pluginId ?? "保存后会加入本地蓝图库"; + + const updateMenuPosition = useCallback(() => { + const rect = triggerRef.current?.getBoundingClientRect(); + if (!rect) { + return; + } + setMenuStyle({ + left: rect.left, + top: rect.bottom + 6, + width: rect.width, + }); + }, []); + + useLayoutEffect(() => { + if (!open) { + return undefined; + } + updateMenuPosition(); + window.addEventListener("resize", updateMenuPosition); + window.addEventListener("scroll", updateMenuPosition, true); + return () => { + window.removeEventListener("resize", updateMenuPosition); + window.removeEventListener("scroll", updateMenuPosition, true); + }; + }, [open, updateMenuPosition]); + + useEffect(() => { + if (!open) { + return undefined; + } + const handlePointerDown = (event: PointerEvent): void => { + const target = event.target as Node; + if (rootRef.current?.contains(target)) { + return; + } + setOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + setOpen(false); + } + }; + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + const selectProject = useCallback((pluginId: string) => { + setOpen(false); + onChange(pluginId); + }, [onChange]); + + return ( +
+ + + {open ? ( +
+ + + {plugins.length > 0 ? ( +
+ {plugins.map((plugin) => { + const selected = value === plugin.pluginId; + return ( + + ); + })} +
+ ) : ( +
暂无本地蓝图
+ )} +
+ ) : null} + + {selectedMeta} +
+ ); +} + +function explainPreviewFile(blueprint: MaiBotPluginBlueprint, relativePath: string): { title: string; detail: string } { + if (relativePath === "_manifest.json") { + return { + title: "Manifest:插件身份信息", + detail: `声明插件 ID、名称、版本、作者和能力。当前插件 ID 是 ${blueprint.manifest.pluginId || "未填写"}。`, + }; + } + if (relativePath === "config.toml") { + return { + title: "config.toml:给用户修改的设置", + detail: blueprint.configFields.length > 0 + ? `这里会生成 ${blueprint.configFields.length} 个配置项,读取配置积木会从这里取值。` + : "当前还没有配置项,选中 Config 节点后可以在左侧属性面板添加。", + }; + } + if (relativePath === "plugin.py") { + const components = blueprint.components.map((component) => + `${componentKindLabel(component.kind)} ${component.name}: ${(component.flowNodes ?? []).map((node) => flowNodeLabel(node.kind)).join(" -> ") || "默认流程"}` + ); + return { + title: "plugin.py:真正运行的插件代码", + detail: components.length > 0 + ? components.slice(0, 3).join(",") + : "当前还没有入口组件,生成后会只有基础插件结构。", + }; + } + return { + title: "生成文件", + detail: "这是编写器根据蓝图自动生成的文件内容。", + }; +} + +function buildPreviewSteps(blueprint: MaiBotPluginBlueprint, relativePath: string): string[] { + if (relativePath === "_manifest.json") { + return [ + `插件 ID:${blueprint.manifest.pluginId || "未填写"}`, + `声明能力:${blueprint.manifest.capabilities.length > 0 ? blueprint.manifest.capabilities.join("、") : "暂无"}`, + `入口组件:${blueprint.components.length} 个`, + ]; + } + if (relativePath === "config.toml") { + if (blueprint.configFields.length === 0) { + return ["还没有配置项,选中 Config 节点后可以在属性面板添加。"]; + } + return blueprint.configFields.slice(0, 6).map((field) => `${field.label || field.name || field.id} = ${String(field.defaultValue ?? "")}`); + } + if (relativePath === "plugin.py") { + const lines = blueprint.components.flatMap((component) => { + const flow = component.flowNodes ?? []; + if (flow.length === 0) { + return [`${component.name} 还没有积木流程`]; + } + return [`${component.name}:${flow.slice(0, 5).map((node) => flowNodeLabel(node.kind)).join(" -> ")}`]; + }); + return lines.length > 0 ? lines.slice(0, 6) : ["还没有可生成的入口组件。"]; + } + return ["选择文件后,这里会解释它在插件中的作用。"]; +} + +function ManifestEditor({ + blueprint, + errors, + onManifestChange, + onPluginIdChange, +}: { + blueprint: MaiBotPluginBlueprint; + errors: string[]; + onManifestChange: (patch: Partial) => void; + onPluginIdChange: (value: string) => void; +}): React.JSX.Element { + const updateCapability = (index: number, value: string): void => { + onManifestChange({ + capabilities: blueprint.manifest.capabilities.map((capability, itemIndex) => + itemIndex === index ? value : capability, + ), + }); + }; + const addCapability = (capability: string): void => { + if (!capability || blueprint.manifest.capabilities.includes(capability)) { + return; + } + onManifestChange({ capabilities: [...blueprint.manifest.capabilities, capability] }); + }; + const removeCapability = (index: number): void => { + onManifestChange({ + capabilities: blueprint.manifest.capabilities.filter((_, itemIndex) => itemIndex !== index), + }); + }; + const manifest = blueprint.manifest; + const folderName = manifest.folderName ?? defaultMaiBotPluginFolderName(manifest.pluginId); + const invalidVersions = { + plugin: !isValidMaiBotPluginVersion(manifest.version), + minHost: !isValidMaiBotPluginVersion(manifest.minHostVersion), + maxHost: !isValidMaiBotPluginVersion(manifest.maxHostVersion), + minSdk: !isValidMaiBotPluginVersion(manifest.minSdkVersion), + maxSdk: !isValidMaiBotPluginVersion(manifest.maxSdkVersion), + }; + + return ( +
+ + onPluginIdChange(event.target.value)} value={blueprint.manifest.pluginId} /> + + + + +
+ + onManifestChange({ name: event.target.value })} value={blueprint.manifest.name} /> + + + onManifestChange({ version: event.target.value })} + placeholder="1.0.0" + value={blueprint.manifest.version} + /> + +
+ +