From 90df159d5e24bb1a318903c8ae1b69c755652bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Tue, 14 Jul 2026 23:25:42 -0700 Subject: [PATCH] chore(release): prepare v0.18.6 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 8 +- CHANGELOG.md | 113 +-- Dockerfile | 4 +- README.md | 4 +- docker-compose.yml | 2 +- docs/docker-deploy.md | 10 +- docs/linux-deploy.md | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/dev-api.js | 797 ++++++++++++++---- scripts/media-background-queue.js | 54 ++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/config.rs | 884 +++++++++++++++----- src-tauri/src/commands/hermes.rs | 353 ++++++-- src-tauri/src/commands/media.rs | 567 +++++++++---- src-tauri/src/commands/model_channels.rs | 214 ++++- src-tauri/src/commands/service.rs | 9 +- src-tauri/tauri.conf.json | 2 +- src/lib/model-channels.js | 108 ++- src/lib/model-presets.js | 33 +- src/lib/tauri-api.js | 18 +- src/locales/modules/media.js | 9 + src/pages/chat.js | 25 +- src/pages/media.js | 104 ++- src/pages/model-channels.js | 27 +- src/pages/models.js | 46 +- tests/hermes-model-sync-runtime.test.js | 32 + tests/hermes-setup-safety.test.js | 60 +- tests/hermes-stable-release.test.js | 87 ++ tests/media-background-queue.test.js | 71 ++ tests/model-channels-runtime.test.js | 295 +++++-- tests/model-channels.test.js | 95 ++- tests/model-presets.test.js | 37 +- tests/openclaw-2026-7-1-compat.test.js | 37 + tests/openclaw-config-runtime.test.js | 81 ++ tests/openclaw-install-safety.test.js | 31 +- tests/release-readiness-regressions.test.js | 27 +- 39 files changed, 3349 insertions(+), 909 deletions(-) create mode 100644 scripts/media-background-queue.js create mode 100644 tests/hermes-model-sync-runtime.test.js create mode 100644 tests/hermes-stable-release.test.js create mode 100644 tests/media-background-queue.test.js create mode 100644 tests/openclaw-config-runtime.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 511dfc7..2ffeff4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: - name: 安装 Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.0 + node-version: 22.22.3 cache: npm # 安装前端依赖 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 205d4e2..f87d1e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,7 @@ jobs: - name: 安装 Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.0 + node-version: 22.22.3 cache: npm - name: 安装前端依赖 @@ -155,7 +155,7 @@ jobs: - name: 安装 Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.0 + node-version: 22.22.3 cache: npm - name: 安装前端依赖 @@ -260,7 +260,7 @@ jobs: - name: 安装 Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.0 + node-version: 22.22.3 cache: npm - name: 安装前端依赖 @@ -435,7 +435,7 @@ jobs: echo "3. 如未安装 OpenClaw,按提示一键安装即可" echo "4. 安装完成后自动跳转仪表盘,开始使用" echo "" - echo '> **系统要求**:ClawPanel Web 后端需要 Node.js 18+;运行 OpenClaw Gateway 会按当前 OpenClaw 的 engines.node 自动检测,建议 Node.js 22.19.0+。' + echo '> **系统要求**:ClawPanel Web 后端需要 Node.js 18+;运行 OpenClaw Gateway 会按当前 OpenClaw 的 engines.node 自动检测,OpenClaw 2026.7.1 要求 Node.js 22.22.3+(22.x)。' echo "" echo "---" echo "" diff --git a/CHANGELOG.md b/CHANGELOG.md index ca25061..30151b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,97 +7,68 @@ ## [未发布 (Unreleased)] -### 改进 (Improvements) - -- **OpenClaw 7.1 安装链路加固** — npm 安装会在停止 Gateway 和清理旧文件前按目标版本校验 Node.js;无系统 Node.js 时仍可选择自带运行时的 standalone 安装 -- **standalone 升级原子切换** — 下载归档必须通过 SHA-256 和目标 CLI 版本校验,再通过同盘 staging/backup 切换;激活失败自动恢复原安装 -- **standalone 双源容灾** — 固定稳定版从 GitHub 下载时不再依赖 CDN `latest.json`,CDN 清单故障不会阻断 GitHub fallback -- **Web 自定义域名兼容** — 自动配对会把浏览器当前 Origin 增量写入 Gateway 白名单,支持 HTTPS 域名和自定义端口部署 - -- **Hermes 安装过程实时可见** — 安装/升级输出改为逐行实时显示(原先要等安装进程结束才出现日志),长时间安装不再只有转圈 -- **Hermes 安装镜像一键配置** — 安装步骤新增「网络与镜像」设置:PyPI 镜像(清华 / 阿里云 / 自定义)与 Git 镜像前缀可直接在向导内选择并保存,国内网络安装成功率显著提升 -- **安装失败提示可操作** — 依赖下载失败(PyPI 超时 / SSL / 代理问题)会给出明确的镜像与代理建议,不再只显示一句"安装失败" -- **环境检测不再一闪而过** — 检测到 Python 版本、Git 缺失等提示时停留在检测页等待确认,不再 0.8 秒后自动跳走;一切正常时仍自动进入下一步 -- **安装向导断点续行** — 中途关闭面板后重新进入,会回到上次进行到的步骤(以实际检测结果为准),不必从头再走 -- **配置步骤错误内联展示** — API Key / 配置保存错误改为页面内醒目提示(原为一闪而过的 toast),并提示可用「获取模型列表」验证 Key - -- **模型配置入口统一** — OpenClaw 模型配置页、Hermes 安装向导配置步、晴辰助手 API 设置均新增「前往模型渠道」引导入口,模型接入配置收口到模型渠道页统一维护;渠道卡片新增「绑定状态」标题,与 OpenClaw / Hermes / 助手的同步绑定关系一目了然 -- **同步契约按内核源码逐条核对** — 渠道同步写入的字段与两个内核的实际解析逻辑对齐:OpenClaw 模型条目恒写含 id/name 的完整对象(内核 strict 校验);Hermes 的默认模型写纯模型 ID(内核不解析 provider 前缀)、OpenAI 兼容渠道回退到 custom provider、Gemini 渠道正确接入(内核经 OpenAI 兼容端点);渠道编辑器新增「可同步到」实时提示,选择 API 类型即知道哪些内核适用 - -### 修复 (Fixes) - -- **模型渠道 API 类型下拉显示异常** — 修复选项显示为 [object Object] 的问题,改为可读的类型名称 -- **未安装时不再反复跳回检测页** — 安装/升级失败事件不再把用户从诊断等页面强行拉回初始设置页;窗口重新聚焦触发的环境重检改为后台静默更新,不再清空已显示的检测结果 -- **默认密码锁定页直接提示** — 访问密码为出厂值 123456 时(含 U 盘便携包预置配置),桌面端锁定页与 Web 端一致地明文预填默认密码并引导首次登录后修改,不再无提示挡住用户 +## [0.18.6] - 2026-07-14 ### 新功能 (Features) -- **便携模式双向迁移** — 便携模式下面板设置新增「迁移回本机」:把 U 盘上的配置、Agent、记忆、模型渠道与媒体数据迁回本机默认位置;本机已有数据先整体改名备份(.backup-时间戳)再替换,避免新旧数据混合,引擎与运行时按常规方式在本机安装 -- **统一模型渠道** — 新增「模型渠道」页面(通用分组):一处维护模型接入配置(服务商预设 / 接口地址 / API Key / 模型列表),一键同步到 OpenClaw、Hermes 与晴辰助手,不再需要在三处重复配置 - - 同步为显式推送并逐项确认:写入 OpenClaw 只更新对应 provider 且保留未知字段(自动备份);写入 Hermes 走环境变量与配置命令(自动备份);同步助手为一次性拷贝 - - 支持从 OpenClaw 现有模型配置一键导入渠道、从服务商在线拉取模型列表、渠道修改后的「有未同步变更」提醒 - - 渠道文件存放在 OpenClaw 数据目录下(便携迁移自动带走);密钥读取只返回掩码,写入支持保留旧 Key - - Hermes 同步覆盖 OpenAI / Anthropic / Gemini 兼容接口(API Key 型);OAuth / 云 SDK 型服务商仍走 Hermes 安装向导 - -## [0.18.6 候选] - 尚未发布 - -### 新功能 (Features) - -- **创作中心** — 新增面板级图片生成与视频生成页面,可在 ClawPanel 内直接对接云端生成服务,功能独立于 OpenClaw 主配置 -- **多服务商媒体生成** — 支持火山方舟、OpenAI / GPT 与 NewAPI / OpenAI 兼容网关,支持分别保存 API Key、Base URL、图片模型、视频模型和超时时间 -- **媒体产物本地管理** — 支持自定义产出目录、图片 / 视频产物本地保存、历史产物网格、分页加载、视频按需预览、复制提示词、定位文件和删除任务 -- **U 盘便携模式** — 支持通过 `portable.json` 或 `CLAWPANEL_PORTABLE_ROOT` 启用便携模式,面板配置、OpenClaw 数据、媒体产物可写入便携目录 -- **迁移为便携式** — 面板设置新增迁移入口,可创建便携目录、写入 `portable.json`、复制当前面板配置与 OpenClaw 数据,并清理本机绝对路径 +- **创作中心与后台生成队列** — 新增图片、视频生成和本地产物管理;提交后立即进入持久化后台队列,支持并发控制、队列状态、历史记录、重启恢复和批量连续提交 +- **多服务商媒体生成** — 支持火山方舟、OpenAI / GPT 与 NewAPI / OpenAI 兼容网关,可分别配置 API Key、Base URL、图片模型、视频模型、超时和产出目录 +- **统一模型渠道** — 新增统一渠道页,一处维护服务商、接口、密钥和模型列表,并显式同步到 OpenClaw、Hermes 与晴辰助手;支持从 OpenClaw 导入、在线拉取模型和未同步状态提示 +- **U 盘便携模式双向迁移** — 支持迁移到便携目录及从 U 盘迁回本机;配置、Agent、记忆、模型渠道和媒体数据一并迁移,目标已有数据会先备份再切换 ### 兼容性 (Compatibility) -- **Hermes Agent 稳定版跟进** — 安装与升级默认固定到 Hermes Agent `0.18.0` / `v2026.7.1`,不再追踪上游 `main` 分支,降低用户安装到未验证提交的风险 -- **Hermes 0.18.0 资源兼容** — 保留 Dashboard 兼容兜底,但新版已内置 `dashboard_auth` 与 `web_dist` 时会自动使用上游真实资源 -- **Hermes Provider Registry 对齐** — 新增 Google Vertex AI provider 入口,移除上游已撤下的 `google-gemini-cli` OAuth 入口,并在安装向导展示外部凭据 / SDK 类 provider,避免误引导用户填写不存在的静态 API Key -- **Hermes Provider 补齐** — 跟进上游 `novita` 与 `stepfun` provider,桌面端和 Web/headless 模式的安装向导保持一致 - **OpenClaw 2026.7.1 稳定版适配** — 推荐稳定版切换到官方 `2026.7.1` / 汉化修复版 `2026.7.1-zh.2`;桌面端、Web/headless、Linux 部署与 standalone 安装统一使用同一稳定基线 - **OpenClaw 7.1 Node.js 门槛对齐** — 优先读取安装包 `engines.node`,缺少包元数据时按 `>=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0` 兜底,避免误放行 Node.js 23 或过旧的 24.x +- **OpenClaw 7.1 配置契约对齐** — 旧字符串模型自动迁移为含 `id/name` 的完整对象,旧 Codex Responses API 类型迁移到正式名称;未知 API 类型和模型能力元数据继续保留 +- **Hermes Agent 稳定版跟进** — 安装与升级固定到 Hermes Agent `0.18.2` / `v2026.7.7.2`,同步最新 Provider Registry,并补齐 Gateway 运行所需的 `croniter`、`httpx`、`openai`、`aiohttp`、`websockets` +- **Hermes Dashboard 兼容** — 优先使用上游 `dashboard_auth`,并提供受管 Web dist 作为 token bootstrap 兜底,桌面与 Web/headless 启动行为一致 ### 改进 (Improvements) -- **便携模式安装链路完善** — 便携模式下 standalone OpenClaw 安装 / 升级会写入 `engines/openclaw`,并优先使用 U 盘内置 Node.js 与 CLI -- **避免污染宿主机** — 便携模式下跳过写入用户 PATH 和系统 symlink,standalone 安装失败时不再自动降级到 `npm -g` -- **安装来源识别更完整** — 仪表盘、安装页、设置页和 Gateway 归属提示补充“U 盘便携版”来源,不再显示未知来源 -- **媒体对接引导增强** — 创作中心内置 API Key、模型 ID、模型列表获取、服务商文档和控制台入口提示,密钥读取时只显示掩码 -- **媒体 Web 模式对齐** — Web/headless 后端补齐媒体配置、任务、产物读取、模型列表、图片生成和视频任务接口,便于服务器模式使用 -- **初始设置页首屏提速** — 环境检测改为本地检测先渲染、版本信息(含网络查询)异步补充,首次进入复用启动缓存,不再被 npm registry 查询拖慢首屏 +- **模型同步改为可验证事务** — OpenClaw 只提交目标 provider 的最小补丁并回读核对;Hermes 在备份、落盘和回读成功后才标记同步完成;助手同步同样执行本地回读确认 +- **模型密钥与能力字段无损往返** — 结构化 `SecretRef`、环境变量引用、上下文长度、输入能力和未知字段不会在编辑、导入或同步时被字符串化或丢失 +- **OpenClaw 安装升级原子化** — npm 安装会先校验目标 Node.js 要求;standalone 归档通过 SHA-256、版本和运行依赖校验后再 staging/backup 切换,失败自动恢复旧安装 +- **OpenClaw standalone 双源容灾** — 固定版本从 GitHub 下载时不再依赖 CDN `latest.json`;便携模式优先使用 U 盘内置 Node.js 与 CLI,并避免污染宿主机 PATH +- **Hermes 安装体验完善** — 安装与升级日志实时输出,支持 PyPI / Git 镜像、断点续行和可操作错误提示;环境异常时停留等待用户处理,不再自动跳页 +- **Hermes 安装运行时加固** — 桌面端受管 `uv` 更新到 `0.11.28`,拒绝早于 `0.11.24` 的缓存冲突版本;wheel 元数据或分发缓存异常时自动使用隔离缓存重试,Web/headless 同步采用相同恢复策略 +- **Web 自定义域名兼容** — 自动配对会把浏览器当前 Origin 增量写入 Gateway 白名单,支持 HTTPS 域名和自定义端口部署 +- **初始设置页首屏提速** — 本地检测先渲染、网络版本信息异步补充,并复用启动缓存,避免 npm registry 查询阻塞首屏 +- **发布流水线加固** — CI 在三平台运行锁定依赖的 Node / Rust 测试、格式、编译和 lint;tag 发布前校验版本、tag 与 HEAD 一致性,并在全平台产物成功后统一发布 ### 安全 (Security) -- **媒体 API Key 防泄漏** — 下载生成产物时仅对与服务商 Base URL 同主机的地址携带 API Key,服务商响应中混入的第三方地址不再收到密钥 -- **助手付费生成必须确认** — 助手的媒体生成工具改为默认关闭、需在设置中显式开启;图片/视频等会产生云端费用的生成在任何助手模式下都会弹出确认,不再受"无限制模式"影响 +- **OpenClaw 配置安全写入** — 配置修改使用备份、同目录临时文件、fsync、原子替换和回读校验;失败保留最后有效备份,避免模型配置保存导致主配置损坏 +- **外部密钥引用保护** — 未改动的环境变量和结构化 SecretRef 不再阻断无关保存,也不会被当作明文写回;`file` / `exec` 引用不会被模型测试误当 API Key +- **媒体 API Key 防泄漏** — 下载产物时仅向与服务商 Base URL 同源的地址携带 Authorization,跨源重定向自动剥离密钥,并限制响应体大小与读取超时 +- **助手付费生成必须确认** — 媒体生成工具默认关闭且需显式启用;图片、视频等付费请求在任何助手模式下都必须二次确认 +- **便携迁移路径保护** — 拒绝父目录穿越、源目标重叠和目录链接绕过,失败迁移不会留下半成品 manifest 或覆盖现有目标 ### 修复 (Fixes) -- **移动端语言菜单点穿** — 修复语言下拉关闭后隐藏的 `#lang-options` 仍占位并拦截点击的问题,关闭态不再接收 pointer events -- **移动端语言切换体验** — 选择语言后会关闭语言菜单并自动收起侧边栏,同时同步 `html lang`,避免误以为点到了下方导航 -- **默认密码提示条位置** — 默认密码提示条会避开桌面侧栏宽度,并在移动端为内容区预留顶部空间,减少遮挡 -- **standalone 解压嵌套目录** — standalone 安装包解压后若存在嵌套 `openclaw/` 目录,会提升到目标目录并覆盖旧文件,避免继续使用旧 CLI -- **便携迁移不再丢失创作中心数据** — 媒体配置与产物历史统一存放在 OpenClaw 数据目录下,迁移到 U 盘后 API Key 和历史记录随迁移直接生效 -- **便携迁移失败反馈** — 迁移出错时结果面板会显示错误原因,不再一直停留在"正在迁移"状态;Web 多实例模式下便携状态查询固定走本地,不再被转发到远程实例 -- **视频任务不再因瞬时错误失联** — 轮询遇到限流(429)或服务端瞬时错误(5xx)时保留任务状态可继续轮询;失败但已有服务商任务 ID 的任务提供重新轮询入口;创建任务时即使无法识别返回的任务 ID 也会保留任务记录与原始响应,便于找回已计费任务 -- **创作中心删除按钮在 macOS 桌面端失效** — 删除确认改用面板统一弹窗(原生 confirm 在部分桌面 WebView 中不可用),并补充不可恢复提示 -- **媒体历史文件不再无限膨胀** — 任务记录持久化前会裁剪图片 base64 等超大原始响应,媒体记录文件和历史列表传输量不再随生成数量失控增长 -- **大文件预览保护** — 预览前先检查文件大小(上限 64MB),超大视频不再整读进内存导致卡顿,超限时引导使用"打开文件夹"本地查看;预览缓存增加数量上限 -- **自建网关支持 http://** — "检查配置"和"获取模型列表"不再强制要求 https,内网/自建 NewAPI 网关配置可通过校验,与实际生成请求行为一致 -- **桌面端与 Web 端视频状态解析对齐** — 桌面端解析服务商响应时跳过空字符串字段并递归嵌套结构,与 Web 端行为一致,避免同一响应在两端结果不同 -- **便携模式 Hermes 警告可读** — 补充 `hermes-cli-missing` 警告的多语言文案,不再显示原始标识符 +- **OpenClaw 7.1 初次安装缺少运行依赖** — standalone 激活前校验 CLI 及核心运行依赖,失败不会替换现有安装;npm 与 standalone 路径统一按目标版本检查环境 +- **OpenClaw 7.1 Gateway 重载不稳定** — 配置写入交给内核 watcher 热重载,不再探测不存在的面板端口;Windows 启动终端会等待受管 Gateway 进程退出,关闭窗口即可停止服务 +- **实时聊天长时间卡住** — 等待首个响应期间也可中止本次运行,输入状态变化会重新同步发送按钮;连接恢复后不会继续保持错误的禁用状态 +- **模型配置破坏未知字段** — 保存和删除 provider 改为最小补丁及显式墓碑,保留其它 provider、认证资料和上游新增字段;旧模型结构会在写入前安全迁移 +- **模型渠道下拉显示 `[object Object]`** — API 类型选项改为可读名称,未知类型在编辑时仍保留原值供用户确认 +- **Hermes 安装 wheel 元数据无效** — 修复旧 `uv` 缓存冲突导致 `annotated-types` 报 `Metadata field Name not found`;自动升级受管运行时并在缓存异常时透明重试 +- **创作中心提交后按钮一直禁用** — 后台刷新后重新定位当前表单按钮,生成完成或失败都会正确解锁;图片和视频提交不再阻塞页面等待云端任务结束 +- **视频任务瞬时错误失联** — 429 / 5xx 时保留任务并继续轮询;已有服务商任务 ID 的失败任务可恢复,无法识别任务 ID 时也保留原始响应供排查 +- **媒体历史膨胀与大文件卡顿** — 持久化前裁剪大体积原始响应,预览限制 64MB 并限制缓存数量;跨端视频状态解析保持一致 +- **便携迁移数据和状态异常** — 媒体配置与历史跟随 OpenClaw 数据迁移,失败会显示原因并保持原目标可恢复;Web 多实例查询固定在本地执行 +- **未安装时反复跳回检测页** — 安装失败不再强制重置向导;窗口聚焦只在后台更新检测结果,API Key / 配置错误改为页面内持续展示 +- **默认密码无提示挡住登录** — 使用出厂密码 `123456` 时,桌面与 Web 锁定页均直接提示并预填,登录后引导立即修改 +- **移动端语言菜单点穿** — 关闭状态不再拦截点击,切换语言后同步收起菜单和侧栏并更新 `html lang` ### 测试与验证 (Testing) -- 已通过 `npm run build` -- 已通过 `node -e "import('./scripts/dev-api.js').then(()=>console.log('dev-api import ok'))"` -- 已通过 `node --test tests/`(全部通过) -- 已通过 `cd src-tauri && cargo fmt --check` -- 已通过 `cd src-tauri && cargo check` -- 已通过 `cd src-tauri && cargo test --lib`(309 passed) -- 已通过 `cd src-tauri && cargo clippy --all-targets -- -D warnings` -- 已通过移动端 418×551 视口检查:语言菜单关闭态不再命中隐藏选项,语言切换后侧栏自动收起 +- Node.js `22.22.3` 下 `node --test tests/*.test.js`:503 passed +- Node.js `22.22.3` 下 `npm run build`:通过 +- `cargo fmt --all -- --check`:通过 +- `cargo check --locked`:通过 +- `cargo clippy --locked --all-targets -- -D warnings`:通过 +- `cargo test --locked`:348 passed +- Hermes Windows 隔离安装 smoke:使用 `uv 0.11.14 --no-cache` 完整安装 72 个依赖,`hermes version` 返回 `v0.18.2 (2026.7.7.2)` ## [0.18.5] - 2026-06-15 diff --git a/Dockerfile b/Dockerfile index 06ee37f..6693b18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ # ----------------------------------------------------------------------------- # 阶段 1: 构建阶段 (builder) # ----------------------------------------------------------------------------- -FROM node:22.19.0-alpine AS builder +FROM node:22.22.3-alpine AS builder # 安装构建依赖 RUN apk add --no-cache \ @@ -41,7 +41,7 @@ RUN npm ci --prefer-offline --registry https://registry.npmmirror.com && \ # ----------------------------------------------------------------------------- # 阶段 2: 生产阶段 (production) # ----------------------------------------------------------------------------- -FROM node:22.19.0-alpine AS production +FROM node:22.22.3-alpine AS production # 安装运行时依赖 RUN apk add --no-cache \ diff --git a/README.md b/README.md index 9ac0e57..a14bf0f 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/script ```bash docker run -d --name clawpanel --restart unless-stopped \ -p 1420:1420 -v clawpanel-data:/root/.openclaw \ - node:22.19.0-slim \ + node:22.22.3-slim \ sh -c "apt-get update && apt-get install -y git && \ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \ git clone https://github.com/qingchencloud/clawpanel.git /app && \ @@ -290,7 +290,7 @@ docker rm clawpanel # 重新启动新容器 docker run -d --name clawpanel --restart unless-stopped \ -p 1420:1420 -v clawpanel-data:/root/.openclaw \ - node:22.19.0-slim \ + node:22.22.3-slim \ sh -c "apt-get update && apt-get install -y git && \ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \ git clone https://github.com/qingchencloud/clawpanel.git /app && \ diff --git a/docker-compose.yml b/docker-compose.yml index b0c0e34..92fc040 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -125,7 +125,7 @@ services: # OpenClaw Gateway 服务(可选,取消注释以启用) # --------------------------------------------------------------------------- # gateway: - # image: node:22.19.0-alpine + # image: node:22.22.3-alpine # container_name: openclaw-gateway # hostname: openclaw-gateway # network_mode: host diff --git a/docs/docker-deploy.md b/docs/docker-deploy.md index 0830e6a..8f3c761 100644 --- a/docs/docker-deploy.md +++ b/docs/docker-deploy.md @@ -48,7 +48,7 @@ docker run -d \ --restart unless-stopped \ -p 1420:1420 \ -v clawpanel-data:/root/.openclaw \ - node:22.19.0-slim \ + node:22.22.3-slim \ sh -c "\ apt-get update && apt-get install -y git && \ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \ @@ -86,7 +86,7 @@ services: - NODE_ENV=production gateway: - image: node:22.19.0-slim + image: node:22.22.3-slim container_name: openclaw-gateway restart: unless-stopped ports: @@ -110,7 +110,7 @@ volumes: 同目录下创建 `Dockerfile.clawpanel`: ```dockerfile -FROM node:22.19.0-slim +FROM node:22.22.3-slim RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* @@ -140,7 +140,7 @@ docker compose up -d 如果只需要 ClawPanel Web(Gateway 在宿主机或其他地方运行): ```dockerfile -FROM node:22.19.0-slim +FROM node:22.22.3-slim RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* @@ -336,7 +336,7 @@ docker logs clawpanel 2. **在 Dockerfile 中预装**:构建镜像时就安装好 OpenClaw,避免运行时下载: ```dockerfile - FROM node:22.19.0-slim + FROM node:22.22.3-slim RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* # 预装 OpenClaw CLI(使用国内镜像源加速) RUN npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com diff --git a/docs/linux-deploy.md b/docs/linux-deploy.md index df65a12..a71372f 100644 --- a/docs/linux-deploy.md +++ b/docs/linux-deploy.md @@ -173,7 +173,7 @@ docker run -d \ --restart unless-stopped \ -p 1420:1420 \ -v clawpanel-data:/root/.openclaw \ - node:22.19.0-slim \ + node:22.22.3-slim \ sh -c "apt-get update && apt-get install -y git && \ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \ git clone https://github.com/qingchencloud/clawpanel.git /app && \ diff --git a/package-lock.json b/package-lock.json index 31182e1..4c95d95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawpanel", - "version": "0.18.5", + "version": "0.18.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawpanel", - "version": "0.18.5", + "version": "0.18.6", "license": "AGPL-3.0", "dependencies": { "@tauri-apps/api": "^2.5.0", diff --git a/package.json b/package.json index 492fe99..3b636c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawpanel", - "version": "0.18.5", + "version": "0.18.6", "private": true, "description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用", "type": "module", diff --git a/scripts/dev-api.js b/scripts/dev-api.js index d3dcaf2..f509839 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -15,6 +15,8 @@ import crypto from 'crypto' import { once } from 'events' import * as YAML from 'yaml' import * as skillhubSdk from './lib/skillhub-sdk.js' +import { createBackgroundJobQueue } from './media-background-queue.js' +import { normalizeModelApiType } from '../src/lib/model-presets.js' const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000 // --------------------------------------------------------------------------- @@ -22,10 +24,27 @@ const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000 // --------------------------------------------------------------------------- const HERMES_HOME = path.join(homedir(), '.hermes') const HERMES_DEFAULT_PORT = 8642 -const HERMES_STABLE_VERSION = '0.18.0' -const HERMES_STABLE_TAG = 'v2026.7.1' +const HERMES_STABLE_VERSION = '0.18.2' +const HERMES_STABLE_TAG = 'v2026.7.7.2' const HERMES_REPO_URL = 'https://github.com/NousResearch/hermes-agent.git' const HERMES_GIT_URL = `git+${HERMES_REPO_URL}@${HERMES_STABLE_TAG}` +const HERMES_MIN_UV_VERSION = '0.11.24' +const HERMES_RUNTIME_EXTRA_DEPS = ['croniter', 'httpx', 'openai', 'aiohttp', 'websockets'] +const HERMES_DASHBOARD_FALLBACK_INDEX_HTML = ` + + + + Hermes Dashboard (ClawPanel stub) + + + +
+

Hermes Dashboard

+

ClawPanel provides this minimal page so the Dashboard backend can issue a session token.

+
+ + +` let _hermesInstallRunning = false function hermesProvider(id, name, authType, baseUrl, baseUrlEnvVar, apiKeyEnvVars, transport, modelsProbe, models, isAggregator = false, cliAuthHint = '') { @@ -41,6 +60,20 @@ function hermesPackageSpec(extras = []) { : `hermes-agent @ ${HERMES_GIT_URL}` } +function hermesRuntimeExtraArgs() { + return HERMES_RUNTIME_EXTRA_DEPS.flatMap(dep => ['--with', dep]) +} + +export function ensureHermesDashboardFallbackDist(home = hermesHome()) { + const dist = path.join(home, 'clawpanel-dashboard-web-dist') + fs.mkdirSync(path.join(dist, 'assets'), { recursive: true }) + const indexPath = path.join(dist, 'index.html') + if (!fs.existsSync(indexPath)) { + fs.writeFileSync(indexPath, HERMES_DASHBOARD_FALLBACK_INDEX_HTML, 'utf8') + } + return dist +} + const HERMES_PROVIDER_REGISTRY = [ hermesProvider('anthropic', 'Anthropic', 'api_key', 'https://api.anthropic.com', '', ['ANTHROPIC_API_KEY', 'ANTHROPIC_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'], 'anthropic_messages', 'anthropic', ['claude-opus-4-7', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-opus-4-5-20251101', 'claude-sonnet-4-5-20250929', 'claude-opus-4-20250514', 'claude-sonnet-4-20250514', 'claude-haiku-4-5-20251001']), hermesProvider('gemini', 'Google AI Studio', 'api_key', 'https://generativelanguage.googleapis.com/v1beta/openai', 'GEMINI_BASE_URL', ['GOOGLE_API_KEY', 'GEMINI_API_KEY'], 'openai_chat', 'openai', ['gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemma-4-31b-it', 'gemma-4-26b-it']), @@ -281,6 +314,50 @@ export function runHermesInstallCommand(program, args, options = {}) { }) } +function parseUvVersion(versionOutput = '') { + const match = String(versionOutput).match(/\b(\d+)\.(\d+)\.(\d+)\b/) + if (!match) return null + return match.slice(1).map(Number) +} + +export function isHermesUvVersionSupported(versionOutput = '') { + const current = parseUvVersion(versionOutput) + const minimum = parseUvVersion(HERMES_MIN_UV_VERSION) + if (!current || !minimum) return false + for (let index = 0; index < minimum.length; index += 1) { + if (current[index] !== minimum[index]) return current[index] > minimum[index] + } + return true +} + +export function isHermesWheelCacheError(text = '') { + const lower = String(text || '').toLowerCase() + return lower.includes('the wheel is invalid') + || lower.includes('metadata field name not found') + || lower.includes('failed to read from the distribution cache') + || lower.includes('failed to fetch wheel') +} + +function withIsolatedUvCache(args) { + return args.includes('--no-cache') ? args : [...args, '--no-cache'] +} + +export async function runHermesInstallWithCacheRecovery( + runner, + program, + args, + options, + uvVersion, +) { + const isolateInitially = !isHermesUvVersionSupported(uvVersion) + const firstArgs = isolateInitially ? withIsolatedUvCache(args) : args + let result = await runner(program, firstArgs, options) + if (result.status !== 0 && !isolateInitially && isHermesWheelCacheError(result.stderr)) { + result = await runner(program, withIsolatedUvCache(args), options) + } + return result +} + // 判断输出是否命中 「网络无法访问」 类失败,命中返回建议文案。 function diagnoseHermesInstallError(text = '') { const lower = String(text || '').toLowerCase() @@ -1489,7 +1566,39 @@ async function verifyStandaloneArchiveChecksum(downloadUrl, buffer) { if (actual !== expected) throw new Error(`standalone SHA-256 校验失败:expected=${expected}, actual=${actual}`) } -function verifyStandaloneInstall(stagingDir, remoteVersion) { +function standalonePackageDir(stagingDir) { + for (const packageName of [['@qingchencloud', 'openclaw-zh'], ['openclaw']]) { + const packageDir = path.join(stagingDir, 'node_modules', ...packageName) + if (fs.existsSync(path.join(packageDir, 'package.json'))) return packageDir + } + return null +} + +function verifyStandaloneRuntimeDependencies(stagingDir) { + const packageDir = standalonePackageDir(stagingDir) + if (!packageDir) throw new Error('standalone 解压后未找到 OpenClaw 主包') + + let packageJson + try { + packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')) + } catch (error) { + throw new Error(`standalone 主包 package.json 无法读取: ${error.message || error}`) + } + + const dependencyRoots = [ + path.join(packageDir, 'node_modules'), + path.join(stagingDir, 'node_modules'), + ] + const missing = Object.keys(packageJson.dependencies || {}).filter(dependency => { + const parts = dependency.split('/').filter(Boolean) + return !dependencyRoots.some(root => fs.existsSync(path.join(root, ...parts, 'package.json'))) + }) + if (missing.length > 0) { + throw new Error(`standalone 安装校验失败:缺少运行时依赖:${missing.sort().join(', ')}`) + } +} + +export function verifyStandaloneInstall(stagingDir, remoteVersion) { const binFile = isWindows ? 'openclaw.cmd' : 'openclaw' const cliPath = path.join(stagingDir, binFile) if (!fs.existsSync(cliPath)) throw new Error('standalone 解压后未找到 openclaw 可执行文件') @@ -1498,6 +1607,7 @@ function verifyStandaloneInstall(stagingDir, remoteVersion) { if (!versionsMatch(verifiedVersion, remoteVersion)) { throw new Error(`standalone 安装校验失败:目标 CLI 版本为 ${verifiedVersion},清单版本为 ${remoteVersion}`) } + verifyStandaloneRuntimeDependencies(stagingDir) return verifiedVersion } @@ -2391,7 +2501,7 @@ function getUid() { return execSync('id -u').toString().trim() } -function stripUiFields(config) { +export function stripUiFields(config) { if (!config || typeof config !== 'object' || Array.isArray(config)) return config // 清理根层级 ClawPanel 内部字段(version info 等),避免污染 openclaw.json // Issue #89: 这些字段被写入 openclaw.json 后导致 Gateway 无法启动(Unknown config keys) @@ -2418,9 +2528,17 @@ function stripUiFields(config) { const providers = config?.models?.providers if (providers) { for (const p of Object.values(providers)) { + if (!p || typeof p !== 'object' || Array.isArray(p)) continue + if (p.api) p.api = normalizeModelApiType(p.api) if (!Array.isArray(p.models)) continue - for (const m of p.models) { - if (typeof m !== 'object') continue + for (let index = 0; index < p.models.length; index += 1) { + if (typeof p.models[index] === 'string' && p.models[index].trim()) { + const id = p.models[index].trim() + p.models[index] = { id, name: id } + } + const m = p.models[index] + if (!m || typeof m !== 'object' || Array.isArray(m)) continue + if (m.api) m.api = normalizeModelApiType(m.api) delete m.lastTestAt delete m.latency delete m.testStatus @@ -2878,14 +2996,19 @@ function readOpenclawConfigRequired() { return cleanLoadedConfig(config) } -function mergeConfigsPreservingFields(existing, next) { +function mergeConfigsPreservingFields(existing, next, currentPath = '') { if (!existing || typeof existing !== 'object' || Array.isArray(existing)) return next if (!next || typeof next !== 'object' || Array.isArray(next)) return next const merged = { ...existing } for (const [key, value] of Object.entries(next)) { + if (currentPath === 'models.providers' && value === null) { + delete merged[key] + continue + } + const childPath = currentPath ? `${currentPath}.${key}` : key const prev = existing[key] if (prev && typeof prev === 'object' && !Array.isArray(prev) && value && typeof value === 'object' && !Array.isArray(value)) { - merged[key] = mergeConfigsPreservingFields(prev, value) + merged[key] = mergeConfigsPreservingFields(prev, value, childPath) } else { merged[key] = value } @@ -2912,12 +3035,14 @@ function modelEnvValuesForConfig(config) { return values } -function validateModelProviderEnvRefs(config) { +export function validateModelProviderEnvRefs(config, previousConfig = null) { const providers = config?.models?.providers if (!providers || typeof providers !== 'object' || Array.isArray(providers)) return + const previousProviders = previousConfig?.models?.providers const values = modelEnvValuesForConfig(config) for (const [providerName, provider] of Object.entries(providers)) { if (!provider || typeof provider !== 'object') continue + if (provider.apiKey === previousProviders?.[providerName]?.apiKey) continue const envKey = modelApiKeyEnvRef(provider.apiKey) if (!envKey) continue const configured = values[envKey] !== undefined && String(values[envKey]).trim() @@ -2928,11 +3053,61 @@ function validateModelProviderEnvRefs(config) { } } +function validateOpenclawModelCandidate(config) { + if (config.models === undefined) return + if (!config.models || typeof config.models !== 'object' || Array.isArray(config.models)) { + throw new Error('OpenClaw models 必须是对象') + } + const providers = config.models.providers + if (providers === undefined) return + if (!providers || typeof providers !== 'object' || Array.isArray(providers)) { + throw new Error('OpenClaw models.providers 必须是对象') + } + for (const [providerId, provider] of Object.entries(providers)) { + if (!provider || typeof provider !== 'object' || Array.isArray(provider)) { + throw new Error(`模型服务商 ${providerId} 配置必须是对象`) + } + if (provider.api !== undefined && (typeof provider.api !== 'string' || !provider.api.trim())) { + throw new Error(`模型服务商 ${providerId} 的 api 必须是非空字符串`) + } + if (provider.api === 'openai-codex-responses') { + throw new Error(`模型服务商 ${providerId} 使用了已移除的 api;请改用 openai-chatgpt-responses`) + } + if (provider.baseUrl !== undefined && typeof provider.baseUrl !== 'string') { + throw new Error(`模型服务商 ${providerId} 的 baseUrl 必须是字符串`) + } + if (provider.baseUrl && !/^https?:\/\//.test(provider.baseUrl)) { + throw new Error(`模型服务商 ${providerId} 的 baseUrl 必须使用 http 或 https`) + } + if (provider.models === undefined) continue + if (!Array.isArray(provider.models)) throw new Error(`模型服务商 ${providerId} 的 models 必须是数组`) + provider.models.forEach((model, index) => { + if (!model || typeof model !== 'object' || Array.isArray(model)) { + throw new Error(`模型服务商 ${providerId} 的第 ${index + 1} 个模型必须是对象`) + } + for (const key of ['id', 'name']) { + if (typeof model[key] !== 'string' || !model[key].trim()) { + throw new Error(`模型服务商 ${providerId} 的第 ${index + 1} 个模型缺少 ${key}`) + } + } + if (model.api === 'openai-codex-responses') { + throw new Error(`模型服务商 ${providerId} 的模型 ${model.id} 使用了已移除的 api`) + } + for (const key of ['contextWindow', 'contextTokens', 'maxTokens']) { + if (model[key] !== undefined && (!Number.isInteger(model[key]) || model[key] <= 0)) { + throw new Error(`模型服务商 ${providerId} 的模型 ${model.id} 字段 ${key} 必须是正整数`) + } + } + }) + } +} + function writeOpenclawConfigFile(config) { const cleaned = stripUiFields(config) - validateModelProviderEnvRefs(cleaned) - if (fs.existsSync(CONFIG_PATH)) fs.copyFileSync(CONFIG_PATH, CONFIG_PATH + '.bak') - fs.writeFileSync(CONFIG_PATH, JSON.stringify(cleaned, null, 2)) + const previous = fs.existsSync(CONFIG_PATH) ? readJsonFileRelaxed(CONFIG_PATH) : null + validateModelProviderEnvRefs(cleaned, previous) + validateOpenclawModelCandidate(cleaned) + writeJsonAtomic(CONFIG_PATH, cleaned, { backup: true }) } function ensureAgentsList(config) { @@ -8048,6 +8223,11 @@ function triggerGatewayReloadNonBlocking(reason) { let _gwRestartInflight = null let _gwRestartLastFinishedAt = 0 const GW_RESTART_COOLDOWN_MS = 2000 +const OPENCLAW_NATIVE_CONFIG_RELOAD_VERSION_FLOOR = '2026.7.1' + +function supportsNativeConfigReload(version = getLocalOpenclawVersion()) { + return !!version && versionGe(baseVersion(version), OPENCLAW_NATIVE_CONFIG_RELOAD_VERSION_FLOOR) +} async function guardedGatewayRestart(source = 'unknown') { if (process.env.DISABLE_GATEWAY_SPAWN === '1' || process.env.DISABLE_GATEWAY_SPAWN === 'true') { @@ -8923,6 +9103,43 @@ function _normalizeBaseUrl(raw) { return base } +const DIRECT_MODEL_TEST_APIS = new Set([ + 'openai-completions', + 'openai-responses', + 'azure-openai-responses', + 'anthropic-messages', + 'google-generative-ai', + 'ollama', +]) + +function _assertDirectModelTestSupported(apiType) { + if (!DIRECT_MODEL_TEST_APIS.has(apiType)) { + throw new Error(`该 API 类型需要由 OpenClaw 运行时验证: ${apiType}`) + } +} + +function _extractSingleModelReply(data) { + if (typeof data?.output_text === 'string' && data.output_text) return data.output_text + if (Array.isArray(data?.output)) { + const output = data.output + .flatMap(item => Array.isArray(item?.content) ? item.content : []) + .filter(part => ['output_text', 'text'].includes(part?.type)) + .map(part => part?.text) + .filter(Boolean) + .join('') + if (output) return output + } + const anthropic = (data?.content || []).filter(b => b.type === 'text').map(b => b.text).join('') + if (anthropic) return anthropic + const gemini = data?.candidates?.[0]?.content?.parts?.map?.(part => part.text).filter(Boolean).join('') || '' + if (gemini) return gemini + const message = data?.choices?.[0]?.message + if (message?.content) return message.content + if (message?.reasoning_content) return `[reasoning] ${message.reasoning_content}` + if (typeof data?.output?.text === 'string') return data.output.text + return '' +} + function isValidEnvKey(key) { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key || '') } @@ -8980,7 +9197,18 @@ function modelEnvValues() { return values } -function resolveModelApiKey(apiKey) { +export function resolveModelApiKey(apiKey) { + if (apiKey && typeof apiKey === 'object' && !Array.isArray(apiKey)) { + const source = String(apiKey.source || '').trim() + const id = String(apiKey.id || '').trim() + if (source !== 'env' || !isValidEnvKey(id)) { + throw new Error('该 SecretRef 需要由 OpenClaw 运行时解析,请使用 OpenClaw 模型探测') + } + const values = modelEnvValues() + const value = values[id] ?? process.env[id] + if (value !== undefined && String(value).trim()) return String(value) + throw new Error(`API Key 引用了环境变量 ${id},但未在 OpenClaw env、~/.openclaw/.env 或当前进程环境中找到`) + } const key = modelApiKeyEnvRef(apiKey) if (!key) return apiKey || '' const values = modelEnvValues() @@ -9347,12 +9575,69 @@ function readJsonOrDefault(file, fallback) { } } -function writeJsonAtomic(file, value) { +export function writeJsonAtomic(file, value, { backup = false } = {}) { fs.mkdirSync(path.dirname(file), { recursive: true }) - const tmp = `${file}.tmp` - fs.writeFileSync(tmp, JSON.stringify(value, null, 2)) - if (fs.existsSync(file)) fs.rmSync(file, { force: true }) - fs.renameSync(tmp, file) + const content = JSON.stringify(value, null, 2) + const parsed = JSON.parse(content) + if (JSON.stringify(parsed) !== JSON.stringify(value)) throw new Error('候选配置序列化后内容不一致') + const suffix = `${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}` + const tmp = `${file}.${suffix}.tmp` + try { + const fd = fs.openSync(tmp, 'wx', 0o600) + try { + fs.writeFileSync(fd, content) + fs.fsyncSync(fd) + } finally { + fs.closeSync(fd) + } + } catch (error) { + fs.rmSync(tmp, { force: true }) + throw new Error(`写入候选配置失败: ${error.message || error}`) + } + + const hadExisting = fs.existsSync(file) + const rollback = hadExisting ? `${file}.${suffix}.old` : '' + try { + if (backup && hadExisting) { + const stableBackup = `${file}.bak` + fs.copyFileSync(file, stableBackup) + const backupFd = fs.openSync(stableBackup, 'r+') + try { + fs.fsyncSync(backupFd) + } finally { + fs.closeSync(backupFd) + } + if (process.platform !== 'win32') fs.chmodSync(stableBackup, 0o600) + } + if (hadExisting) { + if (process.platform === 'win32') fs.renameSync(file, rollback) + else fs.copyFileSync(file, rollback) + } + fs.renameSync(tmp, file) + } catch (error) { + if (rollback && fs.existsSync(rollback)) { + if (fs.existsSync(file)) fs.copyFileSync(rollback, file) + else fs.renameSync(rollback, file) + } + fs.rmSync(tmp, { force: true }) + if (rollback) fs.rmSync(rollback, { force: true }) + throw new Error(`替换配置失败,原配置已保留: ${error.message || error}`) + } + + let readbackMatches = false + try { + const readback = JSON.parse(fs.readFileSync(file, 'utf8')) + readbackMatches = JSON.stringify(readback) === JSON.stringify(value) + } catch {} + if (!readbackMatches) { + if (rollback && fs.existsSync(rollback)) fs.copyFileSync(rollback, file) + else if (backup && fs.existsSync(`${file}.bak`)) fs.copyFileSync(`${file}.bak`, file) + else fs.rmSync(file, { force: true }) + if (rollback) fs.rmSync(rollback, { force: true }) + throw new Error('配置写入后回读不一致,已恢复原配置') + } + if (rollback) fs.rmSync(rollback, { force: true }) + if (process.platform !== 'win32') fs.chmodSync(file, 0o600) } function mediaApiKeyMask(key) { @@ -9377,13 +9662,36 @@ function replaceHermesFilesTransaction(entries) { try { for (const entry of staged) { fs.mkdirSync(path.dirname(entry.file), { recursive: true }) - fs.writeFileSync(entry.temp, entry.content, entry.mode ? { mode: entry.mode } : undefined) + const fd = fs.openSync(entry.temp, 'wx', entry.mode || 0o600) + try { + fs.writeFileSync(fd, entry.content) + fs.fsyncSync(fd) + } finally { + fs.closeSync(fd) + } + } + for (const entry of staged) { + if (!entry.existed) continue + const stableBackup = `${entry.file}.bak` + fs.copyFileSync(entry.file, stableBackup) + const backupFd = fs.openSync(stableBackup, 'r+') + try { + fs.fsyncSync(backupFd) + } finally { + fs.closeSync(backupFd) + } + if (process.platform !== 'win32') fs.chmodSync(stableBackup, entry.mode || 0o600) } for (const entry of staged) { if (entry.existed) fs.renameSync(entry.file, entry.backup) fs.renameSync(entry.temp, entry.file) entry.installed = true } + for (const entry of staged) { + if (fs.readFileSync(entry.file, 'utf8') !== entry.content) { + throw new Error('Hermes 配置写入后回读不一致') + } + } for (const entry of staged) { if (entry.existed) fs.rmSync(entry.backup, { force: true }) } @@ -9455,7 +9763,7 @@ export function syncHermesProviderFilesAt(home, { } replaceHermesFilesTransaction(entries) - return { providerId, envKey: config.apiKeyEnvVars[0] } + return { providerId, envKey: config.apiKeyEnvVars[0], verified: true } } // === 统一模型渠道(与 src-tauri/src/commands/model_channels.rs 行为保持一致) === @@ -9476,14 +9784,24 @@ function normalizeChannelModel(entry) { } const id = String(entry?.id || '').trim() if (!id) return null - const out = { id } + const out = { ...entry, id } const name = String(entry?.name || '').trim() if (name) out.name = name - const ctx = Number(entry?.contextWindow) - if (Number.isFinite(ctx) && ctx > 0) out.contextWindow = ctx + for (const key of ['contextWindow', 'contextTokens', 'maxTokens']) { + const value = Number(entry?.[key]) + if (Number.isFinite(value) && value > 0) out[key] = value + else delete out[key] + } + if (out.api) out.api = normalizeModelApiType(out.api) return out } +function normalizeChannelApiKeyRef(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + if (!String(value.source || '').trim() || !String(value.id || '').trim()) return null + return structuredClone(value) +} + function normalizeModelChannel(entry, current) { const id = String(entry?.id || '').trim() const name = String(entry?.name || '').trim() @@ -9491,7 +9809,18 @@ function normalizeModelChannel(entry, current) { const baseUrl = String(entry?.baseUrl || '').trim().replace(/\/+$/, '') if (baseUrl && !/^https?:\/\//.test(baseUrl)) return null const incomingKey = String(entry?.apiKey || '').trim() - const apiKey = isChannelKeepSentinel(incomingKey) ? String(current?.apiKey || '') : incomingKey + const keepKey = isChannelKeepSentinel(incomingKey) + const currentKey = String(current?.apiKey || '') + const apiKey = keepKey ? currentKey : incomingKey + const incomingRef = normalizeChannelApiKeyRef(entry?.apiKeyRef) + const currentRef = normalizeChannelApiKeyRef(current?.apiKeyRef) + const apiKeyRef = keepKey ? (incomingRef || currentRef) : null + const currentVersion = Number(current?.credentialVersion) || (currentKey ? 1 : 0) + const storedVersion = Number(entry?.credentialVersion) || 0 + const refChanged = JSON.stringify(apiKeyRef) !== JSON.stringify(currentRef) + const credentialVersion = current + ? (((!keepKey && apiKey !== currentKey) || refChanged) ? currentVersion + 1 : currentVersion) + : Math.max(storedVersion, (apiKey || apiKeyRef) ? 1 : 0) const models = [] const seen = new Set() for (const item of Array.isArray(entry?.models) ? entry.models : []) { @@ -9503,13 +9832,20 @@ function normalizeModelChannel(entry, current) { } let defaultModel = String(entry?.defaultModel || '').trim() if (!models.some(m => m.id === defaultModel)) defaultModel = models[0]?.id || '' + const providerConfig = entry?.providerConfig && typeof entry.providerConfig === 'object' && !Array.isArray(entry.providerConfig) + ? { ...entry.providerConfig } + : {} + for (const key of ['baseUrl', 'api', 'apiKey', 'models']) delete providerConfig[key] return { id, name, presetKey: String(entry?.presetKey || '').trim(), baseUrl, - apiType: String(entry?.apiType || '').trim() || 'openai-completions', + apiType: normalizeModelApiType(entry?.apiType), apiKey, + ...(apiKeyRef ? { apiKeyRef } : {}), + credentialVersion, + providerConfig, models, defaultModel, enabled: entry?.enabled !== false, @@ -9517,7 +9853,7 @@ function normalizeModelChannel(entry, current) { } } -function normalizeModelChannelsDoc(config, current) { +export function normalizeModelChannelsDoc(config, current) { const currentChannels = Array.isArray(current?.channels) ? current.channels : [] const channels = [] const seen = new Set() @@ -9529,12 +9865,12 @@ function normalizeModelChannelsDoc(config, current) { if (normalized) channels.push(normalized) } const syncState = config?.syncState && typeof config.syncState === 'object' ? config.syncState : {} - return { version: 1, channels, syncState } + return { version: 2, channels, syncState } } function readModelChannelsPrivate() { return normalizeModelChannelsDoc( - readJsonOrDefault(modelChannelsPath(), { version: 1, channels: [], syncState: {} }), + readJsonOrDefault(modelChannelsPath(), { version: 2, channels: [], syncState: {} }), null, ) } @@ -9545,8 +9881,8 @@ function sanitizeModelChannelsForRead(doc) { channels: doc.channels.map(ch => ({ ...ch, apiKey: '', - apiKeySaved: Boolean(String(ch.apiKey || '').trim()), - apiKeyMask: mediaApiKeyMask(ch.apiKey), + apiKeySaved: Boolean(String(ch.apiKey || '').trim() || ch.apiKeyRef), + apiKeyMask: ch.apiKeyRef ? 'SecretRef' : mediaApiKeyMask(ch.apiKey), })), } } @@ -10278,21 +10614,184 @@ function truncateLargeStrings(value) { return value } -function mediaErrorJob(id, kind, provider, prompt, model, error, rawProviderResponse = null) { - const now = mediaNowIso() - return { - id, - type: kind, - provider, - providerTaskId: null, - status: 'failed', - prompt, - model, - createdAt: now, - updatedAt: now, - assets: [], - error, - rawProviderResponse: rawProviderResponse == null ? null : truncateLargeStrings(rawProviderResponse), +const MEDIA_QUEUE_CONCURRENCY = 2 +const MEDIA_QUEUE_POLL_INTERVAL_MS = 5000 +const MEDIA_TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'canceled']) + +function findMediaJob(jobId) { + return readMediaJobsPrivate().jobs.find(item => item.id === jobId) || null +} + +function markMediaJobFailed(jobId, error) { + try { + return updateMediaJob(jobId, entry => { + if (entry.status === 'canceled' || entry.status === 'succeeded') return + entry.status = 'failed' + entry.providerStatus = 'local-failed' + entry.error = error?.message || String(error) + }) + } catch (writeError) { + console.error('[media-queue] 无法写入失败状态', jobId, writeError) + return null + } +} + +async function executeQueuedImageJob(jobId, request) { + const providerId = mediaStr(request, 'provider') + const provider = loadMediaProviderConfig(providerId, 'image', mediaStr(request, 'model')) + const payload = buildMediaImagePayload(provider, request) + const endpoint = buildMediaApiUrl(provider.baseUrl, '/images/generations') + const resp = await fetchWithTimeout(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${provider.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }, provider.timeoutSeconds) + const parsed = await readProviderJson(resp) + if (!resp.ok) { + throw new Error(sanitizeProviderError(`HTTP ${resp.status}: ${JSON.stringify(parsed)}`, provider.apiKey)) + } + const assets = await saveImageOutputs(provider, jobId, parsed) + return updateMediaJob(jobId, entry => { + entry.request = truncateLargeStrings(payload) + entry.assets = assets + entry.rawProviderResponse = truncateLargeStrings(parsed) + entry.error = null + if (entry.status !== 'canceled') { + entry.status = 'succeeded' + entry.providerStatus = 'completed' + } + }) +} + +async function submitQueuedVideoJob(jobId, request) { + const providerId = mediaStr(request, 'provider') + const prompt = mediaStr(request, 'prompt') + const provider = loadMediaProviderConfig(providerId, 'video', mediaStr(request, 'model')) + let payload + let resp + if (isOpenAICompatibleMediaProvider(provider.provider)) { + payload = buildOpenAIVideoPayload(provider, request) + const form = new FormData() + form.set('model', payload.model) + form.set('prompt', payload.prompt) + form.set('seconds', payload.seconds) + form.set('size', payload.size) + if (provider.provider === MEDIA_PROVIDER_NEWAPI) { + form.set('duration', payload.seconds) + const [width, height] = String(payload.size || '').split('x') + if (width && height) { + form.set('width', width) + form.set('height', height) + } + } + if (payload.image) form.set('image', payload.image) + if (payload.input_reference) form.set('input_reference', JSON.stringify(payload.input_reference)) + resp = await fetchWithTimeout(buildMediaApiUrl(provider.baseUrl, '/videos'), { + method: 'POST', + headers: { Authorization: `Bearer ${provider.apiKey}` }, + body: form, + }, provider.timeoutSeconds) + } else { + const content = [{ type: 'text', text: prompt }] + const imageUrl = mediaStr(request, 'imageUrl') + if (imageUrl) content.push({ type: 'image_url', image_url: { url: imageUrl } }) + payload = { + model: provider.videoModel, + content, + duration: mediaNum(request, 'duration', 5), + } + const ratio = mediaStr(request, 'ratio') + const resolution = mediaStr(request, 'resolution') + if (ratio) payload.ratio = ratio + if (resolution) payload.resolution = resolution + resp = await fetchWithTimeout(buildMediaApiUrl(provider.baseUrl, '/contents/generations/tasks'), { + method: 'POST', + headers: { + Authorization: `Bearer ${provider.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }, provider.timeoutSeconds) + } + const parsed = await readProviderJson(resp) + if (!resp.ok) { + throw new Error(sanitizeProviderError(`HTTP ${resp.status}: ${JSON.stringify(parsed)}`, provider.apiKey)) + } + const providerTaskId = extractProviderTaskId(parsed) + if (!providerTaskId) throw new Error('服务商响应中没有视频任务 ID') + const providerStatus = extractProviderStatus(parsed) + return updateMediaJob(jobId, entry => { + entry.providerTaskId = providerTaskId + entry.providerStatus = providerStatus + entry.status = entry.status === 'canceled' ? 'canceled' : providerStatusToJobStatus(providerStatus) + entry.request = truncateLargeStrings(payload) + entry.rawProviderResponse = truncateLargeStrings(parsed) + entry.error = null + }) +} + +async function processMediaQueueJob(jobId) { + let job = findMediaJob(jobId) + if (!job || MEDIA_TERMINAL_STATUSES.has(job.status)) return + if (job.status === 'queued') { + job = updateMediaJob(jobId, entry => { + if (entry.status !== 'queued') return + entry.status = 'running' + entry.providerStatus = 'local-starting' + entry.startedAt = mediaNowIso() + entry.error = null + }) + if (job.status !== 'running') return + if (job.type === 'image') { + await executeQueuedImageJob(jobId, job.request || {}) + return + } + await submitQueuedVideoJob(jobId, job.request || {}) + } + + while (true) { + job = findMediaJob(jobId) + if (!job || job.status === 'canceled' || job.status === 'failed') return + if (job.status === 'succeeded' && Array.isArray(job.assets) && job.assets.length) return + if (!job.providerTaskId) throw new Error('视频任务缺少服务商任务 ID') + try { + const updated = await handlers.poll_video_task({ jobId }) + if (MEDIA_TERMINAL_STATUSES.has(updated.status)) return + } catch (error) { + updateMediaJob(jobId, entry => { + if (entry.status === 'running') entry.error = error?.message || String(error) + }) + } + await new Promise(resolve => setTimeout(resolve, MEDIA_QUEUE_POLL_INTERVAL_MS)) + } +} + +const mediaQueue = createBackgroundJobQueue({ + concurrency: MEDIA_QUEUE_CONCURRENCY, + worker: processMediaQueueJob, + onError: markMediaJobFailed, +}) + +function queueMediaJob(jobId) { + return mediaQueue.enqueue(jobId) +} + +function recoverMediaQueue() { + const jobs = readMediaJobsPrivate().jobs + for (const job of jobs) { + if (job.status === 'queued') { + queueMediaJob(job.id) + continue + } + if (job.status !== 'running' || mediaQueue.has(job.id)) continue + if (job.type === 'video' && job.providerTaskId) { + queueMediaJob(job.id) + } else { + markMediaJobFailed(job.id, new Error('应用重启时任务仍在提交中,服务商状态未知,请确认账单后重试')) + } } } @@ -10436,6 +10935,10 @@ const handlers = { }, async reload_gateway() { + const version = getLocalOpenclawVersion() + if (supportsNativeConfigReload(version)) { + return `OpenClaw ${version} 已接收配置变更,将由内核自动应用` + } return guardedGatewayRestart('reload_gateway') }, @@ -12219,9 +12722,8 @@ const handlers = { // 模型测试 async test_model({ baseUrl, apiKey, modelId, apiType = 'openai-completions' }) { - const type = ['anthropic', 'anthropic-messages'].includes(apiType) ? 'anthropic-messages' - : apiType === 'google-gemini' ? 'google-gemini' - : 'openai-completions' + const type = normalizeModelApiType(apiType) + _assertDirectModelTestSupported(type) apiKey = resolveModelApiKey(apiKey) let base = _normalizeBaseUrl(baseUrl) // 仅 Anthropic 强制补 /v1,OpenAI 兼容类不强制(火山引擎等用 /v3) @@ -12243,13 +12745,25 @@ const handlers = { }), signal: controller.signal }) - } else if (type === 'google-gemini') { + } else if (type === 'google-generative-ai') { resp = await fetch(`${base}/models/${encodeURIComponent(modelId)}:generateContent?key=${encodeURIComponent(apiKey || '')}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: 'Hi' }] }] }), signal: controller.signal }) + } else if (['openai-responses', 'azure-openai-responses'].includes(type)) { + const headers = { 'Content-Type': 'application/json' } + if (apiKey) { + if (type === 'azure-openai-responses') headers['api-key'] = apiKey + else headers['Authorization'] = `Bearer ${apiKey}` + } + resp = await fetch(`${base}/responses`, { + method: 'POST', + headers, + body: JSON.stringify({ model: modelId, input: 'Hi', max_output_tokens: 16 }), + signal: controller.signal + }) } else { const headers = { 'Content-Type': 'application/json' } if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` @@ -12273,15 +12787,12 @@ const handlers = { const parsed = JSON.parse(text) msg = parsed.error?.message || parsed.message || msg } catch {} - if (resp.status === 401 || resp.status === 403) throw new Error(msg) - return `⚠ 连接正常(API 返回 ${resp.status},部分模型对简单测试不兼容,不影响实际使用)` + throw new Error(`API 返回 ${resp.status}: ${msg}`) } const data = await resp.json() - const anthropicText = (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('') - const geminiText = data.candidates?.[0]?.content?.parts?.map?.(p => p.text).filter(Boolean).join('') || '' - const content = data.choices?.[0]?.message?.content - const reasoning = data.choices?.[0]?.message?.reasoning_content - return anthropicText || geminiText || content || (reasoning ? `[reasoning] ${reasoning}` : '(无回复内容)') + const reply = _extractSingleModelReply(data) + if (!reply) throw new Error('API 已响应但未解析出内容') + return reply } catch (e) { clearTimeout(timeout) if (e.name === 'AbortError') throw new Error('请求超时 (30s)') @@ -12291,9 +12802,8 @@ const handlers = { // 模型测试(详细版 #Compat-1):返回 {success, status, reqUrl, reqBody, respBody, reply, error, elapsedMs, usedApi} async test_model_verbose({ baseUrl, apiKey, modelId, apiType = 'openai-completions' }) { - const type = ['anthropic', 'anthropic-messages'].includes(apiType) ? 'anthropic-messages' - : apiType === 'google-gemini' ? 'google-gemini' - : 'openai-completions' + const type = normalizeModelApiType(apiType) + _assertDirectModelTestSupported(type) apiKey = resolveModelApiKey(apiKey) let base = _normalizeBaseUrl(baseUrl) if (type === 'anthropic-messages' && !/\/v1$/i.test(base)) base += '/v1' @@ -12311,12 +12821,22 @@ const handlers = { reqBody = { model: modelId, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 } headers = { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', 'Accept-Encoding': 'identity' } if (apiKey) headers['x-api-key'] = apiKey - } else if (type === 'google-gemini') { + } else if (type === 'google-generative-ai') { usedApi = 'Gemini' reqUrl = `${base}/models/${encodeURIComponent(modelId)}:generateContent?key=***` realUrl = `${base}/models/${encodeURIComponent(modelId)}:generateContent?key=${encodeURIComponent(apiKey || '')}` reqBody = { contents: [{ role: 'user', parts: [{ text: '你好,请用一句话回复' }] }] } headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity' } + } else if (['openai-responses', 'azure-openai-responses'].includes(type)) { + usedApi = 'Responses' + reqUrl = `${base}/responses` + realUrl = reqUrl + reqBody = { model: modelId, input: '你好,请用一句话回复', max_output_tokens: 200 } + headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity' } + if (apiKey) { + if (type === 'azure-openai-responses') headers['api-key'] = apiKey + else headers['Authorization'] = `Bearer ${apiKey}` + } } else { // OpenAI 兼容路径用 stream:true:部分兼容网关的 non-streaming 分支对某些模型 // 会返回 200 + 空 body,而 streaming 分支所有 provider 都稳定支持,与真实对话一致 @@ -12368,17 +12888,7 @@ const handlers = { if (!reply) { try { const v = JSON.parse(respBody) - if (Array.isArray(v.content)) { - reply = v.content.filter(b => b.type === 'text').map(b => b.text).join('') - } - if (!reply && v.candidates?.[0]?.content?.parts) { - reply = v.candidates[0].content.parts.map(p => p.text).filter(Boolean).join('') - } - if (!reply && v.choices?.[0]?.message) { - const msg = v.choices[0].message - reply = msg.content || (msg.reasoning_content ? `[reasoning] ${msg.reasoning_content}` : '') - } - if (!reply && v.output?.text) reply = v.output.text + reply = _extractSingleModelReply(v) } catch {} } @@ -12398,9 +12908,8 @@ const handlers = { }, async list_remote_models({ baseUrl, apiKey, apiType = 'openai-completions' }) { - const type = ['anthropic', 'anthropic-messages'].includes(apiType) ? 'anthropic-messages' - : apiType === 'google-gemini' ? 'google-gemini' - : 'openai-completions' + const type = normalizeModelApiType(apiType) + _assertDirectModelTestSupported(type) apiKey = resolveModelApiKey(apiKey) let base = _normalizeBaseUrl(baseUrl) // 仅 Anthropic 强制补 /v1,OpenAI 兼容类不强制(火山引擎等用 /v3) @@ -12413,11 +12922,14 @@ const handlers = { const headers = { 'anthropic-version': '2023-06-01' } if (apiKey) headers['x-api-key'] = apiKey resp = await fetch(`${base}/models`, { headers, signal: controller.signal }) - } else if (type === 'google-gemini') { + } else if (type === 'google-generative-ai') { resp = await fetch(`${base}/models?key=${encodeURIComponent(apiKey || '')}`, { signal: controller.signal }) } else { const headers = {} - if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` + if (apiKey) { + if (type === 'azure-openai-responses') headers['api-key'] = apiKey + else headers['Authorization'] = `Bearer ${apiKey}` + } resp = await fetch(`${base}/models`, { headers, signal: controller.signal }) } clearTimeout(timeout) @@ -13381,7 +13893,7 @@ const handlers = { write_model_channels({ config }) { const normalized = normalizeModelChannelsDoc(config, readModelChannelsPrivate()) - writeJsonAtomic(modelChannelsPath(), normalized) + writeJsonAtomic(modelChannelsPath(), normalized, { backup: true }) return sanitizeModelChannelsForRead(normalized) }, @@ -13389,6 +13901,7 @@ const handlers = { const doc = readModelChannelsPrivate() const channel = doc.channels.find(ch => ch.id === String(channelId || '').trim()) if (!channel) throw new Error(`模型渠道不存在: ${channelId}`) + if (channel.apiKeyRef) throw new Error('该渠道使用 OpenClaw SecretRef,只能原样同步到 OpenClaw') return channel.apiKey || '' }, @@ -13462,47 +13975,25 @@ const handlers = { const prompt = mediaStr(request, 'prompt') const provider = loadMediaProviderConfig(providerId, 'image', mediaStr(request, 'model')) const jobId = newMediaJobId('img') - const payload = buildMediaImagePayload(provider, request) - - const endpoint = buildMediaApiUrl(provider.baseUrl, '/images/generations') - const resp = await fetchWithTimeout(endpoint, { - method: 'POST', - headers: { - Authorization: `Bearer ${provider.apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }, provider.timeoutSeconds) - const parsed = await readProviderJson(resp) - if (!resp.ok) { - const error = sanitizeProviderError(`HTTP ${resp.status}: ${JSON.stringify(parsed)}`, provider.apiKey) - upsertMediaJob(mediaErrorJob(jobId, 'image', provider.provider, prompt, provider.imageModel, error, parsed)) - throw new Error(error) - } - let assets - try { - assets = await saveImageOutputs(provider, jobId, parsed) - } catch (error) { - const message = error?.message || String(error) - upsertMediaJob(mediaErrorJob(jobId, 'image', provider.provider, prompt, provider.imageModel, message, parsed)) - throw error - } const now = mediaNowIso() - return upsertMediaJob({ + const job = upsertMediaJob({ id: jobId, type: 'image', provider: provider.provider, providerTaskId: null, - status: 'succeeded', + status: 'queued', + providerStatus: 'local-queued', prompt, model: provider.imageModel, createdAt: now, updatedAt: now, - request: truncateLargeStrings(payload), - assets, + request: truncateLargeStrings(request), + assets: [], error: null, - rawProviderResponse: truncateLargeStrings(parsed), + rawProviderResponse: null, }) + queueMediaJob(jobId) + return job }, async create_video_task({ request }) { @@ -13512,84 +14003,25 @@ const handlers = { const prompt = mediaStr(request, 'prompt') const provider = loadMediaProviderConfig(providerId, 'video', mediaStr(request, 'model')) const jobId = newMediaJobId('vid') - let payload - let resp - if (isOpenAICompatibleMediaProvider(provider.provider)) { - payload = buildOpenAIVideoPayload(provider, request) - const form = new FormData() - form.set('model', payload.model) - form.set('prompt', payload.prompt) - form.set('seconds', payload.seconds) - form.set('size', payload.size) - if (provider.provider === MEDIA_PROVIDER_NEWAPI) { - form.set('duration', payload.seconds) - const [width, height] = String(payload.size || '').split('x') - if (width && height) { - form.set('width', width) - form.set('height', height) - } - } - if (payload.image) form.set('image', payload.image) - if (payload.input_reference) form.set('input_reference', JSON.stringify(payload.input_reference)) - resp = await fetchWithTimeout(buildMediaApiUrl(provider.baseUrl, '/videos'), { - method: 'POST', - headers: { Authorization: `Bearer ${provider.apiKey}` }, - body: form, - }, provider.timeoutSeconds) - } else { - const content = [{ type: 'text', text: prompt }] - const imageUrl = mediaStr(request, 'imageUrl') - if (imageUrl) content.push({ type: 'image_url', image_url: { url: imageUrl } }) - payload = { - model: provider.videoModel, - content, - duration: mediaNum(request, 'duration', 5), - } - const ratio = mediaStr(request, 'ratio') - const resolution = mediaStr(request, 'resolution') - if (ratio) payload.ratio = ratio - if (resolution) payload.resolution = resolution - - resp = await fetchWithTimeout(buildMediaApiUrl(provider.baseUrl, '/contents/generations/tasks'), { - method: 'POST', - headers: { - Authorization: `Bearer ${provider.apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }, provider.timeoutSeconds) - } - const parsed = await readProviderJson(resp) - if (!resp.ok) { - const error = sanitizeProviderError(`HTTP ${resp.status}: ${JSON.stringify(parsed)}`, provider.apiKey) - upsertMediaJob(mediaErrorJob(jobId, 'video', provider.provider, prompt, provider.videoModel, error, parsed)) - throw new Error(error) - } - const providerTaskId = extractProviderTaskId(parsed) - if (!providerTaskId) { - // 任务可能已在服务商侧创建并计费:即使无法识别任务 ID,也要落盘保留原始响应供恢复 - const error = '服务商响应中没有视频任务 ID' - upsertMediaJob(mediaErrorJob(jobId, 'video', provider.provider, prompt, provider.videoModel, error, parsed)) - throw new Error(error) - } - const providerStatus = extractProviderStatus(parsed) const now = mediaNowIso() - return upsertMediaJob({ + const job = upsertMediaJob({ id: jobId, type: 'video', provider: provider.provider, - providerTaskId, - status: providerStatusToJobStatus(providerStatus), - providerStatus, + providerTaskId: null, + status: 'queued', + providerStatus: 'local-queued', prompt, model: provider.videoModel, createdAt: now, updatedAt: now, - request: truncateLargeStrings(payload), + request: truncateLargeStrings(request), assets: [], error: null, - rawProviderResponse: truncateLargeStrings(parsed), + rawProviderResponse: null, }) + queueMediaJob(jobId) + return job }, async poll_video_task({ jobId }) { @@ -13655,6 +14087,7 @@ const handlers = { }, list_media_jobs({ filter } = {}) { + recoverMediaQueue() const doc = readMediaJobsPrivate() return buildMediaJobsResponse(doc, filter || {}) }, @@ -14318,13 +14751,14 @@ const handlers = { // 2. 安装 const pkg = hermesPackageSpec(extras) const installArgs = method === 'uv-pip' - ? ['pip', 'install', pkg] - : ['tool', 'install', '--force', pkg, '--python', '3.11', '--with', 'croniter', '--with', 'httpx', '--with', 'openai', '--with', 'aiohttp', '--with', 'websockets'] - const result = await runHermesInstallCommand(uv, installArgs, { + ? ['pip', 'install', pkg, ...HERMES_RUNTIME_EXTRA_DEPS] + : ['tool', 'install', '--force', pkg, '--python', '3.11', ...hermesRuntimeExtraArgs()] + const uvVersion = runHermesSilent(uv, ['--version']).stdout || '' + const result = await runHermesInstallWithCacheRecovery(runHermesInstallCommand, uv, installArgs, { env: buildHermesInstallEnv(readPanelConfig()), timeout: 600000, windowsHide: true, - }) + }, uvVersion) if (result.status !== 0) { const cleaned = sanitizeHermesInstallOutput((result.stderr || '').trim()) const hint = diagnoseHermesInstallError(cleaned) @@ -15912,7 +16346,7 @@ const handlers = { hermes_sync_provider({ provider, apiKey, baseUrl, model, setDefault } = {}) { return syncHermesProviderFilesAt(hermesHome(), { provider, - apiKey, + apiKey: resolveModelApiKey(apiKey), baseUrl, model, setDefault: Boolean(setDefault), @@ -16131,6 +16565,7 @@ const handlers = { } // 注入 .env const envVars = { ...process.env, PATH: hermesEnhancedPath() } + envVars.HERMES_WEB_DIST = ensureHermesDashboardFallbackDist(home) const envPath = path.join(home, '.env') if (fs.existsSync(envPath)) { for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { @@ -16140,7 +16575,7 @@ const handlers = { if (eq > 0) envVars[t.slice(0, eq).trim()] = t.slice(eq + 1).trim() } } - const child = spawn('hermes', ['dashboard'], { + const child = spawn('hermes', ['dashboard', '--no-open'], { cwd: home, env: envVars, stdio: ['ignore', out, err], @@ -16900,7 +17335,7 @@ const handlers = { const uvPath = path.join(uvBinDir(), isWindows ? 'uv.exe' : 'uv') const uv = fs.existsSync(uvPath) ? uvPath : 'uv' const pkg = hermesPackageSpec(['web']) - const result = spawnSync(uv, ['tool', 'install', '--reinstall', pkg, '--python', '3.11', '--with', 'croniter'], { + const result = spawnSync(uv, ['tool', 'install', '--reinstall', pkg, '--python', '3.11', ...hermesRuntimeExtraArgs()], { env: { ...process.env, PATH: hermesEnhancedPath(), GIT_TERMINAL_PROMPT: '0', ...gitMirrorEnv() }, timeout: 600000, windowsHide: true, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }) diff --git a/scripts/media-background-queue.js b/scripts/media-background-queue.js new file mode 100644 index 0000000..07c9b6f --- /dev/null +++ b/scripts/media-background-queue.js @@ -0,0 +1,54 @@ +export function createBackgroundJobQueue({ concurrency = 2, worker, onError = null } = {}) { + if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error('队列并发数必须是正整数') + if (typeof worker !== 'function') throw new Error('队列缺少 worker') + + const pending = [] + const pendingIds = new Set() + const activeIds = new Set() + const idleWaiters = new Set() + + function settleIdleWaiters() { + if (pending.length || activeIds.size) return + for (const resolve of idleWaiters) resolve() + idleWaiters.clear() + } + + function drain() { + while (activeIds.size < concurrency && pending.length) { + const jobId = pending.shift() + pendingIds.delete(jobId) + activeIds.add(jobId) + Promise.resolve() + .then(() => worker(jobId)) + .catch(error => onError?.(jobId, error)) + .finally(() => { + activeIds.delete(jobId) + drain() + settleIdleWaiters() + }) + } + settleIdleWaiters() + } + + return { + enqueue(jobId) { + const id = String(jobId || '').trim() + if (!id || pendingIds.has(id) || activeIds.has(id)) return false + pending.push(id) + pendingIds.add(id) + queueMicrotask(drain) + return true + }, + has(jobId) { + const id = String(jobId || '').trim() + return pendingIds.has(id) || activeIds.has(id) + }, + stats() { + return { active: activeIds.size, pending: pending.length } + }, + whenIdle() { + if (!pending.length && !activeIds.size) return Promise.resolve() + return new Promise(resolve => idleWaiters.add(resolve)) + }, + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 837551a..7e24aa6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ [[package]] name = "clawpanel" -version = "0.18.5" +version = "0.18.6" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c835c4a..895c16b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clawpanel" -version = "0.18.5" +version = "0.18.6" edition = "2021" description = "ClawPanel - OpenClaw 可视化管理面板" authors = ["qingchencloud"] diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 5d9dc36..700dee3 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -4,9 +4,10 @@ use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashMap; use std::fs; +use std::io::Write; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use crate::models::types::VersionInfo; @@ -1109,6 +1110,212 @@ pub fn save_openclaw_json(config: &Value) -> Result<(), String> { write_openclaw_config(config.clone()) } +fn validate_openclaw_model_candidate(config: &Value) -> Result<(), String> { + let Some(models) = config.get("models") else { + return Ok(()); + }; + let models = models + .as_object() + .ok_or_else(|| "OpenClaw models 必须是对象".to_string())?; + let Some(providers) = models.get("providers") else { + return Ok(()); + }; + let providers = providers + .as_object() + .ok_or_else(|| "OpenClaw models.providers 必须是对象".to_string())?; + for (provider_id, provider) in providers { + let provider = provider + .as_object() + .ok_or_else(|| format!("模型服务商 {provider_id} 配置必须是对象"))?; + if let Some(api) = provider.get("api") { + let api = api + .as_str() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| format!("模型服务商 {provider_id} 的 api 必须是非空字符串"))?; + if api == "openai-codex-responses" { + return Err(format!( + "模型服务商 {provider_id} 使用了已移除的 api;请改用 openai-chatgpt-responses" + )); + } + } + if let Some(base_url) = provider.get("baseUrl") { + let base_url = base_url + .as_str() + .ok_or_else(|| format!("模型服务商 {provider_id} 的 baseUrl 必须是字符串"))?; + if !(base_url.is_empty() + || base_url.starts_with("https://") + || base_url.starts_with("http://")) + { + return Err(format!( + "模型服务商 {provider_id} 的 baseUrl 必须使用 http 或 https" + )); + } + } + let Some(entries) = provider.get("models") else { + continue; + }; + let entries = entries + .as_array() + .ok_or_else(|| format!("模型服务商 {provider_id} 的 models 必须是数组"))?; + for (index, model) in entries.iter().enumerate() { + let model = model.as_object().ok_or_else(|| { + format!( + "模型服务商 {provider_id} 的第 {} 个模型必须是对象", + index + 1 + ) + })?; + for key in ["id", "name"] { + if model + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + return Err(format!( + "模型服务商 {provider_id} 的第 {} 个模型缺少 {key}", + index + 1 + )); + } + } + if model.get("api").and_then(Value::as_str) == Some("openai-codex-responses") { + return Err(format!( + "模型服务商 {provider_id} 的模型 {} 使用了已移除的 api", + model.get("id").and_then(Value::as_str).unwrap_or("") + )); + } + for key in ["contextWindow", "contextTokens", "maxTokens"] { + if let Some(value) = model.get(key) { + let valid = value.as_u64().is_some_and(|number| number > 0); + if !valid { + return Err(format!( + "模型服务商 {provider_id} 的模型 {} 字段 {key} 必须是正整数", + model.get("id").and_then(Value::as_str).unwrap_or("") + )); + } + } + } + } + } + Ok(()) +} + +fn write_verified_json_with_backup(path: &Path, value: &Value) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "配置路径缺少父目录".to_string())?; + fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {e}"))?; + let content = serde_json::to_string_pretty(value).map_err(|e| format!("序列化失败: {e}"))?; + let parsed: Value = + serde_json::from_str(&content).map_err(|e| format!("候选配置校验失败: {e}"))?; + if &parsed != value { + return Err("候选配置序列化后内容不一致".into()); + } + + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let tmp = parent.join(format!( + ".openclaw.json.{}.{suffix}.tmp", + std::process::id() + )); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .map_err(|e| format!("创建候选配置失败: {e}"))?; + if let Err(error) = file + .write_all(content.as_bytes()) + .and_then(|_| file.sync_all()) + { + let _ = fs::remove_file(&tmp); + return Err(format!("写入候选配置失败: {error}")); + } + drop(file); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(error) = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)) { + let _ = fs::remove_file(&tmp); + return Err(format!("设置候选配置权限失败: {error}")); + } + } + + let backup = path.with_extension("json.bak"); + let had_existing = path.exists(); + if had_existing { + fs::copy(path, &backup).map_err(|e| { + let _ = fs::remove_file(&tmp); + format!("备份当前配置失败: {e}") + })?; + fs::OpenOptions::new() + .read(true) + .write(true) + .open(&backup) + .and_then(|file| file.sync_all()) + .map_err(|e| { + let _ = fs::remove_file(&tmp); + format!("同步当前配置备份失败: {e}") + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&backup, fs::Permissions::from_mode(0o600)).map_err(|e| { + let _ = fs::remove_file(&tmp); + format!("设置配置备份权限失败: {e}") + })?; + } + } + + #[cfg(not(target_os = "windows"))] + let replace_result = fs::rename(&tmp, path); + + #[cfg(target_os = "windows")] + let replace_result = { + let swap = parent.join(format!( + ".openclaw.json.{}.{suffix}.old", + std::process::id() + )); + let _ = fs::remove_file(&swap); + if had_existing { + fs::rename(path, &swap).and_then(|_| { + fs::rename(&tmp, path).inspect_err(|_| { + let _ = fs::rename(&swap, path); + }) + }) + } else { + fs::rename(&tmp, path) + } + .map(|_| { + let _ = fs::remove_file(&swap); + }) + }; + + if let Err(error) = replace_result { + let _ = fs::remove_file(&tmp); + return Err(format!("替换 OpenClaw 配置失败,原配置已保留: {error}")); + } + + let readback = fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()); + if readback.as_ref() != Some(value) { + if had_existing && backup.exists() { + let _ = fs::copy(&backup, path); + } else { + let _ = fs::remove_file(path); + } + return Err("OpenClaw 配置写入后回读不一致,已恢复原配置".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + fn model_env_values_for_config(config: &Value) -> HashMap { let mut values = HashMap::new(); if let Some(env) = config.get("env").and_then(|v| v.as_object()) { @@ -1134,7 +1341,10 @@ fn model_env_values_for_config(config: &Value) -> HashMap { values } -fn validate_model_provider_env_refs(config: &Value) -> Result<(), String> { +fn validate_model_provider_env_refs( + config: &Value, + previous_config: Option<&Value>, +) -> Result<(), String> { let values = model_env_values_for_config(config); let Some(providers) = config .get("models") @@ -1148,6 +1358,15 @@ fn validate_model_provider_env_refs(config: &Value) -> Result<(), String> { let Some(api_key) = provider.get("apiKey").and_then(|v| v.as_str()) else { continue; }; + let previous_api_key = previous_config + .and_then(|config| config.get("models")) + .and_then(|models| models.get("providers")) + .and_then(|providers| providers.get(provider_name)) + .and_then(|provider| provider.get("apiKey")) + .and_then(Value::as_str); + if previous_api_key == Some(api_key) { + continue; + } let Some(env_key) = model_api_key_env_ref(api_key)? else { continue; }; @@ -1184,26 +1403,22 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> { .ok() .and_then(|c| parse_json_relaxed(&c)); - // 备份 - let bak = super::openclaw_dir().join("openclaw.json.bak"); - let _ = fs::copy(&path, &bak); - // 合并配置:现有配置 + 新配置 // 策略:遍历现有配置,保留所有非 UI 字段 // 然后将新配置的值覆盖到合并结果中 - let merged = if let Some(existing) = existing_config { - merge_configs_preserving_fields(&existing, &config) + let merged = if let Some(existing) = existing_config.as_ref() { + merge_configs_preserving_fields(existing, &config) } else { config.clone() }; // 清理 UI 专属字段,避免 CLI schema 校验失败 let cleaned = strip_ui_fields(merged); - validate_model_provider_env_refs(&cleaned)?; + validate_model_provider_env_refs(&cleaned, existing_config.as_ref())?; + validate_openclaw_model_candidate(&cleaned)?; - // 写入 - let json = serde_json::to_string_pretty(&cleaned).map_err(|e| format!("序列化失败: {e}"))?; - fs::write(&path, &json).map_err(|e| format!("写入失败: {e}"))?; + // 候选文件写入、备份、替换并回读验证。失败时保持或恢复最后有效配置。 + write_verified_json_with_backup(&path, &cleaned)?; // 同步 provider 配置到所有 agent 的 models.json(运行时注册表) // 必须使用与磁盘一致的 merged+strip 结果,而非前端原始 payload: @@ -1719,6 +1934,10 @@ pub fn calibrate_openclaw_config(mode: String) -> Result { /// 清理的字段: /// - UI 专属字段(通过 strip_ui_fields 处理) fn merge_configs_preserving_fields(existing: &Value, new: &Value) -> Value { + merge_configs_preserving_fields_at(existing, new, "") +} + +fn merge_configs_preserving_fields_at(existing: &Value, new: &Value, path: &str) -> Value { use serde_json::Value; match (existing, new) { @@ -1726,6 +1945,16 @@ fn merge_configs_preserving_fields(existing: &Value, new: &Value) -> Value { let mut merged = existing_obj.clone(); for (key, new_value) in new_obj { + // models.providers. = null 是显式删除墓碑;省略键仍表示保留。 + if path == "models.providers" && new_value.is_null() { + merged.remove(key); + continue; + } + let child_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; if let Some(existing_value) = existing_obj.get(key) { if let (Value::Object(existing_sub), Value::Object(new_sub)) = (existing_value, new_value) @@ -1733,9 +1962,10 @@ fn merge_configs_preserving_fields(existing: &Value, new: &Value) -> Value { // 两边都是对象:递归合并(新值覆盖,旧值保留未覆盖的 key) merged.insert( key.clone(), - merge_configs_preserving_fields( + merge_configs_preserving_fields_at( &Value::Object(existing_sub.clone()), &Value::Object(new_sub.clone()), + &child_path, ), ); } else { @@ -2185,9 +2415,31 @@ fn strip_ui_fields(mut val: Value) -> Value { if let Some(providers_obj) = providers_val.as_object_mut() { for (_provider_name, provider_val) in providers_obj.iter_mut() { if let Some(provider_obj) = provider_val.as_object_mut() { + if let Some(api) = provider_obj.get("api").and_then(Value::as_str) { + provider_obj.insert( + "api".into(), + Value::String(normalize_model_api_type(api)), + ); + } if let Some(Value::Array(arr)) = provider_obj.get_mut("models") { for model in arr.iter_mut() { + if let Some(id) = model + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + { + *model = json!({ "id": id, "name": id }); + } if let Some(mobj) = model.as_object_mut() { + if let Some(api) = + mobj.get("api").and_then(Value::as_str) + { + mobj.insert( + "api".into(), + Value::String(normalize_model_api_type(api)), + ); + } mobj.remove("lastTestAt"); mobj.remove("latency"); mobj.remove("testStatus"); @@ -3515,9 +3767,62 @@ fn verify_standalone_install( "standalone 安装校验失败:目标 CLI 版本为 {verified_version},清单版本为 {remote_version}" )); } + verify_standalone_runtime_dependencies(staging_dir)?; Ok(verified_version) } +fn verify_standalone_runtime_dependencies(staging_dir: &std::path::Path) -> Result<(), String> { + let package_dir = [ + staging_dir + .join("node_modules") + .join("@qingchencloud") + .join("openclaw-zh"), + staging_dir.join("node_modules").join("openclaw"), + ] + .into_iter() + .find(|dir| dir.join("package.json").exists()) + .ok_or_else(|| "standalone 解压后未找到 OpenClaw 主包".to_string())?; + + let package_json_path = package_dir.join("package.json"); + let package_json: Value = serde_json::from_slice( + &std::fs::read(&package_json_path) + .map_err(|e| format!("standalone 主包 package.json 无法读取: {e}"))?, + ) + .map_err(|e| format!("standalone 主包 package.json 无法解析: {e}"))?; + + let mut missing = package_json + .get("dependencies") + .and_then(Value::as_object) + .into_iter() + .flatten() + .filter_map(|(dependency, _)| { + let dependency_path = dependency + .split('/') + .filter(|part| !part.is_empty()) + .fold(PathBuf::new(), |path, part| path.join(part)); + let nested = package_dir + .join("node_modules") + .join(&dependency_path) + .join("package.json"); + let hoisted = staging_dir + .join("node_modules") + .join(&dependency_path) + .join("package.json"); + (!nested.exists() && !hoisted.exists()).then(|| dependency.to_string()) + }) + .collect::>(); + missing.sort(); + + if missing.is_empty() { + Ok(()) + } else { + Err(format!( + "standalone 安装校验失败:缺少运行时依赖:{}", + missing.join(", ") + )) + } +} + fn replace_standalone_install( staging_dir: &std::path::Path, install_dir: &std::path::Path, @@ -5721,120 +6026,37 @@ pub fn delete_backup(name: String) -> Result<(), String> { fs::remove_file(&path).map_err(|e| format!("删除失败: {e}")) } -/// 获取当前用户 UID(macOS/Linux 用 id -u,Windows 返回 0) -#[allow(dead_code)] -fn get_uid() -> Result { - #[cfg(target_os = "windows")] - { - Ok(0) - } - #[cfg(not(target_os = "windows"))] - { - let output = Command::new("id") - .arg("-u") - .output() - .map_err(|e| format!("获取 UID 失败: {e}"))?; - String::from_utf8_lossy(&output.stdout) - .trim() - .parse::() - .map_err(|e| format!("解析 UID 失败: {e}")) - } +const OPENCLAW_NATIVE_CONFIG_RELOAD_VERSION_FLOOR: &str = "2026.7.1"; + +fn supports_native_config_reload(version: &str) -> bool { + let version = parse_version(&base_version(version)); + !version.is_empty() && version >= parse_version(OPENCLAW_NATIVE_CONFIG_RELOAD_VERSION_FLOOR) } -/// 重载 Gateway 配置(热重载,不重启进程) -/// 通过 HTTP POST 向 Gateway 发送 reload 信号,避免触发完整的服务重启循环 -#[allow(dead_code)] -async fn reload_gateway_via_http() -> Result { - // 读取 gateway 端口和 token - let config_path = crate::commands::openclaw_dir().join("openclaw.json"); - let content = - std::fs::read_to_string(&config_path).map_err(|e| format!("读取配置失败: {e}"))?; - let config: serde_json::Value = - serde_json::from_str(&content).map_err(|e| format!("解析配置失败: {e}"))?; - - let gw_port = config - .get("gateway") - .and_then(|g| g.get("port")) - .and_then(|p| p.as_u64()) - .unwrap_or(18789) as u16; - - let token = config - .get("gateway") - .and_then(|g| g.get("auth")) - .and_then(|a| a.get("token")) - .and_then(|t| t.as_str()) - .unwrap_or(""); - - // 尝试两个可能的 control UI 端口 - let control_ports = [gw_port + 2, 18792]; - - for ctrl_port in control_ports { - let url = format!("http://127.0.0.1:{}/__api/reload", ctrl_port); - let client = crate::commands::build_http_client( - std::time::Duration::from_secs(5), - Some("ClawPanel"), - )?; - - let mut req = client.post(&url); - if !token.is_empty() { - req = req.header("Authorization", format!("Bearer {}", token)); - } - - match req.send().await { - Ok(resp) if resp.status().is_success() => { - return Ok("Gateway 配置已热重载".to_string()); - } - Ok(resp) => { - eprintln!( - "[reload_gateway] 端口 {ctrl_port} 返回状态: {}", - resp.status() - ); - } - Err(e) => { - eprintln!("[reload_gateway] 端口 {ctrl_port} 请求失败: {e}"); - } - } - } - - // 所有 HTTP 重载方式都失败,回退到进程重启 - eprintln!("[reload_gateway] HTTP 热重载不可用,将触发进程重启"); - Err("Gateway HTTP 重载不可用".to_string()) +async fn restart_gateway_internal(app: Option<&tauri::AppHandle>) -> Result { + crate::commands::service::restart_service( + app.cloned() + .ok_or_else(|| "缺少 AppHandle,无法重启 Gateway".to_string())?, + "ai.openclaw.gateway".into(), + ) + .await + .map(|_| "Gateway 已重启".to_string()) } -/// 重载 Gateway 服务 -/// Windows/Linux: 优先尝试 HTTP 热重载(不重启进程) -/// 如果 HTTP 重载失败,回退到 restart_service(会触发 Guardian 重启循环) -#[allow(unused_variables)] +/// 让 Gateway 应用最新配置。 +/// OpenClaw 2026.7.1 起由内核文件监听器按变更类型执行热更新或安全重启; +/// ClawPanel 不再探测面板自身的 HTTP 端口,也不重复打断正在运行的 Gateway。 async fn reload_gateway_internal(app: Option<&tauri::AppHandle>) -> Result { - #[cfg(target_os = "macos")] - { - let uid = get_uid()?; - let target = format!("gui/{uid}/ai.openclaw.gateway"); - let output = tokio::process::Command::new("launchctl") - .args(["kickstart", "-k", &target]) - .output() - .await - .map_err(|e| format!("重载失败: {e}"))?; - if output.status.success() { - Ok("Gateway 已重载".to_string()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!("重载失败: {stderr}")) - } - } - #[cfg(not(target_os = "macos"))] - { - match reload_gateway_via_http().await { - Ok(msg) => Ok(msg), - Err(_) => crate::commands::service::restart_service( - app.cloned() - .ok_or_else(|| "缺少 AppHandle,无法回退到 Gateway 进程重启".to_string())?, - "ai.openclaw.gateway".into(), - ) - .await - .map(|_| "Gateway 已重启".to_string()), + if let Some(version) = get_local_version().await { + if supports_native_config_reload(&version) { + return Ok(format!( + "OpenClaw {version} 已接收配置变更,将由内核自动应用" + )); } } + + // 旧内核没有统一的配置监听契约,保留显式重启兼容路径。 + restart_gateway_internal(app).await } /// 全局 Gateway 重启 mutex(单飞行锁) @@ -5863,7 +6085,7 @@ async fn restart_gateway_guarded(app: Option<&tauri::AppHandle>) -> Result) -> Result Result { - restart_gateway_guarded(Some(&app)).await + reload_gateway_internal(Some(&app)).await } -/// 重启 Gateway 服务(与 reload_gateway 相同实现) +/// 用户显式请求重启时才执行进程级 stop/start。 #[tauri::command] pub async fn restart_gateway(app: tauri::AppHandle) -> Result { restart_gateway_guarded(Some(&app)).await @@ -5971,25 +6193,28 @@ fn normalize_base_url(raw: &str) -> String { base } -fn normalize_model_api_type(raw: &str) -> &'static str { - match raw.trim() { - "anthropic" | "anthropic-messages" => "anthropic-messages", - "google-gemini" | "google-generative-ai" => "google-gemini", - "openai" | "openai-completions" | "openai-responses" | "" => "openai-completions", - _ => "openai-completions", +fn normalize_model_api_type(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "" | "openai" | "openai-chat" | "openai-completions" => "openai-completions".into(), + "openai-codex-responses" => "openai-chatgpt-responses".into(), + "anthropic" | "anthropic-messages" => "anthropic-messages".into(), + "google-gemini" | "gemini" | "google" | "google-generative-ai" => { + "google-generative-ai".into() + } + other => other.to_string(), } } fn normalize_base_url_for_api(raw: &str, api_type: &str) -> String { let mut base = normalize_base_url(raw); - match normalize_model_api_type(api_type) { + match normalize_model_api_type(api_type).as_str() { "anthropic-messages" => { if !base.ends_with("/v1") { base.push_str("/v1"); } base } - "google-gemini" => base, + "google-generative-ai" | "google-vertex" => base, _ => { // 不再强制追加 /v1,尊重用户填写的 URL(火山引擎等第三方用 /v3 等路径) // 仅 Ollama (端口 11434) 自动补 /v1 @@ -6442,7 +6667,7 @@ pub fn scan_model_client_configs() -> Result { Ok(json!({ "candidates": candidates })) } -fn resolve_model_api_key(api_key: &str) -> Result { +pub(super) fn resolve_model_api_key(api_key: &str) -> Result { let Some(key) = model_api_key_env_ref(api_key)? else { return Ok(api_key.to_string()); }; @@ -6460,6 +6685,40 @@ fn resolve_model_api_key(api_key: &str) -> Result { )) } +fn resolve_model_api_key_value(api_key: &Value) -> Result { + if let Some(api_key) = api_key.as_str() { + return resolve_model_api_key(api_key); + } + let Some(secret_ref) = api_key.as_object() else { + return Err("API Key 必须是字符串或 OpenClaw SecretRef".into()); + }; + let source = secret_ref + .get("source") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + let id = secret_ref + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + if source != "env" || !is_valid_env_key(id) { + return Err("该 SecretRef 需要由 OpenClaw 运行时解析,请使用 OpenClaw 模型探测".into()); + } + let values = model_env_values(); + if let Some(value) = values.get(id).filter(|value| !value.trim().is_empty()) { + return Ok(value.clone()); + } + if let Ok(value) = std::env::var(id) { + if !value.trim().is_empty() { + return Ok(value); + } + } + Err(format!( + "API Key 引用了环境变量 {id},但未在 openclaw.json env、~/.openclaw/.env 或当前进程环境中找到" + )) +} + fn extract_error_message(text: &str, status: reqwest::StatusCode) -> String { serde_json::from_str::(text) .ok() @@ -6473,23 +6732,27 @@ fn extract_error_message(text: &str, status: reqwest::StatusCode) -> String { .unwrap_or_else(|| format!("HTTP {status}")) } +fn unsupported_direct_model_test(api_type: &str) -> String { + format!("该 API 类型需要由 OpenClaw 运行时验证: {api_type}") +} + /// 测试模型连通性:向 provider 发送一个简单的 chat completion 请求 #[tauri::command] pub async fn test_model( base_url: String, - api_key: String, + api_key: Value, model_id: String, api_type: Option, ) -> Result { let api_type = normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions")); - let base = normalize_base_url_for_api(&base_url, api_type); - let api_key = resolve_model_api_key(&api_key)?; + let base = normalize_base_url_for_api(&base_url, &api_type); + let api_key = resolve_model_api_key_value(&api_key)?; let client = crate::commands::build_http_client_no_proxy(std::time::Duration::from_secs(30), None) .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; - let resp = match api_type { + let resp = match api_type.as_str() { "anthropic-messages" => { let url = format!("{}/messages", base); let body = json!({ @@ -6506,7 +6769,7 @@ pub async fn test_model( } req.send() } - "google-gemini" => { + "google-generative-ai" => { let url = format!( "{}/models/{}:generateContent?key={}", base, model_id, api_key @@ -6516,7 +6779,24 @@ pub async fn test_model( }); client.post(&url).json(&body).send() } - _ => { + "openai-responses" | "azure-openai-responses" => { + let url = format!("{}/responses", base); + let body = json!({ + "model": model_id, + "input": "Hi", + "max_output_tokens": 16 + }); + let mut req = client.post(&url).json(&body); + if !api_key.is_empty() { + req = if api_type == "azure-openai-responses" { + req.header("api-key", api_key.clone()) + } else { + req.header("Authorization", format!("Bearer {api_key}")) + }; + } + req.send() + } + "openai-completions" | "ollama" => { let url = format!("{}/chat/completions", base); let body = json!({ "model": model_id, @@ -6530,6 +6810,7 @@ pub async fn test_model( } req.send() } + _ => return Err(unsupported_direct_model_test(&api_type)), } .await .map_err(|e| { @@ -6547,75 +6828,13 @@ pub async fn test_model( if !status.is_success() { let msg = extract_error_message(&text, status); - // 401/403 是认证错误,一定要报错 - if status.as_u16() == 401 || status.as_u16() == 403 { - return Err(msg); - } - // 其他错误(400/422/429 等):服务器可达、认证通过,仅模型对简单测试不兼容 - // 返回成功但带提示和完整错误信息,方便前端展示 - return Ok(format!( - "⚠ 连接正常(API 返回 {status},部分模型对简单测试不兼容,不影响实际使用)\n{msg}" - )); + return Err(format!("API 返回 {status}: {msg}")); } - // 提取回复内容(兼容多种响应格式) - let reply = serde_json::from_str::(&text) - .ok() - .and_then(|v| { - if let Some(arr) = v.get("content").and_then(|c| c.as_array()) { - let text = arr - .iter() - .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect::>() - .join(""); - if !text.is_empty() { - return Some(text); - } - } - if let Some(t) = v - .get("candidates") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("content")) - .and_then(|c| c.get("parts")) - .and_then(|p| p.get(0)) - .and_then(|p| p.get("text")) - .and_then(|t| t.as_str()) - .filter(|s| !s.is_empty()) - { - return Some(t.to_string()); - } - // 标准 OpenAI 格式: choices[0].message.content - if let Some(msg) = v - .get("choices") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("message")) - { - let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or(""); - if !content.is_empty() { - return Some(content.to_string()); - } - // reasoning 模型 - if let Some(rc) = msg - .get("reasoning_content") - .and_then(|c| c.as_str()) - .filter(|s| !s.is_empty()) - { - return Some(format!("[reasoning] {rc}")); - } - } - // DashScope 格式: output.text - if let Some(t) = v - .get("output") - .and_then(|o| o.get("text")) - .and_then(|t| t.as_str()) - .filter(|s| !s.is_empty()) - { - return Some(t.to_string()); - } - None - }) - .unwrap_or_else(|| "(模型已响应)".into()); + let reply = extract_single_json_reply(&text); + if reply.is_empty() { + return Err("API 已响应但未解析出内容".to_string()); + } Ok(reply) } @@ -6685,6 +6904,31 @@ fn extract_single_json_reply(text: &str) -> String { serde_json::from_str::(text) .ok() .and_then(|v| { + if let Some(t) = v + .get("output_text") + .and_then(|t| t.as_str()) + .filter(|s| !s.is_empty()) + { + return Some(t.to_string()); + } + if let Some(output) = v.get("output").and_then(|o| o.as_array()) { + let text = output + .iter() + .filter_map(|item| item.get("content").and_then(|c| c.as_array())) + .flatten() + .filter(|part| { + matches!( + part.get("type").and_then(|t| t.as_str()), + Some("output_text") | Some("text") + ) + }) + .filter_map(|part| part.get("text").and_then(|t| t.as_str())) + .collect::>() + .join(""); + if !text.is_empty() { + return Some(text); + } + } if let Some(arr) = v.get("content").and_then(|c| c.as_array()) { let text = arr .iter() @@ -6749,15 +6993,15 @@ fn extract_single_json_reply(text: &str) -> String { #[tauri::command] pub async fn test_model_verbose( base_url: String, - api_key: String, + api_key: Value, model_id: String, api_type: Option, ) -> Result { use std::time::Instant; let api_type_norm = normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions")); - let base = normalize_base_url_for_api(&base_url, api_type_norm); - let api_key = resolve_model_api_key(&api_key)?; + let base = normalize_base_url_for_api(&base_url, &api_type_norm); + let api_key = resolve_model_api_key_value(&api_key)?; let start = Instant::now(); let client = @@ -6768,7 +7012,7 @@ pub async fn test_model_verbose( // - reqwest 未启用 brotli feature 时,provider 返回 Content-Encoding: br 导致 text() 失败 // - 某些 CDN 会根据默认 UA 自动压缩响应 // 测试请求的响应体很小(几百字节),不压缩的性能损失可忽略 - let (used_api, req_url, req_body_json, req_builder) = match api_type_norm { + let (used_api, req_url, req_body_json, req_builder) = match api_type_norm.as_str() { "anthropic-messages" => { let url = format!("{}/messages", base); let body = json!({ @@ -6786,7 +7030,7 @@ pub async fn test_model_verbose( } ("Anthropic Messages", url, body, req) } - "google-gemini" => { + "google-generative-ai" => { let url_display = format!("{}/models/{}:generateContent?key=***", base, model_id); let url_real = format!( "{}/models/{}:generateContent?key={}", @@ -6801,7 +7045,27 @@ pub async fn test_model_verbose( .json(&body); ("Gemini", url_display, body, req) } - _ => { + "openai-responses" | "azure-openai-responses" => { + let url = format!("{}/responses", base); + let body = json!({ + "model": model_id, + "input": "你好,请用一句话回复", + "max_output_tokens": 200 + }); + let mut req = client + .post(&url) + .header("Accept-Encoding", "identity") + .json(&body); + if !api_key.is_empty() { + req = if api_type_norm == "azure-openai-responses" { + req.header("api-key", api_key.clone()) + } else { + req.header("Authorization", format!("Bearer {api_key}")) + }; + } + ("Responses", url, body, req) + } + "openai-completions" | "ollama" => { let url = format!("{}/chat/completions", base); // 关键:测试请求用 stream: true 而非 stream: false // 理由:部分兼容网关的 non-streaming 分支对某些模型会返回 200 + 空 body, @@ -6823,6 +7087,7 @@ pub async fn test_model_verbose( } ("Chat Completions (SSE)", url, body, req) } + _ => return Err(unsupported_direct_model_test(&api_type_norm)), }; let resp_result = req_builder.send().await; @@ -6982,18 +7247,18 @@ pub async fn test_model_verbose( #[tauri::command] pub async fn list_remote_models( base_url: String, - api_key: String, + api_key: Value, api_type: Option, ) -> Result, String> { let api_type = normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions")); - let base = normalize_base_url_for_api(&base_url, api_type); - let api_key = resolve_model_api_key(&api_key)?; + let base = normalize_base_url_for_api(&base_url, &api_type); + let api_key = resolve_model_api_key_value(&api_key)?; let client = crate::commands::build_http_client_no_proxy(std::time::Duration::from_secs(15), None) .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; - let resp = match api_type { + let resp = match api_type.as_str() { "anthropic-messages" => { let url = format!("{}/models", base); let mut req = client.get(&url).header("anthropic-version", "2023-06-01"); @@ -7002,11 +7267,11 @@ pub async fn list_remote_models( } req.send() } - "google-gemini" => { + "google-generative-ai" => { let url = format!("{}/models?key={}", base, api_key); client.get(&url).send() } - _ => { + "openai-completions" | "openai-responses" | "ollama" => { let url = format!("{}/models", base); let mut req = client.get(&url); if !api_key.is_empty() { @@ -7014,6 +7279,15 @@ pub async fn list_remote_models( } req.send() } + "azure-openai-responses" => { + let url = format!("{}/models", base); + let mut req = client.get(&url); + if !api_key.is_empty() { + req = req.header("api-key", api_key.clone()); + } + req.send() + } + _ => return Err(unsupported_direct_model_test(&api_type)), } .await .map_err(|e| { @@ -7847,9 +8121,11 @@ mod write_openclaw_config_merge_tests { use super::fallback_openclaw_node_requirement; use super::merge_configs_preserving_fields; use super::node_version_satisfies_requirement; + use super::normalize_model_api_type; use super::path_without_curdir_string; use super::promote_nested_standalone_dir; use super::replace_standalone_install; + use super::resolve_model_api_key_value; #[cfg(target_os = "windows")] use super::resolve_openclaw_cli_input_path; use super::select_calibration_source; @@ -7858,7 +8134,11 @@ mod write_openclaw_config_merge_tests { use super::standalone_install_dir_impl; use super::standalone_install_version; use super::strip_ui_fields; - use serde_json::json; + use super::supports_native_config_reload; + use super::validate_model_provider_env_refs; + use super::verify_standalone_install; + use super::write_verified_json_with_backup; + use serde_json::{json, Value}; use std::path::{Path, PathBuf}; fn unique_temp_dir(name: &str) -> PathBuf { @@ -7912,6 +8192,40 @@ mod write_openclaw_config_merge_tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn standalone_validation_rejects_missing_runtime_dependency() { + let root = unique_temp_dir("standalone-runtime-missing"); + #[cfg(target_os = "windows")] + let cli_name = "openclaw.cmd"; + #[cfg(not(target_os = "windows"))] + let cli_name = "openclaw"; + let package_dir = root + .join("node_modules") + .join("@qingchencloud") + .join("openclaw-zh"); + std::fs::create_dir_all(&package_dir).unwrap(); + std::fs::write(root.join(cli_name), "").unwrap(); + std::fs::write(root.join("VERSION"), "openclaw_version=2026.7.1-zh.2\n").unwrap(); + std::fs::write( + package_dir.join("package.json"), + serde_json::to_vec(&json!({ + "name": "@qingchencloud/openclaw-zh", + "version": "2026.7.1-zh.2", + "dependencies": { + "@openclaw/ai": "2026.7.1" + } + })) + .unwrap(), + ) + .unwrap(); + + let error = verify_standalone_install(&root, "2026.7.1-zh.2").unwrap_err(); + let _ = std::fs::remove_dir_all(&root); + + assert!(error.contains("缺少运行时依赖")); + assert!(error.contains("@openclaw/ai")); + } + /// Regression guard: Issue #127 merge keeps full provider map when the UI payload /// only touches one provider — `sync_providers_to_agent_models` must use the same /// merged view (see `write_openclaw_config`), not the raw `config` argument. @@ -7948,6 +8262,90 @@ mod write_openclaw_config_merge_tests { assert_eq!(prov["a"]["baseUrl"], json!("http://example")); } + #[test] + fn provider_null_tombstone_deletes_only_target_provider() { + let existing = json!({ + "models": { "providers": { + "a": { "models": [{ "id": "m1" }] }, + "b": { "models": [{ "id": "m2" }] } + }}, + "browser": { "profiles": { "keep": {} } } + }); + let patch = json!({ "models": { "providers": { "a": null } } }); + let merged = merge_configs_preserving_fields(&existing, &patch); + assert!(merged["models"]["providers"].get("a").is_none()); + assert_eq!( + merged["models"]["providers"]["b"]["models"][0]["id"], + json!("m2") + ); + assert!(merged["browser"]["profiles"].get("keep").is_some()); + } + + #[test] + fn model_api_normalization_keeps_real_transport_identity() { + assert_eq!( + normalize_model_api_type("openai-responses"), + "openai-responses" + ); + assert_eq!( + normalize_model_api_type("openai-codex-responses"), + "openai-chatgpt-responses" + ); + assert_eq!(normalize_model_api_type("ollama"), "ollama"); + assert_eq!(normalize_model_api_type("future-adapter"), "future-adapter"); + } + + #[test] + fn strip_ui_fields_migrates_retired_api_at_provider_and_model_level() { + let cleaned = strip_ui_fields(json!({ + "models": { "providers": { "legacy": { + "api": "openai-codex-responses", + "models": [ + { "id": "gpt-test", "api": "openai-codex-responses" }, + "legacy-string-model" + ] + }}} + })); + assert_eq!( + cleaned["models"]["providers"]["legacy"]["api"], + json!("openai-chatgpt-responses") + ); + assert_eq!( + cleaned["models"]["providers"]["legacy"]["models"][0]["api"], + json!("openai-chatgpt-responses") + ); + assert_eq!( + cleaned["models"]["providers"]["legacy"]["models"][1], + json!({ "id": "legacy-string-model", "name": "legacy-string-model" }) + ); + } + + #[test] + fn verified_writer_round_trips_and_preserves_last_good_backup() { + let root = unique_temp_dir("verified-config-write"); + let path = root.join("openclaw.json"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(&path, r#"{"models":{"mode":"merge"}}"#).unwrap(); + let next = json!({ + "models": { "mode": "merge", "providers": { + "custom": { "api": "openai-responses", "models": [{ "id": "gpt-test", "name": "GPT Test" }] } + }} + }); + + write_verified_json_with_backup(&path, &next).unwrap(); + + let written: Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let backup: Value = + serde_json::from_str(&std::fs::read_to_string(root.join("openclaw.json.bak")).unwrap()) + .unwrap(); + assert_eq!(written, next); + assert_eq!(backup["models"]["mode"], json!("merge")); + assert!(backup["models"].get("providers").is_none()); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn calibration_reset_inherits_memory_and_security_extensions() { let baseline = json!({ @@ -8358,4 +8756,54 @@ mod write_openclaw_config_merge_tests { assert_eq!(resolved, Some(node_bin)); } + + #[test] + fn unchanged_external_env_reference_does_not_block_unrelated_save() { + let previous = json!({ + "models": { "providers": { "external": { + "apiKey": "${CLAWPANEL_TEST_EXTERNAL_ONLY}" + } } } + }); + let unchanged = json!({ + "models": { "providers": { "external": { + "apiKey": "${CLAWPANEL_TEST_EXTERNAL_ONLY}" + } } }, + "gateway": { "port": 18789 } + }); + assert!(validate_model_provider_env_refs(&unchanged, Some(&previous)).is_ok()); + + let changed = json!({ + "models": { "providers": { "external": { + "apiKey": "${CLAWPANEL_TEST_NEW_MISSING}" + } } } + }); + let error = validate_model_provider_env_refs(&changed, Some(&previous)).unwrap_err(); + assert!(error.contains("CLAWPANEL_TEST_NEW_MISSING")); + } + + #[test] + fn structured_secret_ref_never_becomes_fake_plaintext() { + let missing_env = json!({ + "source": "env", + "provider": "default", + "id": "CLAWPANEL_TEST_SECRET_REF_MISSING" + }); + let error = resolve_model_api_key_value(&missing_env).unwrap_err(); + assert!(error.contains("CLAWPANEL_TEST_SECRET_REF_MISSING")); + + let file_ref = json!({ + "source": "file", + "provider": "default", + "id": "providers/openai/apiKey" + }); + let error = resolve_model_api_key_value(&file_ref).unwrap_err(); + assert!(error.contains("OpenClaw") && error.contains("运行时")); + } + + #[test] + fn native_config_reload_starts_at_openclaw_2026_7_1() { + assert!(!supports_native_config_reload("2026.6.5")); + assert!(supports_native_config_reload("2026.7.1")); + assert!(supports_native_config_reload("2026.7.1-zh.2")); + } } diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 0e5e083..c708323 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -6,6 +6,7 @@ //! 3. 写入 Hermes Home 下的 config.yaml + .env use serde_json::Value; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; @@ -1681,12 +1682,12 @@ pub async fn hermes_dashboard_start(app: tauri::AppHandle) -> Result Result Option<[u64; 3]> { + text.split_whitespace().find_map(|part| { + let normalized = part.trim_matches(|ch: char| !ch.is_ascii_digit() && ch != '.'); + let values = normalized + .split('.') + .map(str::parse::) + .collect::, _>>() + .ok()?; + (values.len() == 3).then(|| [values[0], values[1], values[2]]) + }) +} + +fn is_hermes_uv_version_supported(version_output: &str) -> bool { + let Some(current) = parse_version_triplet(version_output) else { + return false; + }; + let Some(minimum) = parse_version_triplet(HERMES_MIN_UV_VERSION) else { + return false; + }; + current >= minimum +} + +fn is_uv_wheel_cache_error(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + lower.contains("the wheel is invalid") + || lower.contains("metadata field name not found") + || lower.contains("failed to read from the distribution cache") + || lower.contains("failed to fetch wheel") +} + +/// 确保 uv 二进制可用;版本低于缓存冲突修复基线时下载受管版本。 async fn ensure_uv(app: &tauri::AppHandle) -> Result { ensure_hermes_portable_dirs()?; let uv_path = uv_bin_path(); @@ -1909,8 +1944,16 @@ async fn ensure_uv(app: &tauri::AppHandle) -> Result { if uv_path.exists() { let path_str = uv_path.to_string_lossy().to_string(); if let Ok(ver) = run_silent(&path_str, &["--version"]) { - let _ = app.emit("hermes-install-log", format!("✓ uv 已就绪: {ver}")); - return Ok(path_str); + if is_hermes_uv_version_supported(&ver) { + let _ = app.emit("hermes-install-log", format!("✓ uv 已就绪: {ver}")); + return Ok(path_str); + } + let _ = app.emit( + "hermes-install-log", + format!( + "⚠ uv 版本过低: {ver};Hermes 安装要求 >= {HERMES_MIN_UV_VERSION},将更新受管 uv" + ), + ); } } @@ -1923,11 +1966,19 @@ async fn ensure_uv(app: &tauri::AppHandle) -> Result { // 系统 PATH 中有 uv let enhanced = hermes_enhanced_path(); if let Ok(ver) = run_at_path("uv", &["--version"], &enhanced) { - let _ = app.emit("hermes-install-log", format!("✓ 系统 uv 已就绪: {ver}")); - if let Some(path) = find_executable_path("uv", &enhanced) { - return Ok(path); + if is_hermes_uv_version_supported(&ver) { + let _ = app.emit("hermes-install-log", format!("✓ 系统 uv 已就绪: {ver}")); + if let Some(path) = find_executable_path("uv", &enhanced) { + return Ok(path); + } + return Ok("uv".into()); } - return Ok("uv".into()); + let _ = app.emit( + "hermes-install-log", + format!( + "⚠ 系统 {ver} 早于缓存修复版本 {HERMES_MIN_UV_VERSION},将安装受管 uv {HERMES_UV_VERSION}" + ), + ); } } @@ -1935,7 +1986,7 @@ async fn ensure_uv(app: &tauri::AppHandle) -> Result { let _ = app.emit("hermes-install-log", "📦 下载 uv 包管理器..."); let _ = app.emit("hermes-install-progress", 5u32); - let version = "0.7.12"; // 稳定版本 + let version = HERMES_UV_VERSION; let url = uv_download_url(version); let _ = app.emit("hermes-install-log", format!("下载: {url}")); @@ -2051,10 +2102,41 @@ fn extract_uv_tar_gz(data: &[u8], dest: &std::path::Path) -> Result<(), String> Err("tar.gz 中未找到 uv".into()) } -const HERMES_STABLE_VERSION: &str = "0.18.0"; -const HERMES_STABLE_TAG: &str = "v2026.7.1"; +const HERMES_STABLE_VERSION: &str = "0.18.2"; +const HERMES_STABLE_TAG: &str = "v2026.7.7.2"; const HERMES_GIT_REPO_URL: &str = "https://github.com/NousResearch/hermes-agent.git"; +#[cfg(test)] +mod hermes_uv_install_tests { + use super::*; + + #[test] + fn rejects_uv_before_archive_collision_fix() { + assert!(!is_hermes_uv_version_supported( + "uv 0.11.14 (3fdfdc7d4 2026-05-12 x86_64-pc-windows-msvc)" + )); + assert!(is_hermes_uv_version_supported( + "uv 0.11.24 (5e04460 2026-06-23 x86_64-pc-windows-msvc)" + )); + assert!(is_hermes_uv_version_supported( + "uv 0.11.28 (ebf0f43 2026-07-07 x86_64-pc-windows-msvc)" + )); + } + + #[test] + fn recognizes_invalid_wheel_metadata_as_cache_recoverable() { + assert!(is_uv_wheel_cache_error( + "The wheel is invalid: Metadata field Name not found" + )); + assert!(is_uv_wheel_cache_error( + "Failed to read from the distribution cache" + )); + assert!(!is_uv_wheel_cache_error( + "Could not resolve host: github.com" + )); + } +} + /// Runtime Python deps that `hermes-agent` needs at runtime but are NOT declared as /// install-time dependencies in its `[project].dependencies` (e.g. lazy-loaded /// platform adapters). Without these, `hermes gateway run` starts but cannot bring @@ -2089,8 +2171,10 @@ fn hermes_runtime_extras_log_segment() -> String { // particular, the missing dashboard_auth subpackage breaks `hermes dashboard` // completely, taking down every ClawPanel page that talks to port 9119 // (Profile, Kanban, OAuth, Channels, Sessions detail). -// Current stable Hermes v0.18.0 / v2026.7.1 ships these files; this remains a -// no-op compatibility fallback for users upgrading from older broken installs. +// Current stable Hermes v0.18.2 / v2026.7.7.2 ships dashboard_auth, but a source +// install still omits web_dist. ClawPanel-spawned Dashboard processes use the +// managed HERMES_WEB_DIST below; package injection remains as an idempotent +// compatibility fallback for existing and externally started installations. // // To stay self-sufficient (per project policy: do not patch upstream), we // inject a minimal pass-through stub into the installed venv: @@ -2246,6 +2330,18 @@ const HERMES_DASHBOARD_WEB_DIST_INDEX_HTML: &str = r#" "#; +fn ensure_hermes_dashboard_fallback_dist(home: &Path) -> Result { + let dist = home.join("clawpanel-dashboard-web-dist"); + std::fs::create_dir_all(dist.join("assets")) + .map_err(|e| format!("创建 Hermes Dashboard 兼容资源目录失败: {e}"))?; + let index_path = dist.join("index.html"); + if !index_path.exists() { + std::fs::write(&index_path, HERMES_DASHBOARD_WEB_DIST_INDEX_HTML) + .map_err(|e| format!("写入 Hermes Dashboard 兼容首页失败: {e}"))?; + } + Ok(dist) +} + /// Resolve `/hermes-agent` — the venv root that `uv tool install` /// creates. Returns `None` if `uv` is unavailable or hermes-agent isn't installed /// via the uv-tool path (e.g. user is on the legacy `~/.hermes-venv` uv-pip path). @@ -2632,26 +2728,25 @@ async fn install_via_uv_tool( let pkg = hermes_package_spec(extras); - let mut cmd = tokio::process::Command::new(uv_path); - cmd.args(["tool", "install", "--force", &pkg, "--python", "3.11"]); - append_hermes_runtime_extras(&mut cmd); - - // 配置 PyPI 镜像(extras 的依赖仍从 PyPI 下载) - if let Some(mirror) = pypi_mirror_url() { - cmd.args(["--index-url", &mirror]); - } - - // 代理 - super::apply_proxy_env_tokio(&mut cmd); - let enhanced = hermes_enhanced_path(); - apply_hermes_runtime_env_tokio(&mut cmd, &enhanced); - // uv 需要 git 来克隆仓库 - cmd.env("GIT_TERMINAL_PROMPT", "0"); - // 用户配置了 Git 镜像(如 ghproxy)→ 进程级注入 insteadOf 重写 - apply_git_mirror_env(&mut cmd); - - #[cfg(target_os = "windows")] - cmd.creation_flags(CREATE_NO_WINDOW); + let build_install_command = |no_cache: bool| { + let mut cmd = tokio::process::Command::new(uv_path); + cmd.args(["tool", "install", "--force", &pkg, "--python", "3.11"]); + append_hermes_runtime_extras(&mut cmd); + if no_cache { + cmd.arg("--no-cache"); + } + if let Some(mirror) = pypi_mirror_url() { + cmd.args(["--index-url", &mirror]); + } + super::apply_proxy_env_tokio(&mut cmd); + let enhanced = hermes_enhanced_path(); + apply_hermes_runtime_env_tokio(&mut cmd, &enhanced); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + apply_git_mirror_env(&mut cmd); + #[cfg(target_os = "windows")] + cmd.creation_flags(CREATE_NO_WINDOW); + cmd + }; let _ = app.emit( "hermes-install-log", @@ -2662,7 +2757,16 @@ async fn install_via_uv_tool( ); // 流式执行:安装输出逐行实时显示,不再等进程结束 - let (status, stderr_text) = run_install_command_streaming(app, cmd).await?; + let (mut status, mut stderr_text) = + run_install_command_streaming(app, build_install_command(false)).await?; + if !status.success() && is_uv_wheel_cache_error(&stderr_text) { + let _ = app.emit( + "hermes-install-log", + "⚠ 检测到 uv wheel 缓存异常,正在使用隔离缓存自动重试...", + ); + (status, stderr_text) = + run_install_command_streaming(app, build_install_command(true)).await?; + } if status.success() { let _ = app.emit("hermes-install-log", "✓ uv tool install 完成"); @@ -2747,24 +2851,43 @@ async fn install_via_uv_pip( let pkg = hermes_package_spec(extras); let _ = app.emit( "hermes-install-log", - format!("> uv pip install hermes-agent@{HERMES_STABLE_TAG}"), + format!( + "> uv pip install hermes-agent@{HERMES_STABLE_TAG} {}", + HERMES_RUNTIME_EXTRA_DEPS.join(" ") + ), ); - let mut pip_cmd = tokio::process::Command::new(uv_path); - pip_cmd.args(["pip", "install", &pkg]); - pip_cmd.env("GIT_TERMINAL_PROMPT", "0"); - pip_cmd.env("VIRTUAL_ENV", &venv_str); - apply_hermes_runtime_env_tokio(&mut pip_cmd, &enhanced); - if let Some(mirror) = pypi_mirror_url() { - pip_cmd.args(["--index-url", &mirror]); - } - apply_git_mirror_env(&mut pip_cmd); - super::apply_proxy_env_tokio(&mut pip_cmd); - #[cfg(target_os = "windows")] - pip_cmd.creation_flags(CREATE_NO_WINDOW); + let build_pip_command = |no_cache: bool| { + let mut pip_cmd = tokio::process::Command::new(uv_path); + pip_cmd.args(["pip", "install", &pkg]); + pip_cmd.args(HERMES_RUNTIME_EXTRA_DEPS); + if no_cache { + pip_cmd.arg("--no-cache"); + } + pip_cmd.env("GIT_TERMINAL_PROMPT", "0"); + pip_cmd.env("VIRTUAL_ENV", &venv_str); + apply_hermes_runtime_env_tokio(&mut pip_cmd, &enhanced); + if let Some(mirror) = pypi_mirror_url() { + pip_cmd.args(["--index-url", &mirror]); + } + apply_git_mirror_env(&mut pip_cmd); + super::apply_proxy_env_tokio(&mut pip_cmd); + #[cfg(target_os = "windows")] + pip_cmd.creation_flags(CREATE_NO_WINDOW); + pip_cmd + }; // 流式执行:pip 下载/构建输出逐行实时显示 - let (pip_status, pip_stderr) = run_install_command_streaming(app, pip_cmd).await?; + let (mut pip_status, mut pip_stderr) = + run_install_command_streaming(app, build_pip_command(false)).await?; + if !pip_status.success() && is_uv_wheel_cache_error(&pip_stderr) { + let _ = app.emit( + "hermes-install-log", + "⚠ 检测到 uv wheel 缓存异常,正在使用隔离缓存自动重试...", + ); + (pip_status, pip_stderr) = + run_install_command_streaming(app, build_pip_command(true)).await?; + } if !pip_status.success() { let cleaned = sanitize_hermes_install_output(pip_stderr.trim()); @@ -3104,31 +3227,104 @@ fn merge_env_file(existing: &str, managed_keys: &[&str], new_pairs: &[(String, S content } +fn hermes_stable_backup_path(file: &Path) -> PathBuf { + let mut backup = file.as_os_str().to_os_string(); + backup.push(".bak"); + PathBuf::from(backup) +} + fn replace_hermes_files_transaction(entries: &[(PathBuf, String, bool)]) -> Result<(), String> { let suffix = format!( "{}-{}", std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() ); - let mut staged: Vec<(PathBuf, PathBuf, PathBuf, bool, bool)> = Vec::new(); + let mut staged: Vec<(PathBuf, PathBuf, PathBuf, bool, bool, String)> = Vec::new(); - for (file, content, private) in entries { - #[cfg(not(unix))] - let _ = private; - if let Some(parent) = file.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("创建 Hermes 配置目录失败: {e}"))?; + let staging_result = (|| { + for (file, content, private) in entries { + #[cfg(not(unix))] + let _ = private; + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("创建 Hermes 配置目录失败: {e}"))?; + } + let temp = file.with_extension(format!("tmp-{suffix}")); + let backup = file.with_extension(format!("bak-sync-{suffix}")); + let mut staged_file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|e| format!("创建 Hermes 临时配置失败: {e}"))?; + if let Err(error) = staged_file + .write_all(content.as_bytes()) + .and_then(|_| staged_file.sync_all()) + { + drop(staged_file); + let _ = std::fs::remove_file(&temp); + return Err(format!("写入 Hermes 临时配置失败: {error}")); + } + drop(staged_file); + #[cfg(unix)] + if *private { + use std::os::unix::fs::PermissionsExt; + if let Err(error) = + std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600)) + { + let _ = std::fs::remove_file(&temp); + return Err(format!("设置临时配置权限失败: {error}")); + } + } + staged.push(( + file.clone(), + temp, + backup, + file.exists(), + false, + content.clone(), + )); } - let temp = file.with_extension(format!("tmp-{suffix}")); - let backup = file.with_extension(format!("bak-sync-{suffix}")); - std::fs::write(&temp, content).map_err(|e| format!("写入临时配置失败: {e}"))?; - #[cfg(unix)] - if *private { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600)) - .map_err(|e| format!("设置临时配置权限失败: {e}"))?; + Ok(()) + })(); + if let Err(error) = staging_result { + for entry in &staged { + let _ = std::fs::remove_file(&entry.1); + let _ = std::fs::remove_file(&entry.2); } - staged.push((file.clone(), temp, backup, file.exists(), false)); + return Err(error); + } + + let stable_backup_result = (|| { + for (file, _, private) in entries { + #[cfg(not(unix))] + let _ = private; + if !file.exists() { + continue; + } + let stable_backup = hermes_stable_backup_path(file); + std::fs::copy(file, &stable_backup) + .map_err(|e| format!("创建 Hermes 稳定备份失败: {e}"))?; + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&stable_backup) + .and_then(|backup| backup.sync_all()) + .map_err(|e| format!("同步 Hermes 稳定备份失败: {e}"))?; + #[cfg(unix)] + if *private { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&stable_backup, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("设置 Hermes 稳定备份权限失败: {e}"))?; + } + } + Ok(()) + })(); + if let Err(error) = stable_backup_result { + for entry in &staged { + let _ = std::fs::remove_file(&entry.1); + let _ = std::fs::remove_file(&entry.2); + } + return Err(error); } let result = (|| { @@ -3141,6 +3337,13 @@ fn replace_hermes_files_transaction(entries: &[(PathBuf, String, bool)]) -> Resu .map_err(|e| format!("提交 Hermes 配置失败: {e}"))?; entry.4 = true; } + for entry in &staged { + let readback = std::fs::read_to_string(&entry.0) + .map_err(|e| format!("回读 Hermes 配置失败: {e}"))?; + if readback != entry.5 { + return Err("Hermes 配置写入后回读不一致".into()); + } + } Ok(()) })(); @@ -3231,7 +3434,7 @@ fn sync_hermes_provider_files_at( } replace_hermes_files_transaction(&entries)?; - Ok(serde_json::json!({ "providerId": provider, "envKey": env_key })) + Ok(serde_json::json!({ "providerId": provider, "envKey": env_key, "verified": true })) } #[tauri::command] @@ -3242,6 +3445,7 @@ pub fn hermes_sync_provider( model: Option, set_default: bool, ) -> Result { + let api_key = super::config::resolve_model_api_key(&api_key)?; sync_hermes_provider_files_at( &hermes_home(), &provider, @@ -26197,7 +26401,7 @@ mod hermes_provider_sync_tests { ) .unwrap(); - sync_hermes_provider_files_at( + let result = sync_hermes_provider_files_at( &home, "custom", "sk-new", @@ -26206,6 +26410,7 @@ mod hermes_provider_sync_tests { true, ) .unwrap(); + assert_eq!(result["verified"], serde_json::json!(true)); let env = std::fs::read_to_string(home.join(".env")).unwrap(); assert!(env.contains("ANTHROPIC_API_KEY=keep-me")); @@ -26218,6 +26423,14 @@ mod hermes_provider_sync_tests { assert!(config.contains("default: gpt-test")); assert!(config.contains("provider: custom")); assert!(config.contains("level: INFO")); + assert_eq!( + std::fs::read_to_string(home.join(".env.bak")).unwrap(), + "ANTHROPIC_API_KEY=keep-me\nOPENAI_API_KEY=old\nCUSTOM_FLAG=keep\n" + ); + assert_eq!( + std::fs::read_to_string(home.join("config.yaml.bak")).unwrap(), + "model:\n default: old-model\n provider: anthropic\nlogging:\n level: INFO\n" + ); let _ = std::fs::remove_dir_all(&home); } diff --git a/src-tauri/src/commands/media.rs b/src-tauri/src/commands/media.rs index 40ab264..b6ae7e2 100644 --- a/src-tauri/src/commands/media.rs +++ b/src-tauri/src/commands/media.rs @@ -1,13 +1,17 @@ use base64::{engine::general_purpose, Engine as _}; use futures_util::StreamExt; use serde_json::{json, Map, Value}; +use std::collections::HashSet; use std::path::{Component, Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use tokio::io::AsyncWriteExt; +use tokio::sync::Semaphore; /// media-jobs.json 的读改写锁:并发轮询/写入时防止互相覆盖 static MEDIA_JOBS_LOCK: Mutex<()> = Mutex::new(()); +static MEDIA_QUEUE_INFLIGHT: OnceLock>> = OnceLock::new(); +static MEDIA_QUEUE_SEMAPHORE: OnceLock> = OnceLock::new(); fn lock_media_jobs() -> std::sync::MutexGuard<'static, ()> { MEDIA_JOBS_LOCK @@ -25,6 +29,8 @@ const MEDIA_CONFIG_FILE: &str = "media-config.json"; const MEDIA_JOBS_FILE: &str = "media-jobs.json"; const MAX_IMAGE_COUNT: u64 = 4; const MAX_ASSET_BYTES: u64 = 512 * 1024 * 1024; +const MEDIA_QUEUE_CONCURRENCY: usize = 2; +const MEDIA_QUEUE_POLL_INTERVAL: Duration = Duration::from_secs(5); /// 内嵌预览(base64 IPC)上限:超过此大小引导用户打开文件夹本地查看 const MAX_INLINE_PREVIEW_BYTES: u64 = 64 * 1024 * 1024; @@ -366,16 +372,95 @@ fn read_json_or_default(path: &Path, default: Value) -> Value { } pub(super) fn write_json_atomic(path: &Path, value: &Value) -> Result<(), String> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?; - } + let parent = path + .parent() + .ok_or_else(|| "JSON 路径缺少父目录".to_string())?; + std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?; let content = serde_json::to_string_pretty(value).map_err(|e| format!("序列化失败: {e}"))?; - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, content).map_err(|e| format!("写入临时文件失败: {e}"))?; - if path.exists() { - let _ = std::fs::remove_file(path); + let parsed: Value = + serde_json::from_str(&content).map_err(|e| format!("候选 JSON 校验失败: {e}"))?; + if &parsed != value { + return Err("候选 JSON 序列化后内容不一致".into()); } - std::fs::rename(&tmp, path).map_err(|e| format!("替换文件失败: {e}")) + + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("data.json"); + let tmp = parent.join(format!(".{name}.{}.{suffix}.tmp", std::process::id())); + let rollback = parent.join(format!(".{name}.{}.{suffix}.old", std::process::id())); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .map_err(|e| format!("创建临时文件失败: {e}"))?; + if let Err(error) = + std::io::Write::write_all(&mut file, content.as_bytes()).and_then(|_| file.sync_all()) + { + let _ = std::fs::remove_file(&tmp); + return Err(format!("写入临时文件失败: {error}")); + } + drop(file); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(error) = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) { + let _ = std::fs::remove_file(&tmp); + return Err(format!("设置临时文件权限失败: {error}")); + } + } + + let had_existing = path.exists(); + #[cfg(not(target_os = "windows"))] + let replace_result = { + if had_existing { + std::fs::copy(path, &rollback).map(|_| ()) + } else { + Ok(()) + } + .and_then(|_| std::fs::rename(&tmp, path)) + }; + + #[cfg(target_os = "windows")] + let replace_result = if had_existing { + std::fs::rename(path, &rollback).and_then(|_| { + std::fs::rename(&tmp, path).inspect_err(|_| { + let _ = std::fs::rename(&rollback, path); + }) + }) + } else { + std::fs::rename(&tmp, path) + }; + + if let Err(error) = replace_result { + let _ = std::fs::remove_file(&tmp); + let _ = std::fs::remove_file(&rollback); + return Err(format!("替换 JSON 文件失败,原文件已保留: {error}")); + } + + let readback = std::fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()); + if readback.as_ref() != Some(value) { + if had_existing && rollback.exists() { + let _ = std::fs::copy(&rollback, path); + } else { + let _ = std::fs::remove_file(path); + } + let _ = std::fs::remove_file(&rollback); + return Err("JSON 写入后回读不一致,已恢复原文件".into()); + } + let _ = std::fs::remove_file(&rollback); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) } fn read_media_config_private() -> Value { @@ -465,6 +550,57 @@ fn new_job_id(prefix: &str) -> String { ) } +fn build_queued_media_job( + job_id: &str, + kind: &str, + provider: &MediaProviderConfig, + request: &Value, +) -> Value { + let now = now_iso(); + let model = if kind == "video" { + &provider.video_model + } else { + &provider.image_model + }; + json!({ + "id": job_id, + "type": kind, + "provider": provider.provider, + "providerTaskId": null, + "status": "queued", + "providerStatus": "local-queued", + "prompt": str_field(request, "prompt"), + "model": model, + "createdAt": now, + "updatedAt": now, + "request": truncate_large_strings(request), + "assets": [], + "error": null, + "rawProviderResponse": null + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MediaRecoveryAction { + Schedule, + FailUnknown, + Ignore, +} + +fn media_recovery_action(job: &Value) -> MediaRecoveryAction { + match str_field(job, "status") { + "queued" => MediaRecoveryAction::Schedule, + "running" + if str_field(job, "type") == "video" + && !str_field(job, "providerTaskId").is_empty() => + { + MediaRecoveryAction::Schedule + } + "running" => MediaRecoveryAction::FailUnknown, + _ => MediaRecoveryAction::Ignore, + } +} + fn validate_provider_id(provider: &str) -> Result<(), String> { if is_supported_media_provider(provider) { Ok(()) @@ -1396,32 +1532,6 @@ fn truncate_large_strings(value: &Value) -> Value { } } -fn media_error_job( - id: &str, - kind: &str, - provider: &str, - prompt: &str, - model: &str, - error: String, - raw_response: Option, -) -> Value { - let now = now_iso(); - json!({ - "id": id, - "type": kind, - "provider": provider, - "providerTaskId": null, - "status": "failed", - "prompt": prompt, - "model": model, - "createdAt": now, - "updatedAt": now, - "assets": [], - "error": error, - "rawProviderResponse": raw_response.map(|v| truncate_large_strings(&v)).unwrap_or(Value::Null) - }) -} - #[tauri::command] pub fn read_media_config() -> Result { ensure_media_root()?; @@ -1524,15 +1634,11 @@ pub async fn fetch_media_models(provider: String) -> Result { })) } -#[tauri::command] -pub async fn generate_image(request: Value) -> Result { - validate_image_request(&request)?; - let provider_id = str_field(&request, "provider").to_string(); - let prompt = str_field(&request, "prompt").to_string(); - let provider = load_provider_config(&provider_id, "image", str_field(&request, "model"))?; - let job_id = new_job_id("img"); +async fn execute_queued_image_job(job_id: &str, request: &Value) -> Result { + let provider_id = str_field(request, "provider").to_string(); + let provider = load_provider_config(&provider_id, "image", str_field(request, "model"))?; let client = media_http_client(provider.timeout_seconds)?; - let payload = build_image_generation_payload(&provider, &request); + let payload = build_image_generation_payload(&provider, request); let endpoint = build_api_url(&provider.base_url, "/images/generations"); let resp = client .post(endpoint) @@ -1549,63 +1655,28 @@ pub async fn generate_image(request: Value) -> Result { let parsed = serde_json::from_str::(&text).unwrap_or_else(|_| json!({ "raw": text })); if !status.is_success() { let error = sanitize_provider_error(&format!("HTTP {status}: {parsed}"), &provider.api_key); - let job = media_error_job( - &job_id, - "image", - &provider.provider, - &prompt, - &provider.image_model, - error.clone(), - Some(parsed), - ); - let _ = upsert_media_job(job); return Err(error); } - let assets = match save_image_outputs(&client, &provider, &job_id, &parsed).await { - Ok(assets) => assets, - Err(error) => { - let job = media_error_job( - &job_id, - "image", - &provider.provider, - &prompt, - &provider.image_model, - error.clone(), - Some(parsed), - ); - let _ = upsert_media_job(job); - return Err(error); + let assets = save_image_outputs(&client, &provider, job_id, &parsed).await?; + update_media_job(job_id, |entry| { + entry["request"] = truncate_large_strings(&payload); + entry["assets"] = Value::Array(assets.clone()); + entry["rawProviderResponse"] = truncate_large_strings(&parsed); + entry["error"] = Value::Null; + if str_field(entry, "status") != "canceled" { + entry["status"] = Value::String("succeeded".into()); + entry["providerStatus"] = Value::String("completed".into()); } - }; - let now = now_iso(); - let job = json!({ - "id": job_id, - "type": "image", - "provider": provider.provider, - "providerTaskId": null, - "status": "succeeded", - "prompt": prompt, - "model": provider.image_model, - "createdAt": now, - "updatedAt": now, - "request": truncate_large_strings(&payload), - "assets": assets, - "error": null, - "rawProviderResponse": truncate_large_strings(&parsed) - }); - upsert_media_job(job) + }) } -#[tauri::command] -pub async fn create_video_task(request: Value) -> Result { - validate_video_request(&request)?; - let provider_id = str_field(&request, "provider").to_string(); - let prompt = str_field(&request, "prompt").to_string(); - let provider = load_provider_config(&provider_id, "video", str_field(&request, "model"))?; - let job_id = new_job_id("vid"); +async fn submit_queued_video_job(job_id: &str, request: &Value) -> Result { + let provider_id = str_field(request, "provider").to_string(); + let prompt = str_field(request, "prompt").to_string(); + let provider = load_provider_config(&provider_id, "video", str_field(request, "model"))?; let client = media_http_client(provider.timeout_seconds)?; let (payload, resp) = if is_openai_compatible_provider(&provider.provider) { - let payload = build_openai_video_payload(&provider, &request); + let payload = build_openai_video_payload(&provider, request); let mut form = reqwest::multipart::Form::new() .text("model", str_field(&payload, "model").to_string()) .text("prompt", str_field(&payload, "prompt").to_string()) @@ -1647,9 +1718,9 @@ pub async fn create_video_task(request: Value) -> Result { let mut payload = json!({ "model": provider.video_model.clone(), "content": content, - "ratio": str_field(&request, "ratio"), - "resolution": str_field(&request, "resolution"), - "duration": u64_field(&request, "duration", 5) + "ratio": str_field(request, "ratio"), + "resolution": str_field(request, "resolution"), + "duration": u64_field(request, "duration", 5) }); for key in ["ratio", "resolution"] { if payload[key].as_str().unwrap_or("").is_empty() { @@ -1674,58 +1745,28 @@ pub async fn create_video_task(request: Value) -> Result { let parsed = serde_json::from_str::(&text).unwrap_or_else(|_| json!({ "raw": text })); if !status.is_success() { let error = sanitize_provider_error(&format!("HTTP {status}: {parsed}"), &provider.api_key); - let job = media_error_job( - &job_id, - "video", - &provider.provider, - &prompt, - &provider.video_model, - error.clone(), - Some(parsed), - ); - let _ = upsert_media_job(job); return Err(error); } let provider_task_id = extract_provider_task_id(&parsed); if provider_task_id.is_empty() { - // 任务可能已在服务商侧创建并计费:即使无法识别任务 ID,也要落盘保留原始响应供恢复 - let error = "服务商响应中没有视频任务 ID".to_string(); - let _ = upsert_media_job(media_error_job( - &job_id, - "video", - &provider.provider, - &prompt, - &provider.video_model, - error.clone(), - Some(parsed), - )); - return Err(error); + return Err("服务商响应中没有视频任务 ID".into()); } let provider_status = extract_provider_status(&parsed); let job_status = provider_status_to_job_status(&provider_status); - let now = now_iso(); - let job = json!({ - "id": job_id, - "type": "video", - "provider": provider.provider, - "providerTaskId": provider_task_id, - "status": job_status, - "providerStatus": provider_status, - "prompt": prompt, - "model": provider.video_model, - "createdAt": now, - "updatedAt": now, - "request": truncate_large_strings(&payload), - "assets": [], - "error": null, - "rawProviderResponse": truncate_large_strings(&parsed) - }); - upsert_media_job(job) + update_media_job(job_id, |entry| { + entry["providerTaskId"] = Value::String(provider_task_id.clone()); + entry["providerStatus"] = Value::String(provider_status.clone()); + if str_field(entry, "status") != "canceled" { + entry["status"] = Value::String(job_status.into()); + } + entry["request"] = truncate_large_strings(&payload); + entry["rawProviderResponse"] = truncate_large_strings(&parsed); + entry["error"] = Value::Null; + }) } -#[tauri::command] -pub async fn poll_video_task(job_id: String) -> Result { - let job = media_job_by_id(&job_id).ok_or_else(|| format!("媒体任务不存在: {job_id}"))?; +async fn poll_video_task_internal(job_id: &str) -> Result { + let job = media_job_by_id(job_id).ok_or_else(|| format!("媒体任务不存在: {job_id}"))?; if str_field(&job, "type") != "video" { return Err("只支持轮询视频任务".into()); } @@ -1760,7 +1801,7 @@ pub async fn poll_video_task(job_id: String) -> Result { let error = sanitize_provider_error(&format!("HTTP {status}: {parsed}"), &provider.api_key); // 429 / 5xx 属于瞬时错误:保留原状态允许继续轮询,避免把服务商侧仍在运行的付费任务永久标记为失败 let transient = status.as_u16() == 429 || status.is_server_error(); - return update_media_job(&job_id, |entry| { + return update_media_job(job_id, |entry| { if !transient { entry["status"] = Value::String("failed".into()); } @@ -1776,7 +1817,7 @@ pub async fn poll_video_task(job_id: String) -> Result { let urls = collect_video_urls(&parsed); let mut saved = Vec::new(); for (idx, url) in urls.iter().enumerate() { - match download_asset_to_media_root(&client, &provider, url, "video", &job_id, idx).await + match download_asset_to_media_root(&client, &provider, url, "video", job_id, idx).await { Ok(asset) => saved.push(asset), Err(e) => download_error = Some(e), @@ -1787,7 +1828,7 @@ pub async fn poll_video_task(job_id: String) -> Result { &client, &provider, &provider_task_id, - &job_id, + job_id, saved.len(), ) .await @@ -1798,7 +1839,7 @@ pub async fn poll_video_task(job_id: String) -> Result { } assets = Value::Array(saved); } - update_media_job(&job_id, |entry| { + update_media_job(job_id, |entry| { entry["status"] = Value::String(job_status.into()); entry["providerStatus"] = Value::String(provider_status.clone()); entry["assets"] = assets.clone(); @@ -1811,6 +1852,196 @@ pub async fn poll_video_task(job_id: String) -> Result { }) } +fn media_queue_inflight() -> &'static Mutex> { + MEDIA_QUEUE_INFLIGHT.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn media_queue_semaphore() -> Arc { + MEDIA_QUEUE_SEMAPHORE + .get_or_init(|| Arc::new(Semaphore::new(MEDIA_QUEUE_CONCURRENCY))) + .clone() +} + +fn media_job_is_inflight(job_id: &str) -> bool { + media_queue_inflight() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(job_id) +} + +fn mark_media_job_failed(job_id: &str, error: &str) { + let _ = update_media_job(job_id, |entry| { + if matches!(str_field(entry, "status"), "canceled" | "succeeded") { + return; + } + entry["status"] = Value::String("failed".into()); + entry["providerStatus"] = Value::String("local-failed".into()); + entry["error"] = Value::String(error.to_string()); + }); +} + +async fn process_media_queue_job(job_id: &str) -> Result<(), String> { + let mut job = media_job_by_id(job_id).ok_or_else(|| format!("媒体任务不存在: {job_id}"))?; + if matches!( + str_field(&job, "status"), + "succeeded" | "failed" | "canceled" + ) { + return Ok(()); + } + if str_field(&job, "status") == "queued" { + job = update_media_job(job_id, |entry| { + if str_field(entry, "status") != "queued" { + return; + } + entry["status"] = Value::String("running".into()); + entry["providerStatus"] = Value::String("local-starting".into()); + entry["startedAt"] = Value::String(now_iso()); + entry["error"] = Value::Null; + })?; + if str_field(&job, "status") != "running" { + return Ok(()); + } + let request = job.get("request").cloned().unwrap_or_else(|| json!({})); + if str_field(&job, "type") == "image" { + execute_queued_image_job(job_id, &request).await?; + return Ok(()); + } + submit_queued_video_job(job_id, &request).await?; + } + + loop { + job = match media_job_by_id(job_id) { + Some(job) => job, + None => return Ok(()), + }; + if matches!(str_field(&job, "status"), "canceled" | "failed") { + return Ok(()); + } + if str_field(&job, "status") == "succeeded" + && job + .get("assets") + .and_then(Value::as_array) + .is_some_and(|assets| !assets.is_empty()) + { + return Ok(()); + } + if str_field(&job, "providerTaskId").is_empty() { + return Err("视频任务缺少服务商任务 ID".into()); + } + match poll_video_task_internal(job_id).await { + Ok(updated) + if matches!( + str_field(&updated, "status"), + "succeeded" | "failed" | "canceled" + ) => + { + return Ok(()) + } + Ok(_) => {} + Err(error) => { + let _ = update_media_job(job_id, |entry| { + if str_field(entry, "status") == "running" { + entry["error"] = Value::String(error.clone()); + } + }); + } + } + tokio::time::sleep(MEDIA_QUEUE_POLL_INTERVAL).await; + } +} + +fn schedule_media_job(job_id: String) -> bool { + { + let mut inflight = media_queue_inflight() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !inflight.insert(job_id.clone()) { + return false; + } + } + tauri::async_runtime::spawn(async move { + let permit = match media_queue_semaphore().acquire_owned().await { + Ok(permit) => permit, + Err(error) => { + mark_media_job_failed(&job_id, &format!("后台队列不可用: {error}")); + media_queue_inflight() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&job_id); + return; + } + }; + if let Err(error) = process_media_queue_job(&job_id).await { + mark_media_job_failed(&job_id, &error); + } + drop(permit); + media_queue_inflight() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&job_id); + }); + true +} + +fn recover_media_queue() { + let jobs = read_media_jobs_private() + .get("jobs") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for job in jobs { + let job_id = str_field(&job, "id").to_string(); + if job_id.is_empty() || media_job_is_inflight(&job_id) { + continue; + } + match media_recovery_action(&job) { + MediaRecoveryAction::Schedule => { + schedule_media_job(job_id); + } + MediaRecoveryAction::FailUnknown => mark_media_job_failed( + &job_id, + "应用重启时任务仍在提交中,服务商状态未知,请确认账单后重试", + ), + MediaRecoveryAction::Ignore => {} + } + } +} + +#[tauri::command] +pub async fn generate_image(request: Value) -> Result { + validate_image_request(&request)?; + let provider_id = str_field(&request, "provider"); + let provider = load_provider_config(provider_id, "image", str_field(&request, "model"))?; + let job_id = new_job_id("img"); + let job = upsert_media_job(build_queued_media_job( + &job_id, "image", &provider, &request, + ))?; + schedule_media_job(job_id); + Ok(job) +} + +#[tauri::command] +pub async fn create_video_task(request: Value) -> Result { + validate_video_request(&request)?; + let provider_id = str_field(&request, "provider"); + let provider = load_provider_config(provider_id, "video", str_field(&request, "model"))?; + let job_id = new_job_id("vid"); + let job = upsert_media_job(build_queued_media_job( + &job_id, "video", &provider, &request, + ))?; + schedule_media_job(job_id); + Ok(job) +} + +#[tauri::command] +pub async fn poll_video_task(job_id: String) -> Result { + let job = poll_video_task_internal(&job_id).await?; + if str_field(&job, "status") == "running" { + schedule_media_job(job_id); + } + Ok(job) +} + #[tauri::command] pub async fn cancel_media_job(job_id: String) -> Result { update_media_job(&job_id, |entry| { @@ -1821,6 +2052,7 @@ pub async fn cancel_media_job(job_id: String) -> Result { #[tauri::command] pub fn list_media_jobs(filter: Option) -> Result { + recover_media_queue(); let filter = filter.unwrap_or_else(|| json!({})); let jobs_doc = read_media_jobs_private(); Ok(media_jobs_response_from_doc(&jobs_doc, Some(&filter))) @@ -2114,6 +2346,55 @@ mod tests { assert_eq!(provider_status_to_job_status("cancelled"), "canceled"); } + #[test] + fn queued_media_job_keeps_request_without_provider_credentials() { + let provider = MediaProviderConfig { + provider: "openai".into(), + base_url: "https://api.openai.com/v1".into(), + api_key: "sk-secret".into(), + image_model: "gpt-image-1".into(), + video_model: "sora-2".into(), + timeout_seconds: 600, + }; + let request = json!({ + "provider": "openai", + "prompt": "batch image", + "model": "gpt-image-1", + "count": 2 + }); + + let job = build_queued_media_job("img-test", "image", &provider, &request); + + assert_eq!(job["status"], "queued"); + assert_eq!(job["request"]["prompt"], "batch image"); + assert_eq!(job["request"]["count"], 2); + assert!(job.to_string().find("sk-secret").is_none()); + } + + #[test] + fn recovery_only_resumes_safe_persisted_jobs() { + assert_eq!( + media_recovery_action(&json!({ "status": "queued", "type": "image" })), + MediaRecoveryAction::Schedule + ); + assert_eq!( + media_recovery_action(&json!({ + "status": "running", + "type": "video", + "providerTaskId": "provider-task-1" + })), + MediaRecoveryAction::Schedule + ); + assert_eq!( + media_recovery_action(&json!({ "status": "running", "type": "image" })), + MediaRecoveryAction::FailUnknown + ); + assert_eq!( + media_recovery_action(&json!({ "status": "succeeded", "type": "image" })), + MediaRecoveryAction::Ignore + ); + } + #[test] fn same_host_asset_download_retries_without_auth_on_auth_rejection() { assert!(should_retry_asset_without_auth( diff --git a/src-tauri/src/commands/model_channels.rs b/src-tauri/src/commands/model_channels.rs index 22f0779..5929bfe 100644 --- a/src-tauri/src/commands/model_channels.rs +++ b/src-tauri/src/commands/model_channels.rs @@ -13,7 +13,7 @@ //! 存储位置:openclaw_dir/clawpanel/model-channels.json —— 跟随 OpenClaw //! 数据目录,便携迁移整体复制后自动生效(与媒体数据同一决策)。 -use serde_json::{json, Map, Value}; +use serde_json::{json, Value}; use std::path::PathBuf; const CHANNELS_FILE: &str = "model-channels.json"; @@ -25,7 +25,7 @@ fn channels_path() -> PathBuf { } fn default_channels_doc() -> Value { - json!({ "version": 1, "channels": [], "syncState": {} }) + json!({ "version": 2, "channels": [], "syncState": {} }) } fn str_of(value: &Value, key: &str) -> String { @@ -41,7 +41,18 @@ fn is_keep_sentinel(key: &str) -> bool { key.is_empty() || key == "__KEEP__" || key == "••••••••" || key == "********" } -/// 归一化单个模型条目:接受字符串或 { id, name? } 对象,id 为空则丢弃 +fn normalize_api_type(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "" => "openai-completions".into(), + "openai-codex-responses" => "openai-chatgpt-responses".into(), + "google-gemini" | "gemini" | "google" => "google-generative-ai".into(), + "anthropic" => "anthropic-messages".into(), + "openai" | "openai-chat" => "openai-completions".into(), + other => other.to_string(), + } +} + +/// 归一化单个模型条目,并保留 OpenClaw 的能力、成本和兼容性元数据。 fn normalize_model_entry(entry: &Value) -> Option { if let Some(id) = entry.as_str() { let id = id.trim(); @@ -54,18 +65,44 @@ fn normalize_model_entry(entry: &Value) -> Option { if id.is_empty() { return None; } - let mut out = Map::new(); + let mut out = entry.as_object()?.clone(); out.insert("id".into(), Value::String(id)); let name = str_of(entry, "name"); if !name.is_empty() { out.insert("name".into(), Value::String(name)); + } else { + out.remove("name"); } - if let Some(ctx) = entry.get("contextWindow").and_then(Value::as_u64) { - out.insert("contextWindow".into(), Value::Number(ctx.into())); + for key in ["contextWindow", "contextTokens", "maxTokens"] { + match entry + .get(key) + .and_then(Value::as_u64) + .filter(|value| *value > 0) + { + Some(value) => { + out.insert(key.into(), Value::Number(value.into())); + } + None => { + out.remove(key); + } + } + } + if let Some(api) = entry.get("api").and_then(Value::as_str) { + out.insert("api".into(), Value::String(normalize_api_type(api))); } Some(Value::Object(out)) } +fn normalize_api_key_ref(value: Option<&Value>) -> Option { + let value = value?.as_object()?; + if str_of(&Value::Object(value.clone()), "source").is_empty() + || str_of(&Value::Object(value.clone()), "id").is_empty() + { + return None; + } + Some(Value::Object(value.clone())) +} + /// 归一化单个渠道;current 为同 id 的旧渠道(用于保留旧 Key)。 /// 返回 None 表示条目非法(缺 id/名称),直接丢弃。 fn normalize_channel(entry: &Value, current: Option<&Value>) -> Option { @@ -81,11 +118,37 @@ fn normalize_channel(entry: &Value, current: Option<&Value>) -> Option { } let incoming_key = str_of(entry, "apiKey"); - let api_key = if is_keep_sentinel(&incoming_key) { - current.map(|c| str_of(c, "apiKey")).unwrap_or_default() + let keep_key = is_keep_sentinel(&incoming_key); + let current_key = current.map(|c| str_of(c, "apiKey")).unwrap_or_default(); + let api_key = if keep_key { + current_key.clone() } else { incoming_key }; + let incoming_ref = normalize_api_key_ref(entry.get("apiKeyRef")); + let current_ref = normalize_api_key_ref(current.and_then(|value| value.get("apiKeyRef"))); + let api_key_ref = if keep_key { + incoming_ref.or_else(|| current_ref.clone()) + } else { + None + }; + let stored_version = entry + .get("credentialVersion") + .and_then(Value::as_u64) + .unwrap_or(0); + let current_version = current + .and_then(|value| value.get("credentialVersion")) + .and_then(Value::as_u64) + .unwrap_or_else(|| u64::from(!current_key.is_empty())); + let credential_version = if current.is_some() { + if (!keep_key && api_key != current_key) || api_key_ref != current_ref { + current_version.saturating_add(1) + } else { + current_version + } + } else { + stored_version.max(u64::from(!api_key.is_empty() || api_key_ref.is_some())) + }; let mut models: Vec = Vec::new(); let mut seen = std::collections::HashSet::new(); @@ -109,27 +172,34 @@ fn normalize_channel(entry: &Value, current: Option<&Value>) -> Option { models.first().map(|m| str_of(m, "id")).unwrap_or_default() }; - let api_type = { - let t = str_of(entry, "apiType"); - if t.is_empty() { - "openai-completions".to_string() - } else { - t - } - }; + let api_type = normalize_api_type(&str_of(entry, "apiType")); + let mut provider_config = entry + .get("providerConfig") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + for managed in ["baseUrl", "api", "apiKey", "models"] { + provider_config.remove(managed); + } - Some(json!({ + let mut normalized = json!({ "id": id, "name": name, "presetKey": str_of(entry, "presetKey"), "baseUrl": base_url, "apiType": api_type, "apiKey": api_key, + "credentialVersion": credential_version, + "providerConfig": provider_config, "models": models, "defaultModel": default_model, "enabled": entry.get("enabled").and_then(Value::as_bool).unwrap_or(true), "updatedAt": str_of(entry, "updatedAt"), - })) + }); + if let Some(api_key_ref) = api_key_ref { + normalized["apiKeyRef"] = api_key_ref; + } + Some(normalized) } /// 归一化整个文档;current 提供旧文档以支持保留旧 Key @@ -161,7 +231,7 @@ fn normalize_channels_doc(config: &Value, current: Option<&Value>) -> Value { .cloned() .unwrap_or_else(|| json!({})); - json!({ "version": 1, "channels": channels, "syncState": sync_state }) + json!({ "version": 2, "channels": channels, "syncState": sync_state }) } fn read_channels_private() -> Value { @@ -182,14 +252,19 @@ fn sanitize_doc_for_read(doc: &Value) -> Value { .and_then(Value::as_str) .unwrap_or("") .to_string(); + let has_api_key_ref = normalize_api_key_ref(obj.get("apiKeyRef")).is_some(); obj.insert("apiKey".into(), Value::String(String::new())); obj.insert( "apiKeySaved".into(), - Value::Bool(!api_key.trim().is_empty()), + Value::Bool(!api_key.trim().is_empty() || has_api_key_ref), ); obj.insert( "apiKeyMask".into(), - Value::String(super::media::api_key_mask(&api_key)), + Value::String(if has_api_key_ref { + "SecretRef".into() + } else { + super::media::api_key_mask(&api_key) + }), ); } } @@ -206,7 +281,24 @@ pub fn read_model_channels() -> Result { pub fn write_model_channels(config: Value) -> Result { let current = read_channels_private(); let normalized = normalize_channels_doc(&config, Some(¤t)); - super::media::write_json_atomic(&channels_path(), &normalized)?; + let path = channels_path(); + if path.exists() { + let backup = path.with_extension("json.bak"); + std::fs::copy(&path, &backup).map_err(|e| format!("备份模型渠道配置失败: {e}"))?; + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&backup) + .and_then(|file| file.sync_all()) + .map_err(|e| format!("同步模型渠道备份失败: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("设置模型渠道备份权限失败: {e}"))?; + } + } + super::media::write_json_atomic(&path, &normalized)?; Ok(sanitize_doc_for_read(&normalized)) } @@ -222,6 +314,9 @@ pub fn reveal_model_channel_key(channel_id: String) -> Result { .iter() .find(|c| str_of(c, "id") == channel_id.trim()) .ok_or_else(|| format!("模型渠道不存在: {channel_id}"))?; + if normalize_api_key_ref(channel.get("apiKeyRef")).is_some() { + return Err("该渠道使用 OpenClaw SecretRef,只能原样同步到 OpenClaw".into()); + } Ok(str_of(channel, "apiKey")) } @@ -316,4 +411,79 @@ mod tests { let doc = normalize_channels_doc(&json!({ "channels": [entry] }), None); assert_eq!(doc["channels"][0]["defaultModel"], "gpt-4o"); } + + #[test] + fn rich_model_metadata_survives_normalization() { + let mut entry = sample_channel("ch-rich", "sk-rich"); + entry["models"] = json!([{ + "id": "vision", + "name": "Vision", + "input": ["text", "image"], + "reasoning": true, + "contextWindow": 200000, + "contextTokens": 160000, + "maxTokens": 8192, + "compat": { "supportsDeveloperRole": false }, + "cost": { "input": 1, "output": 2 } + }]); + let doc = normalize_channels_doc(&json!({ "channels": [entry] }), None); + let model = &doc["channels"][0]["models"][0]; + assert_eq!(model["input"], json!(["text", "image"])); + assert_eq!(model["reasoning"], json!(true)); + assert_eq!(model["contextTokens"], json!(160000)); + assert_eq!(model["maxTokens"], json!(8192)); + assert_eq!(model["compat"]["supportsDeveloperRole"], json!(false)); + assert_eq!(model["cost"]["output"], json!(2)); + } + + #[test] + fn retired_api_type_is_migrated_and_key_revision_tracks_changes() { + let mut initial = sample_channel("ch-legacy", "sk-old-same-tail"); + initial["apiType"] = json!("openai-codex-responses"); + let current = normalize_channels_doc(&json!({ "channels": [initial] }), None); + assert_eq!( + current["channels"][0]["apiType"], + json!("openai-chatgpt-responses") + ); + assert_eq!(current["channels"][0]["credentialVersion"], json!(1)); + + let kept = normalize_channels_doc( + &json!({ "channels": [sample_channel("ch-legacy", "__KEEP__")] }), + Some(¤t), + ); + assert_eq!(kept["channels"][0]["credentialVersion"], json!(1)); + + let changed = normalize_channels_doc( + &json!({ "channels": [sample_channel("ch-legacy", "sk-new-same-tail")] }), + Some(¤t), + ); + assert_eq!(changed["channels"][0]["credentialVersion"], json!(2)); + } + + #[test] + fn structured_secret_ref_survives_and_plaintext_replaces_it() { + let secret_ref = json!({ + "source": "env", + "provider": "default", + "id": "OPENAI_API_KEY" + }); + let initial = json!({ + "id": "secret-ref", + "name": "Secret Ref", + "baseUrl": "https://example.com/v1", + "apiType": "openai-responses", + "apiKey": "", + "apiKeyRef": secret_ref, + "models": [{ "id": "gpt-test" }] + }); + let stored = normalize_channel(&initial, None).unwrap(); + assert_eq!(stored["apiKey"], ""); + assert_eq!(stored["apiKeyRef"], secret_ref); + + let mut replacement = stored.clone(); + replacement["apiKey"] = json!("sk-new"); + let replaced = normalize_channel(&replacement, Some(&stored)).unwrap(); + assert_eq!(replaced["apiKey"], "sk-new"); + assert!(replaced.get("apiKeyRef").is_none()); + } } diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index 74588ce..4a956d2 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -1702,6 +1702,10 @@ mod platform { if cli.to_ascii_lowercase().ends_with(".js") { return format!("node {} gateway", quote_batch_path(cli)); } + if cli.to_ascii_lowercase().ends_with(".cmd") || cli.to_ascii_lowercase().ends_with(".bat") + { + return format!("call {} gateway", quote_batch_path(cli)); + } format!("{} gateway", quote_batch_path(cli)) } @@ -1709,7 +1713,7 @@ mod platform { fs::create_dir_all(openclaw_dir).map_err(|e| format!("创建 OpenClaw 目录失败: {e}"))?; let runner_path = openclaw_dir.join("clawpanel-gateway.cmd"); let content = format!( - "@echo off\r\ntitle {GATEWAY_WINDOW_TITLE}\r\necho Starting OpenClaw Gateway. Keep this window open after it starts.\r\necho Close this window to stop Gateway.\r\necho.\r\n{}\r\necho.\r\necho Gateway exited. You can close this window.\r\n", + "@echo off\r\ntitle {GATEWAY_WINDOW_TITLE}\r\necho Starting OpenClaw Gateway. Keep this window open after it starts.\r\necho Close this window to stop Gateway.\r\necho.\r\n{}\r\nexit /b %errorlevel%\r\n", gateway_terminal_command(cli) ); fs::write(&runner_path, content).map_err(|e| format!("写入 Gateway 启动脚本失败: {e}"))?; @@ -1756,6 +1760,7 @@ mod platform { // 外层 cmd /c 自身用 CREATE_NO_WINDOW 隐藏(短命桥接进程), // 内部 `start` 会创建一个真正可见的新控制台窗口运行 runner.cmd。 + // 使用 /C 保证 Gateway 无论以何种状态退出,包装终端都会关闭;错误由面板状态和日志展示。 let mut cmd = StdCommand::new("cmd"); cmd.args([ "/c", @@ -1765,7 +1770,7 @@ mod platform { openclaw_dir_str.as_str(), "cmd", "/D", - "/K", + "/C", runner_path_str.as_str(), ]) .creation_flags(CREATE_NO_WINDOW) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9740365..02b83fd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "ClawPanel", - "version": "0.18.5", + "version": "0.18.6", "identifier": "ai.openclaw.clawpanel", "build": { "frontendDist": "../dist", diff --git a/src/lib/model-channels.js b/src/lib/model-channels.js index 02c006a..5a73a1c 100644 --- a/src/lib/model-channels.js +++ b/src/lib/model-channels.js @@ -4,11 +4,12 @@ * 渠道是唯一维护入口(Base URL + API Key + 模型列表),通过显式同步推送到 * OpenClaw / Hermes / 晴辰助手。同步全部组合现有 API 完成: * - OpenClaw:read/write_openclaw_config(后端自带备份,合并保留未知字段) - * - Hermes:hermes_env_set(写 .env)+ hermes_model_config_save(自带备份) + * - Hermes:hermes_sync_provider(事务更新 .env + config.yaml) * - 助手:一次性拷贝到 localStorage(clawpanel-assistant) * 本模块自身不直接写任何引擎配置文件。 */ import { api } from './tauri-api.js' +import { normalizeModelApiType } from './model-presets.js' export const ASSISTANT_STORAGE_KEY = 'clawpanel-assistant' @@ -32,24 +33,26 @@ export const ASSISTANT_SUPPORTED_API_TYPES = [ ] export function hermesSyncSupported(channel) { - return Boolean(HERMES_TARGET_MAP[channel?.apiType]) + return !channel?.apiKeyRef && Boolean(HERMES_TARGET_MAP[channel?.apiType]) } export function assistantSyncSupported(channel) { - return ASSISTANT_SUPPORTED_API_TYPES.includes(channel?.apiType) + return !channel?.apiKeyRef && ASSISTANT_SUPPORTED_API_TYPES.includes(channel?.apiType) } /** * 渠道内容指纹(djb2):基于脱敏字段计算,用于「已同步 / 有未同步变更」徽标。 - * apiKeyMask 参与计算 —— Key 更换时掩码随之变化。 + * credentialVersion 覆盖明文 Key 变更,apiKeyRef 覆盖结构化凭据引用变更。 */ export function channelFingerprint(channel) { const src = JSON.stringify([ channel?.name || '', channel?.baseUrl || '', - channel?.apiType || '', - channel?.apiKeyMask || '', - (channel?.models || []).map(m => m.id), + normalizeModelApiType(channel?.apiType), + channel?.credentialVersion || channel?.apiKeyMask || '', + channel?.apiKeyRef || null, + channel?.models || [], + channel?.providerConfig || {}, channel?.defaultModel || '', ]) let hash = 5381 @@ -74,17 +77,32 @@ function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {} } +function matchesSubset(expected, actual) { + if (Array.isArray(expected)) { + return Array.isArray(actual) + && expected.length === actual.length + && expected.every((value, index) => matchesSubset(value, actual[index])) + } + if (expected && typeof expected === 'object') { + if (!actual || typeof actual !== 'object' || Array.isArray(actual)) return false + return Object.entries(expected).every(([key, value]) => matchesSubset(value, actual[key])) + } + return Object.is(expected, actual) +} + /** * 同步到 OpenClaw:只 upsert 渠道对应的 models.providers.{key}, * 展开旧对象保留未知字段;渠道模型为准但保留目标已有模型的测试元数据。 */ export async function syncChannelToOpenclaw(channel, { setDefault = false } = {}) { - const apiKey = await api.revealModelChannelKey(channel.id) + const apiKeyRef = asObject(channel?.apiKeyRef) + const apiKeyValue = Object.keys(apiKeyRef).length + ? apiKeyRef + : await api.revealModelChannelKey(channel.id) const config = asObject(await api.readOpenclawConfig()) - config.models = asObject(config.models) - config.models.providers = asObject(config.models.providers) + const providers = asObject(asObject(config.models).providers) const providerKey = channelProviderKey(channel) - const existing = asObject(config.models.providers[providerKey]) + const existing = asObject(providers[providerKey]) const existingModels = Array.isArray(existing.models) ? existing.models : [] // 内核 ModelDefinitionSchema 为 strict 且 id/name 必填(zod-schema.core.ts): @@ -95,6 +113,7 @@ export async function syncChannelToOpenclaw(channel, { setDefault = false } = {} const prevObj = prev && typeof prev === 'object' ? prev : {} const merged = { ...prevObj, + ...model, id: model.id, name: model.name || prevObj.name || model.id, } @@ -102,25 +121,40 @@ export async function syncChannelToOpenclaw(channel, { setDefault = false } = {} return merged }) - config.models.providers[providerKey] = { + const providerPatch = { ...existing, + ...asObject(channel.providerConfig), baseUrl: channel.baseUrl, - api: channel.apiType, - apiKey, + api: normalizeModelApiType(channel.apiType), + apiKey: apiKeyValue, models, } + const patch = { models: { providers: { [providerKey]: providerPatch } } } if (setDefault && channel.defaultModel) { - config.agents = asObject(config.agents) - config.agents.defaults = asObject(config.agents.defaults) - config.agents.defaults.model = { - ...asObject(config.agents.defaults.model), - primary: `${providerKey}/${channel.defaultModel}`, - } + patch.agents = { defaults: { model: { primary: `${providerKey}/${channel.defaultModel}` } } } } - await api.writeOpenclawConfig(config) - return { providerKey, modelCount: models.length } + await api.writeOpenclawConfig(patch) + const readback = asObject(await api.readOpenclawConfig()) + const savedProvider = asObject(asObject(readback.models).providers)[providerKey] + const expectedProvider = { + ...asObject(channel.providerConfig), + baseUrl: channel.baseUrl, + api: normalizeModelApiType(channel.apiType), + apiKey: apiKeyValue, + models, + } + if (!savedProvider || !matchesSubset(expectedProvider, savedProvider)) { + throw new Error(`OpenClaw 配置写入后回读核对失败: ${providerKey}`) + } + if (setDefault && channel.defaultModel) { + const primary = readback?.agents?.defaults?.model?.primary + if (primary !== `${providerKey}/${channel.defaultModel}`) { + throw new Error(`OpenClaw 默认模型写入后回读核对失败: ${providerKey}/${channel.defaultModel}`) + } + } + return { providerKey, modelCount: models.length, verified: true } } /** 解析渠道对应的 Hermes provider(API Key 型;预设命中优先,否则用核对过的回退 id),不支持返回 null */ @@ -168,6 +202,9 @@ export async function syncChannelToHermes(channel, { setDefault = false } = {}) * apiKey 由调用方 reveal 后传入,避免本模块内多次取明文。 */ export function syncChannelToAssistant(channel, apiKey, model = '') { + if (/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(String(apiKey || '').trim())) { + throw new Error('环境变量引用不能直接保存到浏览器助手,请改用明文 Key 或通过后端代理') + } let config = {} try { config = JSON.parse(localStorage.getItem(ASSISTANT_STORAGE_KEY) || '{}') || {} @@ -179,7 +216,18 @@ export function syncChannelToAssistant(channel, apiKey, model = '') { config.model = model || channel.defaultModel || config.model || '' config.apiType = channel.apiType localStorage.setItem(ASSISTANT_STORAGE_KEY, JSON.stringify(config)) - return { model: config.model } + let readback + try { + readback = JSON.parse(localStorage.getItem(ASSISTANT_STORAGE_KEY) || '{}') + } catch { + throw new Error('助手模型配置写入后回读失败') + } + const verified = readback?.baseUrl === config.baseUrl + && readback?.apiKey === config.apiKey + && readback?.model === config.model + && readback?.apiType === config.apiType + if (!verified) throw new Error('助手模型配置写入后回读不一致') + return { model: config.model, verified: true } } /** 从 OpenClaw 现有 providers 生成渠道(跳过已按 presetKey 存在的),用于冷启动导入 */ @@ -191,16 +239,22 @@ export async function importChannelsFromOpenclaw(existingChannels = []) { for (const [key, provider] of Object.entries(providers)) { if (!provider || typeof provider !== 'object' || taken.has(key)) continue const models = (Array.isArray(provider.models) ? provider.models : []) - .map(m => (typeof m === 'string' ? { id: m } : { id: String(m?.id || '').trim(), name: m?.name })) + .map(m => (typeof m === 'string' + ? { id: m } + : { ...m, id: String(m?.id || '').trim(), name: m?.name })) .filter(m => m.id) + const { baseUrl, api: providerApi, apiKey, models: _models, ...providerConfig } = provider + const apiKeyRef = asObject(apiKey) imported.push({ id: `ch-${key}-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e4)}`, name: key, presetKey: key, - baseUrl: String(provider.baseUrl || '').trim().replace(/\/+$/, ''), - apiType: String(provider.api || 'openai-completions').trim(), - apiKey: String(provider.apiKey || ''), + baseUrl: String(baseUrl || '').trim().replace(/\/+$/, ''), + apiType: normalizeModelApiType(providerApi), + apiKey: typeof apiKey === 'string' ? apiKey : '', + ...(Object.keys(apiKeyRef).length ? { apiKeyRef } : {}), models, + providerConfig, defaultModel: '', enabled: true, }) diff --git a/src/lib/model-presets.js b/src/lib/model-presets.js index 03a0541..a983e01 100644 --- a/src/lib/model-presets.js +++ b/src/lib/model-presets.js @@ -6,15 +6,44 @@ // API 接口类型选项 export const API_TYPES = [ { value: 'openai-completions', label: 'OpenAI Chat Completions (最常用)' }, - { value: 'anthropic-messages', label: 'Anthropic Messages' }, { value: 'openai-responses', label: 'OpenAI Responses' }, - { value: 'openai-codex-responses', label: 'OpenAI Codex Responses' }, + { value: 'openai-chatgpt-responses', label: 'OpenAI ChatGPT Responses' }, + { value: 'anthropic-messages', label: 'Anthropic Messages' }, { value: 'google-generative-ai', label: 'Google Gemini' }, + { value: 'google-vertex', label: 'Google Vertex AI' }, { value: 'github-copilot', label: 'GitHub Copilot' }, { value: 'bedrock-converse-stream', label: 'AWS Bedrock' }, { value: 'ollama', label: 'Ollama 本地模型' }, + { value: 'azure-openai-responses', label: 'Azure OpenAI Responses' }, ] +const API_TYPE_ALIASES = new Map([ + ['openai-codex-responses', 'openai-chatgpt-responses'], + ['google-gemini', 'google-generative-ai'], + ['gemini', 'google-generative-ai'], + ['google', 'google-generative-ai'], + ['anthropic', 'anthropic-messages'], + ['openai', 'openai-completions'], + ['openai-chat', 'openai-completions'], +]) + +export function normalizeModelApiType(value) { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized) return 'openai-completions' + return API_TYPE_ALIASES.get(normalized) || normalized +} + +export function isSupportedModelApiType(value) { + const normalized = normalizeModelApiType(value) + return API_TYPES.some(item => item.value === normalized) +} + +export function modelApiTypeOptions(value) { + const normalized = normalizeModelApiType(value) + if (isSupportedModelApiType(normalized)) return API_TYPES + return [{ value: normalized, label: `${normalized} (OpenClaw)` }, ...API_TYPES] +} + // 服务商快捷预设 export const PROVIDER_PRESETS = [ { key: 'qtcool', label: '晴辰云', badge: '推荐', baseUrl: 'https://gpt.qt.cool/v1', api: 'openai-completions', site: 'https://gpt.qt.cool/', desc: '每日签到领免费模型测试额度,邀请好友再送额度,付费低至官方价 2-3 折' }, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 467d230..686e05b 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -263,9 +263,9 @@ function _debouncedReloadGateway() { export const api = { // 服务管理(状态用短缓存,操作不缓存) getServicesStatus: () => cachedInvoke('get_services_status', {}, 10000), - startService: (label) => { invalidate('get_services_status'); return invoke('start_service', { label }) }, - stopService: (label) => { invalidate('get_services_status'); return invoke('stop_service', { label }) }, - restartService: (label) => { invalidate('get_services_status'); return invoke('restart_service', { label }) }, + startService: (label) => { invalidate('get_services_status'); return invoke('start_service', { label }).finally(() => invalidate('get_services_status')) }, + stopService: (label) => { invalidate('get_services_status'); return invoke('stop_service', { label }).finally(() => invalidate('get_services_status')) }, + restartService: (label) => { invalidate('get_services_status'); return invoke('restart_service', { label }).finally(() => invalidate('get_services_status')) }, claimGateway: () => { invalidate('get_services_status'); return invoke('claim_gateway') }, probeGatewayPort: () => invoke('probe_gateway_port'), diagnoseGatewayConnection: () => invoke('diagnose_gateway_connection'), @@ -285,10 +285,18 @@ export const api = { readOpenclawConfig: () => cachedInvoke('read_openclaw_config'), calibrateOpenclawConfig: (mode = 'inherit') => { invalidate('read_openclaw_config', 'check_installation', 'list_backups', 'get_services_status', 'get_status_summary'); return invoke('calibrate_openclaw_config', { mode }).then(r => { _debouncedReloadGateway(); return r }) }, writeOpenclawConfig: (config, opts = {}) => { invalidate('read_openclaw_config'); return invoke('write_openclaw_config', { config }).then(r => { if (opts.noReload !== true) _debouncedReloadGateway(); return r }) }, + deleteOpenclawModelProvider: (providerKey, opts = {}) => { + invalidate('read_openclaw_config') + const config = { models: { providers: { [providerKey]: null } } } + return invoke('write_openclaw_config', { config }).then(r => { + if (opts.noReload !== true) _debouncedReloadGateway() + return r + }) + }, readMcpConfig: () => cachedInvoke('read_mcp_config'), writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) }, reloadGateway: () => invoke('reload_gateway'), - restartGateway: () => invoke('restart_gateway'), + restartGateway: () => { invalidate('get_services_status'); return invoke('restart_gateway').finally(() => invalidate('get_services_status')) }, doctorCheck: () => invoke('doctor_check'), doctorFix: () => invoke('doctor_fix'), listOpenclawVersions: (source = 'chinese') => invoke('list_openclaw_versions', { source }), @@ -521,7 +529,7 @@ export const api = { invalidate('list_media_jobs') return invoke('cancel_media_job', { jobId }) }, - listMediaJobs: (filter = {}) => cachedInvoke('list_media_jobs', { filter }, 5000), + listMediaJobs: (filter = {}) => cachedInvoke('list_media_jobs', { filter }, 1000), deleteMediaJob: (jobId, deleteAssets = true) => { invalidate('list_media_jobs') return invoke('delete_media_job', { jobId, deleteAssets }) diff --git a/src/locales/modules/media.js b/src/locales/modules/media.js index 0f21558..8630531 100644 --- a/src/locales/modules/media.js +++ b/src/locales/modules/media.js @@ -21,12 +21,14 @@ export default { outputDirPlaceholder: _('留空使用默认目录,或填写绝对路径', 'Leave empty for the default folder, or enter an absolute path', '留空使用預設目錄,或填寫絕對路徑'), outputDirHint: _('当前实际目录:{path}', 'Current folder: {path}', '目前實際目錄:{path}'), outputDirOpen: _('打开目录', 'Open Folder', '打開目錄'), + queued: _('排队中', 'Queued', '排隊中'), running: _('运行中', 'Running', '執行中'), succeeded: _('已完成', 'Succeeded', '已完成'), failed: _('失败', 'Failed', '失敗'), canceled: _('已取消', 'Canceled', '已取消'), tabImage: _('图片生成', 'Image', '圖片生成'), tabVideo: _('视频生成', 'Video', '影片生成'), + tabQueue: _('生成队列', 'Generation Queue', '生成佇列'), tabHistory: _('历史产物', 'History', '歷史產物'), tabSettings: _('对接配置', 'Integration', '對接設定'), configureFirst: _('先完成对接配置,保存 API Key 和模型 ID 后即可生成。', 'Finish integration first. Save the API key and model IDs before generating.', '先完成對接設定,保存 API Key 和模型 ID 後即可生成。'), @@ -53,6 +55,11 @@ export default { type: _('类型', 'Type', '類型'), status: _('状态', 'Status', '狀態'), historyEmpty: _('还没有媒体任务。', 'No media jobs yet.', '還沒有媒體任務。'), + queueTitle: _('生成队列', 'Generation Queue', '生成佇列'), + queueCount: _('活跃 {active} 个,共加载 {total} 个任务', '{active} active, {total} loaded', '活躍 {active} 個,共載入 {total} 個任務'), + queueActive: _('正在生成', 'Active Jobs', '正在生成'), + queueEmpty: _('当前没有排队或运行中的任务,可以继续批量提交。', 'No queued or running jobs. You can continue submitting batches.', '目前沒有排隊或執行中的任務,可以繼續批次提交。'), + queueRecent: _('最近完成', 'Recent Jobs', '最近完成'), historyCount: _('已加载 {shown}/{total} 个任务,显示 {assets} 个产物卡片。', 'Loaded {shown}/{total} jobs, showing {assets} asset cards.', '已載入 {shown}/{total} 個任務,顯示 {assets} 個產物卡片。'), loadMore: _('加载更多', 'Load More', '載入更多'), prompt: _('提示词', 'Prompt', '提示詞'), @@ -114,7 +121,9 @@ export default { providerSaved: _('对接配置已保存', 'Integration settings saved', '對接設定已保存'), providerTestOk: _('对接配置检查通过', 'Integration settings look valid', '對接設定檢查通過'), imageSuccess: _('图片生成完成', 'Image generation finished', '圖片生成完成'), + imageQueued: _('图片任务已加入后台队列', 'Image job added to the background queue', '圖片任務已加入背景佇列'), videoTaskCreated: _('视频任务已创建,稍后轮询状态。', 'Video task created. Poll status later.', '影片任務已建立,稍後輪詢狀態。'), + videoQueued: _('视频任务已加入后台队列', 'Video job added to the background queue', '影片任務已加入背景佇列'), taskPolled: _('任务状态已更新', 'Task status updated', '任務狀態已更新'), taskDeleted: _('任务已删除', 'Task deleted', '任務已刪除'), copied: _('已复制', 'Copied', '已複製'), diff --git a/src/pages/chat.js b/src/pages/chat.js index ec12281..afd16e1 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -63,6 +63,7 @@ let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInp let _modelSelectEl = null let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0 +let _isAwaitingResponse = false let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' let _autoScrollEnabled = true, _lastScrollTop = 0, _touchStartY = 0 let _isLoadingHistory = false @@ -405,7 +406,7 @@ function bindEvents(page) { }) _sendBtn.addEventListener('click', () => { - if (_isStreaming) stopGeneration() + if (_isStreaming || _isAwaitingResponse) stopGeneration() else sendMessage() }) @@ -1676,7 +1677,7 @@ function sendMessage() { const attachments = [..._attachments] _attachments = [] renderAttachments() - if (_isSending || _isStreaming) { _messageQueue.push({ text, attachments }); return } + if (_isSending || _isStreaming || _isAwaitingResponse) { _messageQueue.push({ text, attachments }); return } doSend(text, attachments) } @@ -1692,10 +1693,14 @@ async function doSend(text, attachments = []) { }) showTyping(true) _isSending = true + _isAwaitingResponse = true + updateSendState() _startResponseWatchdog() try { - await wsClient.chatSend(_sessionKey, text, attachments.length ? attachments : undefined) + const result = await wsClient.chatSend(_sessionKey, text, attachments.length ? attachments : undefined) + if (result?.runId) _currentRunId = result.runId } catch (err) { + _isAwaitingResponse = false showTyping(false) _cancelResponseWatchdog() _sendTimestamp = 0 @@ -1707,14 +1712,19 @@ async function doSend(text, attachments = []) { } function processMessageQueue() { - if (_messageQueue.length === 0 || _isSending || _isStreaming) return + if (_messageQueue.length === 0 || _isSending || _isStreaming || _isAwaitingResponse) return const msg = _messageQueue.shift() if (typeof msg === 'string') doSend(msg, []) else doSend(msg.text, msg.attachments || []) } function stopGeneration() { - if (_currentRunId) wsClient.chatAbort(_sessionKey, _currentRunId).catch(() => {}) + if (!_sessionKey || (!_currentRunId && !_isStreaming && !_isAwaitingResponse)) return + wsClient.chatAbort(_sessionKey, _currentRunId || undefined).catch((err) => { + console.warn('[chat] 停止生成失败:', err) + }) + _cancelResponseWatchdog() + resetStreamState() } // ── 事件处理(参照 clawapp 实现) ── @@ -1872,6 +1882,7 @@ function handleChatEvent(payload) { if (state === 'delta') { _cancelResponseWatchdog() + _isAwaitingResponse = false const c = extractChatContent(payload.message) if (c?.images?.length) _currentAiImages = c.images if (c?.videos?.length) _currentAiVideos = c.videos @@ -2382,6 +2393,7 @@ function resetStreamState() { _currentAiTools = [] _currentRunId = null _isStreaming = false + _isAwaitingResponse = false _streamStartTime = 0 _lastErrorMsg = null _errorTimer = null @@ -2949,7 +2961,7 @@ function isAtBottom() { function updateSendState() { if (!_sendBtn || !_textarea) return - if (_isStreaming) { + if (_isStreaming || _isAwaitingResponse) { _sendBtn.disabled = false _sendBtn.innerHTML = '' _sendBtn.title = t('chat.cmdStopGen') @@ -3451,6 +3463,7 @@ export function cleanup() { _currentRunId = null _isStreaming = false _isSending = false + _isAwaitingResponse = false _messageQueue = [] _lastHistoryHash = '' _hostedBtn = null diff --git a/src/pages/media.js b/src/pages/media.js index 26901f9..38964c3 100644 --- a/src/pages/media.js +++ b/src/pages/media.js @@ -23,6 +23,15 @@ const MEDIA_PROVIDERS = [ { id: 'newapi', icon: 'globe', defaultBaseUrl: 'https://your-newapi.example.com/v1' }, ] +let activeMediaState = null + +export function cleanup() { + if (!activeMediaState) return + if (activeMediaState.refreshTimer) clearInterval(activeMediaState.refreshTimer) + activeMediaState.previewObserver?.disconnect?.() + activeMediaState = null +} + function providerMeta(providerId) { return MEDIA_PROVIDERS.find(item => item.id === providerId) || MEDIA_PROVIDERS[0] } @@ -95,6 +104,7 @@ function outputDirLabel(config) { } export async function render() { + cleanup() const page = document.createElement('div') page.className = 'page media-page' const state = { @@ -111,7 +121,10 @@ export async function render() { modelFetch: null, modelFetchBusy: false, busy: '', + refreshBusy: false, + refreshTimer: null, } + activeMediaState = state page.innerHTML = `