From b04deb4e547b6298be1c14f8d3a738a8e9a4d9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 5 Jul 2026 11:43:33 -0700 Subject: [PATCH] =?UTF-8?q?fix(release):=200.18.6=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=E3=80=81?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E4=B8=AD=E5=BF=83=E4=B8=8E=E4=BE=BF=E6=90=BA?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=8A=A0=E5=9B=BA=E3=80=81=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=8F=90=E9=80=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按全量代码审查(8 角度 × 对抗验证)修复 10 项确认问题并完成收口: 安全: - 媒体资产下载仅对与服务商 Base URL 同主机的地址携带 API Key,防止密钥外泄 - 助手媒体工具默认关闭需显式开启;付费图片/视频生成在任何模式下强制确认 数据与容错: - 媒体数据统一存放 openclaw 数据目录下,便携迁移后配置与历史直接生效 - 视频轮询 429/5xx 瞬时错误不再永久标记失败;失败任务可重新轮询找回 - 创建视频任务无法识别任务 ID 时保留任务记录与原始响应(可能已计费) - 便携迁移失败时正确显示错误状态;便携命令加入 Web 模式 ALWAYS_LOCAL 性能与体验: - 任务记录落盘前裁剪 base64 等超大字段,媒体历史文件不再膨胀 - 资产预览 stat 预检 + 64MB 上限 + 缓存淘汰;轮询并行化(后端 jobs 加锁) - 初始设置页两阶段渲染:本地检测先出首屏,版本网络查询异步补充 - 创作中心删除改用统一确认弹窗(原生 confirm 在 macOS WebView 失效) - 检查配置/获取模型列表放行 http:// 自建网关;桌面/Web 状态解析对齐 - 统一三处路径包含检查为 utils 共享 helper(Linux 大小写误判修复) - 补充便携模式 hermes-cli-missing 警告翻译(简/繁/英) 其他:修复 web-headless-reload-policy 陈旧断言(e16ff2b 后 ws-client 已无隐式 reload) Co-Authored-By: Claude Fable 5 --- .gitignore | 4 + CHANGELOG.md | 59 + openclaw-version-policy.json | 4 +- scripts/dev-api.js | 1108 ++++++++++- scripts/smoke-portable-usb.ps1 | 146 ++ src-tauri/Cargo.lock | 17 + src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/config.rs | 420 +++- src-tauri/src/commands/hermes.rs | 679 ++++++- src-tauri/src/commands/hermes_providers.rs | 86 +- src-tauri/src/commands/media.rs | 2048 ++++++++++++++++++++ src-tauri/src/commands/mod.rs | 33 +- src-tauri/src/commands/portable.rs | 873 +++++++++ src-tauri/src/lib.rs | 22 +- src-tauri/src/utils.rs | 40 + src/components/sidebar.js | 59 +- src/engines/hermes/lib/providers.js | 3 +- src/engines/hermes/pages/services.js | 8 +- src/engines/hermes/pages/setup.js | 6 + src/lib/gateway-ownership.js | 1 + src/lib/i18n.js | 9 + src/lib/icons.js | 3 + src/lib/tauri-api.js | 39 + src/locales/index.js | 3 +- src/locales/modules/assistant.js | 9 + src/locales/modules/dashboard.js | 1 + src/locales/modules/engine.js | 3 + src/locales/modules/media.js | 123 ++ src/locales/modules/settings.js | 19 + src/locales/modules/setup.js | 5 + src/locales/modules/sidebar.js | 2 + src/main.js | 10 +- src/pages/assistant.js | 217 ++- src/pages/dashboard.js | 2 +- src/pages/media.js | 1090 +++++++++++ src/pages/security.js | 1 + src/pages/settings.js | 109 +- src/pages/setup.js | 63 +- src/style/layout.css | 72 +- tests/assistant-media-tools.test.js | 31 + tests/web-headless-reload-policy.test.js | 7 +- 41 files changed, 7213 insertions(+), 223 deletions(-) create mode 100644 scripts/smoke-portable-usb.ps1 create mode 100644 src-tauri/src/commands/media.rs create mode 100644 src-tauri/src/commands/portable.rs create mode 100644 src/locales/modules/media.js create mode 100644 src/pages/media.js create mode 100644 tests/assistant-media-tools.test.js diff --git a/.gitignore b/.gitignore index 1c2efb1..c2e551a 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,7 @@ src-tauri/gen/schemas/linux-schema.json CONTEXT_STATE.md PROJECT_CONTRACT.md project.yaml + +# 本地测试产物与工具缓存 +output/ +.playwright-cli/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ed3a132..4c8aa34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,65 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [0.18.6] - 2026-07-04 + +### 新功能 (Features) + +- **创作中心** — 新增面板级图片生成与视频生成页面,可在 ClawPanel 内直接对接云端生成服务,功能独立于 OpenClaw 主配置 +- **多服务商媒体生成** — 支持火山方舟、OpenAI / GPT 与 NewAPI / OpenAI 兼容网关,支持分别保存 API Key、Base URL、图片模型、视频模型和超时时间 +- **媒体产物本地管理** — 支持自定义产出目录、图片 / 视频产物本地保存、历史产物网格、分页加载、视频按需预览、复制提示词、定位文件和删除任务 +- **U 盘便携模式** — 支持通过 `portable.json` 或 `CLAWPANEL_PORTABLE_ROOT` 启用便携模式,面板配置、OpenClaw 数据、媒体产物可写入便携目录 +- **迁移为便携式** — 面板设置新增迁移入口,可创建便携目录、写入 `portable.json`、复制当前面板配置与 OpenClaw 数据,并清理本机绝对路径 + +### 兼容性 (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.6.11` / 汉化版 `2026.6.11-zh.1`,并保留新版 `tui`、`tools.toolSearch`、`secrets` 与 `auth.profiles` 等高级配置字段,避免校准修复误清理 + +### 改进 (Improvements) + +- **便携模式安装链路完善** — 便携模式下 standalone OpenClaw 安装 / 升级会写入 `engines/openclaw`,并优先使用 U 盘内置 Node.js 与 CLI +- **避免污染宿主机** — 便携模式下跳过写入用户 PATH 和系统 symlink,standalone 安装失败时不再自动降级到 `npm -g` +- **安装来源识别更完整** — 仪表盘、安装页、设置页和 Gateway 归属提示补充“U 盘便携版”来源,不再显示未知来源 +- **媒体对接引导增强** — 创作中心内置 API Key、模型 ID、模型列表获取、服务商文档和控制台入口提示,密钥读取时只显示掩码 +- **媒体 Web 模式对齐** — Web/headless 后端补齐媒体配置、任务、产物读取、模型列表、图片生成和视频任务接口,便于服务器模式使用 +- **初始设置页首屏提速** — 环境检测改为本地检测先渲染、版本信息(含网络查询)异步补充,首次进入复用启动缓存,不再被 npm registry 查询拖慢首屏 + +### 安全 (Security) + +- **媒体 API Key 防泄漏** — 下载生成产物时仅对与服务商 Base URL 同主机的地址携带 API Key,服务商响应中混入的第三方地址不再收到密钥 +- **助手付费生成必须确认** — 助手的媒体生成工具改为默认关闭、需在设置中显式开启;图片/视频等会产生云端费用的生成在任何助手模式下都会弹出确认,不再受"无限制模式"影响 + +### 修复 (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` 警告的多语言文案,不再显示原始标识符 + +### 测试与验证 (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 视口检查:语言菜单关闭态不再命中隐藏选项,语言切换后侧栏自动收起 + ## [0.18.5] - 2026-06-15 ### 修复 (Fixes) diff --git a/openclaw-version-policy.json b/openclaw-version-policy.json index 733c393..576addd 100644 --- a/openclaw-version-policy.json +++ b/openclaw-version-policy.json @@ -9,10 +9,10 @@ }, "default": { "official": { - "recommended": "2026.5.19" + "recommended": "2026.6.11" }, "chinese": { - "recommended": "2026.5.18-zh.1" + "recommended": "2026.6.11-zh.1" } }, "panels": { diff --git a/scripts/dev-api.js b/scripts/dev-api.js index a25c593..4fac0ed 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -21,11 +21,24 @@ 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_REPO_URL = 'https://github.com/NousResearch/hermes-agent.git' +const HERMES_GIT_URL = `git+${HERMES_REPO_URL}@${HERMES_STABLE_TAG}` function hermesProvider(id, name, authType, baseUrl, baseUrlEnvVar, apiKeyEnvVars, transport, modelsProbe, models, isAggregator = false, cliAuthHint = '') { return { id, name, authType, baseUrl, baseUrlEnvVar, apiKeyEnvVars, transport, modelsProbe, models, isAggregator, cliAuthHint } } +function hermesPackageSpec(extras = []) { + const normalized = Array.isArray(extras) + ? extras.map(v => String(v || '').trim()).filter(Boolean) + : [] + return normalized.length + ? `hermes-agent[${normalized.join(',')}] @ ${HERMES_GIT_URL}` + : `hermes-agent @ ${HERMES_GIT_URL}` +} + 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']), @@ -36,6 +49,7 @@ const HERMES_PROVIDER_REGISTRY = [ hermesProvider('arcee', 'Arcee AI', 'api_key', 'https://api.arcee.ai/api/v1', 'ARCEE_BASE_URL', ['ARCEEAI_API_KEY'], 'openai_chat', 'openai', []), hermesProvider('azure-foundry', 'Azure Foundry', 'api_key', '', 'AZURE_FOUNDRY_BASE_URL', ['AZURE_FOUNDRY_API_KEY'], 'openai_chat', 'openai', [], true), hermesProvider('gmi', 'GMI Cloud', 'api_key', 'https://api.gmi-serving.com/v1', 'GMI_BASE_URL', ['GMI_API_KEY'], 'openai_chat', 'openai', []), + hermesProvider('novita', 'NovitaAI', 'api_key', 'https://api.novita.ai/openai/v1', 'NOVITA_BASE_URL', ['NOVITA_API_KEY'], 'openai_chat', 'openai', ['moonshotai/kimi-k2.5', 'minimax/minimax-m2.7', 'zai-org/glm-5', 'deepseek/deepseek-v3-0324', 'deepseek/deepseek-r1-0528', 'qwen/qwen3-235b-a22b-fp8'], true), hermesProvider('lmstudio', 'LM Studio', 'api_key', 'http://127.0.0.1:1234/v1', 'LM_BASE_URL', ['LM_API_KEY'], 'openai_chat', 'openai', []), hermesProvider('nvidia', 'NVIDIA NIM', 'api_key', 'https://integrate.api.nvidia.com/v1', 'NVIDIA_BASE_URL', ['NVIDIA_API_KEY'], 'openai_chat', 'openai', []), hermesProvider('ollama-cloud', 'Ollama Cloud', 'api_key', 'https://ollama.com/v1', 'OLLAMA_BASE_URL', ['OLLAMA_API_KEY'], 'openai_chat', 'openai', []), @@ -47,7 +61,9 @@ const HERMES_PROVIDER_REGISTRY = [ hermesProvider('alibaba-coding-plan', 'Alibaba Cloud (Coding Plan)', 'api_key', 'https://coding-intl.dashscope.aliyuncs.com/v1', 'ALIBABA_CODING_PLAN_BASE_URL', ['ALIBABA_CODING_PLAN_API_KEY', 'DASHSCOPE_API_KEY'], 'openai_chat', 'openai', ['qwen3-coder-plus', 'qwen3-coder-next', 'qwen3.5-plus', 'qwen3.5-coder']), hermesProvider('minimax-cn', 'MiniMax (China)', 'api_key', 'https://api.minimaxi.com/v1', 'MINIMAX_CN_BASE_URL', ['MINIMAX_CN_API_KEY'], 'anthropic_messages', 'anthropic', ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed']), hermesProvider('xiaomi', 'Xiaomi MiMo', 'api_key', 'https://api.xiaomimimo.com/v1', 'XIAOMI_BASE_URL', ['XIAOMI_API_KEY'], 'openai_chat', 'openai', ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash']), + hermesProvider('stepfun', 'StepFun', 'api_key', 'https://api.stepfun.ai/step_plan/v1', '', ['STEPFUN_API_KEY'], 'openai_chat', 'openai', ['step-3.5-flash']), hermesProvider('bedrock', 'AWS Bedrock', 'aws_sdk', 'https://bedrock-runtime.us-east-1.amazonaws.com', 'BEDROCK_BASE_URL', [], 'anthropic_messages', 'none', []), + hermesProvider('vertex', 'Google Vertex AI', 'vertex', 'https://aiplatform.googleapis.com', '', [], 'openai_chat', 'none', ['google/gemini-3-pro-preview', 'google/gemini-3-flash-preview', 'google/gemini-2.5-pro', 'google/gemini-2.5-flash'], false, '使用 Google Cloud Application Default Credentials 或服务账号 JSON'), hermesProvider('openrouter', 'OpenRouter', 'api_key', 'https://openrouter.ai/api/v1', 'OPENAI_BASE_URL', ['OPENROUTER_API_KEY'], 'openai_chat', 'openai', [], true), hermesProvider('ai-gateway', 'Vercel AI Gateway', 'api_key', 'https://ai-gateway.vercel.sh/v1', 'AI_GATEWAY_BASE_URL', ['AI_GATEWAY_API_KEY'], 'openai_chat', 'openai', ['anthropic/claude-opus-4.6', 'anthropic/claude-sonnet-4.6', 'anthropic/claude-sonnet-4.5', 'anthropic/claude-haiku-4.5', 'openai/gpt-5', 'openai/gpt-4.1', 'openai/gpt-4.1-mini', 'google/gemini-3-pro-preview', 'google/gemini-3-flash', 'google/gemini-2.5-pro', 'google/gemini-2.5-flash', 'deepseek/deepseek-v3.2'], true), hermesProvider('opencode-zen', 'OpenCode Zen', 'api_key', 'https://opencode.ai/zen/v1', 'OPENCODE_ZEN_BASE_URL', ['OPENCODE_ZEN_API_KEY'], 'openai_chat', 'openai', ['gpt-5.4-pro', 'gpt-5.4', 'gpt-5.3-codex', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5', 'gemini-3.1-pro', 'gemini-3-pro', 'minimax-m2.7', 'glm-5.2', 'glm-5', 'kimi-k2.7-code', 'kimi-k2.5', 'qwen3-coder'], true), @@ -56,7 +72,6 @@ const HERMES_PROVIDER_REGISTRY = [ hermesProvider('nous', 'Nous Portal', 'oauth_device_code', 'https://inference-api.nousresearch.com/v1', '', [], 'openai_chat', 'none', ['moonshotai/kimi-k2.6', 'moonshotai/kimi-k2.7-code', 'anthropic/claude-opus-4.7', 'anthropic/claude-sonnet-4.6', 'openai/gpt-5.4', 'google/gemini-3-pro-preview', 'qwen/qwen3.5-plus-02-15', 'minimax/minimax-m2.7', 'z-ai/glm-5.1', 'x-ai/grok-4.20-beta'], true, 'hermes auth login nous'), hermesProvider('openai-codex', 'OpenAI Codex', 'oauth_external', 'https://chatgpt.com/backend-api/codex', '', [], 'codex_responses', 'none', ['gpt-5.5', 'gpt-5.4-mini', 'gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini'], false, 'hermes auth login openai-codex'), hermesProvider('qwen-oauth', 'Qwen OAuth', 'oauth_external', 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', '', [], 'openai_chat', 'none', ['qwen3.5-plus', 'qwen3-coder-plus', 'qwen3-coder-next'], false, 'hermes auth login qwen-oauth'), - hermesProvider('google-gemini-cli', 'Google Gemini (OAuth)', 'oauth_external', 'https://generativelanguage.googleapis.com/v1beta/openai', '', [], 'openai_chat', 'none', ['gemini-2.5-pro', 'gemini-2.5-flash'], false, 'hermes auth login google-gemini-cli'), hermesProvider('minimax-oauth', 'MiniMax (OAuth)', 'oauth_minimax', 'https://api.minimax.io/anthropic', '', [], 'anthropic_messages', 'none', ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'], false, 'hermes auth login minimax-oauth'), hermesProvider('copilot-acp', 'GitHub Copilot ACP', 'external_process', 'http://127.0.0.1:0', 'COPILOT_ACP_BASE_URL', [], 'openai_chat', 'none', ['gpt-4o', 'gpt-4.1', 'claude-3.5-sonnet', 'claude-3.7-sonnet'], false, 'hermes auth login copilot-acp'), hermesProvider('custom', 'Custom OpenAI-Compatible', 'api_key', '', 'OPENAI_BASE_URL', ['OPENAI_API_KEY', 'CUSTOM_API_KEY'], 'openai_chat', 'openai', [], true), @@ -178,8 +193,10 @@ function runHermesSilent(program, args) { function sanitizeHermesInstallOutput(text = '') { return String(text || '') - .replaceAll('git+https://github.com/NousResearch/hermes-agent.git', 'hermes-agent') - .replaceAll('https://github.com/NousResearch/hermes-agent.git', 'hermes-agent') + .replaceAll(HERMES_GIT_URL, 'hermes-agent') + .replaceAll(`${HERMES_REPO_URL}@${HERMES_STABLE_TAG}`, 'hermes-agent') + .replaceAll(`git+${HERMES_REPO_URL}`, 'hermes-agent') + .replaceAll(HERMES_REPO_URL, 'hermes-agent') .replaceAll('https://github.com/NousResearch/hermes-agent', 'hermes-agent') .replaceAll('github.com/NousResearch/hermes-agent.git', 'hermes-agent') .replaceAll('github.com/NousResearch/hermes-agent', 'hermes-agent') @@ -2273,9 +2290,6 @@ function stripUiFields(config) { for (const key of uiRootKeys) { delete config[key] } - if (config.auth && typeof config.auth === 'object' && !Array.isArray(config.auth)) { - delete config.auth.profiles - } if (config.agents && typeof config.agents === 'object' && !Array.isArray(config.agents)) { delete config.agents.profiles if (Array.isArray(config.agents.list)) { @@ -2365,9 +2379,11 @@ const CALIBRATION_RESET_INHERIT_KEYS = [ 'memory', 'models', 'plugins', + 'secrets', 'security', 'session', 'skills', + 'tui', 'wizard', ] @@ -2505,10 +2521,10 @@ function applyResetInheritance(baseConfig, seed) { inheritedKeys.push(key) } } - if (seed.tools?.web) { + if (seed.tools && typeof seed.tools === 'object' && !Array.isArray(seed.tools)) { config.tools = config.tools && typeof config.tools === 'object' && !Array.isArray(config.tools) ? config.tools : {} - config.tools.web = seed.tools.web - inheritedKeys.push('tools.web') + config.tools = mergeConfigsPreservingFields(config.tools, seed.tools) + inheritedKeys.push('tools') } return [config, inheritedKeys] } @@ -8762,6 +8778,12 @@ const ALWAYS_LOCAL = new Set([ 'assistant_list_dir', 'assistant_system_info', 'assistant_list_processes', 'assistant_check_port', 'assistant_web_search', 'assistant_fetch_url', 'assistant_ensure_data_dir', 'assistant_save_image', 'assistant_load_image', 'assistant_delete_image', + 'read_media_config', 'write_media_config', 'test_media_provider', 'fetch_media_models', + 'generate_image', 'create_video_task', 'poll_video_task', + 'cancel_media_job', 'list_media_jobs', 'delete_media_job', + 'reveal_media_asset', 'reveal_media_output_dir', 'load_media_asset', + // 便携模式是本进程属性,不能代理到远程实例 + 'get_portable_status', 'migrate_to_portable', ]) // === 工具函数 === @@ -9100,6 +9122,745 @@ function serverCached(key, ttlMs, fn) { return result } +// === 云端媒体生成(Web 模式) === + +const MEDIA_PROVIDER_VOLCENGINE = 'volcengine' +const MEDIA_PROVIDER_OPENAI = 'openai' +const MEDIA_PROVIDER_NEWAPI = 'newapi' +const MEDIA_DEFAULT_VOLCENGINE_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3' +const MEDIA_DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1' +const MEDIA_DEFAULT_NEWAPI_BASE_URL = 'https://your-newapi.example.com/v1' +const MEDIA_CONFIG_FILE = 'media-config.json' +const MEDIA_JOBS_FILE = 'media-jobs.json' +const MEDIA_MAX_IMAGE_COUNT = 4 +const MEDIA_MAX_ASSET_BYTES = 512 * 1024 * 1024 + +function mediaNowIso() { + return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') +} + +function mediaRoot() { + return path.join(OPENCLAW_DIR, 'clawpanel', 'media') +} + +function mediaOutputRoot(config = readMediaConfigPrivate()) { + const raw = String(config?.outputDir || '').trim() + if (!raw) return mediaRoot() + return path.isAbsolute(raw) ? raw : path.join(mediaRoot(), raw) +} + +function mediaConfigPath() { + return path.join(mediaRoot(), MEDIA_CONFIG_FILE) +} + +function mediaJobsPath() { + return path.join(mediaRoot(), MEDIA_JOBS_FILE) +} + +function ensureMediaRoot() { + const root = mediaRoot() + fs.mkdirSync(path.join(root, 'assets'), { recursive: true }) + return root +} + +function ensureMediaOutputRoot(config = readMediaConfigPrivate()) { + ensureMediaRoot() + const root = mediaOutputRoot(config) + fs.mkdirSync(path.join(root, 'assets'), { recursive: true }) + return root +} + +function defaultMediaConfig() { + return { + version: 1, + outputDir: '', + providers: { + volcengine: { + enabled: false, + baseUrl: MEDIA_DEFAULT_VOLCENGINE_BASE_URL, + apiKey: '', + imageModel: '', + videoModel: '', + timeoutSeconds: 600, + }, + openai: { + enabled: false, + baseUrl: MEDIA_DEFAULT_OPENAI_BASE_URL, + apiKey: '', + imageModel: 'gpt-image-1', + videoModel: 'sora-2', + timeoutSeconds: 600, + }, + newapi: { + enabled: false, + baseUrl: MEDIA_DEFAULT_NEWAPI_BASE_URL, + apiKey: '', + imageModel: 'gpt-image-1', + videoModel: 'sora-2', + timeoutSeconds: 600, + }, + }, + defaults: { + provider: MEDIA_PROVIDER_VOLCENGINE, + image: { size: '2K', count: 1, watermark: true }, + video: { ratio: '16:9', resolution: '720p', duration: 5, pollIntervalSeconds: 5 }, + }, + } +} + +function defaultMediaJobs() { + return { version: 1, jobs: [] } +} + +function readJsonOrDefault(file, fallback) { + try { + const raw = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '') + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed : fallback + } catch { + return fallback + } +} + +function writeJsonAtomic(file, value) { + 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) +} + +function mediaApiKeyMask(key) { + const value = String(key || '').trim() + if (!value) return '' + if ([...value].length <= 8) return '已保存' + return `${value.slice(0, 3)}***${value.slice(-4)}` +} + +function mediaProviderIds() { + return [MEDIA_PROVIDER_VOLCENGINE, MEDIA_PROVIDER_OPENAI, MEDIA_PROVIDER_NEWAPI] +} + +function isSupportedMediaProvider(provider) { + return mediaProviderIds().includes(provider) +} + +function isOpenAICompatibleMediaProvider(provider) { + return provider === MEDIA_PROVIDER_OPENAI || provider === MEDIA_PROVIDER_NEWAPI +} + +function defaultMediaProviderBaseUrl(provider) { + if (provider === MEDIA_PROVIDER_OPENAI) return MEDIA_DEFAULT_OPENAI_BASE_URL + if (provider === MEDIA_PROVIDER_NEWAPI) return MEDIA_DEFAULT_NEWAPI_BASE_URL + return MEDIA_DEFAULT_VOLCENGINE_BASE_URL +} + +function normalizeMediaProviderConfig(providerId, rawProvider = {}, currentProvider = {}) { + const incoming = rawProvider && typeof rawProvider === 'object' ? rawProvider : {} + const current = currentProvider && typeof currentProvider === 'object' ? currentProvider : {} + const incomingKey = String(incoming.apiKey || '').trim() + const oldKey = String(current.apiKey || '').trim() + const keepOld = !incomingKey || incomingKey === '__KEEP__' || incomingKey === '••••••••' || incomingKey === '********' + const timeout = Number(incoming.timeoutSeconds || 600) + return { + enabled: Boolean(incoming.enabled), + baseUrl: String(incoming.baseUrl || defaultMediaProviderBaseUrl(providerId)).trim().replace(/\/+$/, ''), + apiKey: keepOld ? oldKey : incomingKey, + imageModel: String(incoming.imageModel || '').trim(), + videoModel: String(incoming.videoModel || '').trim(), + timeoutSeconds: Math.min(1800, Math.max(30, Number.isFinite(timeout) ? timeout : 600)), + } +} + +function normalizeMediaConfig(config = {}, current = null) { + const base = defaultMediaConfig() + const raw = config && typeof config === 'object' ? config : {} + const rawProviders = raw.providers && typeof raw.providers === 'object' ? raw.providers : {} + const providers = { ...base.providers, ...rawProviders } + for (const providerId of mediaProviderIds()) { + providers[providerId] = normalizeMediaProviderConfig( + providerId, + rawProviders?.[providerId], + current?.providers?.[providerId] || {}, + ) + } + const rawDefaults = raw.defaults && typeof raw.defaults === 'object' ? raw.defaults : {} + const defaultProvider = isSupportedMediaProvider(String(rawDefaults.provider || '').trim()) + ? String(rawDefaults.provider).trim() + : MEDIA_PROVIDER_VOLCENGINE + return { + ...base, + ...raw, + version: 1, + outputDir: String(raw.outputDir || '').trim(), + providers, + defaults: { + ...base.defaults, + ...rawDefaults, + provider: defaultProvider, + }, + } +} + +function sanitizeMediaConfigForRead(config) { + const sanitized = normalizeMediaConfig(config) + sanitized.resolvedOutputDir = mediaOutputRoot(sanitized) + for (const providerId of mediaProviderIds()) { + const provider = sanitized.providers[providerId] + const apiKey = provider?.apiKey || '' + if (!provider) continue + provider.apiKey = '' + provider.apiKeySaved = !!apiKey.trim() + provider.apiKeyMask = mediaApiKeyMask(apiKey) + } + return sanitized +} + +function readMediaConfigPrivate() { + return normalizeMediaConfig(readJsonOrDefault(mediaConfigPath(), defaultMediaConfig())) +} + +function readMediaJobsPrivate() { + const doc = readJsonOrDefault(mediaJobsPath(), defaultMediaJobs()) + return { + version: 1, + jobs: Array.isArray(doc.jobs) ? doc.jobs : [], + } +} + +function writeMediaJobsPrivate(doc) { + writeJsonAtomic(mediaJobsPath(), { + version: 1, + jobs: Array.isArray(doc.jobs) ? doc.jobs : [], + }) +} + +function mediaStr(value, key) { + return String(value?.[key] || '').trim() +} + +function mediaPageNumber(value, key, fallback, max) { + const n = Number(value?.[key]) + if (!Number.isFinite(n) || n < 0) return Math.min(fallback, max) + return Math.min(Math.floor(n), max) +} + +function buildMediaJobsResponse(doc, filter = {}) { + let jobs = Array.isArray(doc?.jobs) ? [...doc.jobs] : [] + const type = mediaStr(filter, 'type') + const status = mediaStr(filter, 'status') + if (type) jobs = jobs.filter(job => job.type === type) + if (status) jobs = jobs.filter(job => job.status === status) + jobs.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || ''))) + const total = jobs.length + const offset = mediaPageNumber(filter, 'offset', 0, total) + const limit = Math.max(1, mediaPageNumber(filter, 'limit', 24, 200)) + const paged = jobs.slice(offset, offset + limit) + return { + version: 1, + jobs: paged, + total, + offset, + limit, + hasMore: offset + paged.length < total, + } +} + +function mediaNum(value, key, fallback) { + const n = Number(value?.[key]) + return Number.isFinite(n) ? n : fallback +} + +function validateMediaProviderId(provider) { + if (!isSupportedMediaProvider(provider)) throw new Error(`暂不支持媒体服务商: ${provider}`) +} + +function validateMediaPrompt(prompt) { + const value = String(prompt || '').trim() + if (!value) throw new Error('提示词不能为空') + if ([...value].length > 6000) throw new Error('提示词过长,请控制在 6000 字以内') +} + +function isValidMediaSize(size) { + const value = String(size || '').trim() + if (!value) return true + if (['1K', '2K', '4K', 'adaptive', 'auto'].includes(value)) return true + const match = value.match(/^(\d{3,4})[xX](\d{3,4})$/) + if (!match) return false + const w = Number(match[1]) + const h = Number(match[2]) + return w >= 256 && w <= 8192 && h >= 256 && h <= 8192 +} + +function validateImageRequest(request) { + validateMediaProviderId(mediaStr(request, 'provider')) + validateMediaPrompt(mediaStr(request, 'prompt')) + const count = mediaNum(request, 'count', 1) + if (count < 1 || count > MEDIA_MAX_IMAGE_COUNT) throw new Error(`图片数量必须在 1-${MEDIA_MAX_IMAGE_COUNT} 之间`) + if (!isValidMediaSize(mediaStr(request, 'size'))) throw new Error('图片尺寸格式不正确,可填 2K、4K 或 1024x1024') +} + +function validateVideoRequest(request) { + validateMediaProviderId(mediaStr(request, 'provider')) + validateMediaPrompt(mediaStr(request, 'prompt')) + const duration = mediaNum(request, 'duration', 5) + if (duration < 1 || duration > 30) throw new Error('视频时长必须在 1-30 秒之间') + const ratio = mediaStr(request, 'ratio') + if (ratio && !['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'].includes(ratio)) { + throw new Error('视频比例暂只支持 16:9、9:16、1:1、4:3、3:4、21:9') + } + const resolution = mediaStr(request, 'resolution') + if (resolution && !['480p', '720p', '1080p', '1440p'].includes(resolution)) { + throw new Error('视频分辨率暂只支持 480p、720p、1080p、1440p') + } +} + +function loadMediaProviderConfig(provider, kind, requestModel = '') { + validateMediaProviderId(provider) + const raw = readMediaConfigPrivate().providers?.[provider] + if (!raw) throw new Error(`媒体服务商未配置: ${provider}`) + const apiKey = String(raw.apiKey || '').trim() + if (!apiKey) throw new Error('请先在创作中心对接配置里填写 API Key') + const imageModel = String(raw.imageModel || '').trim() + const videoModel = String(raw.videoModel || '').trim() + const selectedModel = String(requestModel || '').trim() || (kind === 'image' ? imageModel : videoModel) + if (!selectedModel) throw new Error(kind === 'image' ? '请先填写图片模型 ID' : '请先填写视频模型 ID') + return { + provider, + baseUrl: String(raw.baseUrl || defaultMediaProviderBaseUrl(provider)).trim().replace(/\/+$/, ''), + apiKey, + imageModel: kind === 'image' ? selectedModel : imageModel, + videoModel: kind === 'video' ? selectedModel : videoModel, + timeoutSeconds: Math.min(1800, Math.max(30, Number(raw.timeoutSeconds || 600))), + } +} + +function buildMediaApiUrl(baseUrl, suffix) { + return `${String(baseUrl || MEDIA_DEFAULT_VOLCENGINE_BASE_URL).replace(/\/+$/, '')}${suffix}` +} + +function mediaProviderDocs(provider) { + if (provider === MEDIA_PROVIDER_OPENAI) { + return { + apiKey: 'https://platform.openai.com/api-keys', + modelList: 'https://developers.openai.com/api/docs/models', + image: 'https://developers.openai.com/api/docs/guides/image-generation', + video: 'https://developers.openai.com/api/docs/guides/video-generation', + } + } + if (provider === MEDIA_PROVIDER_NEWAPI) { + return { + apiKey: 'https://docs.newapi.pro/en/docs/api', + modelList: 'https://docs.newapi.pro/en/docs/api', + image: 'https://docs.newapi.pro/en/docs/api/ai-model/images/openai/post-v1-images-generations', + video: 'https://docs.newapi.pro/en/docs/api/ai-model/videos/sora/createvideo', + } + } + return { + apiKey: 'https://www.volcengine.com/docs/82379/1541594', + modelList: 'https://www.volcengine.com/docs/82379/1330310', + } +} + +function inferMediaModelCapabilities(id, label = '') { + const text = `${id || ''} ${label || ''}`.toLowerCase() + const caps = [] + if (text.includes('seedream') || text.includes('gpt-image') || text.includes('dall-e') || text.includes('text-to-image') || text.includes('image-generation') || text.includes('image_generation') || text.includes('t2i')) { + caps.push('image') + } + if (text.includes('seedance') || text.includes('sora') || text.includes('text-to-video') || text.includes('image-to-video') || text.includes('video-generation') || text.includes('video_generation') || text.includes('t2v') || text.includes('i2v')) { + caps.push('video') + } + return caps +} + +function isGptImageModel(model = '') { + return String(model || '').trim().toLowerCase().startsWith('gpt-image') +} + +function normalizeOpenAIImageSize(size = '') { + const value = String(size || '').trim() + if (!value) return '' + if (value.toLowerCase() === 'auto') return 'auto' + if (['1K', '2K', '4K', 'adaptive'].includes(value)) return '1024x1024' + return value +} + +function buildMediaImagePayload(provider, request) { + const payload = { + model: provider.imageModel, + prompt: mediaStr(request, 'prompt'), + n: mediaNum(request, 'count', 1), + } + if (isOpenAICompatibleMediaProvider(provider.provider)) { + const size = normalizeOpenAIImageSize(mediaStr(request, 'size')) + if (size) payload.size = size + if (!isGptImageModel(provider.imageModel)) payload.response_format = 'b64_json' + } else { + const size = mediaStr(request, 'size') + if (size) payload.size = size + payload.response_format = 'url' + payload.watermark = request.watermark !== false + } + if (Array.isArray(request.images)) payload.images = request.images + return payload +} + +function openAIVideoSeconds(duration) { + const value = Number(duration || 5) + const candidates = [4, 8, 12] + return String(candidates.reduce((best, item) => Math.abs(item - value) < Math.abs(best - value) ? item : best, 4)) +} + +function openAIVideoSize(ratio = '', resolution = '') { + const high = ['1080p', '1440p'].includes(String(resolution || '').trim()) + if (['9:16', '3:4'].includes(String(ratio || '').trim())) { + return high ? '1024x1792' : '720x1280' + } + return high ? '1792x1024' : '1280x720' +} + +function buildOpenAIVideoPayload(provider, request) { + const payload = { + model: provider.videoModel, + prompt: mediaStr(request, 'prompt'), + seconds: openAIVideoSeconds(mediaNum(request, 'duration', 5)), + size: openAIVideoSize(mediaStr(request, 'ratio'), mediaStr(request, 'resolution')), + } + const imageUrl = mediaStr(request, 'imageUrl') + if (imageUrl) { + if (provider.provider === MEDIA_PROVIDER_NEWAPI) payload.image = imageUrl + else payload.input_reference = { type: 'image_url', image_url: { url: imageUrl } } + } + return payload +} + +function firstModelString(item, keys) { + if (!item || typeof item !== 'object') return '' + for (const key of keys) { + const value = item[key] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return '' +} + +function collectMediaModelItems(value, out = []) { + if (Array.isArray(value)) { + out.push(...value) + return out + } + if (!value || typeof value !== 'object') return out + for (const key of ['data', 'models', 'items', 'Items', 'ModelList', 'model_list', 'foundationModels', 'FoundationModels']) { + if (Array.isArray(value[key])) out.push(...value[key]) + } + for (const key of ['result', 'Result', 'response', 'Response']) { + collectMediaModelItems(value[key], out) + } + return out +} + +function extractMediaModels(payload) { + const seen = new Set() + const models = [] + for (const item of collectMediaModelItems(payload)) { + const id = firstModelString(item, ['id', 'model', 'model_id', 'modelId', 'ModelId', 'ModelID', 'ModelName', 'FoundationModelId', 'FoundationModelName', 'EndpointId', 'endpoint_id']) + if (!id || seen.has(id)) continue + seen.add(id) + const label = firstModelString(item, ['name', 'display_name', 'displayName', 'DisplayName', 'Description', 'description']) || id + models.push({ id, label, capabilities: inferMediaModelCapabilities(id, label) }) + } + return models.sort((a, b) => a.id.localeCompare(b.id)) +} + +function providerStatusToJobStatus(status) { + const value = String(status || '').trim().toLowerCase() + if (['created', 'queued', 'pending', 'running', 'processing', 'in_progress'].includes(value)) return 'running' + if (['succeeded', 'success', 'completed', 'done'].includes(value)) return 'succeeded' + if (['cancelled', 'canceled'].includes(value)) return 'canceled' + if (['failed', 'error', 'expired'].includes(value)) return 'failed' + return 'running' +} + +function extractNestedString(value, keys) { + if (!value || typeof value !== 'object') return '' + for (const key of keys) { + if (typeof value[key] === 'string' && value[key].trim()) return value[key].trim() + } + for (const key of ['data', 'task', 'result', 'output']) { + const nested = extractNestedString(value[key], keys) + if (nested) return nested + } + return '' +} + +function extractProviderTaskId(value) { + return extractNestedString(value, ['id', 'task_id', 'taskId', 'providerTaskId']) +} + +function extractProviderStatus(value) { + return extractNestedString(value, ['status', 'state', 'task_status', 'taskStatus']) || 'running' +} + +function safeMediaRelativePath(relPath) { + const raw = String(relPath || '').trim() + if (!raw || path.isAbsolute(raw)) return false + const normalized = path.normalize(raw) + if (normalized.startsWith('..') || normalized.includes(`..${path.sep}`)) return false + return true +} + +function validateMediaOutputDir(outputDir = '') { + const raw = String(outputDir || '').trim() + if (!raw || path.isAbsolute(raw)) return + const normalized = path.normalize(raw) + if (normalized.startsWith('..') || normalized.includes(`..${path.sep}`)) { + throw new Error('产出目录不能包含 .. 或非法路径片段') + } +} + +function sameMediaPath(a, b) { + const left = path.resolve(String(a || '')) + const right = path.resolve(String(b || '')) + return process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right +} + +function mediaJobsKnowAssetRoot(relPath, root) { + const doc = readMediaJobsPrivate() + return doc.jobs.some(job => Array.isArray(job.assets) && job.assets.some(asset => { + return String(asset?.path || '') === String(relPath || '') && asset?.root && sameMediaPath(asset.root, root) + })) +} + +function resolveMediaPath(relPath, rootHint = '') { + if (!safeMediaRelativePath(relPath)) throw new Error('非法媒体文件路径') + const defaultRoot = path.resolve(mediaRoot()) + const root = path.resolve(String(rootHint || '').trim() || defaultRoot) + if ( + String(rootHint || '').trim() && + !sameMediaPath(root, defaultRoot) && + !sameMediaPath(root, mediaOutputRoot(readMediaConfigPrivate())) && + !mediaJobsKnowAssetRoot(relPath, root) + ) { + throw new Error('媒体文件路径不在已知产出目录中') + } + const target = path.resolve(root, relPath) + if (target !== root && !target.startsWith(root + path.sep)) throw new Error('非法媒体文件路径') + return target +} + +function contentTypeToExt(contentType, fallbackKind) { + const ct = String(contentType || '').toLowerCase() + if (ct.includes('image/png')) return 'png' + if (ct.includes('image/webp')) return 'webp' + if (ct.includes('image/gif')) return 'gif' + if (ct.includes('video/mp4')) return 'mp4' + if (ct.includes('video/webm')) return 'webm' + if (fallbackKind === 'video') return 'mp4' + return 'jpg' +} + +function extToMime(ext, kind) { + const value = String(ext || '').toLowerCase() + if (value === 'png') return 'image/png' + if (value === 'webp') return 'image/webp' + if (value === 'gif') return 'image/gif' + if (value === 'mp4') return 'video/mp4' + if (value === 'webm') return 'video/webm' + if (value === 'mov') return 'video/quicktime' + return kind === 'video' ? 'video/mp4' : 'image/jpeg' +} + +function mediaAssetRelativePath(jobId, index, ext) { + const now = new Date() + const yyyy = String(now.getFullYear()) + const mm = String(now.getMonth() + 1).padStart(2, '0') + return path.join('assets', yyyy, mm, `${jobId}-${index}.${ext}`).replace(/\\/g, '/') +} + +function writeMediaAssetBytes(kind, jobId, index, buffer, mime, sourceUrl = '') { + if (buffer.length > MEDIA_MAX_ASSET_BYTES) throw new Error('媒体文件超过 512MB,已停止保存') + const ext = contentTypeToExt(mime, kind) + const rel = mediaAssetRelativePath(jobId, index, ext) + const root = ensureMediaOutputRoot() + const target = resolveMediaPath(rel, root) + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, buffer) + return { kind, path: rel, root, mime, bytes: buffer.length, sourceUrl } +} + +async function fetchWithTimeout(url, options = {}, timeoutSeconds = 600) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000) + try { + return await fetch(url, { ...options, signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +async function readProviderJson(resp) { + const text = await resp.text() + try { + return JSON.parse(text) + } catch { + return { raw: text } + } +} + +function sanitizeProviderError(raw, apiKey) { + const value = String(raw || '') + return apiKey ? value.replaceAll(apiKey, '***') : value +} + +// 仅当资产 URL 与服务商 Base URL 同主机同端口时才允许携带 API Key, +// 防止服务商响应中混入第三方 URL 后把密钥发给任意主机 +function mediaAssetUrlSameHost(url, baseUrl) { + try { + return new URL(url).host.toLowerCase() === new URL(baseUrl).host.toLowerCase() + } catch { + return false + } +} + +async function downloadMediaAsset(provider, url, kind, jobId, index) { + const sameHost = mediaAssetUrlSameHost(url, provider.baseUrl) + let resp = await fetchWithTimeout(url, sameHost ? { + headers: { Authorization: `Bearer ${provider.apiKey}` }, + } : {}, provider.timeoutSeconds) + if (!resp.ok && sameHost && [401, 403].includes(resp.status)) { + resp = await fetchWithTimeout(url, {}, provider.timeoutSeconds) + } + if (!resp.ok) throw new Error(`下载媒体资产失败: HTTP ${resp.status}`) + const mime = resp.headers.get('content-type') || (kind === 'video' ? 'video/mp4' : 'image/jpeg') + const bytes = Buffer.from(await resp.arrayBuffer()) + return writeMediaAssetBytes(kind, jobId, index, bytes, mime, url) +} + +async function downloadOpenAIVideoContent(provider, providerTaskId, jobId, index) { + const resp = await fetchWithTimeout(buildMediaApiUrl(provider.baseUrl, `/videos/${providerTaskId}/content`), { + headers: { Authorization: `Bearer ${provider.apiKey}` }, + }, provider.timeoutSeconds) + if (!resp.ok) throw new Error(`下载视频内容失败: HTTP ${resp.status}`) + const mime = resp.headers.get('content-type') || 'video/mp4' + const bytes = Buffer.from(await resp.arrayBuffer()) + return writeMediaAssetBytes('video', jobId, index, bytes, mime) +} + +function collectImageOutputs(value) { + if (Array.isArray(value?.data)) return value.data + if (Array.isArray(value?.images)) return value.images + if (value?.url || value?.b64_json || value?.image_url) return [value] + return [] +} + +async function saveImageOutputs(provider, jobId, response) { + const outputs = collectImageOutputs(response) + if (!outputs.length) throw new Error('服务商响应中没有图片结果') + const assets = [] + for (let i = 0; i < outputs.length; i += 1) { + const item = outputs[i] + const rawUrl = typeof item?.url === 'string' ? item.url + : typeof item?.image_url === 'string' ? item.image_url + : typeof item?.image_url?.url === 'string' ? item.image_url.url + : '' + if (rawUrl) { + assets.push(await downloadMediaAsset(provider, rawUrl, 'image', jobId, i)) + continue + } + if (typeof item?.b64_json === 'string') { + const pure = item.b64_json.includes(',') ? item.b64_json.split(',').pop() : item.b64_json + assets.push(writeMediaAssetBytes('image', jobId, i, Buffer.from(pure, 'base64'), 'image/png')) + } + } + if (!assets.length) throw new Error('服务商响应中没有可保存的图片 URL 或 base64') + return assets +} + +function collectVideoUrls(value, keyHint = '', out = []) { + if (typeof value === 'string') { + const lower = value.toLowerCase() + if ((lower.startsWith('http://') || lower.startsWith('https://')) && + (keyHint.includes('video') || lower.includes('.mp4') || lower.includes('.mov') || lower.includes('.webm') || lower.includes('video')) && + !out.includes(value)) { + out.push(value) + } + } else if (Array.isArray(value)) { + for (const item of value) collectVideoUrls(item, keyHint, out) + } else if (value && typeof value === 'object') { + for (const [key, item] of Object.entries(value)) { + collectVideoUrls(item, `${keyHint} ${key.toLowerCase()}`.trim(), out) + } + } + return out +} + +function newMediaJobId(prefix) { + const stamp = new Date().toISOString().replace(/\D/g, '').slice(0, 17) + return `${prefix}-${stamp}-${crypto.randomBytes(4).toString('hex')}` +} + +function upsertMediaJob(job) { + if (!job?.id) throw new Error('媒体任务缺少 id') + const doc = readMediaJobsPrivate() + const index = doc.jobs.findIndex(item => item.id === job.id) + if (index >= 0) doc.jobs[index] = job + else doc.jobs.push(job) + writeMediaJobsPrivate(doc) + return job +} + +function updateMediaJob(jobId, update) { + const doc = readMediaJobsPrivate() + const index = doc.jobs.findIndex(item => item.id === jobId) + if (index < 0) throw new Error(`媒体任务不存在: ${jobId}`) + const job = { ...doc.jobs[index] } + update(job) + job.updatedAt = mediaNowIso() + doc.jobs[index] = job + writeMediaJobsPrivate(doc) + return job +} + +// 落盘前允许保留的最长字符串(字符数)。超过即视为 base64/data URL 等大负载, +// 只保留开头片段,避免 media-jobs.json 随任务数量膨胀到 GB 级 +const MEDIA_MAX_PERSISTED_STRING = 2048 + +// 递归裁剪 JSON 中的超长字符串(如 gpt-image 响应里的 b64_json),用于持久化前瘦身 +function truncateLargeStrings(value) { + if (typeof value === 'string') { + return value.length > MEDIA_MAX_PERSISTED_STRING + ? `${value.slice(0, 64)}…[已截断 ${value.length} 字符]` + : value + } + if (Array.isArray(value)) return value.map(truncateLargeStrings) + if (value && typeof value === 'object') { + const out = {} + for (const [key, item] of Object.entries(value)) out[key] = truncateLargeStrings(item) + return out + } + 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), + } +} + // === API Handlers === const handlers = { @@ -12177,6 +12938,318 @@ const handlers = { return null }, + // 云端媒体生成 + read_media_config() { + ensureMediaRoot() + return sanitizeMediaConfigForRead(readMediaConfigPrivate()) + }, + + write_media_config({ config }) { + ensureMediaRoot() + const current = readMediaConfigPrivate() + const normalized = normalizeMediaConfig(config, current) + validateMediaOutputDir(normalized.outputDir) + ensureMediaOutputRoot(normalized) + writeJsonAtomic(mediaConfigPath(), normalized) + return true + }, + + test_media_provider({ provider = MEDIA_PROVIDER_VOLCENGINE } = {}) { + validateMediaProviderId(provider) + const raw = readMediaConfigPrivate().providers?.[provider] + if (!raw?.apiKey) throw new Error('请先填写 API Key') + // 允许 http://:自建 NewAPI / 内网网关是合法场景,与实际生成请求的校验保持一致 + if (!/^https?:\/\//.test(String(raw.baseUrl || ''))) throw new Error('Base URL 必须以 http:// 或 https:// 开头') + return { + ok: true, + provider, + baseUrl: raw.baseUrl, + message: '本地配置检查通过。生成图片或创建视频任务时才会调用服务商接口。', + } + }, + + async fetch_media_models({ provider = MEDIA_PROVIDER_VOLCENGINE } = {}) { + validateMediaProviderId(provider) + const raw = readMediaConfigPrivate().providers?.[provider] + const apiKey = String(raw?.apiKey || '').trim() + const baseUrl = String(raw?.baseUrl || MEDIA_DEFAULT_VOLCENGINE_BASE_URL).trim().replace(/\/+$/, '') + if (!apiKey) throw new Error('请先填写 API Key') + if (!/^https?:\/\//.test(baseUrl)) throw new Error('Base URL 必须以 http:// 或 https:// 开头') + const resp = await fetchWithTimeout(buildMediaApiUrl(baseUrl, '/models'), { + headers: { Authorization: `Bearer ${apiKey}` }, + }, Math.min(1800, Math.max(30, Number(raw?.timeoutSeconds || 600)))) + const parsed = await readProviderJson(resp) + if (!resp.ok) { + if ([404, 405, 501].includes(resp.status)) { + return { + ok: false, + supported: false, + models: [], + message: '当前 Base URL 未开放 /models 列表接口,请按官方模型列表或控制台填写模型 ID。', + docs: mediaProviderDocs(provider), + } + } + throw new Error(sanitizeProviderError(`HTTP ${resp.status}: ${JSON.stringify(parsed)}`, apiKey)) + } + const models = extractMediaModels(parsed) + return { + ok: true, + supported: models.length > 0, + models, + message: models.length ? '模型列表已获取。' : '服务商响应中没有可识别的模型 ID,请按官方模型列表或控制台填写。', + docs: mediaProviderDocs(provider), + } + }, + + async generate_image({ request }) { + request = request || {} + validateImageRequest(request) + const providerId = mediaStr(request, 'provider') + 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({ + id: jobId, + type: 'image', + provider: provider.provider, + providerTaskId: null, + status: 'succeeded', + prompt, + model: provider.imageModel, + createdAt: now, + updatedAt: now, + request: truncateLargeStrings(payload), + assets, + error: null, + rawProviderResponse: truncateLargeStrings(parsed), + }) + }, + + async create_video_task({ request }) { + request = request || {} + validateVideoRequest(request) + const providerId = mediaStr(request, 'provider') + 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({ + id: jobId, + type: 'video', + provider: provider.provider, + providerTaskId, + status: providerStatusToJobStatus(providerStatus), + providerStatus, + prompt, + model: provider.videoModel, + createdAt: now, + updatedAt: now, + request: truncateLargeStrings(payload), + assets: [], + error: null, + rawProviderResponse: truncateLargeStrings(parsed), + }) + }, + + async poll_video_task({ jobId }) { + const doc = readMediaJobsPrivate() + const job = doc.jobs.find(item => item.id === jobId) + if (!job) throw new Error(`媒体任务不存在: ${jobId}`) + if (job.type !== 'video') throw new Error('只支持轮询视频任务') + if (!job.providerTaskId) throw new Error('视频任务缺少服务商任务 ID') + const provider = loadMediaProviderConfig(job.provider, 'video', job.model) + const endpoint = isOpenAICompatibleMediaProvider(provider.provider) + ? buildMediaApiUrl(provider.baseUrl, `/videos/${job.providerTaskId}`) + : buildMediaApiUrl(provider.baseUrl, `/contents/generations/tasks/${job.providerTaskId}`) + const resp = await fetchWithTimeout(endpoint, { + headers: { Authorization: `Bearer ${provider.apiKey}` }, + }, provider.timeoutSeconds) + const parsed = await readProviderJson(resp) + if (!resp.ok) { + const error = sanitizeProviderError(`HTTP ${resp.status}: ${JSON.stringify(parsed)}`, provider.apiKey) + // 429 / 5xx 属于瞬时错误:保留原状态允许继续轮询,避免把服务商侧仍在运行的付费任务永久标记为失败 + const transient = resp.status === 429 || resp.status >= 500 + return updateMediaJob(jobId, entry => { + if (!transient) entry.status = 'failed' + entry.error = error + entry.rawProviderResponse = truncateLargeStrings(parsed) + }) + } + const providerStatus = extractProviderStatus(parsed) + const status = providerStatusToJobStatus(providerStatus) + let assets = Array.isArray(job.assets) ? job.assets : [] + let downloadError = '' + if (status === 'succeeded' && assets.length === 0) { + const urls = collectVideoUrls(parsed) + assets = [] + for (let i = 0; i < urls.length; i += 1) { + try { + assets.push(await downloadMediaAsset(provider, urls[i], 'video', jobId, i)) + } catch (error) { + downloadError = error?.message || String(error) + } + } + if (!assets.length && isOpenAICompatibleMediaProvider(provider.provider)) { + try { + assets.push(await downloadOpenAIVideoContent(provider, job.providerTaskId, jobId, assets.length)) + } catch (error) { + downloadError = error?.message || String(error) + } + } + } + return updateMediaJob(jobId, entry => { + entry.status = status + entry.providerStatus = providerStatus + entry.assets = assets + entry.rawProviderResponse = truncateLargeStrings(parsed) + entry.error = downloadError || (status === 'failed' ? entry.error : null) + }) + }, + + cancel_media_job({ jobId }) { + return updateMediaJob(jobId, entry => { + entry.status = 'canceled' + entry.providerStatus = 'local-canceled' + }) + }, + + list_media_jobs({ filter } = {}) { + const doc = readMediaJobsPrivate() + return buildMediaJobsResponse(doc, filter || {}) + }, + + delete_media_job({ jobId, deleteAssets = true }) { + const doc = readMediaJobsPrivate() + const index = doc.jobs.findIndex(item => item.id === jobId) + if (index < 0) throw new Error(`媒体任务不存在: ${jobId}`) + const [job] = doc.jobs.splice(index, 1) + if (deleteAssets && Array.isArray(job.assets)) { + for (const asset of job.assets) { + if (!asset?.path) continue + try { + fs.rmSync(resolveMediaPath(asset.path, asset.root), { force: true }) + } catch {} + } + } + writeMediaJobsPrivate(doc) + return true + }, + + reveal_media_asset({ path: relPath, root = '' }) { + const full = resolveMediaPath(relPath, root) + return { path: full, parent: path.dirname(full), webMode: true } + }, + + reveal_media_output_dir() { + const full = ensureMediaOutputRoot() + return { path: full, webMode: true } + }, + + load_media_asset({ path: relPath, root = '' }) { + const full = resolveMediaPath(relPath, root) + if (!fs.existsSync(full)) throw new Error(`媒体文件不存在: ${relPath}`) + // 先 stat 后读:内嵌预览走 base64,上限 64MB,超限引导用户打开文件夹本地查看 + if (fs.statSync(full).size > 64 * 1024 * 1024) { + throw new Error('媒体文件过大,无法内嵌预览,请使用「打开文件夹」在本地查看') + } + const ext = path.extname(full).slice(1).toLowerCase() + const kind = ['mp4', 'mov', 'webm'].includes(ext) ? 'video' : 'image' + const bytes = fs.readFileSync(full) + return { + path: relPath, + mime: extToMime(ext, kind), + bytes: bytes.length, + dataUrl: `data:${extToMime(ext, kind)};base64,${bytes.toString('base64')}`, + } + }, + // === AI 助手工具(Web 模式真实执行) === assistant_exec({ command, cwd }) { @@ -12391,6 +13464,15 @@ const handlers = { // === 面板配置(Web 模式) === + // 便携模式一期仅支持桌面端(Tauri);Web 模式固定返回未启用,防止前端 404 + get_portable_status() { + return { enabled: false } + }, + + migrate_to_portable() { + throw new Error('Web 模式不支持迁移为便携式,请使用桌面客户端执行') + }, + get_openclaw_dir() { const panelConfig = readPanelConfig() const info = applyOpenclawPathConfig(panelConfig) @@ -12773,9 +13855,7 @@ const handlers = { if (!uv && runHermesSilent('uv', ['--version']).ok) uv = 'uv' if (!uv) throw new Error('uv 未安装。请先安装 uv 或使用 Tauri 桌面版自动下载') // 2. 安装 - const pkg = extras.length - ? `hermes-agent[${extras.join(',')}] @ git+https://github.com/NousResearch/hermes-agent.git` - : 'hermes-agent @ git+https://github.com/NousResearch/hermes-agent.git' + 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'] @@ -15335,7 +16415,7 @@ const handlers = { async update_hermes() { const uvPath = path.join(uvBinDir(), isWindows ? 'uv.exe' : 'uv') const uv = fs.existsSync(uvPath) ? uvPath : 'uv' - const pkg = 'hermes-agent[web] @ git+https://github.com/NousResearch/hermes-agent.git' + const pkg = hermesPackageSpec(['web']) const result = spawnSync(uv, ['tool', 'install', '--reinstall', pkg, '--python', '3.11', '--with', 'croniter'], { env: { ...process.env, PATH: hermesEnhancedPath(), GIT_TERMINAL_PROMPT: '0', ...gitMirrorEnv() }, timeout: 600000, windowsHide: true, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], @@ -15346,7 +16426,7 @@ const handlers = { if (hint) throw new Error(`升级失败: ${cleaned}\n\n${hint}`) throw new Error(`升级失败: ${cleaned}`) } - return '升级完成' + return `升级完成,当前稳定版: Hermes Agent ${HERMES_STABLE_VERSION} (${HERMES_STABLE_TAG})` }, async uninstall_hermes({ cleanConfig = false } = {}) { diff --git a/scripts/smoke-portable-usb.ps1 b/scripts/smoke-portable-usb.ps1 new file mode 100644 index 0000000..17b1b02 --- /dev/null +++ b/scripts/smoke-portable-usb.ps1 @@ -0,0 +1,146 @@ +param( + [string]$DriveLetter = "", + [string]$RootName = "ClawPanelPortable", + [switch]$Keep +) + +$ErrorActionPreference = "Stop" + +function Find-FreeDriveLetter { + foreach ($letter in @("Z", "Y", "X", "W", "V", "U", "T", "S", "R", "Q", "P")) { + if (-not (Get-PSDrive -Name $letter -ErrorAction SilentlyContinue)) { + return $letter + } + } + throw "No free test drive letter found" +} + +if ([string]::IsNullOrWhiteSpace($DriveLetter)) { + $DriveLetter = Find-FreeDriveLetter +} +$DriveLetter = $DriveLetter.Replace(':', '').ToUpperInvariant() +if (Get-PSDrive -Name $DriveLetter -ErrorAction SilentlyContinue) { + throw "Drive $DriveLetter already exists. Choose another DriveLetter." +} + +$hostRoot = Join-Path $env:TEMP ("clawpanel-usb-smoke-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $hostRoot | Out-Null + +try { + $driveName = $DriveLetter + ":" + $driveRoot = $DriveLetter + ":\" + subst $driveName $hostRoot + $usbRoot = Join-Path $driveRoot $RootName + $dataDir = Join-Path $usbRoot "data" + $panelDir = Join-Path $dataDir "clawpanel" + $openclawDir = Join-Path $dataDir "openclaw" + $hermesHome = Join-Path $dataDir "hermes" + $openclawEngine = Join-Path $usbRoot "engines\openclaw" + $hermesBin = Join-Path $usbRoot "engines\hermes\bin" + $uvBin = Join-Path $usbRoot "runtimes\uv\bin" + $gitCmd = Join-Path $usbRoot "runtimes\git\cmd" + + foreach ($dir in @($panelDir, $openclawDir, $hermesHome, $openclawEngine, $hermesBin, $uvBin, $gitCmd)) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + } + + @{ + mode = "portable" + dataDir = "./data" + enginesDir = "./engines" + runtimesDir = "./runtimes" + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $usbRoot "portable.json") -Encoding UTF8 + + @{ + accessPassword = "portable-smoke" + engine = "openclaw" + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $panelDir "clawpanel.json") -Encoding UTF8 + + '{ "gateway": { "port": 18789 }, "agents": { "main": { "name": "main" } } }' | + Set-Content -LiteralPath (Join-Path $openclawDir "openclaw.json") -Encoding UTF8 + "model: smoke" | Set-Content -LiteralPath (Join-Path $hermesHome "config.yaml") -Encoding UTF8 + + @( + "@echo off", + "echo openclaw portable smoke" + ) | Set-Content -LiteralPath (Join-Path $openclawEngine "openclaw.cmd") -Encoding ASCII + + @( + "@echo off", + 'if "%1"=="version" (', + " echo Hermes Agent v0.0.0-portable-smoke", + " exit /b 0", + ")", + 'if "%1"=="--version" (', + " echo Hermes Agent v0.0.0-portable-smoke", + " exit /b 0", + ")", + 'if "%1"=="gateway" (', + ' if "%2"=="status" (', + " echo stopped", + " exit /b 0", + " )", + ")", + "echo hermes portable smoke", + "exit /b 0" + ) | Set-Content -LiteralPath (Join-Path $hermesBin "hermes.cmd") -Encoding ASCII + + @( + "@echo off", + "echo uv 0.0.0-portable-smoke" + ) | Set-Content -LiteralPath (Join-Path $uvBin "uv.cmd") -Encoding ASCII + + @( + "@echo off", + "echo git version 0.0.0-portable-smoke" + ) | Set-Content -LiteralPath (Join-Path $gitCmd "git.cmd") -Encoding ASCII + + $oldPortableRoot = $env:CLAWPANEL_PORTABLE_ROOT + $oldHermesHome = $env:HERMES_HOME + $oldUvToolDir = $env:UV_TOOL_DIR + $oldUvToolBinDir = $env:UV_TOOL_BIN_DIR + $oldUvCacheDir = $env:UV_CACHE_DIR + $oldUvPythonInstallDir = $env:UV_PYTHON_INSTALL_DIR + $oldPath = $env:PATH + + $env:CLAWPANEL_PORTABLE_ROOT = $usbRoot + $env:HERMES_HOME = $hermesHome + $env:UV_TOOL_DIR = Join-Path $usbRoot "engines\hermes" + $env:UV_TOOL_BIN_DIR = $hermesBin + $env:UV_CACHE_DIR = Join-Path $usbRoot "runtimes\uv\cache" + $env:UV_PYTHON_INSTALL_DIR = Join-Path $usbRoot "runtimes\uv\python" + $env:PATH = "$hermesBin;$openclawEngine;$uvBin;$gitCmd;$env:SystemRoot\System32" + + try { + $hermesVersion = & hermes version + $openclawVersion = & openclaw + $gitVersion = & git --version + $uvVersion = & uv --version + } finally { + $env:CLAWPANEL_PORTABLE_ROOT = $oldPortableRoot + $env:HERMES_HOME = $oldHermesHome + $env:UV_TOOL_DIR = $oldUvToolDir + $env:UV_TOOL_BIN_DIR = $oldUvToolBinDir + $env:UV_CACHE_DIR = $oldUvCacheDir + $env:UV_PYTHON_INSTALL_DIR = $oldUvPythonInstallDir + $env:PATH = $oldPath + } + + [pscustomobject]@{ + ok = $true + drive = $driveName + usbRoot = $usbRoot + hostRoot = $hostRoot + hermes = ($hermesVersion -join "`n") + openclaw = ($openclawVersion -join "`n") + git = ($gitVersion -join "`n") + uv = ($uvVersion -join "`n") + } | ConvertTo-Json -Depth 4 +} finally { + if (-not $Keep) { + subst $driveName /D 2>$null + Remove-Item -LiteralPath $hostRoot -Recurse -Force -ErrorAction SilentlyContinue + } else { + Write-Host "Keeping test drive $driveName -> $hostRoot" + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2422bb9..837551a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2117,6 +2117,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3141,6 +3151,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -4634,6 +4645,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4a6732d..c835c4a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,7 +24,7 @@ serde_yaml = "0.9" dirs = "6" chrono = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } -reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream", "gzip", "brotli", "deflate"], default-features = false } +reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls", "stream", "gzip", "brotli", "deflate"], default-features = false } futures-util = "0.3" ed25519-dalek = { version = "2", features = ["rand_core"] } sha2 = "0.10" diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index b5e4ffc..32922dc 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -217,10 +217,14 @@ fn openclaw_version_requires_node_22_19(version: &str) -> bool { parse_version(&base_version(version)) >= parse_version(OPENCLAW_NODE_REQUIREMENT_VERSION_FLOOR) } +fn cli_source_prefers_zh_package(cli_source: &str) -> bool { + matches!(cli_source, "npm-zh" | "standalone" | "portable") +} + fn find_openclaw_package_json(cli_path: &std::path::Path) -> Option { let dir = cli_path.parent()?; let cli_source = crate::utils::classify_cli_source(&cli_path.to_string_lossy()); - let pkg_names: &[&str] = if cli_source == "npm-zh" || cli_source == "standalone" { + let pkg_names: &[&str] = if cli_source_prefers_zh_package(&cli_source) { &["@qingchencloud/openclaw-zh", "openclaw"] } else { &["openclaw", "@qingchencloud/openclaw-zh"] @@ -444,8 +448,52 @@ fn standalone_archive_ext() -> &'static str { } } +fn promote_nested_standalone_dir( + install_dir: &std::path::Path, + node_bin: &str, +) -> Result<(), String> { + let nested = install_dir.join("openclaw"); + if !(nested.exists() && nested.join(node_bin).exists()) { + return Ok(()); + } + + for entry in std::fs::read_dir(&nested) + .map_err(|e| format!("读取目录 {} 失败: {e}", nested.display()))? + { + let entry = entry.map_err(|e| format!("读取目录项失败: {e}"))?; + let dest = install_dir.join(entry.file_name()); + if dest.exists() { + let meta = std::fs::metadata(&dest) + .map_err(|e| format!("读取旧文件 {} 失败: {e}", dest.display()))?; + if meta.is_dir() { + std::fs::remove_dir_all(&dest) + .map_err(|e| format!("删除旧目录 {} 失败: {e}", dest.display()))?; + } else { + std::fs::remove_file(&dest) + .map_err(|e| format!("删除旧文件 {} 失败: {e}", dest.display()))?; + } + } + std::fs::rename(entry.path(), &dest) + .map_err(|e| format!("移动 {} 失败: {e}", dest.display()))?; + } + std::fs::remove_dir_all(&nested) + .map_err(|e| format!("删除临时目录 {} 失败: {e}", nested.display()))?; + Ok(()) +} + /// standalone 安装目录 pub(crate) fn standalone_install_dir() -> Option { + standalone_install_dir_impl(crate::commands::portable::portable_context()) +} + +/// 实际逻辑拆出以便单测(portable context 是进程级 OnceLock,测试中无法控制) +fn standalone_install_dir_impl( + portable: Option<&crate::commands::portable::PortableContext>, +) -> Option { + // 便携模式:standalone 安装/升级一律落 U 盘 engines/openclaw,不碰本机目录 + if let Some(ctx) = portable { + return Some(ctx.engines_openclaw_dir.clone()); + } #[cfg(target_os = "windows")] { // Inno Setup PrivilegesRequired=lowest 默认安装到 %LOCALAPPDATA%\Programs @@ -1126,7 +1174,7 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> { const CALIBRATION_RESET_INHERIT_KEYS: &[&str] = &[ "agents", "auth", "bindings", "browser", "channels", "commands", "env", "hooks", "memory", - "models", "plugins", "security", "session", "skills", "wizard", + "models", "plugins", "secrets", "security", "session", "skills", "tui", "wizard", ]; fn calibration_required_origins() -> Vec { @@ -1350,15 +1398,13 @@ fn apply_reset_inheritance(mut config: Value, seed: &Value) -> (Value, Vec bool { return true; } } - if obj - .get("auth") - .and_then(|v| v.as_object()) - .map(|auth| auth.contains_key("profiles")) - .unwrap_or(false) - { - return true; - } if obj .get("agents") .and_then(|v| v.as_object()) @@ -2098,11 +2136,6 @@ fn strip_ui_fields(mut val: Value) -> Value { ] { obj.remove(*key); } - if let Some(auth_val) = obj.get_mut("auth") { - if let Some(auth_obj) = auth_val.as_object_mut() { - auth_obj.remove("profiles"); - } - } // 处理 models.providers.xxx.models 结构 if let Some(models_val) = obj.get_mut("models") { if let Some(models_obj) = models_val.as_object_mut() { @@ -2474,7 +2507,7 @@ fn detect_installed_source() -> String { .ok() .unwrap_or_else(|| PathBuf::from(&cli_path)); let source = crate::utils::classify_cli_source(&resolved.to_string_lossy()); - if source == "standalone" { + if source == "standalone" || source == "portable" { return detect_standalone_source_from_cli_path(&resolved) .unwrap_or_else(|| "chinese".into()); } @@ -2512,7 +2545,7 @@ fn detect_installed_source() -> String { if let Some(cli_path) = crate::utils::resolve_openclaw_cli_path() { let source = crate::utils::classify_cli_source(&cli_path); // 路径本身能确定的情况(standalone 目录、npm-zh 路径含 openclaw-zh) - if source == "standalone" { + if source == "standalone" || source == "portable" { return detect_standalone_source_from_cli_path(std::path::Path::new(&cli_path)) .unwrap_or_else(|| "chinese".into()); } @@ -2550,7 +2583,7 @@ fn detect_installed_source() -> String { .ok() .unwrap_or_else(|| PathBuf::from(&cli_path)); let source = crate::utils::classify_cli_source(&resolved.to_string_lossy()); - if source == "standalone" { + if source == "standalone" || source == "portable" { return detect_standalone_source_from_cli_path(&resolved) .unwrap_or_else(|| "chinese".into()); } @@ -2602,7 +2635,9 @@ fn detect_active_cli_install_mode() -> &'static str { .ok() .unwrap_or_else(|| PathBuf::from(&cli_path)); let source = crate::utils::classify_cli_source(&resolved.to_string_lossy()); - if source == "standalone" { + if source == "portable" { + "portable" + } else if source == "standalone" { "standalone" } else if source == "npm-zh" || source == "npm-official" || source == "npm-global" { "npm" @@ -2611,8 +2646,35 @@ fn detect_active_cli_install_mode() -> &'static str { } } -fn should_fallback_standalone_to_npm(current_install_mode: &str, method: &str) -> bool { - method == "auto" && current_install_mode != "standalone" +/// auto 模式下 standalone 安装失败后是否允许降级到 npm 全局安装。 +/// 便携模式一律禁止(npm -g 会写宿主机,违背便携原则); +/// 当前已是 standalone / portable 模式的安装也禁止(避免静默切换安装方式)。 +fn should_fallback_standalone_to_npm( + current_install_mode: &str, + method: &str, + portable_mode: bool, +) -> bool { + if portable_mode { + return false; + } + method == "auto" && current_install_mode != "standalone" && current_install_mode != "portable" +} + +fn standalone_install_version( + requested_version: Option<&str>, + recommended_version: Option<&str>, + method: &str, + portable_mode: bool, +) -> String { + if let Some(version) = requested_version { + return version.to_string(); + } + + if portable_mode || method == "standalone-r2" || method == "standalone-github" { + return "latest".to_string(); + } + + recommended_version.unwrap_or("latest").to_string() } #[tauri::command] @@ -3106,7 +3168,7 @@ fn read_version_from_installation(cli_path: &std::path::Path) -> Option // 根据 CLI 路径判断来源,决定 package.json 检查顺序 // 避免残留的另一来源包被优先读取 let cli_source = crate::utils::classify_cli_source(&cli_path.to_string_lossy()); - let pkg_names: &[&str] = if cli_source == "npm-zh" || cli_source == "standalone" { + let pkg_names: &[&str] = if cli_source_prefers_zh_package(&cli_source) { &["@qingchencloud/openclaw-zh", "openclaw"] } else { &["openclaw", "@qingchencloud/openclaw-zh"] @@ -3410,6 +3472,12 @@ async fn try_standalone_install( return Err("当前平台不支持 standalone 安装包".into()); } let install_dir = standalone_install_dir().ok_or("无法确定 standalone 安装目录")?; + if crate::commands::portable::portable_context().is_some() { + let _ = app.emit( + "upgrade-log", + format!("便携模式:将安装到 U 盘 {}", install_dir.display()), + ); + } // 1. 动态查询最新版本 let _ = app.emit( @@ -3464,7 +3532,8 @@ async fn try_standalone_install( } let default_base = format!("{base_url}/{remote_version}"); - let remote_base = if let Some(ovr) = override_base_url { + let override_base = override_base_url.map(|ovr| ovr.replace("{version}", remote_version)); + let remote_base = if let Some(ovr) = override_base.as_deref() { ovr } else { manifest_base_url.unwrap_or(&default_base) @@ -3545,7 +3614,8 @@ async fn try_standalone_install( // 4. 清理旧安装 & 创建目录 if install_dir.exists() { - let _ = std::fs::remove_dir_all(&install_dir); + std::fs::remove_dir_all(&install_dir) + .map_err(|e| format!("清理旧 standalone 安装目录失败: {e}"))?; } std::fs::create_dir_all(&install_dir).map_err(|e| format!("创建安装目录失败: {e}"))?; @@ -3561,17 +3631,7 @@ async fn try_standalone_install( .extract(&install_dir) .map_err(|e| format!("ZIP 解压失败: {e}"))?; // 归档内可能有 openclaw/ 子目录,需要提升一层 - let nested = install_dir.join("openclaw"); - if nested.exists() && nested.join("node.exe").exists() { - for entry in std::fs::read_dir(&nested) - .map_err(|e| format!("读取目录失败: {e}"))? - .flatten() - { - let dest = install_dir.join(entry.file_name()); - let _ = std::fs::rename(entry.path(), &dest); - } - let _ = std::fs::remove_dir_all(&nested); - } + promote_nested_standalone_dir(&install_dir, "node.exe")?; } #[cfg(not(target_os = "windows"))] { @@ -3620,9 +3680,18 @@ async fn try_standalone_install( ), ); - // 7. 添加到 PATH(Windows 用户 PATH,Unix 创建 symlink) + // 7. 添加到 PATH(Windows 用户 PATH,Unix 创建 symlink)。 + // 便携模式跳过:不写用户 PATH / 不建系统 symlink,避免在宿主机留下痕迹; + // 后续 bind_openclaw_cli_path 绑定的是 U 盘 clawpanel.json,解析链可直接命中 + let portable_mode = crate::commands::portable::portable_context().is_some(); + if portable_mode { + let _ = app.emit( + "upgrade-log", + "便携模式:跳过写入用户 PATH / 系统 symlink,仅绑定 U 盘内 CLI", + ); + } #[cfg(target_os = "windows")] - { + if !portable_mode { let install_str = install_dir.to_string_lossy().to_string(); // 检查是否已在 PATH 中 let current_path = std::env::var("PATH").unwrap_or_default(); @@ -3655,7 +3724,7 @@ async fn try_standalone_install( } } #[cfg(not(target_os = "windows"))] - { + if !portable_mode { // Unix: 创建 /usr/local/bin/openclaw symlink 或 ~/bin/openclaw let link_targets = [ PathBuf::from("/usr/local/bin/openclaw"), @@ -4021,10 +4090,17 @@ async fn upgrade_openclaw_inner( let pkg_name = npm_package_name(&source); let requested_version = version.clone(); let recommended_version = recommended_version_for(&source); + let portable_mode = crate::commands::portable::portable_context().is_some(); let ver = requested_version .as_deref() .or(recommended_version.as_deref()) .unwrap_or("latest"); + let standalone_ver = standalone_install_version( + requested_version.as_deref(), + recommended_version.as_deref(), + &method, + portable_mode, + ); let pkg = format!("{}@{}", pkg_name, ver); let active_cli_before = crate::utils::resolve_openclaw_cli_path(); let current_version_before = get_local_version().await; @@ -4058,14 +4134,12 @@ async fn upgrade_openclaw_inner( && (method == "auto" || method == "standalone-r2" || method == "standalone-github"); if try_standalone { - let github_release_base = format!( - "https://github.com/qingchencloud/openclaw-standalone/releases/download/v{}", - ver - ); + let github_release_base = + "https://github.com/qingchencloud/openclaw-standalone/releases/download/v{version}"; if method == "standalone-github" { // standalone-github 模式:只走 GitHub - match try_standalone_install(&app, ver, Some(&github_release_base)).await { + match try_standalone_install(&app, &standalone_ver, Some(github_release_base)).await { Ok(installed_ver) => { let _ = app.emit("upgrade-progress", 100); super::refresh_enhanced_path(); @@ -4084,7 +4158,7 @@ async fn upgrade_openclaw_inner( } } else { // auto / standalone-r2 模式:R2 CDN → GitHub Releases fallback - match try_standalone_install(&app, ver, None).await { + match try_standalone_install(&app, &standalone_ver, None).await { Ok(installed_ver) => { let _ = app.emit("upgrade-progress", 100); super::refresh_enhanced_path(); @@ -4104,7 +4178,9 @@ async fn upgrade_openclaw_inner( ); let _ = app.emit("upgrade-progress", 5); // Fallback: GitHub Releases - match try_standalone_install(&app, ver, Some(&github_release_base)).await { + match try_standalone_install(&app, &standalone_ver, Some(github_release_base)) + .await + { Ok(installed_ver) => { let _ = app.emit("upgrade-progress", 100); super::refresh_enhanced_path(); @@ -4120,12 +4196,20 @@ async fn upgrade_openclaw_inner( return Ok(msg); } Err(gh_reason) => { - if should_fallback_standalone_to_npm(current_install_mode, &method) { + if should_fallback_standalone_to_npm( + current_install_mode, + &method, + portable_mode, + ) { let _ = app.emit( "upgrade-log", format!("standalone 不可用(GitHub: {gh_reason}),降级到 npm 安装..."), ); let _ = app.emit("upgrade-progress", 5); + } else if method == "auto" && portable_mode { + return Err(format!( + "当前处于便携模式,已阻止自动降级到 npm 全局安装(npm -g 会写入本机而非 U 盘)。请检查网络后重试独立包安装。standalone 安装失败: CDN={cdn_reason}, GitHub={gh_reason}" + )); } else if method == "auto" { return Err(format!( "当前 OpenClaw 使用 standalone 独立包模式,已阻止自动降级到 npm 全局安装。请稍后重试独立包升级,或在升级方式中手动选择 npm。standalone 安装失败: CDN={cdn_reason}, GitHub={gh_reason}" @@ -4842,8 +4926,13 @@ pub fn check_node() -> Result { // standalone 安装会在 openclaw 启动脚本中优先使用同目录 Node.js。 // 这里按实际运行时检测,避免被 PATH 中较旧的系统 Node.js 误判拦截。 + // 便携模式下 standalone 包装进 U 盘 engines/openclaw(来源归类为 portable), + // 同样自带 node.exe,一并识别,避免误报"未安装 Node" if let Some(cli_path) = crate::utils::resolve_openclaw_cli_path() { - if crate::utils::classify_cli_source(&cli_path) == "standalone" { + if matches!( + crate::utils::classify_cli_source(&cli_path).as_str(), + "standalone" | "portable" + ) { if let Some(bundled) = standalone_bundled_node_bin(&cli_path) { if let Some(ver) = node_version_from_bin(&bundled) { populate_node_detection_result( @@ -7016,6 +7105,14 @@ pub fn write_panel_config(config: Value) -> Result<(), String> { fs::write(&path, json).map_err(|e| format!("写入失败: {e}")) } +fn path_without_curdir_string(path: &std::path::Path) -> String { + let cleaned: PathBuf = path + .components() + .filter(|component| !matches!(component, std::path::Component::CurDir)) + .collect(); + cleaned.to_string_lossy().to_string() +} + fn bind_openclaw_cli_path(cli_path: &std::path::Path) -> Result<(), String> { let mut config = read_panel_config().unwrap_or_else(|_| json!({})); if !config.is_object() { @@ -7024,7 +7121,7 @@ fn bind_openclaw_cli_path(cli_path: &std::path::Path) -> Result<(), String> { if let Some(obj) = config.as_object_mut() { obj.insert( "openclawCliPath".into(), - Value::String(cli_path.to_string_lossy().to_string()), + Value::String(path_without_curdir_string(cli_path)), ); } write_panel_config(config)?; @@ -7606,12 +7703,18 @@ mod write_openclaw_config_merge_tests { use super::merge_configs_preserving_fields; use super::node_version_satisfies_requirement; use super::openclaw_version_requires_node_22_19; + use super::path_without_curdir_string; + use super::promote_nested_standalone_dir; #[cfg(target_os = "windows")] use super::resolve_openclaw_cli_input_path; use super::select_calibration_source; + use super::should_fallback_standalone_to_npm; use super::standalone_bundled_node_bin; + use super::standalone_install_dir_impl; + use super::standalone_install_version; + use super::strip_ui_fields; use serde_json::json; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; fn unique_temp_dir(name: &str) -> PathBuf { let suffix = std::time::SystemTime::now() @@ -7659,7 +7762,12 @@ mod write_openclaw_config_merge_tests { #[test] fn calibration_reset_inherits_memory_and_security_extensions() { - let baseline = json!({}); + let baseline = json!({ + "tools": { + "profile": "full", + "sessions": { "visibility": "all" } + } + }); let seed = json!({ "memory": { "qmd": { "rerank": false }, @@ -7669,6 +7777,22 @@ mod write_openclaw_config_merge_tests { "enabled": true, "targets": ["skill", "plugin"] } + }, + "secrets": { + "defaults": { "source": "env", "provider": "default" }, + "providers": { + "vaultfile": { + "source": "file", + "path": "/etc/openclaw/secrets.json" + } + } + }, + "tui": { + "footer": { "showRemoteHost": true } + }, + "tools": { + "toolSearch": { "enabled": true, "mode": "directory" }, + "web": { "search": { "backend": "parallel" } } } }); @@ -7676,11 +7800,53 @@ mod write_openclaw_config_merge_tests { assert!(inherited.contains(&"memory".to_string())); assert!(inherited.contains(&"security".to_string())); + assert!(inherited.contains(&"secrets".to_string())); + assert!(inherited.contains(&"tui".to_string())); + assert!(inherited.contains(&"tools".to_string())); assert_eq!(next["memory"]["qmd"]["rerank"], json!(false)); assert_eq!( next["security"]["installPolicy"]["targets"][1], json!("plugin") ); + assert_eq!(next["tui"]["footer"]["showRemoteHost"], json!(true)); + assert_eq!( + next["secrets"]["providers"]["vaultfile"]["source"], + json!("file") + ); + assert_eq!(next["tools"]["profile"], json!("full")); + assert_eq!(next["tools"]["toolSearch"]["mode"], json!("directory")); + assert_eq!(next["tools"]["web"]["search"]["backend"], json!("parallel")); + } + + #[test] + fn strip_ui_fields_preserves_auth_profiles_metadata() { + let config = json!({ + "current": "2026.6.11", + "auth": { + "profiles": { + "bedrock:default": { + "provider": "bedrock", + "mode": "aws-sdk" + } + }, + "order": { "bedrock": ["bedrock:default"] } + }, + "agents": { + "profiles": { + "legacy-ui-field": {} + }, + "list": [] + } + }); + + let cleaned = strip_ui_fields(config); + + assert!(cleaned.get("current").is_none()); + assert_eq!( + cleaned["auth"]["profiles"]["bedrock:default"]["mode"], + json!("aws-sdk") + ); + assert!(cleaned["agents"].get("profiles").is_none()); } #[test] @@ -7828,6 +7994,142 @@ mod write_openclaw_config_merge_tests { )); } + fn test_portable_ctx(root: &Path) -> crate::commands::portable::PortableContext { + crate::commands::portable::PortableContext { + root: root.to_path_buf(), + data_dir: root.join("data"), + panel_config_path: root.join("data").join("clawpanel").join("clawpanel.json"), + openclaw_dir: root.join("data").join("openclaw"), + hermes_home: root.join("data").join("hermes"), + node_dir: None, + engines_openclaw_dir: root.join("engines").join("openclaw"), + engines_hermes_dir: root.join("engines").join("hermes"), + openclaw_cli_path: None, + hermes_cli_path: None, + warnings: Vec::new(), + } + } + + #[test] + fn standalone_install_dir_portable_stays_inside_root() { + let root = unique_temp_dir("portable-install-root"); + let ctx = test_portable_ctx(&root); + let dir = standalone_install_dir_impl(Some(&ctx)).unwrap(); + assert!(dir.starts_with(&root), "安装目录必须在 portable root 内"); + assert_eq!(dir, root.join("engines").join("openclaw")); + } + + #[test] + fn standalone_install_dir_normal_mode_unchanged() { + let dir = standalone_install_dir_impl(None); + #[cfg(target_os = "windows")] + { + let expected = std::env::var("LOCALAPPDATA") + .ok() + .map(|d| PathBuf::from(d).join("Programs").join("OpenClaw")); + assert_eq!(dir, expected); + } + #[cfg(not(target_os = "windows"))] + { + let expected = dirs::home_dir().map(|h| h.join(".openclaw-bin")); + assert_eq!(dir, expected); + } + } + + #[test] + fn npm_fallback_blocked_in_portable_mode() { + // 便携模式:无论当前安装模式如何,auto 均不得降级到 npm 全局安装 + for mode in ["unknown", "npm", "standalone", "portable"] { + assert!(!should_fallback_standalone_to_npm(mode, "auto", true)); + } + // 普通模式原行为不变 + assert!(should_fallback_standalone_to_npm("npm", "auto", false)); + assert!(should_fallback_standalone_to_npm("unknown", "auto", false)); + assert!(!should_fallback_standalone_to_npm( + "standalone", + "auto", + false + )); + assert!(!should_fallback_standalone_to_npm( + "portable", "auto", false + )); + // 非 auto 方式从不触发降级分支 + assert!(!should_fallback_standalone_to_npm( + "npm", + "standalone-r2", + false + )); + } + + #[test] + fn standalone_version_uses_latest_for_portable_without_explicit_version() { + assert_eq!( + standalone_install_version(None, Some("2026.5.18-zh.1"), "auto", true), + "latest" + ); + } + + #[test] + fn standalone_version_uses_latest_for_explicit_standalone_method() { + assert_eq!( + standalone_install_version(None, Some("2026.5.18-zh.1"), "standalone-r2", false), + "latest" + ); + assert_eq!( + standalone_install_version(None, Some("2026.5.18-zh.1"), "standalone-github", false), + "latest" + ); + } + + #[test] + fn standalone_version_keeps_recommended_for_auto_non_portable() { + assert_eq!( + standalone_install_version(None, Some("2026.5.18-zh.1"), "auto", false), + "2026.5.18-zh.1" + ); + } + + #[test] + fn standalone_version_keeps_explicit_requested_version() { + assert_eq!( + standalone_install_version( + Some("2026.6.1-zh.1"), + Some("2026.5.18-zh.1"), + "standalone-r2", + true + ), + "2026.6.1-zh.1" + ); + } + + #[test] + fn cli_binding_path_omits_curdir_components() { + let path = PathBuf::from(r"U:\ClawPanelPortable\.\engines\openclaw\openclaw.cmd"); + assert_eq!( + path_without_curdir_string(&path), + r"U:\ClawPanelPortable\engines\openclaw\openclaw.cmd" + ); + } + + #[test] + fn standalone_extract_promotes_nested_dir_over_existing_files() { + let dir = unique_temp_dir("standalone-promote"); + std::fs::create_dir_all(dir.join("openclaw")).unwrap(); + std::fs::write(dir.join("openclaw.cmd"), b"old").unwrap(); + std::fs::write(dir.join("VERSION"), b"old").unwrap(); + let node_bin = if cfg!(windows) { "node.exe" } else { "node" }; + std::fs::write(dir.join("openclaw").join(node_bin), b"node").unwrap(); + std::fs::write(dir.join("openclaw").join("openclaw.cmd"), b"new").unwrap(); + std::fs::write(dir.join("openclaw").join("VERSION"), b"new").unwrap(); + + promote_nested_standalone_dir(&dir, node_bin).unwrap(); + + assert!(!dir.join("openclaw").exists()); + assert_eq!(std::fs::read(dir.join("openclaw.cmd")).unwrap(), b"new"); + assert_eq!(std::fs::read(dir.join("VERSION")).unwrap(), b"new"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn standalone_bundled_node_bin_resolves_next_to_cli() { let dir = unique_temp_dir("standalone-bundled-node"); diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 685f8c8..ff14b36 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3,10 +3,10 @@ //! 通过 uv 实现零依赖安装: //! 1. 下载 uv 单文件二进制 //! 2. uv tool install hermes-agent --python 3.11 -//! 3. 写入 ~/.hermes/config.yaml + .env +//! 3. 写入 Hermes Home 下的 config.yaml + .env use serde_json::Value; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::OnceLock; @@ -598,13 +598,13 @@ async fn do_restart_gateway() -> Result<(), String> { .try_clone() .map_err(|e| format!("克隆日志句柄失败: {e}"))?; - let mut cmd = std::process::Command::new("hermes"); + let mut cmd = std::process::Command::new(hermes_program_for_spawn()?); cmd.args(["gateway", "run"]) .current_dir(&home) - .env("PATH", &enhanced) .stdin(std::process::Stdio::null()) .stdout(log_file) .stderr(log_err); + apply_hermes_runtime_env(&mut cmd, &enhanced); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); @@ -684,16 +684,26 @@ fn stop_guardian() { // 路径工具 // --------------------------------------------------------------------------- -/// Hermes 配置目录 (~/.hermes) +/// Hermes 配置目录 fn hermes_home() -> PathBuf { + if let Some(ctx) = crate::commands::portable::portable_context() { + return ctx.hermes_home.clone(); + } if let Ok(h) = std::env::var("HERMES_HOME") { return PathBuf::from(h); } dirs::home_dir().unwrap_or_default().join(".hermes") } +pub(crate) fn hermes_home_path() -> PathBuf { + hermes_home() +} + /// ClawPanel 管理的 uv 二进制存放路径 fn uv_bin_dir() -> PathBuf { + if let Some(ctx) = crate::commands::portable::portable_context() { + return ctx.root.join("runtimes").join("uv").join("bin"); + } #[cfg(target_os = "windows")] { let appdata = std::env::var("APPDATA").unwrap_or_default(); @@ -725,6 +735,268 @@ fn uv_bin_dir() -> PathBuf { } } +fn hermes_tool_dir() -> Option { + crate::commands::portable::portable_context().map(|ctx| ctx.engines_hermes_dir.clone()) +} + +fn hermes_tool_bin_dir() -> Option { + hermes_tool_dir().map(|dir| dir.join("bin")) +} + +fn hermes_uv_cache_dir() -> Option { + crate::commands::portable::portable_context() + .map(|ctx| ctx.root.join("runtimes").join("uv").join("cache")) +} + +fn hermes_uv_python_dir() -> Option { + crate::commands::portable::portable_context() + .map(|ctx| ctx.root.join("runtimes").join("uv").join("python")) +} + +fn hermes_venv_dir() -> PathBuf { + if let Some(ctx) = crate::commands::portable::portable_context() { + return ctx.engines_hermes_dir.join("venv"); + } + dirs::home_dir().unwrap_or_default().join(".hermes-venv") +} + +fn ensure_hermes_portable_dirs() -> Result<(), String> { + let Some(ctx) = crate::commands::portable::portable_context() else { + return Ok(()); + }; + for dir in [ + ctx.hermes_home.clone(), + ctx.engines_hermes_dir.clone(), + ctx.engines_hermes_dir.join("bin"), + ctx.root.join("runtimes").join("uv").join("bin"), + ctx.root.join("runtimes").join("uv").join("cache"), + ctx.root.join("runtimes").join("uv").join("python"), + ] { + std::fs::create_dir_all(&dir) + .map_err(|e| format!("创建便携目录 {} 失败: {e}", dir.display()))?; + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn portable_git_exe() -> Option { + crate::commands::portable::portable_context().map(|ctx| { + ctx.root + .join("runtimes") + .join("git") + .join("cmd") + .join("git.exe") + }) +} + +#[cfg(target_os = "windows")] +fn zip_entry_safe_path(name: &str) -> Option { + let normalized = name.replace('\\', "/"); + if normalized.starts_with('/') + || normalized.contains(':') + || normalized + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return None; + } + Some(normalized.split('/').collect()) +} + +#[cfg(target_os = "windows")] +fn extract_zip_archive(data: &[u8], dest: &Path) -> Result<(), String> { + let reader = std::io::Cursor::new(data); + let mut archive = zip::ZipArchive::new(reader).map_err(|e| format!("ZIP 解析失败: {e}"))?; + std::fs::create_dir_all(dest).map_err(|e| format!("创建目录 {} 失败: {e}", dest.display()))?; + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| format!("ZIP 条目读取失败: {e}"))?; + let Some(rel_path) = zip_entry_safe_path(file.name()) else { + continue; + }; + let out_path = dest.join(rel_path); + if file.is_dir() || file.name().ends_with('/') { + std::fs::create_dir_all(&out_path) + .map_err(|e| format!("创建目录 {} 失败: {e}", out_path.display()))?; + continue; + } + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("创建目录 {} 失败: {e}", parent.display()))?; + } + let mut out_file = std::fs::File::create(&out_path) + .map_err(|e| format!("创建文件 {} 失败: {e}", out_path.display()))?; + std::io::copy(&mut file, &mut out_file) + .map_err(|e| format!("写入文件 {} 失败: {e}", out_path.display()))?; + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn mingit_asset_url(release: &Value) -> Option { + let assets = release.get("assets")?.as_array()?; + assets + .iter() + .filter_map(|asset| { + let name = asset.get("name")?.as_str()?; + let url = asset.get("browser_download_url")?.as_str()?; + Some((name, url)) + }) + .find(|(name, _)| { + name.starts_with("MinGit-") + && name.ends_with("-64-bit.zip") + && !name.to_ascii_lowercase().contains("busybox") + }) + .map(|(_, url)| url.to_string()) +} + +#[cfg(target_os = "windows")] +async fn ensure_portable_git(app: &tauri::AppHandle) -> Result<(), String> { + let Some(ctx) = crate::commands::portable::portable_context() else { + return Ok(()); + }; + let Some(git_exe) = portable_git_exe() else { + return Ok(()); + }; + if git_exe.is_file() { + let _ = app.emit( + "hermes-install-log", + format!("✓ 便携 Git 已就绪: {}", git_exe.display()), + ); + return Ok(()); + } + + let _ = app.emit("hermes-install-log", "📦 便携模式:下载 MinGit 到 U 盘..."); + let client = super::build_http_client(std::time::Duration::from_secs(300), Some("ClawPanel")) + .map_err(|e| format!("HTTP 客户端创建失败: {e}"))?; + let release: Value = client + .get("https://api.github.com/repos/git-for-windows/git/releases/latest") + .send() + .await + .map_err(|e| format!("查询 MinGit 最新版本失败: {e}"))? + .json() + .await + .map_err(|e| format!("解析 MinGit 最新版本失败: {e}"))?; + let url = + mingit_asset_url(&release).ok_or("未在 Git for Windows 最新版本中找到 MinGit 64-bit 包")?; + let _ = app.emit("hermes-install-log", format!("下载: {url}")); + let bytes = client + .get(&url) + .send() + .await + .map_err(|e| format!("MinGit 下载失败: {e}"))? + .bytes() + .await + .map_err(|e| format!("MinGit 下载读取失败: {e}"))?; + + let git_root = ctx.root.join("runtimes").join("git"); + if git_root.exists() { + std::fs::remove_dir_all(&git_root).map_err(|e| format!("清理旧便携 Git 目录失败: {e}"))?; + } + extract_zip_archive(&bytes, &git_root)?; + if !git_exe.is_file() { + return Err(format!("MinGit 解压后未找到 {}", git_exe.display())); + } + let _ = app.emit( + "hermes-install-log", + format!("✓ 便携 Git 已安装: {}", git_exe.display()), + ); + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +async fn ensure_portable_git(_app: &tauri::AppHandle) -> Result<(), String> { + Ok(()) +} + +fn portable_hermes_cli_candidates() -> Vec { + let Some(tool_dir) = hermes_tool_dir() else { + return Vec::new(); + }; + let bin_dir = tool_dir.join("bin"); + let root = tool_dir.join("hermes-agent"); + let mut paths = vec![ + bin_dir.join("hermes.cmd"), + bin_dir.join("hermes.exe"), + bin_dir.join("hermes.bat"), + bin_dir.join("hermes"), + ]; + if cfg!(target_os = "windows") { + paths.push(root.join("Scripts").join("hermes.exe")); + paths.push(root.join("Scripts").join("hermes.cmd")); + } else { + paths.push(root.join("bin").join("hermes")); + } + paths +} + +fn portable_hermes_cli_path() -> Option { + portable_hermes_cli_candidates() + .into_iter() + .find(|p| p.is_file()) +} + +fn hermes_program_for_spawn() -> Result { + if crate::commands::portable::portable_context().is_some() { + return portable_hermes_cli_path().ok_or_else(|| { + "便携模式未找到 U 盘内 Hermes CLI,请先在 Hermes 服务页安装或修复 Hermes".to_string() + }); + } + Ok(PathBuf::from("hermes")) +} + +fn command_program(program: &str) -> Result { + if program == "hermes" { + hermes_program_for_spawn() + } else { + Ok(PathBuf::from(program)) + } +} + +fn append_existing_path(extra: &mut Vec, path: impl AsRef) { + let path = path.as_ref(); + if path.exists() { + extra.push(path.to_string_lossy().to_string()); + } +} + +fn apply_hermes_runtime_env(cmd: &mut Command, path: &str) { + cmd.env("PATH", path); + if let Some(ctx) = crate::commands::portable::portable_context() { + let tool_bin_dir = + hermes_tool_bin_dir().unwrap_or_else(|| ctx.engines_hermes_dir.join("bin")); + let cache_dir = hermes_uv_cache_dir() + .unwrap_or_else(|| ctx.root.join("runtimes").join("uv").join("cache")); + let python_dir = hermes_uv_python_dir() + .unwrap_or_else(|| ctx.root.join("runtimes").join("uv").join("python")); + cmd.env("HERMES_HOME", &ctx.hermes_home); + cmd.env("UV_TOOL_DIR", &ctx.engines_hermes_dir); + cmd.env("UV_TOOL_BIN_DIR", tool_bin_dir); + cmd.env("UV_CACHE_DIR", cache_dir); + cmd.env("UV_PYTHON_INSTALL_DIR", python_dir); + cmd.env("UV_LINK_MODE", "copy"); + } +} + +fn apply_hermes_runtime_env_tokio(cmd: &mut tokio::process::Command, path: &str) { + cmd.env("PATH", path); + if let Some(ctx) = crate::commands::portable::portable_context() { + let tool_bin_dir = + hermes_tool_bin_dir().unwrap_or_else(|| ctx.engines_hermes_dir.join("bin")); + let cache_dir = hermes_uv_cache_dir() + .unwrap_or_else(|| ctx.root.join("runtimes").join("uv").join("cache")); + let python_dir = hermes_uv_python_dir() + .unwrap_or_else(|| ctx.root.join("runtimes").join("uv").join("python")); + cmd.env("HERMES_HOME", &ctx.hermes_home); + cmd.env("UV_TOOL_DIR", &ctx.engines_hermes_dir); + cmd.env("UV_TOOL_BIN_DIR", tool_bin_dir); + cmd.env("UV_CACHE_DIR", cache_dir); + cmd.env("UV_PYTHON_INSTALL_DIR", python_dir); + cmd.env("UV_LINK_MODE", "copy"); + } +} + /// uv 二进制完整路径 fn uv_bin_path() -> PathBuf { #[cfg(target_os = "windows")] @@ -761,6 +1033,44 @@ fn hermes_enhanced_path() -> String { // ClawPanel 管理的 uv 二进制目录 extra.push(uv_bin_dir().to_string_lossy().to_string()); + if let Some(ctx) = crate::commands::portable::portable_context() { + extra.push( + ctx.engines_hermes_dir + .join("bin") + .to_string_lossy() + .to_string(), + ); + let tool_root = ctx.engines_hermes_dir.join("hermes-agent"); + if cfg!(target_os = "windows") { + extra.push(tool_root.join("Scripts").to_string_lossy().to_string()); + append_existing_path( + &mut extra, + ctx.root.join("runtimes").join("git").join("cmd"), + ); + append_existing_path( + &mut extra, + ctx.root + .join("runtimes") + .join("git") + .join("usr") + .join("bin"), + ); + } else { + extra.push(tool_root.join("bin").to_string_lossy().to_string()); + append_existing_path( + &mut extra, + ctx.root.join("runtimes").join("git").join("bin"), + ); + } + if let Some(node_dir) = &ctx.node_dir { + extra.push(node_dir.to_string_lossy().to_string()); + } + append_existing_path( + &mut extra, + ctx.root.join("runtimes").join("ffmpeg").join("bin"), + ); + append_existing_path(&mut extra, ctx.root.join("runtimes").join("rg")); + } // uv tool 安装的可执行文件目录 #[cfg(target_os = "windows")] @@ -797,8 +1107,9 @@ fn hermes_enhanced_path() -> String { /// 执行命令并获取 stdout(静默,无窗口) fn run_silent(program: &str, args: &[&str]) -> Result { let enhanced = hermes_enhanced_path(); - let mut cmd = Command::new(program); - cmd.args(args).env("PATH", &enhanced); + let mut cmd = Command::new(command_program(program)?); + cmd.args(args); + apply_hermes_runtime_env(&mut cmd, &enhanced); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); let output = cmd.output().map_err(|e| format!("{program}: {e}"))?; @@ -812,8 +1123,9 @@ fn run_silent(program: &str, args: &[&str]) -> Result { /// 在指定路径上执行命令 fn run_at_path(program: &str, args: &[&str], path: &str) -> Result { - let mut cmd = Command::new(program); - cmd.args(args).env("PATH", path); + let mut cmd = Command::new(command_program(program)?); + cmd.args(args); + apply_hermes_runtime_env(&mut cmd, path); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); let output = cmd.output().map_err(|e| format!("{program}: {e}"))?; @@ -1011,6 +1323,56 @@ pub fn check_hermes() -> Result { let enhanced = hermes_enhanced_path(); let mut result = serde_json::Map::new(); let home = hermes_home(); + if let Some(ctx) = crate::commands::portable::portable_context() { + let hermes_cli_path = portable_hermes_cli_candidates() + .into_iter() + .find(|p| p.is_file()); + result.insert("portable".into(), Value::Bool(true)); + result.insert( + "portableRoot".into(), + Value::String(ctx.root.to_string_lossy().to_string()), + ); + result.insert( + "enginesHermesDir".into(), + Value::String(ctx.engines_hermes_dir.to_string_lossy().to_string()), + ); + result.insert( + "uvBinPath".into(), + Value::String(uv_bin_path().to_string_lossy().to_string()), + ); + result.insert( + "uvToolBinDir".into(), + Value::String( + hermes_tool_bin_dir() + .unwrap_or_else(|| ctx.engines_hermes_dir.join("bin")) + .to_string_lossy() + .to_string(), + ), + ); + result.insert( + "portableHermesCliPath".into(), + hermes_cli_path + .map(|p| Value::String(p.to_string_lossy().to_string())) + .unwrap_or(Value::Null), + ); + #[cfg(target_os = "windows")] + { + let git_path = portable_git_exe(); + result.insert( + "portableGitPath".into(), + git_path + .as_ref() + .map(|p| Value::String(p.to_string_lossy().to_string())) + .unwrap_or(Value::Null), + ); + result.insert( + "portableGitReady".into(), + Value::Bool(git_path.as_ref().is_some_and(|p| p.is_file())), + ); + } + } else { + result.insert("portable".into(), Value::Bool(false)); + } // 1. 检测 hermes CLI let hermes_version = run_at_path("hermes", &["version"], &enhanced) @@ -1312,13 +1674,13 @@ pub async fn hermes_dashboard_start(app: tauri::AppHandle) -> Result Result { + ensure_hermes_portable_dirs()?; let uv_path = uv_bin_path(); + let portable_mode = crate::commands::portable::portable_context().is_some(); // 已有 uv if uv_path.exists() { @@ -1528,14 +1892,21 @@ 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 portable_mode { + let _ = app.emit( + "hermes-install-log", + format!("💾 便携模式:uv 将安装到 {}", uv_path.display()), + ); + } else { + // 系统 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); + } + return Ok("uv".into()); } - return Ok("uv".into()); } // 需要下载 uv @@ -1658,7 +2029,9 @@ fn extract_uv_tar_gz(data: &[u8], dest: &std::path::Path) -> Result<(), String> Err("tar.gz 中未找到 uv".into()) } -const HERMES_GIT_URL: &str = "git+https://github.com/NousResearch/hermes-agent.git"; +const HERMES_STABLE_VERSION: &str = "0.18.0"; +const HERMES_STABLE_TAG: &str = "v2026.7.1"; +const HERMES_GIT_REPO_URL: &str = "https://github.com/NousResearch/hermes-agent.git"; /// 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 @@ -1687,13 +2060,15 @@ fn hermes_runtime_extras_log_segment() -> String { // --------------------------------------------------------------------------- // Hermes Dashboard compat stubs // -// hermes-agent 0.14.0 (both PyPI wheel and `git+...` source) ships +// Older hermes-agent 0.14.x distributions shipped // `hermes_cli/web_server.py` with hard imports of `hermes_cli.dashboard_auth.*` -// submodules whose source files are NOT included in the distribution. It also -// omits the built dashboard SPA (`hermes_cli/web_dist/`). On Windows in +// submodules whose source files were NOT included in the distribution. They also +// omitted the built dashboard SPA (`hermes_cli/web_dist/`). On Windows in // 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. // // To stay self-sufficient (per project policy: do not patch upstream), we // inject a minimal pass-through stub into the installed venv: @@ -1853,6 +2228,10 @@ const HERMES_DASHBOARD_WEB_DIST_INDEX_HTML: &str = r#" /// 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). fn hermes_uv_tool_root() -> Option { + if let Some(tool_dir) = hermes_tool_dir() { + let root = tool_dir.join("hermes-agent"); + return root.exists().then_some(root); + } let uv_path = uv_bin_path(); let uv_cmd = if uv_path.exists() { uv_path.to_string_lossy().to_string() @@ -1861,7 +2240,8 @@ fn hermes_uv_tool_root() -> Option { }; let mut cmd = std::process::Command::new(&uv_cmd); cmd.args(["tool", "dir"]); - cmd.env("PATH", hermes_enhanced_path()); + let enhanced = hermes_enhanced_path(); + apply_hermes_runtime_env(&mut cmd, &enhanced); #[cfg(target_os = "windows")] { use std::os::windows::process::CommandExt; @@ -2021,11 +2401,14 @@ fn inject_hermes_dashboard_compat_stub(app: &tauri::AppHandle) { } fn sanitize_hermes_install_output(text: &str) -> String { - let mut out = text.replace(HERMES_GIT_URL, "hermes-agent"); + let hermes_git_url = hermes_git_url(); + let mut out = text.replace(&hermes_git_url, "hermes-agent"); out = out.replace( - "https://github.com/NousResearch/hermes-agent.git", + &format!("{HERMES_GIT_REPO_URL}@{HERMES_STABLE_TAG}"), "hermes-agent", ); + out = out.replace(&format!("git+{HERMES_GIT_REPO_URL}"), "hermes-agent"); + out = out.replace(HERMES_GIT_REPO_URL, "hermes-agent"); out = out.replace( "https://github.com/NousResearch/hermes-agent", "hermes-agent", @@ -2035,6 +2418,26 @@ fn sanitize_hermes_install_output(text: &str) -> String { out.replace("NousResearch/hermes-agent", "hermes-agent") } +fn hermes_git_url() -> String { + format!("git+{HERMES_GIT_REPO_URL}@{HERMES_STABLE_TAG}") +} + +fn hermes_package_spec(extras: &[String]) -> String { + let hermes_git_url = hermes_git_url(); + if extras.is_empty() { + format!("hermes-agent @ {hermes_git_url}") + } else { + format!("hermes-agent[{}] @ {hermes_git_url}", extras.join(",")) + } +} + +fn emit_hermes_stable_version_log(app: &tauri::AppHandle) { + let _ = app.emit( + "hermes-install-log", + format!("📌 Hermes 稳定版: {HERMES_STABLE_VERSION} ({HERMES_STABLE_TAG})"), + ); +} + /// 从 panelConfig.gitMirror 读取镜像前缀(如 "https://ghproxy.com/")。 /// 为空/未设置 → 不启用镜像。 fn git_mirror_prefix() -> Option { @@ -2100,14 +2503,18 @@ async fn install_via_uv_tool( "hermes-install-log", "📦 通过 uv tool install 安装 Hermes Agent...", ); + emit_hermes_stable_version_log(app); let _ = app.emit("hermes-install-progress", 25u32); + ensure_hermes_portable_dirs()?; + ensure_portable_git(app).await?; + if let Some(tool_dir) = hermes_tool_dir() { + let _ = app.emit( + "hermes-install-log", + format!("💾 便携模式:Hermes 将安装到 {}", tool_dir.display()), + ); + } - // 构造安装规格 - let pkg = if extras.is_empty() { - format!("hermes-agent @ {}", HERMES_GIT_URL) - } else { - format!("hermes-agent[{}] @ {}", extras.join(","), HERMES_GIT_URL) - }; + let pkg = hermes_package_spec(extras); let mut cmd = tokio::process::Command::new(uv_path); cmd.args(["tool", "install", "--force", &pkg, "--python", "3.11"]); @@ -2120,7 +2527,8 @@ async fn install_via_uv_tool( // 代理 super::apply_proxy_env_tokio(&mut cmd); - cmd.env("PATH", hermes_enhanced_path()); + let enhanced = hermes_enhanced_path(); + apply_hermes_runtime_env_tokio(&mut cmd, &enhanced); // uv 需要 git 来克隆仓库 cmd.env("GIT_TERMINAL_PROMPT", "0"); // 用户配置了 Git 镜像(如 ghproxy)→ 进程级注入 insteadOf 重写 @@ -2136,7 +2544,7 @@ async fn install_via_uv_tool( let _ = app.emit( "hermes-install-log", format!( - "uv tool install hermes-agent --python 3.11 {}", + "uv tool install hermes-agent@{HERMES_STABLE_TAG} --python 3.11 {}", hermes_runtime_extras_log_segment() ), ); @@ -2162,12 +2570,21 @@ async fn install_via_uv_tool( if output.status.success() { let _ = app.emit("hermes-install-log", "✓ uv tool install 完成"); - // 更新 shell PATH - let mut update_cmd = tokio::process::Command::new(uv_path); - update_cmd.args(["tool", "update-shell"]); - #[cfg(target_os = "windows")] - update_cmd.creation_flags(CREATE_NO_WINDOW); - let _ = update_cmd.output().await; + if crate::commands::portable::portable_context().is_some() { + let _ = app.emit( + "hermes-install-log", + "💾 便携模式:已跳过写入用户 Shell PATH", + ); + } else { + // 更新 shell PATH + let mut update_cmd = tokio::process::Command::new(uv_path); + update_cmd.args(["tool", "update-shell"]); + let enhanced = hermes_enhanced_path(); + apply_hermes_runtime_env_tokio(&mut update_cmd, &enhanced); + #[cfg(target_os = "windows")] + update_cmd.creation_flags(CREATE_NO_WINDOW); + let _ = update_cmd.output().await; + } Ok(()) } else { let cleaned = sanitize_hermes_install_output(stderr.trim()); @@ -2199,10 +2616,12 @@ async fn install_via_uv_pip( "hermes-install-log", "📦 通过 uv venv + pip install 安装...", ); + emit_hermes_stable_version_log(app); let _ = app.emit("hermes-install-progress", 25u32); - let home = dirs::home_dir().unwrap_or_default(); - let venv_dir = home.join(".hermes-venv"); + ensure_hermes_portable_dirs()?; + ensure_portable_git(app).await?; + let venv_dir = hermes_venv_dir(); let venv_str = venv_dir.to_string_lossy().to_string(); // 创建 venv @@ -2212,6 +2631,8 @@ async fn install_via_uv_pip( ); let mut venv_cmd = tokio::process::Command::new(uv_path); venv_cmd.args(["venv", &venv_str, "--python", "3.11"]); + let enhanced = hermes_enhanced_path(); + apply_hermes_runtime_env_tokio(&mut venv_cmd, &enhanced); super::apply_proxy_env_tokio(&mut venv_cmd); #[cfg(target_os = "windows")] venv_cmd.creation_flags(CREATE_NO_WINDOW); @@ -2227,17 +2648,17 @@ async fn install_via_uv_pip( let _ = app.emit("hermes-install-progress", 40u32); // pip install - let pkg = if extras.is_empty() { - format!("hermes-agent @ {}", HERMES_GIT_URL) - } else { - format!("hermes-agent[{}] @ {}", extras.join(","), HERMES_GIT_URL) - }; - let _ = app.emit("hermes-install-log", "> uv pip install hermes-agent"); + let pkg = hermes_package_spec(extras); + let _ = app.emit( + "hermes-install-log", + format!("> uv pip install hermes-agent@{HERMES_STABLE_TAG}"), + ); 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]); } @@ -2271,9 +2692,18 @@ async fn install_via_uv_pip( let _ = app.emit("hermes-install-log", "✓ pip install 完成"); + if crate::commands::portable::portable_context().is_some() { + let _ = app.emit( + "hermes-install-log", + "💾 便携模式:已跳过写入用户 PATH / 全局链接", + ); + return Ok(()); + } + // 创建全局命令链接 #[cfg(not(target_os = "windows"))] { + let home = dirs::home_dir().unwrap_or_default(); let hermes_bin = venv_dir.join("bin").join("hermes"); let link_dir = home.join(".local").join("bin"); let _ = std::fs::create_dir_all(&link_dir); @@ -13362,14 +13792,14 @@ pub async fn hermes_gateway_action( .try_clone() .map_err(|e| format!("克隆日志句柄失败: {e}"))?; - let mut cmd = std::process::Command::new("hermes"); + let mut cmd = std::process::Command::new(hermes_program_for_spawn()?); cmd.args(["gateway", "run"]) .current_dir(&home) - .env("PATH", &enhanced) .stdin(std::process::Stdio::null()) .stdout(log_file) .stderr(log_err) .creation_flags(CREATE_NO_WINDOW); + apply_hermes_runtime_env(&mut cmd, &enhanced); // 注入 .env 环境变量 let env_path = home.join(".env"); if let Ok(env_content) = std::fs::read_to_string(&env_path) { @@ -13447,13 +13877,13 @@ pub async fn hermes_gateway_action( // 先精准杀掉之前我们 spawn 的进程 kill_gateway_pid(); - let mut cmd = std::process::Command::new("hermes"); + let mut cmd = std::process::Command::new(hermes_program_for_spawn()?); cmd.args(["gateway", "run"]) .current_dir(&home) - .env("PATH", &enhanced) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); + apply_hermes_runtime_env(&mut cmd, &enhanced); // 注入 .env 环境变量 let env_path = home.join(".env"); @@ -13499,8 +13929,10 @@ pub async fn hermes_gateway_action( } Err(e) => { // fallback: hermes gateway start - let mut fallback = tokio::process::Command::new("hermes"); - fallback.args(["gateway", "start"]).env("PATH", &enhanced); + let mut fallback = + tokio::process::Command::new(hermes_program_for_spawn()?); + fallback.args(["gateway", "start"]); + apply_hermes_runtime_env_tokio(&mut fallback, &enhanced); let out = fallback .output() .await @@ -13532,8 +13964,9 @@ pub async fn hermes_gateway_action( let killed = kill_gateway_pid(); // 2. 尝试 hermes gateway stop(作为补充) - let mut cmd = tokio::process::Command::new("hermes"); - cmd.args(["gateway", "stop"]).env("PATH", &enhanced); + let mut cmd = tokio::process::Command::new(hermes_program_for_spawn()?); + cmd.args(["gateway", "stop"]); + apply_hermes_runtime_env_tokio(&mut cmd, &enhanced); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); let stop_result = cmd.output().await; @@ -13575,8 +14008,9 @@ pub async fn hermes_gateway_action( } } "status" => { - let mut cmd = tokio::process::Command::new("hermes"); - cmd.args(["gateway", "status"]).env("PATH", &enhanced); + let mut cmd = tokio::process::Command::new(hermes_program_for_spawn()?); + cmd.args(["gateway", "status"]); + apply_hermes_runtime_env_tokio(&mut cmd, &enhanced); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); let out = cmd.output().await.map_err(|e| format!("查询失败: {e}"))?; @@ -13584,8 +14018,9 @@ pub async fn hermes_gateway_action( Ok(stdout) } "install" => { - let mut cmd = tokio::process::Command::new("hermes"); - cmd.args(["gateway", "install"]).env("PATH", &enhanced); + let mut cmd = tokio::process::Command::new(hermes_program_for_spawn()?); + cmd.args(["gateway", "install"]); + apply_hermes_runtime_env_tokio(&mut cmd, &enhanced); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); let out = cmd.output().await.map_err(|e| format!("安装失败: {e}"))?; @@ -13596,8 +14031,9 @@ pub async fn hermes_gateway_action( } } "uninstall" => { - let mut cmd = tokio::process::Command::new("hermes"); - cmd.args(["gateway", "uninstall"]).env("PATH", &enhanced); + let mut cmd = tokio::process::Command::new(hermes_program_for_spawn()?); + cmd.args(["gateway", "uninstall"]); + apply_hermes_runtime_env_tokio(&mut cmd, &enhanced); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); let out = cmd.output().await.map_err(|e| format!("卸载失败: {e}"))?; @@ -13862,16 +14298,14 @@ pub async fn hermes_set_gateway_url(url: Option) -> Result Result { let _ = app.emit("hermes-install-log", "📦 升级 Hermes Agent..."); + emit_hermes_stable_version_log(&app); let _ = app.emit("hermes-install-progress", 0u32); + ensure_hermes_portable_dirs()?; + ensure_portable_git(&app).await?; - let uv_path = uv_bin_path(); - let uv = if uv_path.exists() { - uv_path.to_string_lossy().to_string() - } else { - "uv".into() - }; + let uv = ensure_uv(&app).await?; - let pkg = format!("hermes-agent[web] @ {}", HERMES_GIT_URL); + let pkg = hermes_package_spec(&["web".to_string()]); let mut cmd = tokio::process::Command::new(&uv); cmd.args(["tool", "install", "--reinstall", &pkg, "--python", "3.11"]); append_hermes_runtime_extras(&mut cmd); @@ -13879,7 +14313,7 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result { let _ = app.emit( "hermes-install-log", format!( - "uv tool install --reinstall hermes-agent --python 3.11 {}", + "uv tool install --reinstall hermes-agent@{HERMES_STABLE_TAG} --python 3.11 {}", hermes_runtime_extras_log_segment() ), ); @@ -13889,7 +14323,8 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result { } apply_git_mirror_env(&mut cmd); super::apply_proxy_env_tokio(&mut cmd); - cmd.env("PATH", hermes_enhanced_path()); + let enhanced = hermes_enhanced_path(); + apply_hermes_runtime_env_tokio(&mut cmd, &enhanced); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); @@ -13911,7 +14346,9 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result { inject_hermes_dashboard_compat_stub(&app); let _ = app.emit("hermes-install-log", "✅ 升级完成"); let _ = app.emit("hermes-install-progress", 100u32); - Ok("升级完成".into()) + Ok(format!( + "升级完成,当前稳定版: Hermes Agent {HERMES_STABLE_VERSION} ({HERMES_STABLE_TAG})" + )) } else { let cleaned = sanitize_hermes_install_output(stderr.trim()); if let Some(hint) = diagnose_install_network_error(&cleaned) { @@ -13930,38 +14367,47 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result { pub async fn uninstall_hermes(app: tauri::AppHandle, clean_config: bool) -> Result { let _ = app.emit("hermes-install-log", "🗑️ 卸载 Hermes Agent..."); let _ = app.emit("hermes-install-progress", 10u32); + let portable_mode = crate::commands::portable::portable_context().is_some(); let uv_path = uv_bin_path(); - let uv = if uv_path.exists() { - uv_path.to_string_lossy().to_string() + if portable_mode && !uv_path.exists() { + let _ = app.emit( + "hermes-install-log", + "💾 便携模式:未找到 U 盘 uv,跳过 uv tool uninstall", + ); } else { - "uv".into() - }; + let uv = if uv_path.exists() { + uv_path.to_string_lossy().to_string() + } else { + "uv".into() + }; - // uv tool uninstall - let mut cmd = tokio::process::Command::new(&uv); - cmd.args(["tool", "uninstall", "hermes-agent"]); - let _ = app.emit("hermes-install-log", "> uv tool uninstall hermes-agent"); - cmd.env("PATH", hermes_enhanced_path()); - #[cfg(target_os = "windows")] - cmd.creation_flags(CREATE_NO_WINDOW); + // uv tool uninstall + let mut cmd = tokio::process::Command::new(&uv); + cmd.args(["tool", "uninstall", "hermes-agent"]); + let _ = app.emit("hermes-install-log", "> uv tool uninstall hermes-agent"); + let enhanced = hermes_enhanced_path(); + apply_hermes_runtime_env_tokio(&mut cmd, &enhanced); + #[cfg(target_os = "windows")] + cmd.creation_flags(CREATE_NO_WINDOW); - let output = cmd.output().await.map_err(|e| format!("卸载失败: {e}"))?; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - for line in stdout.lines().chain(stderr.lines()) { - if !line.trim().is_empty() { - let _ = app.emit("hermes-install-log", line.trim()); + let output = cmd.output().await.map_err(|e| format!("卸载失败: {e}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + for line in stdout.lines().chain(stderr.lines()) { + if !line.trim().is_empty() { + let _ = app.emit("hermes-install-log", line.trim()); + } } - } - if !output.status.success() { - return Err(format!("卸载失败: {}", stderr.trim())); + if !output.status.success() && !portable_mode { + return Err(format!("卸载失败: {}", stderr.trim())); + } } let _ = app.emit("hermes-install-progress", 65u32); // 清理 venv(如果存在) - let venv_dir = dirs::home_dir().unwrap_or_default().join(".hermes-venv"); + let venv_dir = hermes_venv_dir(); if venv_dir.exists() { let _ = app.emit( "hermes-install-log", @@ -13970,6 +14416,21 @@ pub async fn uninstall_hermes(app: tauri::AppHandle, clean_config: bool) -> Resu let _ = std::fs::remove_dir_all(&venv_dir); } + if let Some(ctx) = crate::commands::portable::portable_context() { + for dir in [ + ctx.engines_hermes_dir.join("hermes-agent"), + ctx.engines_hermes_dir.join("bin"), + ] { + if dir.exists() { + let _ = app.emit( + "hermes-install-log", + format!("清理便携引擎目录: {}", dir.display()), + ); + let _ = std::fs::remove_dir_all(&dir); + } + } + } + // 可选:清理配置 if clean_config { let home = hermes_home(); @@ -17159,13 +17620,13 @@ pub async fn hermes_multi_gateway_start( .try_clone() .map_err(|e| format!("克隆日志句柄失败: {e}"))?; - let mut cmd = std::process::Command::new("hermes"); + let mut cmd = std::process::Command::new(hermes_program_for_spawn()?); cmd.args(["--profile", &profile, "gateway", "run"]) .current_dir(&home) - .env("PATH", &enhanced) .stdin(std::process::Stdio::null()) .stdout(log_file) .stderr(log_err); + apply_hermes_runtime_env(&mut cmd, &enhanced); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); @@ -19846,6 +20307,36 @@ streaming: } } +#[cfg(all(test, target_os = "windows"))] +mod hermes_portable_runtime_tests { + use super::{mingit_asset_url, zip_entry_safe_path}; + use serde_json::json; + + #[test] + fn mingit_asset_url_prefers_non_busybox_64_bit_zip() { + let release = json!({ + "assets": [ + { "name": "Git-2.50.1-64-bit.exe", "browser_download_url": "https://example.invalid/git.exe" }, + { "name": "MinGit-2.50.1-busybox-64-bit.zip", "browser_download_url": "https://example.invalid/busybox.zip" }, + { "name": "MinGit-2.50.1-64-bit.zip", "browser_download_url": "https://example.invalid/mingit.zip" } + ] + }); + assert_eq!( + mingit_asset_url(&release).as_deref(), + Some("https://example.invalid/mingit.zip") + ); + } + + #[test] + fn zip_entry_safe_path_rejects_traversal_and_absolute_paths() { + assert!(zip_entry_safe_path("cmd/git.exe").is_some()); + assert!(zip_entry_safe_path("../evil.exe").is_none()); + assert!(zip_entry_safe_path("/evil.exe").is_none()); + assert!(zip_entry_safe_path("C:/evil.exe").is_none()); + assert!(zip_entry_safe_path("cmd//git.exe").is_none()); + } +} + #[cfg(test)] mod hermes_context_config_tests { use super::{build_hermes_context_config_values, merge_hermes_context_config}; diff --git a/src-tauri/src/commands/hermes_providers.rs b/src-tauri/src/commands/hermes_providers.rs index bcf655d..a9881d9 100644 --- a/src-tauri/src/commands/hermes_providers.rs +++ b/src-tauri/src/commands/hermes_providers.rs @@ -16,12 +16,14 @@ use serde::Serialize; /// - `oauth_device_code`: interactive device-code OAuth flow (Nous) /// - `oauth_external`: OAuth handled by external process (Codex, Qwen) /// - `external_process`: backing process handles auth (Copilot ACP) +/// - `vertex`: Google Cloud ADC / service-account based OAuth tokens pub const AUTH_API_KEY: &str = "api_key"; pub const AUTH_OAUTH_DEVICE: &str = "oauth_device_code"; pub const AUTH_OAUTH_EXTERNAL: &str = "oauth_external"; pub const AUTH_EXTERNAL_PROCESS: &str = "external_process"; pub const AUTH_AWS_SDK: &str = "aws_sdk"; pub const AUTH_OAUTH_MINIMAX: &str = "oauth_minimax"; +pub const AUTH_VERTEX: &str = "vertex"; /// Transport negotiated with the provider. pub const TRANSPORT_OPENAI_CHAT: &str = "openai_chat"; @@ -334,6 +336,20 @@ const P_XIAOMI: HermesProvider = HermesProvider { cli_auth_hint: "", }; +const P_STEPFUN: HermesProvider = HermesProvider { + id: "stepfun", + name: "StepFun", + auth_type: AUTH_API_KEY, + base_url: "https://api.stepfun.ai/step_plan/v1", + base_url_env_var: "", + api_key_env_vars: &["STEPFUN_API_KEY"], + transport: TRANSPORT_OPENAI_CHAT, + models_probe: PROBE_OPENAI, + models: &["step-3.5-flash"], + is_aggregator: false, + cli_auth_hint: "", +}; + const P_ARCEE: HermesProvider = HermesProvider { id: "arcee", name: "Arcee AI", @@ -376,6 +392,25 @@ const P_BEDROCK: HermesProvider = HermesProvider { cli_auth_hint: "", }; +const P_VERTEX: HermesProvider = HermesProvider { + id: "vertex", + name: "Google Vertex AI", + auth_type: AUTH_VERTEX, + base_url: "https://aiplatform.googleapis.com", + base_url_env_var: "", + api_key_env_vars: &[], + transport: TRANSPORT_OPENAI_CHAT, + models_probe: PROBE_NONE, + models: &[ + "google/gemini-3-pro-preview", + "google/gemini-3-flash-preview", + "google/gemini-2.5-pro", + "google/gemini-2.5-flash", + ], + is_aggregator: false, + cli_auth_hint: "使用 Google Cloud Application Default Credentials 或服务账号 JSON", +}; + const P_GMI: HermesProvider = HermesProvider { id: "gmi", name: "GMI Cloud", @@ -390,6 +425,27 @@ const P_GMI: HermesProvider = HermesProvider { cli_auth_hint: "", }; +const P_NOVITA: HermesProvider = HermesProvider { + id: "novita", + name: "NovitaAI", + auth_type: AUTH_API_KEY, + base_url: "https://api.novita.ai/openai/v1", + base_url_env_var: "NOVITA_BASE_URL", + api_key_env_vars: &["NOVITA_API_KEY"], + transport: TRANSPORT_OPENAI_CHAT, + models_probe: PROBE_OPENAI, + models: &[ + "moonshotai/kimi-k2.5", + "minimax/minimax-m2.7", + "zai-org/glm-5", + "deepseek/deepseek-v3-0324", + "deepseek/deepseek-r1-0528", + "qwen/qwen3-235b-a22b-fp8", + ], + is_aggregator: true, + cli_auth_hint: "", +}; + const P_LMSTUDIO: HermesProvider = HermesProvider { id: "lmstudio", name: "LM Studio", @@ -627,20 +683,6 @@ const P_QWEN_OAUTH: HermesProvider = HermesProvider { cli_auth_hint: "hermes auth login qwen-oauth", }; -const P_GOOGLE_GEMINI_CLI: HermesProvider = HermesProvider { - id: "google-gemini-cli", - name: "Google Gemini (OAuth)", - auth_type: AUTH_OAUTH_EXTERNAL, - base_url: "https://generativelanguage.googleapis.com/v1beta/openai", - base_url_env_var: "", - api_key_env_vars: &[], - transport: TRANSPORT_OPENAI_CHAT, - models_probe: PROBE_NONE, - models: &["gemini-2.5-pro", "gemini-2.5-flash"], - is_aggregator: false, - cli_auth_hint: "hermes auth login google-gemini-cli", -}; - const P_COPILOT_ACP: HermesProvider = HermesProvider { id: "copilot-acp", name: "GitHub Copilot ACP", @@ -688,6 +730,7 @@ pub const ALL_PROVIDERS: &[HermesProvider] = &[ P_ARCEE, P_AZURE_FOUNDRY, P_GMI, + P_NOVITA, P_LMSTUDIO, P_NVIDIA, P_OLLAMA_CLOUD, @@ -700,8 +743,10 @@ pub const ALL_PROVIDERS: &[HermesProvider] = &[ P_ALIBABA_CODING_PLAN, P_MINIMAX_CN, P_XIAOMI, + P_STEPFUN, // SDK-backed providers P_BEDROCK, + P_VERTEX, // Aggregators / routers P_OPENROUTER, P_AI_GATEWAY, @@ -712,7 +757,6 @@ pub const ALL_PROVIDERS: &[HermesProvider] = &[ P_NOUS, P_OPENAI_CODEX, P_QWEN_OAUTH, - P_GOOGLE_GEMINI_CLI, P_MINIMAX_OAUTH, P_COPILOT_ACP, // Custom (frontend placeholder) @@ -827,11 +871,15 @@ mod tests { #[test] fn registry_has_expected_providers() { - assert_eq!(ALL_PROVIDERS.len(), 33); + assert_eq!(ALL_PROVIDERS.len(), 35); assert!(get_provider("anthropic").is_some()); assert!(get_provider("gemini").is_some()); assert!(get_provider("alibaba-coding-plan").is_some()); assert!(get_provider("bedrock").is_some()); + assert!(get_provider("vertex").is_some()); + assert!(get_provider("novita").is_some()); + assert!(get_provider("stepfun").is_some()); + assert!(get_provider("google-gemini-cli").is_none()); assert!(get_provider("lmstudio").is_some()); assert!(get_provider("nous").is_some()); assert!(get_provider("custom").is_some()); @@ -843,7 +891,10 @@ mod tests { assert_eq!(primary_api_key_env("anthropic"), Some("ANTHROPIC_API_KEY")); assert_eq!(primary_api_key_env("gemini"), Some("GOOGLE_API_KEY")); assert_eq!(primary_api_key_env("zai"), Some("GLM_API_KEY")); + assert_eq!(primary_api_key_env("novita"), Some("NOVITA_API_KEY")); + assert_eq!(primary_api_key_env("stepfun"), Some("STEPFUN_API_KEY")); assert_eq!(primary_api_key_env("bedrock"), None); + assert_eq!(primary_api_key_env("vertex"), None); assert_eq!(primary_api_key_env("nous"), None); } @@ -857,6 +908,9 @@ mod tests { assert!(keys.contains(&"GEMINI_BASE_URL")); assert!(keys.contains(&"ALIBABA_CODING_PLAN_API_KEY")); assert!(keys.contains(&"LM_API_KEY")); + assert!(keys.contains(&"NOVITA_API_KEY")); + assert!(keys.contains(&"NOVITA_BASE_URL")); + assert!(keys.contains(&"STEPFUN_API_KEY")); assert!(keys.contains(&"GATEWAY_ALLOW_ALL_USERS")); assert!(keys.contains(&"API_SERVER_KEY")); // No duplicates diff --git a/src-tauri/src/commands/media.rs b/src-tauri/src/commands/media.rs new file mode 100644 index 0000000..7f6e6ad --- /dev/null +++ b/src-tauri/src/commands/media.rs @@ -0,0 +1,2048 @@ +use base64::{engine::general_purpose, Engine as _}; +use serde_json::{json, Map, Value}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Mutex; +use std::time::Duration; + +/// media-jobs.json 的读改写锁:并发轮询/写入时防止互相覆盖 +static MEDIA_JOBS_LOCK: Mutex<()> = Mutex::new(()); + +fn lock_media_jobs() -> std::sync::MutexGuard<'static, ()> { + MEDIA_JOBS_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +const PROVIDER_VOLCENGINE: &str = "volcengine"; +const PROVIDER_OPENAI: &str = "openai"; +const PROVIDER_NEWAPI: &str = "newapi"; +const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3"; +const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; +const DEFAULT_NEWAPI_BASE_URL: &str = "https://your-newapi.example.com/v1"; +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; +/// 内嵌预览(base64 IPC)上限:超过此大小引导用户打开文件夹本地查看 +const MAX_INLINE_PREVIEW_BYTES: u64 = 64 * 1024 * 1024; + +#[derive(Debug, Clone)] +struct MediaProviderConfig { + provider: String, + base_url: String, + api_key: String, + image_model: String, + video_model: String, + timeout_seconds: u64, +} + +fn now_iso() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +fn str_field<'a>(value: &'a Value, key: &str) -> &'a str { + value.get(key).and_then(Value::as_str).unwrap_or("").trim() +} + +fn u64_field(value: &Value, key: &str, default: u64) -> u64 { + value.get(key).and_then(Value::as_u64).unwrap_or(default) +} + +fn bool_field(value: &Value, key: &str, default: bool) -> bool { + value.get(key).and_then(Value::as_bool).unwrap_or(default) +} + +fn default_media_config() -> Value { + json!({ + "version": 1, + "outputDir": "", + "providers": { + "volcengine": { + "enabled": false, + "baseUrl": DEFAULT_VOLCENGINE_BASE_URL, + "apiKey": "", + "imageModel": "", + "videoModel": "", + "timeoutSeconds": 600 + }, + "openai": { + "enabled": false, + "baseUrl": DEFAULT_OPENAI_BASE_URL, + "apiKey": "", + "imageModel": "gpt-image-1", + "videoModel": "sora-2", + "timeoutSeconds": 600 + }, + "newapi": { + "enabled": false, + "baseUrl": DEFAULT_NEWAPI_BASE_URL, + "apiKey": "", + "imageModel": "gpt-image-1", + "videoModel": "sora-2", + "timeoutSeconds": 600 + } + }, + "defaults": { + "provider": PROVIDER_VOLCENGINE, + "image": { "size": "2K", "count": 1, "watermark": true }, + "video": { "ratio": "16:9", "resolution": "720p", "duration": 5, "pollIntervalSeconds": 5 } + } + }) +} + +fn default_media_jobs() -> Value { + json!({ "version": 1, "jobs": [] }) +} + +pub(super) fn api_key_mask(api_key: &str) -> String { + let trimmed = api_key.trim(); + if trimmed.is_empty() { + return String::new(); + } + let chars: Vec = trimmed.chars().collect(); + if chars.len() <= 8 { + return "已保存".to_string(); + } + let head: String = chars.iter().take(3).collect(); + let tail: String = chars.iter().skip(chars.len().saturating_sub(4)).collect(); + format!("{head}***{tail}") +} + +fn ensure_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = Value::Object(Map::new()); + } + value.as_object_mut().expect("object ensured") +} + +fn media_provider_ids() -> [&'static str; 3] { + [PROVIDER_VOLCENGINE, PROVIDER_OPENAI, PROVIDER_NEWAPI] +} + +fn is_supported_media_provider(provider: &str) -> bool { + media_provider_ids().contains(&provider) +} + +fn is_openai_compatible_provider(provider: &str) -> bool { + matches!(provider, PROVIDER_OPENAI | PROVIDER_NEWAPI) +} + +fn default_provider_base_url(provider: &str) -> &'static str { + match provider { + PROVIDER_OPENAI => DEFAULT_OPENAI_BASE_URL, + PROVIDER_NEWAPI => DEFAULT_NEWAPI_BASE_URL, + _ => DEFAULT_VOLCENGINE_BASE_URL, + } +} + +fn normalize_provider_config(provider_id: &str, provider: &mut Value, current: Option<&Value>) { + let obj = ensure_object(provider); + obj.entry("enabled").or_insert(Value::Bool(false)); + let base_url = obj + .get("baseUrl") + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| default_provider_base_url(provider_id)) + .trim_end_matches('/') + .to_string(); + obj.insert("baseUrl".into(), Value::String(base_url)); + + let incoming_key = obj + .get("apiKey") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or(""); + let old_key = current + .and_then(|v| v.get("apiKey")) + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or(""); + let keep_old = incoming_key.is_empty() + || incoming_key == "__KEEP__" + || incoming_key == "••••••••" + || incoming_key == "********"; + obj.insert( + "apiKey".into(), + Value::String(if keep_old { old_key } else { incoming_key }.to_string()), + ); + + for key in ["imageModel", "videoModel"] { + let value = obj + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or("") + .to_string(); + obj.insert(key.into(), Value::String(value)); + } + let timeout = obj + .get("timeoutSeconds") + .and_then(Value::as_u64) + .unwrap_or(600) + .clamp(30, 1800); + obj.insert("timeoutSeconds".into(), Value::Number(timeout.into())); +} + +fn normalize_media_config(mut config: Value, current: Option<&Value>) -> Value { + if !config.is_object() { + config = default_media_config(); + } + let default = default_media_config(); + let root = ensure_object(&mut config); + root.entry("version").or_insert(Value::Number(1.into())); + let output_dir = root + .get("outputDir") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or("") + .to_string(); + root.insert("outputDir".into(), Value::String(output_dir)); + root.entry("providers") + .or_insert_with(|| default["providers"].clone()); + root.entry("defaults") + .or_insert_with(|| default["defaults"].clone()); + + { + let providers = root.get_mut("providers").expect("providers exists"); + let providers_obj = ensure_object(providers); + for provider_id in media_provider_ids() { + providers_obj + .entry(provider_id) + .or_insert_with(|| default["providers"][provider_id].clone()); + let current_provider = current + .and_then(|v| v.get("providers")) + .and_then(|v| v.get(provider_id)); + if let Some(provider) = providers_obj.get_mut(provider_id) { + normalize_provider_config(provider_id, provider, current_provider); + } + } + } + + if let Some(defaults) = root.get_mut("defaults") { + let defaults_obj = ensure_object(defaults); + defaults_obj + .entry("image") + .or_insert_with(|| default["defaults"]["image"].clone()); + defaults_obj + .entry("video") + .or_insert_with(|| default["defaults"]["video"].clone()); + let provider = defaults_obj + .get("provider") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| is_supported_media_provider(value)) + .unwrap_or(PROVIDER_VOLCENGINE) + .to_string(); + defaults_obj.insert("provider".into(), Value::String(provider)); + } + + config +} + +fn sanitize_media_config_for_read(config: &Value) -> Value { + let mut sanitized = normalize_media_config(config.clone(), None); + let resolved_output_dir = media_output_root_from_config(&sanitized) + .to_string_lossy() + .to_string(); + if let Some(root) = sanitized.as_object_mut() { + root.insert( + "resolvedOutputDir".into(), + Value::String(resolved_output_dir), + ); + } + if let Some(providers) = sanitized + .get_mut("providers") + .and_then(Value::as_object_mut) + { + for provider_id in media_provider_ids() { + if let Some(provider) = providers + .get_mut(provider_id) + .and_then(Value::as_object_mut) + { + let api_key = provider + .get("apiKey") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + provider.insert("apiKey".into(), Value::String(String::new())); + provider.insert( + "apiKeySaved".into(), + Value::Bool(!api_key.trim().is_empty()), + ); + provider.insert("apiKeyMask".into(), Value::String(api_key_mask(&api_key))); + } + } + } + sanitized +} + +/// 媒体数据固定放在 OpenClaw 数据目录下的 clawpanel/media。 +/// 便携模式下 openclaw_dir() 本身已指向便携数据目录,因此"迁移为便携式" +/// 整体复制 OpenClaw 目录后,媒体配置与历史无需额外搬运即可被读取。 +fn media_root_impl(openclaw_dir: &Path) -> PathBuf { + openclaw_dir.join("clawpanel").join("media") +} + +fn media_root() -> PathBuf { + media_root_impl(&super::openclaw_dir()) +} + +fn is_safe_relative_output_dir(raw: &str) -> bool { + let path = Path::new(raw); + !raw.trim().is_empty() + && !path.is_absolute() + && path + .components() + .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)) +} + +fn validate_media_output_dir(raw: &str) -> Result<(), String> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(()); + } + let path = Path::new(trimmed); + if path.is_absolute() || is_safe_relative_output_dir(trimmed) { + Ok(()) + } else { + Err("产出目录不能包含 .. 或非法路径片段".into()) + } +} + +fn media_output_root_impl(media_root: &Path, output_dir: &str) -> PathBuf { + let trimmed = output_dir.trim(); + if trimmed.is_empty() { + return media_root.to_path_buf(); + } + let configured = PathBuf::from(trimmed); + if configured.is_absolute() { + configured + } else if is_safe_relative_output_dir(trimmed) { + media_root.join(configured) + } else { + media_root.to_path_buf() + } +} + +fn media_output_root_from_config(config: &Value) -> PathBuf { + media_output_root_impl(&media_root(), str_field(config, "outputDir")) +} + +fn media_config_path() -> PathBuf { + media_root().join(MEDIA_CONFIG_FILE) +} + +fn media_jobs_path() -> PathBuf { + media_root().join(MEDIA_JOBS_FILE) +} + +fn ensure_media_root() -> Result { + let root = media_root(); + std::fs::create_dir_all(root.join("assets")).map_err(|e| format!("创建媒体目录失败: {e}"))?; + Ok(root) +} + +fn ensure_media_output_root_from_config(config: &Value) -> Result { + ensure_media_root()?; + let root = media_output_root_from_config(config); + std::fs::create_dir_all(root.join("assets")) + .map_err(|e| format!("创建媒体产出目录失败: {e}"))?; + Ok(root) +} + +fn read_json_or_default(path: &Path, default: Value) -> Value { + let Ok(bytes) = std::fs::read(path) else { + return default; + }; + let bytes = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + &bytes[3..] + } else { + &bytes + }; + serde_json::from_slice(bytes).unwrap_or(default) +} + +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 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); + } + std::fs::rename(&tmp, path).map_err(|e| format!("替换文件失败: {e}")) +} + +fn read_media_config_private() -> Value { + let cfg = read_json_or_default(&media_config_path(), default_media_config()); + normalize_media_config(cfg, None) +} + +fn read_media_jobs_private() -> Value { + let mut jobs = read_json_or_default(&media_jobs_path(), default_media_jobs()); + if !jobs.is_object() { + jobs = default_media_jobs(); + } + let obj = ensure_object(&mut jobs); + obj.entry("version").or_insert(Value::Number(1.into())); + obj.entry("jobs") + .or_insert_with(|| Value::Array(Vec::new())); + if !obj.get("jobs").is_some_and(Value::is_array) { + obj.insert("jobs".into(), Value::Array(Vec::new())); + } + jobs +} + +fn write_media_jobs_private(jobs: &Value) -> Result<(), String> { + write_json_atomic(&media_jobs_path(), jobs) +} + +fn upsert_media_job(job: Value) -> Result { + let job_id = str_field(&job, "id").to_string(); + if job_id.is_empty() { + return Err("媒体任务缺少 id".into()); + } + let _guard = lock_media_jobs(); + let mut jobs_doc = read_media_jobs_private(); + let jobs = jobs_doc + .get_mut("jobs") + .and_then(Value::as_array_mut) + .ok_or_else(|| "媒体任务文件格式错误".to_string())?; + if let Some(existing) = jobs + .iter_mut() + .find(|entry| str_field(entry, "id") == job_id.as_str()) + { + *existing = job.clone(); + } else { + jobs.push(job.clone()); + } + write_media_jobs_private(&jobs_doc)?; + Ok(job) +} + +fn update_media_job(job_id: &str, update: F) -> Result +where + F: FnOnce(&mut Value), +{ + let _guard = lock_media_jobs(); + let mut jobs_doc = read_media_jobs_private(); + let jobs = jobs_doc + .get_mut("jobs") + .and_then(Value::as_array_mut) + .ok_or_else(|| "媒体任务文件格式错误".to_string())?; + let job = jobs + .iter_mut() + .find(|entry| str_field(entry, "id") == job_id) + .ok_or_else(|| format!("媒体任务不存在: {job_id}"))?; + update(job); + if let Some(obj) = job.as_object_mut() { + obj.insert("updatedAt".into(), Value::String(now_iso())); + } + let result = job.clone(); + write_media_jobs_private(&jobs_doc)?; + Ok(result) +} + +fn media_job_by_id(job_id: &str) -> Option { + read_media_jobs_private() + .get("jobs") + .and_then(Value::as_array) + .and_then(|jobs| jobs.iter().find(|entry| str_field(entry, "id") == job_id)) + .cloned() +} + +fn new_job_id(prefix: &str) -> String { + format!( + "{}-{}-{}", + prefix, + chrono::Utc::now().format("%Y%m%d%H%M%S%3f"), + rand::random::() + ) +} + +fn validate_provider_id(provider: &str) -> Result<(), String> { + if is_supported_media_provider(provider) { + Ok(()) + } else { + Err(format!("暂不支持媒体服务商: {provider}")) + } +} + +fn validate_common_prompt(prompt: &str) -> Result<(), String> { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("提示词不能为空".into()); + } + if prompt.chars().count() > 6000 { + return Err("提示词过长,请控制在 6000 字以内".into()); + } + Ok(()) +} + +fn valid_size(size: &str) -> bool { + let value = size.trim(); + if value.is_empty() { + return true; + } + matches!(value, "1K" | "2K" | "4K" | "adaptive" | "auto") + || value + .split_once(['x', 'X']) + .and_then(|(w, h)| Some((w.parse::().ok()?, h.parse::().ok()?))) + .is_some_and(|(w, h)| (256..=8192).contains(&w) && (256..=8192).contains(&h)) +} + +fn validate_image_request(request: &Value) -> Result<(), String> { + validate_provider_id(str_field(request, "provider"))?; + validate_common_prompt(str_field(request, "prompt"))?; + let count = u64_field(request, "count", 1); + if !(1..=MAX_IMAGE_COUNT).contains(&count) { + return Err(format!("图片数量必须在 1-{MAX_IMAGE_COUNT} 之间")); + } + let size = str_field(request, "size"); + if !valid_size(size) { + return Err("图片尺寸格式不正确,可填 2K、4K 或 1024x1024".into()); + } + Ok(()) +} + +fn validate_video_request(request: &Value) -> Result<(), String> { + validate_provider_id(str_field(request, "provider"))?; + validate_common_prompt(str_field(request, "prompt"))?; + let duration = u64_field(request, "duration", 5); + if !(1..=30).contains(&duration) { + return Err("视频时长必须在 1-30 秒之间".into()); + } + let ratio = str_field(request, "ratio"); + if !ratio.is_empty() && !matches!(ratio, "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "21:9") { + return Err("视频比例暂只支持 16:9、9:16、1:1、4:3、3:4、21:9".into()); + } + let resolution = str_field(request, "resolution"); + if !resolution.is_empty() && !matches!(resolution, "480p" | "720p" | "1080p" | "1440p") { + return Err("视频分辨率暂只支持 480p、720p、1080p、1440p".into()); + } + Ok(()) +} + +fn load_provider_config( + provider: &str, + kind: &str, + request_model: &str, +) -> Result { + validate_provider_id(provider)?; + let cfg = read_media_config_private(); + let raw = cfg + .get("providers") + .and_then(|v| v.get(provider)) + .ok_or_else(|| format!("媒体服务商未配置: {provider}"))?; + let api_key = str_field(raw, "apiKey").to_string(); + if api_key.is_empty() { + return Err("请先在创作中心对接配置里填写 API Key".into()); + } + let image_model = str_field(raw, "imageModel").to_string(); + let video_model = str_field(raw, "videoModel").to_string(); + let selected_model = if request_model.trim().is_empty() { + if kind == "image" { + image_model.clone() + } else { + video_model.clone() + } + } else { + request_model.trim().to_string() + }; + if selected_model.is_empty() { + return Err(if kind == "image" { + "请先填写图片模型 ID".to_string() + } else { + "请先填写视频模型 ID".to_string() + }); + } + let base_url = str_field(raw, "baseUrl").trim_end_matches('/').to_string(); + Ok(MediaProviderConfig { + provider: provider.to_string(), + base_url: if base_url.is_empty() { + default_provider_base_url(provider).to_string() + } else { + base_url + }, + api_key, + image_model: if kind == "image" { + selected_model.clone() + } else { + image_model + }, + video_model: if kind == "video" { + selected_model + } else { + video_model + }, + timeout_seconds: u64_field(raw, "timeoutSeconds", 600).clamp(30, 1800), + }) +} + +fn build_api_url(base_url: &str, path: &str) -> String { + format!("{}{}", base_url.trim_end_matches('/'), path) +} + +fn provider_docs(provider: &str) -> Value { + match provider { + PROVIDER_OPENAI => json!({ + "apiKey": "https://platform.openai.com/api-keys", + "modelList": "https://developers.openai.com/api/docs/models", + "image": "https://developers.openai.com/api/docs/guides/image-generation", + "video": "https://developers.openai.com/api/docs/guides/video-generation" + }), + PROVIDER_NEWAPI => json!({ + "apiKey": "https://docs.newapi.pro/en/docs/api", + "modelList": "https://docs.newapi.pro/en/docs/api", + "image": "https://docs.newapi.pro/en/docs/api/ai-model/images/openai/post-v1-images-generations", + "video": "https://docs.newapi.pro/en/docs/api/ai-model/videos/sora/createvideo" + }), + _ => json!({ + "apiKey": "https://www.volcengine.com/docs/82379/1541594", + "modelList": "https://www.volcengine.com/docs/82379/1330310" + }), + } +} + +fn infer_model_capabilities(id: &str, label: &str) -> Vec<&'static str> { + let text = format!("{id} {label}").to_ascii_lowercase(); + let mut caps = Vec::new(); + if text.contains("seedream") + || text.contains("gpt-image") + || text.contains("dall-e") + || text.contains("text-to-image") + || text.contains("image-generation") + || text.contains("image_generation") + || text.contains("t2i") + { + caps.push("image"); + } + if text.contains("seedance") + || text.contains("sora") + || text.contains("text-to-video") + || text.contains("image-to-video") + || text.contains("video-generation") + || text.contains("video_generation") + || text.contains("t2v") + || text.contains("i2v") + { + caps.push("video"); + } + caps +} + +fn model_string_field<'a>(value: &'a Value, keys: &[&str]) -> &'a str { + for key in keys { + if let Some(s) = value.get(*key).and_then(Value::as_str) { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return trimmed; + } + } + } + "" +} + +fn collect_media_model_items<'a>(value: &'a Value, out: &mut Vec<&'a Value>) { + if let Some(arr) = value.as_array() { + out.extend(arr.iter()); + return; + } + if !value.is_object() { + return; + } + for key in [ + "data", + "models", + "items", + "Items", + "ModelList", + "model_list", + "foundationModels", + "FoundationModels", + ] { + if let Some(arr) = value.get(key).and_then(Value::as_array) { + out.extend(arr.iter()); + } + } + for key in ["result", "Result", "response", "Response"] { + if let Some(nested) = value.get(key) { + collect_media_model_items(nested, out); + } + } +} + +fn extract_media_models(value: &Value) -> Vec { + let mut items = Vec::new(); + collect_media_model_items(value, &mut items); + + let mut result: Vec = Vec::new(); + for item in items { + let id = model_string_field( + item, + &[ + "id", + "model", + "model_id", + "modelId", + "ModelId", + "ModelID", + "ModelName", + "FoundationModelId", + "FoundationModelName", + "EndpointId", + "endpoint_id", + ], + ); + if id.is_empty() || result.iter().any(|m| m["id"].as_str() == Some(id)) { + continue; + } + let label = model_string_field( + item, + &[ + "name", + "display_name", + "displayName", + "DisplayName", + "Description", + "description", + ], + ); + let caps = infer_model_capabilities(id, label); + result.push(json!({ + "id": id, + "label": if label.is_empty() { id } else { label }, + "capabilities": caps, + })); + } + result.sort_by(|a, b| str_field(a, "id").cmp(str_field(b, "id"))); + result +} + +fn is_gpt_image_model(model: &str) -> bool { + model.trim().to_ascii_lowercase().starts_with("gpt-image") +} + +fn normalize_openai_image_size(size: &str) -> Option { + let value = size.trim(); + if value.is_empty() { + return None; + } + if value.eq_ignore_ascii_case("auto") { + return Some("auto".into()); + } + if value.eq_ignore_ascii_case("1K") + || value.eq_ignore_ascii_case("2K") + || value.eq_ignore_ascii_case("4K") + || value.eq_ignore_ascii_case("adaptive") + { + return Some("1024x1024".into()); + } + Some(value.to_string()) +} + +fn build_image_generation_payload(provider: &MediaProviderConfig, request: &Value) -> Value { + let mut payload = json!({ + "model": provider.image_model.clone(), + "prompt": str_field(request, "prompt"), + "n": u64_field(request, "count", 1) + }); + + if is_openai_compatible_provider(&provider.provider) { + if let Some(size) = normalize_openai_image_size(str_field(request, "size")) { + payload["size"] = Value::String(size); + } + if !is_gpt_image_model(&provider.image_model) { + payload["response_format"] = Value::String("b64_json".into()); + } + } else { + payload["response_format"] = Value::String("url".into()); + payload["watermark"] = Value::Bool(bool_field(request, "watermark", true)); + let size = str_field(request, "size"); + if !size.is_empty() { + payload["size"] = Value::String(size.to_string()); + } + } + + if let Some(images) = request.get("images").filter(|v| v.is_array()) { + payload["images"] = images.clone(); + } + payload +} + +fn openai_video_seconds(duration: u64) -> &'static str { + let candidates = [4_u64, 8, 12]; + let selected = candidates + .into_iter() + .min_by_key(|value| value.abs_diff(duration)) + .unwrap_or(4); + match selected { + 8 => "8", + 12 => "12", + _ => "4", + } +} + +fn openai_video_size(ratio: &str, resolution: &str) -> &'static str { + let high = matches!(resolution.trim(), "1080p" | "1440p"); + match ratio.trim() { + "9:16" | "3:4" => { + if high { + "1024x1792" + } else { + "720x1280" + } + } + _ => { + if high { + "1792x1024" + } else { + "1280x720" + } + } + } +} + +fn build_openai_video_payload(provider: &MediaProviderConfig, request: &Value) -> Value { + let mut payload = json!({ + "model": provider.video_model.clone(), + "prompt": str_field(request, "prompt"), + "seconds": openai_video_seconds(u64_field(request, "duration", 5)), + "size": openai_video_size(str_field(request, "ratio"), str_field(request, "resolution")) + }); + if let Some(image_url) = request + .get("imageUrl") + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + if provider.provider == PROVIDER_NEWAPI { + payload["image"] = Value::String(image_url.to_string()); + } else { + payload["input_reference"] = json!({ + "type": "image_url", + "image_url": { "url": image_url } + }); + } + } + payload +} + +fn openai_video_dimensions(size: &str) -> Option<(&str, &str)> { + size.split_once('x') +} + +fn provider_status_to_job_status(status: &str) -> &'static str { + match status.trim().to_ascii_lowercase().as_str() { + "created" | "queued" | "pending" | "running" | "processing" | "in_progress" => "running", + "succeeded" | "success" | "completed" | "done" => "succeeded", + "failed" | "error" | "cancelled" | "canceled" | "expired" => { + if status.trim().eq_ignore_ascii_case("cancelled") + || status.trim().eq_ignore_ascii_case("canceled") + { + "canceled" + } else { + "failed" + } + } + _ => "running", + } +} + +fn extract_nested_string<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> { + for key in keys { + // 空字符串视为未命中,继续向嵌套容器找(与 dev-api.js 的 extractNestedString 行为一致) + if let Some(s) = value.get(*key).and_then(Value::as_str) { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + } + for container in ["data", "task", "result", "output"] { + if let Some(s) = value + .get(container) + .and_then(|v| extract_nested_string(v, keys)) + { + return Some(s); + } + } + None +} + +fn extract_provider_task_id(value: &Value) -> String { + extract_nested_string(value, &["id", "task_id", "taskId", "providerTaskId"]) + .unwrap_or("") + .to_string() +} + +fn extract_provider_status(value: &Value) -> String { + extract_nested_string(value, &["status", "state", "task_status", "taskStatus"]) + .unwrap_or("running") + .to_string() +} + +fn looks_like_video_url(url: &str, key_hint: &str) -> bool { + let lower = url.to_ascii_lowercase(); + (lower.starts_with("http://") || lower.starts_with("https://")) + && (key_hint.contains("video") + || lower.contains(".mp4") + || lower.contains(".mov") + || lower.contains(".webm") + || lower.contains("video")) +} + +fn collect_video_urls_inner(value: &Value, key_hint: &str, out: &mut Vec) { + match value { + Value::String(s) if looks_like_video_url(s, key_hint) && !out.iter().any(|v| v == s) => { + out.push(s.to_string()); + } + Value::String(_) => {} + Value::Array(arr) => { + for item in arr { + collect_video_urls_inner(item, key_hint, out); + } + } + Value::Object(map) => { + for (key, item) in map { + let hint = if key_hint.is_empty() { + key.to_ascii_lowercase() + } else { + format!("{} {}", key_hint, key.to_ascii_lowercase()) + }; + collect_video_urls_inner(item, &hint, out); + } + } + _ => {} + } +} + +fn collect_video_urls(value: &Value) -> Vec { + let mut urls = Vec::new(); + collect_video_urls_inner(value, "", &mut urls); + urls +} + +fn is_safe_relative_media_path(raw: &str) -> bool { + let path = Path::new(raw); + if raw.trim().is_empty() || path.is_absolute() { + return false; + } + path.components() + .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)) +} + +fn path_compare_key(path: &Path) -> String { + crate::utils::path_compare_key(path) +} + +fn paths_equivalent(a: &Path, b: &Path) -> bool { + path_compare_key(a) == path_compare_key(b) +} + +fn media_job_knows_asset_root(raw: &str, root: &Path) -> bool { + read_media_jobs_private() + .get("jobs") + .and_then(Value::as_array) + .is_some_and(|jobs| { + jobs.iter().any(|job| { + job.get("assets") + .and_then(Value::as_array) + .is_some_and(|assets| { + assets.iter().any(|asset| { + asset.get("path").and_then(Value::as_str) == Some(raw) + && asset + .get("root") + .and_then(Value::as_str) + .is_some_and(|stored| paths_equivalent(Path::new(stored), root)) + }) + }) + }) + }) +} + +fn resolve_media_asset_path(raw: &str, root_hint: Option<&str>) -> Result { + if !is_safe_relative_media_path(raw) { + return Err("非法媒体文件路径".into()); + } + + let default_root = media_root(); + let root = root_hint + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| default_root.clone()); + if root_hint.is_some() + && !paths_equivalent(&root, &default_root) + && !paths_equivalent( + &root, + &media_output_root_from_config(&read_media_config_private()), + ) + && !media_job_knows_asset_root(raw, &root) + { + return Err("媒体文件路径不在已知产出目录中".into()); + } + + let target = root.join(raw); + let normalized_root = path_compare_key(&root); + let normalized_target = path_compare_key(&target); + if normalized_target != normalized_root + && !normalized_target.starts_with(&format!("{normalized_root}/")) + { + return Err("非法媒体文件路径".into()); + } + Ok(target) +} + +fn asset_ext_from_content_type(content_type: &str, fallback: &str) -> &'static str { + let ct = content_type.to_ascii_lowercase(); + if ct.contains("image/png") { + "png" + } else if ct.contains("image/webp") { + "webp" + } else if ct.contains("image/gif") { + "gif" + } else if ct.contains("video/mp4") { + "mp4" + } else if ct.contains("video/webm") { + "webm" + } else if fallback == "video" { + "mp4" + } else { + "jpg" + } +} + +fn guess_mime_from_ext(ext: &str, kind: &str) -> &'static str { + match ext { + "png" => "image/png", + "webp" => "image/webp", + "gif" => "image/gif", + "mp4" => "video/mp4", + "webm" => "video/webm", + "mov" => "video/quicktime", + _ if kind == "video" => "video/mp4", + _ => "image/jpeg", + } +} + +fn relative_asset_path(_kind: &str, job_id: &str, index: usize, ext: &str) -> PathBuf { + PathBuf::from("assets") + .join(chrono::Utc::now().format("%Y").to_string()) + .join(chrono::Utc::now().format("%m").to_string()) + .join(format!("{job_id}-{index}.{ext}")) +} + +async fn write_asset_bytes( + kind: &str, + job_id: &str, + index: usize, + bytes: &[u8], + mime: &str, + source_url: Option<&str>, +) -> Result { + if bytes.len() as u64 > MAX_ASSET_BYTES { + return Err("媒体文件超过 512MB,已停止保存".into()); + } + let cfg = read_media_config_private(); + let root = ensure_media_output_root_from_config(&cfg)?; + let ext = asset_ext_from_content_type(mime, kind); + let relative = relative_asset_path(kind, job_id, index, ext); + let target = root.join(&relative); + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("创建媒体资产目录失败: {e}"))?; + } + tokio::fs::write(&target, bytes) + .await + .map_err(|e| format!("保存媒体资产失败: {e}"))?; + Ok(json!({ + "kind": kind, + "path": relative.to_string_lossy().replace('\\', "/"), + "root": root.to_string_lossy(), + "mime": mime, + "bytes": bytes.len() as u64, + "sourceUrl": source_url.unwrap_or("") + })) +} + +/// 仅当资产 URL 与服务商 Base URL 同主机同端口时才允许携带 API Key, +/// 防止服务商响应中混入第三方 URL 后把密钥发给任意主机 +fn asset_url_same_host(url: &str, base_url: &str) -> bool { + let (Ok(asset), Ok(base)) = (reqwest::Url::parse(url), reqwest::Url::parse(base_url)) else { + return false; + }; + asset.host_str().map(str::to_ascii_lowercase) == base.host_str().map(str::to_ascii_lowercase) + && asset.port_or_known_default() == base.port_or_known_default() +} + +async fn download_asset_to_media_root( + client: &reqwest::Client, + provider: &MediaProviderConfig, + url: &str, + kind: &str, + job_id: &str, + index: usize, +) -> Result { + let mut request = client.get(url); + if asset_url_same_host(url, &provider.base_url) { + request = request.bearer_auth(&provider.api_key); + } + let resp = request + .send() + .await + .map_err(|e| format!("下载媒体资产失败: {e}"))?; + if !resp.status().is_success() { + return Err(format!("下载媒体资产失败: HTTP {}", resp.status())); + } + let mime_owned = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_else(|| { + if kind == "video" { + "video/mp4" + } else { + "image/jpeg" + } + }) + .to_string(); + let bytes = resp + .bytes() + .await + .map_err(|e| format!("读取媒体资产失败: {e}"))?; + write_asset_bytes(kind, job_id, index, bytes.as_ref(), &mime_owned, Some(url)).await +} + +async fn download_openai_video_content( + client: &reqwest::Client, + provider: &MediaProviderConfig, + provider_task_id: &str, + job_id: &str, + index: usize, +) -> Result { + let endpoint = build_api_url( + &provider.base_url, + &format!("/videos/{provider_task_id}/content"), + ); + let resp = client + .get(endpoint) + .bearer_auth(&provider.api_key) + .send() + .await + .map_err(|e| format!("下载视频内容失败: {e}"))?; + if !resp.status().is_success() { + return Err(format!("下载视频内容失败: HTTP {}", resp.status())); + } + let mime = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("video/mp4") + .to_string(); + let bytes = resp + .bytes() + .await + .map_err(|e| format!("读取视频内容失败: {e}"))?; + write_asset_bytes("video", job_id, index, bytes.as_ref(), &mime, None).await +} + +fn collect_image_outputs(value: &Value) -> Vec { + let mut outputs = Vec::new(); + if let Some(arr) = value.get("data").and_then(Value::as_array) { + outputs.extend(arr.iter().cloned()); + } else if let Some(arr) = value.get("images").and_then(Value::as_array) { + outputs.extend(arr.iter().cloned()); + } else if value.get("url").is_some() || value.get("b64_json").is_some() { + outputs.push(value.clone()); + } + outputs +} + +async fn save_image_outputs( + client: &reqwest::Client, + provider: &MediaProviderConfig, + job_id: &str, + response: &Value, +) -> Result, String> { + let outputs = collect_image_outputs(response); + if outputs.is_empty() { + return Err("服务商响应中没有图片结果".into()); + } + let mut assets = Vec::new(); + for (idx, item) in outputs.iter().enumerate() { + if let Some(url) = item + .get("url") + .or_else(|| item.get("image_url")) + .and_then(|v| { + if v.is_string() { + v.as_str() + } else { + v.get("url").and_then(Value::as_str) + } + }) + { + assets.push( + download_asset_to_media_root(client, provider, url, "image", job_id, idx).await?, + ); + continue; + } + if let Some(raw_b64) = item.get("b64_json").and_then(Value::as_str) { + let pure = raw_b64.split_once(',').map(|(_, v)| v).unwrap_or(raw_b64); + let bytes = general_purpose::STANDARD + .decode(pure) + .map_err(|e| format!("图片 base64 解码失败: {e}"))?; + assets.push(write_asset_bytes("image", job_id, idx, &bytes, "image/png", None).await?); + } + } + if assets.is_empty() { + return Err("服务商响应中没有可保存的图片 URL 或 base64".into()); + } + Ok(assets) +} + +fn sanitize_provider_error(raw: &str, api_key: &str) -> String { + if api_key.is_empty() { + raw.to_string() + } else { + raw.replace(api_key, "***") + } +} + +fn media_http_client(timeout_seconds: u64) -> Result { + super::build_http_client_no_proxy( + Duration::from_secs(timeout_seconds), + Some("ClawPanel Media"), + ) +} + +/// 落盘前允许保留的最长字符串(字节)。超过即视为 base64/data URL 等大负载, +/// 只保留开头片段,避免 media-jobs.json 随任务数量膨胀到 GB 级 +const MAX_PERSISTED_STRING_BYTES: usize = 2048; + +/// 递归裁剪 JSON 中的超长字符串(如 gpt-image 响应里的 b64_json),用于持久化前瘦身 +fn truncate_large_strings(value: &Value) -> Value { + match value { + Value::String(s) if s.len() > MAX_PERSISTED_STRING_BYTES => { + let head: String = s.chars().take(64).collect(); + Value::String(format!("{head}…[已截断 {} 字节]", s.len())) + } + Value::Array(arr) => Value::Array(arr.iter().map(truncate_large_strings).collect()), + Value::Object(map) => Value::Object( + map.iter() + .map(|(k, v)| (k.clone(), truncate_large_strings(v))) + .collect(), + ), + other => other.clone(), + } +} + +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()?; + Ok(sanitize_media_config_for_read(&read_media_config_private())) +} + +#[tauri::command] +pub fn write_media_config(config: Value) -> Result<(), String> { + ensure_media_root()?; + let current = read_media_config_private(); + let normalized = normalize_media_config(config, Some(¤t)); + validate_media_output_dir(str_field(&normalized, "outputDir"))?; + ensure_media_output_root_from_config(&normalized)?; + write_json_atomic(&media_config_path(), &normalized) +} + +#[tauri::command] +pub async fn test_media_provider(provider: String) -> Result { + validate_provider_id(&provider)?; + let cfg = read_media_config_private(); + let raw = cfg + .get("providers") + .and_then(|v| v.get(&provider)) + .ok_or_else(|| format!("媒体服务商未配置: {provider}"))?; + let api_key = str_field(raw, "apiKey"); + if api_key.is_empty() { + return Err("请先填写 API Key".into()); + } + let base_url = str_field(raw, "baseUrl"); + // 允许 http://:自建 NewAPI / 内网网关是合法场景,与实际生成请求的校验保持一致 + if base_url.is_empty() || !(base_url.starts_with("https://") || base_url.starts_with("http://")) + { + return Err("Base URL 必须以 http:// 或 https:// 开头".into()); + } + Ok(json!({ + "ok": true, + "provider": provider, + "baseUrl": base_url, + "message": "本地配置检查通过。生成图片或创建视频任务时才会调用服务商接口。" + })) +} + +#[tauri::command] +pub async fn fetch_media_models(provider: String) -> Result { + validate_provider_id(&provider)?; + let cfg = read_media_config_private(); + let raw = cfg + .get("providers") + .and_then(|v| v.get(&provider)) + .ok_or_else(|| format!("媒体服务商未配置: {provider}"))?; + let api_key = str_field(raw, "apiKey").to_string(); + if api_key.is_empty() { + return Err("请先填写 API Key".into()); + } + let base_url = str_field(raw, "baseUrl").trim_end_matches('/').to_string(); + // 允许 http://:自建 NewAPI / 内网网关是合法场景,与实际生成请求的校验保持一致 + if base_url.is_empty() || !(base_url.starts_with("https://") || base_url.starts_with("http://")) + { + return Err("Base URL 必须以 http:// 或 https:// 开头".into()); + } + let timeout_seconds = u64_field(raw, "timeoutSeconds", 600).clamp(30, 1800); + let client = media_http_client(timeout_seconds)?; + let endpoint = build_api_url(&base_url, "/models"); + let resp = client + .get(&endpoint) + .bearer_auth(&api_key) + .send() + .await + .map_err(|e| format!("获取模型列表失败: {e}"))?; + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| format!("读取模型列表响应失败: {e}"))?; + let parsed = serde_json::from_str::(&text).unwrap_or_else(|_| json!({ "raw": text })); + if !status.is_success() { + if matches!(status.as_u16(), 404 | 405 | 501) { + return Ok(json!({ + "ok": false, + "supported": false, + "models": [], + "message": "当前 Base URL 未开放 /models 列表接口,请按官方模型列表或控制台填写模型 ID。", + "docs": provider_docs(&provider) + })); + } + let error = sanitize_provider_error(&format!("HTTP {status}: {parsed}"), &api_key); + return Err(error); + } + let models = extract_media_models(&parsed); + Ok(json!({ + "ok": true, + "supported": !models.is_empty(), + "models": models, + "message": if models.is_empty() { + "服务商响应中没有可识别的模型 ID,请按官方模型列表或控制台填写。" + } else { + "模型列表已获取。" + }, + "docs": provider_docs(&provider) + })) +} + +#[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"); + let client = media_http_client(provider.timeout_seconds)?; + let payload = build_image_generation_payload(&provider, &request); + let endpoint = build_api_url(&provider.base_url, "/images/generations"); + let resp = client + .post(endpoint) + .bearer_auth(&provider.api_key) + .json(&payload) + .send() + .await + .map_err(|e| format!("图片生成请求失败: {e}"))?; + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| format!("读取图片生成响应失败: {e}"))?; + 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 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"); + 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 mut form = reqwest::multipart::Form::new() + .text("model", str_field(&payload, "model").to_string()) + .text("prompt", str_field(&payload, "prompt").to_string()) + .text("seconds", str_field(&payload, "seconds").to_string()) + .text("size", str_field(&payload, "size").to_string()); + if provider.provider == PROVIDER_NEWAPI { + form = form.text("duration", str_field(&payload, "seconds").to_string()); + if let Some((width, height)) = openai_video_dimensions(str_field(&payload, "size")) { + form = form + .text("width", width.to_string()) + .text("height", height.to_string()); + } + } + if let Some(image_url) = payload.get("image").and_then(Value::as_str) { + form = form.text("image", image_url.to_string()); + } + if let Some(input_reference) = payload.get("input_reference").filter(|v| v.is_object()) { + form = form.text("input_reference", input_reference.to_string()); + } + let endpoint = build_api_url(&provider.base_url, "/videos"); + let resp = client + .post(&endpoint) + .bearer_auth(&provider.api_key) + .multipart(form) + .send() + .await + .map_err(|e| format!("创建视频任务失败: {e}"))?; + (payload, resp) + } else { + let mut content = vec![json!({ "type": "text", "text": prompt })]; + if let Some(url) = request + .get("imageUrl") + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + content.push(json!({ "type": "image_url", "image_url": { "url": url } })); + } + 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) + }); + for key in ["ratio", "resolution"] { + if payload[key].as_str().unwrap_or("").is_empty() { + payload.as_object_mut().unwrap().remove(key); + } + } + let endpoint = build_api_url(&provider.base_url, "/contents/generations/tasks"); + let resp = client + .post(&endpoint) + .bearer_auth(&provider.api_key) + .json(&payload) + .send() + .await + .map_err(|e| format!("创建视频任务失败: {e}"))?; + (payload, resp) + }; + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| format!("读取视频任务响应失败: {e}"))?; + 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); + } + 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) +} + +#[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}"))?; + if str_field(&job, "type") != "video" { + return Err("只支持轮询视频任务".into()); + } + let provider_id = str_field(&job, "provider"); + let provider_task_id = str_field(&job, "providerTaskId").to_string(); + if provider_task_id.is_empty() { + return Err("视频任务缺少服务商任务 ID".into()); + } + let provider = load_provider_config(provider_id, "video", str_field(&job, "model"))?; + let client = media_http_client(provider.timeout_seconds)?; + let endpoint = if is_openai_compatible_provider(&provider.provider) { + build_api_url(&provider.base_url, &format!("/videos/{provider_task_id}")) + } else { + build_api_url( + &provider.base_url, + &format!("/contents/generations/tasks/{provider_task_id}"), + ) + }; + let resp = client + .get(endpoint) + .bearer_auth(&provider.api_key) + .send() + .await + .map_err(|e| format!("查询视频任务失败: {e}"))?; + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| format!("读取视频任务查询响应失败: {e}"))?; + 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); + // 429 / 5xx 属于瞬时错误:保留原状态允许继续轮询,避免把服务商侧仍在运行的付费任务永久标记为失败 + let transient = status.as_u16() == 429 || status.is_server_error(); + return update_media_job(&job_id, |entry| { + if !transient { + entry["status"] = Value::String("failed".into()); + } + entry["error"] = Value::String(error.clone()); + entry["rawProviderResponse"] = truncate_large_strings(&parsed); + }); + } + let provider_status = extract_provider_status(&parsed); + let job_status = provider_status_to_job_status(&provider_status); + let mut assets = job.get("assets").cloned().unwrap_or_else(|| json!([])); + let mut download_error = None; + if job_status == "succeeded" && assets.as_array().is_none_or(|arr| arr.is_empty()) { + 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 + { + Ok(asset) => saved.push(asset), + Err(e) => download_error = Some(e), + } + } + if saved.is_empty() && is_openai_compatible_provider(&provider.provider) { + match download_openai_video_content( + &client, + &provider, + &provider_task_id, + &job_id, + saved.len(), + ) + .await + { + Ok(asset) => saved.push(asset), + Err(e) => download_error = Some(e), + } + } + assets = Value::Array(saved); + } + update_media_job(&job_id, |entry| { + entry["status"] = Value::String(job_status.into()); + entry["providerStatus"] = Value::String(provider_status.clone()); + entry["assets"] = assets.clone(); + entry["rawProviderResponse"] = truncate_large_strings(&parsed); + if let Some(err) = download_error.clone() { + entry["error"] = Value::String(err); + } else if job_status != "failed" { + entry["error"] = Value::Null; + } + }) +} + +#[tauri::command] +pub async fn cancel_media_job(job_id: String) -> Result { + update_media_job(&job_id, |entry| { + entry["status"] = Value::String("canceled".into()); + entry["providerStatus"] = Value::String("local-canceled".into()); + }) +} + +#[tauri::command] +pub fn list_media_jobs(filter: Option) -> Result { + let filter = filter.unwrap_or_else(|| json!({})); + let jobs_doc = read_media_jobs_private(); + Ok(media_jobs_response_from_doc(&jobs_doc, Some(&filter))) +} + +fn media_filter_usize(filter: Option<&Value>, key: &str, default: usize, max: usize) -> usize { + let Some(value) = filter.and_then(|v| v.get(key)) else { + return default.min(max); + }; + let parsed = value + .as_u64() + .or_else(|| { + value + .as_str() + .and_then(|raw| raw.trim().parse::().ok()) + }) + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(default); + parsed.min(max) +} + +fn media_jobs_response_from_doc(jobs_doc: &Value, filter: Option<&Value>) -> Value { + let mut jobs: Vec = jobs_doc + .get("jobs") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if let Some(kind) = filter + .and_then(|v| v.get("type")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + jobs.retain(|job| str_field(job, "type") == kind); + } + if let Some(status) = filter + .and_then(|v| v.get("status")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + jobs.retain(|job| str_field(job, "status") == status); + } + jobs.sort_by(|a, b| str_field(b, "createdAt").cmp(str_field(a, "createdAt"))); + let total = jobs.len(); + let offset = media_filter_usize(filter, "offset", 0, total); + let limit = media_filter_usize(filter, "limit", 24, 200).max(1); + let paged: Vec = jobs.into_iter().skip(offset).take(limit).collect(); + let has_more = offset.saturating_add(paged.len()) < total; + json!({ + "version": 1, + "jobs": paged, + "total": total, + "offset": offset, + "limit": limit, + "hasMore": has_more + }) +} + +#[tauri::command] +pub fn delete_media_job(job_id: String, delete_assets: Option) -> Result<(), String> { + let _guard = lock_media_jobs(); + let mut jobs_doc = read_media_jobs_private(); + let jobs = jobs_doc + .get_mut("jobs") + .and_then(Value::as_array_mut) + .ok_or_else(|| "媒体任务文件格式错误".to_string())?; + let Some(pos) = jobs + .iter() + .position(|entry| str_field(entry, "id") == job_id) + else { + return Err(format!("媒体任务不存在: {job_id}")); + }; + let job = jobs.remove(pos); + if delete_assets.unwrap_or(true) { + if let Some(assets) = job.get("assets").and_then(Value::as_array) { + for asset in assets { + if let Some(path) = asset.get("path").and_then(Value::as_str) { + let root = asset.get("root").and_then(Value::as_str); + if let Ok(full) = resolve_media_asset_path(path, root) { + let _ = std::fs::remove_file(full); + } + } + } + } + } + write_media_jobs_private(&jobs_doc) +} + +#[tauri::command] +pub fn reveal_media_asset(path: String, root: Option) -> Result { + let full = resolve_media_asset_path(&path, root.as_deref())?; + let parent = full + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + Ok(json!({ + "path": full.to_string_lossy(), + "parent": parent + })) +} + +#[tauri::command] +pub fn reveal_media_output_dir() -> Result { + let cfg = read_media_config_private(); + let root = ensure_media_output_root_from_config(&cfg)?; + Ok(json!({ + "path": root.to_string_lossy() + })) +} + +#[tauri::command] +pub async fn load_media_asset(path: String, root: Option) -> Result { + let full = resolve_media_asset_path(&path, root.as_deref())?; + // 先 stat 后读:避免超大文件整读进内存才被拒绝; + // 内嵌预览走 base64 IPC,上限收紧到 64MB,超限引导用户打开文件夹本地查看 + let meta = tokio::fs::metadata(&full) + .await + .map_err(|e| format!("读取媒体资产失败: {e}"))?; + if meta.len() > MAX_INLINE_PREVIEW_BYTES { + return Err("媒体文件过大,无法内嵌预览,请使用「打开文件夹」在本地查看".into()); + } + let bytes = tokio::fs::read(&full) + .await + .map_err(|e| format!("读取媒体资产失败: {e}"))?; + let ext = full + .extension() + .and_then(|v| v.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let kind = if matches!(ext.as_str(), "mp4" | "webm" | "mov") { + "video" + } else { + "image" + }; + let mime = guess_mime_from_ext(&ext, kind); + let encoded = general_purpose::STANDARD.encode(&bytes); + Ok(json!({ + "path": path, + "kind": kind, + "mime": mime, + "dataUrl": format!("data:{mime};base64,{encoded}") + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::PathBuf; + + #[test] + fn read_config_masks_saved_api_key() { + let cfg = json!({ + "providers": { + "volcengine": { + "apiKey": "sk-secret", + "baseUrl": "https://ark.cn-beijing.volces.com/api/v3" + } + } + }); + + let public = sanitize_media_config_for_read(&cfg); + let provider = &public["providers"]["volcengine"]; + assert_eq!(provider["apiKey"], ""); + assert_eq!(provider["apiKeySaved"], true); + assert_eq!(provider["apiKeyMask"], "sk-***cret"); + } + + #[test] + fn media_config_preserves_openai_compatible_providers() { + let cfg = normalize_media_config( + json!({ + "defaults": { "provider": "openai" }, + "providers": { + "openai": { + "apiKey": "sk-openai-secret", + "baseUrl": "https://api.openai.com/v1/", + "imageModel": "gpt-image-1", + "videoModel": "sora-2" + }, + "newapi": { + "apiKey": "sk-newapi-secret", + "baseUrl": "https://newapi.example.com/v1/", + "imageModel": "gpt-image-1", + "videoModel": "sora-2" + } + } + }), + None, + ); + + assert_eq!(cfg["defaults"]["provider"], "openai"); + assert_eq!( + cfg["providers"]["openai"]["baseUrl"], + "https://api.openai.com/v1" + ); + assert_eq!( + cfg["providers"]["newapi"]["baseUrl"], + "https://newapi.example.com/v1" + ); + + let public = sanitize_media_config_for_read(&cfg); + assert_eq!(public["providers"]["openai"]["apiKey"], ""); + assert_eq!(public["providers"]["openai"]["apiKeySaved"], true); + assert_eq!(public["providers"]["newapi"]["apiKey"], ""); + assert_eq!(public["providers"]["newapi"]["apiKeySaved"], true); + } + + #[test] + fn gpt_image_payload_omits_openai_unsupported_fields() { + let provider = MediaProviderConfig { + provider: "openai".into(), + base_url: "https://api.openai.com/v1".into(), + api_key: "sk-test".into(), + image_model: "gpt-image-1".into(), + video_model: "sora-2".into(), + timeout_seconds: 600, + }; + let payload = build_image_generation_payload( + &provider, + &json!({ + "prompt": "a product shot", + "size": "1024x1024", + "count": 2, + "watermark": true + }), + ); + + assert_eq!(payload["model"], "gpt-image-1"); + assert_eq!(payload["n"], 2); + assert!(payload.get("response_format").is_none()); + assert!(payload.get("watermark").is_none()); + } + + #[test] + fn openai_video_payload_maps_panel_controls() { + let provider = MediaProviderConfig { + provider: "newapi".into(), + base_url: "https://newapi.example.com/v1".into(), + api_key: "sk-test".into(), + image_model: "gpt-image-1".into(), + video_model: "sora-2".into(), + timeout_seconds: 600, + }; + let payload = build_openai_video_payload( + &provider, + &json!({ + "prompt": "cinematic launch", + "ratio": "9:16", + "resolution": "720p", + "duration": 5, + "imageUrl": "https://example.com/ref.png" + }), + ); + + assert_eq!(payload["model"], "sora-2"); + assert_eq!(payload["prompt"], "cinematic launch"); + assert_eq!(payload["size"], "720x1280"); + assert_eq!(payload["seconds"], "4"); + assert_eq!(payload["image"], "https://example.com/ref.png"); + } + + #[test] + fn validate_image_request_rejects_empty_prompt_and_bad_count() { + let empty_prompt = json!({ "provider": "volcengine", "prompt": " ", "count": 1 }); + assert!(validate_image_request(&empty_prompt).is_err()); + + let bad_count = json!({ "provider": "volcengine", "prompt": "cat", "count": 9 }); + assert!(validate_image_request(&bad_count).is_err()); + } + + #[test] + fn media_paths_must_stay_relative_to_media_root() { + assert!(is_safe_relative_media_path("assets/2026/07/job-1.png")); + assert!(!is_safe_relative_media_path("../openclaw.json")); + assert!(!is_safe_relative_media_path("assets/../../openclaw.json")); + assert!(!is_safe_relative_media_path("C:/Users/test/secret.txt")); + } + + #[test] + fn provider_statuses_map_to_stable_job_statuses() { + assert_eq!(provider_status_to_job_status("queued"), "running"); + assert_eq!(provider_status_to_job_status("processing"), "running"); + assert_eq!(provider_status_to_job_status("succeeded"), "succeeded"); + assert_eq!(provider_status_to_job_status("failed"), "failed"); + assert_eq!(provider_status_to_job_status("cancelled"), "canceled"); + } + + #[test] + fn extracts_media_models_from_common_response_shapes() { + let openai = json!({ + "data": [ + { "id": "doubao-seedream-4-0" }, + { "id": "doubao-seedance-2-0-pro" } + ] + }); + let models = extract_media_models(&openai); + assert_eq!(models.len(), 2); + assert_eq!(models[0]["capabilities"][0], "video"); + assert_eq!(models[1]["capabilities"][0], "image"); + + let control_plane = json!({ + "Result": { + "Items": [ + { "FoundationModelName": "seedream-image", "DisplayName": "Seedream" } + ] + } + }); + let models = extract_media_models(&control_plane); + assert_eq!(models[0]["id"], "seedream-image"); + assert_eq!(models[0]["label"], "Seedream"); + } + + #[test] + fn media_root_follows_openclaw_dir() { + // 普通模式与便携模式共用同一相对布局:openclaw_dir/clawpanel/media。 + // 便携模式下 openclaw_dir 已指向便携数据目录,迁移复制后无需搬运。 + let normal = media_root_impl(Path::new("/home/alice/.openclaw")); + assert_eq!( + normal, + PathBuf::from("/home/alice/.openclaw/clawpanel/media") + ); + + let portable = media_root_impl(Path::new("/mnt/usb/data/openclaw")); + assert_eq!( + portable, + PathBuf::from("/mnt/usb/data/openclaw/clawpanel/media") + ); + } + + #[test] + fn normalize_config_keeps_trimmed_output_dir() { + let cfg = normalize_media_config(json!({ "outputDir": " exports/media " }), None); + assert_eq!(cfg["outputDir"], "exports/media"); + + let defaulted = normalize_media_config(json!({}), None); + assert_eq!(defaulted["outputDir"], ""); + } + + #[test] + fn media_output_root_prefers_custom_dir() { + let media_root = Path::new("/home/alice/.openclaw/clawpanel/media"); + assert_eq!(media_output_root_impl(media_root, ""), media_root); + assert_eq!( + media_output_root_impl(media_root, "exports"), + media_root.join("exports") + ); + + let absolute = if cfg!(windows) { + PathBuf::from(r"D:\ClawPanelMedia") + } else { + PathBuf::from("/mnt/ClawPanelMedia") + }; + assert_eq!( + media_output_root_impl(media_root, absolute.to_string_lossy().as_ref()), + absolute + ); + } + + #[test] + fn media_job_collection_filters_sorts_and_paginates() { + let jobs_doc = json!({ + "jobs": [ + { "id": "old", "type": "image", "status": "succeeded", "createdAt": "2026-07-01T00:00:00Z" }, + { "id": "running-video", "type": "video", "status": "running", "createdAt": "2026-07-03T00:00:00Z" }, + { "id": "new-image", "type": "image", "status": "succeeded", "createdAt": "2026-07-04T00:00:00Z" }, + { "id": "middle-image", "type": "image", "status": "succeeded", "createdAt": "2026-07-02T00:00:00Z" } + ] + }); + let filter = json!({ "type": "image", "status": "succeeded", "offset": 1, "limit": 1 }); + + let result = media_jobs_response_from_doc(&jobs_doc, Some(&filter)); + assert_eq!(result["total"], 3); + assert_eq!(result["offset"], 1); + assert_eq!(result["limit"], 1); + assert_eq!(result["hasMore"], true); + assert_eq!(result["jobs"][0]["id"], "middle-image"); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 19065eb..bd33b36 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -23,9 +23,11 @@ pub mod extensions; pub mod hermes; pub mod hermes_providers; pub mod logs; +pub mod media; pub mod memory; pub mod messaging; pub mod pairing; +pub mod portable; pub mod service; pub mod site_api; pub mod skillhub; @@ -107,6 +109,11 @@ fn push_unique_panel_config_path(paths: &mut Vec, path: PathBuf) { } fn panel_config_candidate_paths() -> Vec { + // 便携模式:只认 U 盘上的唯一候选,绝不回退主目录, + // 防止本机 clawpanel.json 里的绝对路径(nodePath/openclawCliPath 等)污染便携会话 + if let Some(ctx) = portable::portable_context() { + return vec![ctx.panel_config_path.clone()]; + } let mut paths = Vec::new(); push_unique_panel_config_path(&mut paths, default_openclaw_dir().join("clawpanel.json")); @@ -184,6 +191,10 @@ fn normalize_custom_openclaw_dir(raw: &str) -> Option { if expanded.is_absolute() { Some(expanded) + } else if let Some(ctx) = portable::portable_context() { + // 便携模式:相对路径以 portable root 为基准(cwd 在 U 盘双击启动时不可靠), + // 这样 U 盘配置里可以写 "./data/openclaw" 而不怕盘符变化 + Some(ctx.root.join(expanded)) } else { std::env::current_dir().ok().map(|cwd| cwd.join(expanded)) } @@ -211,12 +222,18 @@ pub fn openclaw_search_paths() -> Vec { /// 获取 OpenClaw 配置目录 /// 优先使用 clawpanel.json 中的 openclawDir 自定义路径,不存在则回退默认 ~/.openclaw pub fn openclaw_dir() -> PathBuf { + // 便携模式下 read_panel_config_value 读的已经是 U 盘上的 clawpanel.json + // (见 panel_config_candidate_paths),因此自定义 openclawDir 放在便携默认值之前是安全的, + // 还允许高级用户在 U 盘配置里进一步自定义 if let Some(custom) = read_panel_config_value() .and_then(|v| v.get("openclawDir")?.as_str().map(String::from)) .and_then(|v| normalize_custom_openclaw_dir(&v)) { return custom; } + if let Some(ctx) = portable::portable_context() { + return ctx.openclaw_dir.clone(); + } default_openclaw_dir() } @@ -454,6 +471,11 @@ fn build_enhanced_path() -> String { let custom_path = read_panel_config_value().and_then(|v| v.get("nodePath")?.as_str().map(String::from)); + // 便携模式:U 盘 Node 运行时优先级最高(排在自定义 nodePath 之前) + let portable_node_dir = portable::portable_context() + .and_then(|ctx| ctx.node_dir.as_ref()) + .map(|p| p.to_string_lossy().to_string()); + #[cfg(target_os = "macos")] { // 版本管理器路径优先于系统路径,确保 nvm/volta/fnm 管理的 Node.js 版本被优先检测到 @@ -532,6 +554,9 @@ fn build_enhanced_path() -> String { extra.push("/usr/local/bin".into()); extra.push("/opt/homebrew/bin".into()); let mut parts: Vec<&str> = vec![]; + if let Some(ref pn) = portable_node_dir { + parts.push(pn.as_str()); + } if let Some(ref cp) = custom_path { parts.push(cp.as_str()); } @@ -638,6 +663,9 @@ fn build_enhanced_path() -> String { } } let mut parts: Vec<&str> = vec![]; + if let Some(ref pn) = portable_node_dir { + parts.push(pn.as_str()); + } if let Some(ref cp) = custom_path { parts.push(cp.as_str()); } @@ -845,7 +873,10 @@ fn build_enhanced_path() -> String { } let mut parts: Vec<&str> = vec![]; - // 用户自定义路径优先级最高 + // 便携 Node 优先,其次用户自定义路径 + if let Some(ref pn) = portable_node_dir { + parts.push(pn.as_str()); + } if let Some(ref cp) = custom_path { parts.push(cp.as_str()); } diff --git a/src-tauri/src/commands/portable.rs b/src-tauri/src/commands/portable.rs new file mode 100644 index 0000000..c4a2829 --- /dev/null +++ b/src-tauri/src/commands/portable.rs @@ -0,0 +1,873 @@ +//! U 盘便携模式(Portable Mode) +//! +//! 检测规则(启动时一次性判定,运行期不变): +//! 1. 环境变量 `CLAWPANEL_PORTABLE_ROOT` 指向的目录(开发/测试用); +//! 2. 否则取 exe 所在目录。 +//! +//! 该目录下存在 `portable.json` 且 `mode == "portable"` 时进入便携模式。 +//! +//! portable.json 里的相对路径一律相对 portable root 解析(不是 cwd), +//! 保证 U 盘换盘符后无需修改任何配置。 +//! 解析失败(JSON 损坏等)只记日志并按普通模式运行,绝不影响启动。 + +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +pub struct PortableContext { + /// portable root(exe 所在目录,启动时动态解析,不落盘) + pub root: PathBuf, + pub data_dir: PathBuf, + /// data/clawpanel/clawpanel.json + pub panel_config_path: PathBuf, + /// data/openclaw + pub openclaw_dir: PathBuf, + /// data/hermes + pub hermes_home: PathBuf, + /// runtimes/node(存在 node 可执行文件才 Some) + pub node_dir: Option, + /// engines/openclaw(standalone 安装/升级在便携模式下的目标目录,不要求已存在) + pub engines_openclaw_dir: PathBuf, + /// engines/hermes(uv tool 安装/升级在便携模式下的目标目录,不要求已存在) + pub engines_hermes_dir: PathBuf, + /// engines/openclaw 下的 CLI 入口(存在才 Some) + pub openclaw_cli_path: Option, + /// engines/hermes/bin 下的 CLI 入口(存在才 Some) + pub hermes_cli_path: Option, + /// key 风格告警:`absolute-path:` / `node-missing` / `cli-missing` + pub warnings: Vec, +} + +static PORTABLE_CONTEXT: OnceLock> = OnceLock::new(); + +/// 启动时尽早调用(必须先于任何 openclaw_dir()/panel config 读取) +pub fn init() { + let _ = portable_context(); +} + +/// 便携模式上下文;普通模式返回 None +pub fn portable_context() -> Option<&'static PortableContext> { + PORTABLE_CONTEXT.get_or_init(detect).as_ref() +} + +fn detect() -> Option { + let root = detect_root()?; + detect_at(&root) +} + +/// 对指定 root 执行完整检测流程(读 manifest → 解析 → 建目录),便于测试 +fn detect_at(root: &Path) -> Option { + let manifest_path = root.join("portable.json"); + if !manifest_path.is_file() { + return None; + } + let raw = match std::fs::read(&manifest_path) { + Ok(bytes) => bytes, + Err(e) => { + eprintln!("[portable] 读取 portable.json 失败,按普通模式运行: {e}"); + return None; + } + }; + // 兼容记事本保存的 UTF-8 BOM + let text = if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { + String::from_utf8_lossy(&raw[3..]).into_owned() + } else { + String::from_utf8_lossy(&raw).into_owned() + }; + let manifest: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(e) => { + eprintln!("[portable] portable.json 解析失败,按普通模式运行: {e}"); + return None; + } + }; + let ctx = resolve_portable_paths(&manifest, root)?; + // 首次启动自动创建面板与 OpenClaw 数据目录(失败不阻塞启动, + // 后续 write_panel_config 等写入时还会再尝试建目录) + for dir in [ + ctx.panel_config_path.parent().map(Path::to_path_buf), + Some(ctx.openclaw_dir.clone()), + Some(ctx.hermes_home.clone()), + ] + .into_iter() + .flatten() + { + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!("[portable] 创建目录 {} 失败: {e}", dir.display()); + } + } + eprintln!("[portable] 便携模式已启用,root: {}", ctx.root.display()); + Some(ctx) +} + +fn detect_root() -> Option { + if let Ok(custom) = std::env::var("CLAWPANEL_PORTABLE_ROOT") { + let trimmed = custom.trim(); + if !trimmed.is_empty() { + let p = PathBuf::from(trimmed); + if p.is_dir() { + return Some(p); + } + eprintln!("[portable] CLAWPANEL_PORTABLE_ROOT 不是有效目录,忽略: {trimmed}"); + } + } + std::env::current_exe() + .ok()? + .parent() + .map(Path::to_path_buf) +} + +fn clean_portable_path(path: PathBuf) -> PathBuf { + path.components() + .filter(|component| !matches!(component, std::path::Component::CurDir)) + .collect() +} + +/// 相对路径按 root 解析;绝对路径接受但记 warning(换盘符会失效) +fn resolve_dir( + manifest: &Value, + root: &Path, + field: &str, + default: &str, + warnings: &mut Vec, +) -> PathBuf { + let raw = manifest + .get(field) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(default); + let p = PathBuf::from(raw); + if p.is_absolute() { + warnings.push(format!("absolute-path:{field}")); + clean_portable_path(p) + } else { + clean_portable_path(root.join(p)) + } +} + +/// 纯解析逻辑(不建目录),便于单元测试 +fn resolve_portable_paths(manifest: &Value, root: &Path) -> Option { + let mode = manifest.get("mode").and_then(|v| v.as_str()).unwrap_or(""); + if mode != "portable" { + eprintln!("[portable] portable.json 存在但 mode != \"portable\",按普通模式运行"); + return None; + } + + let mut warnings = Vec::new(); + let data_dir = resolve_dir(manifest, root, "dataDir", "./data", &mut warnings); + let engines_dir = resolve_dir(manifest, root, "enginesDir", "./engines", &mut warnings); + let runtimes_dir = resolve_dir(manifest, root, "runtimesDir", "./runtimes", &mut warnings); + + // Node 运行时:目录不存在 → 静默回退本机;目录存在但缺可执行文件 → 半拷贝状态,告警 + let node_root = runtimes_dir.join("node"); + let node_bin = if cfg!(windows) { "node.exe" } else { "node" }; + let node_dir = if node_root.join(node_bin).is_file() { + Some(node_root) + } else { + if node_root.is_dir() { + warnings.push("node-missing".into()); + } + None + }; + + // OpenClaw CLI:与 utils.rs 的候选文件名保持一致 + let cli_root = engines_dir.join("openclaw"); + let openclaw_cli_path = portable_cli_candidates(&cli_root) + .into_iter() + .find(|p| p.is_file()); + if openclaw_cli_path.is_none() && cli_root.is_dir() { + warnings.push("cli-missing".into()); + } + + let hermes_root = engines_dir.join("hermes"); + let hermes_cli_path = portable_hermes_cli_candidates(&hermes_root) + .into_iter() + .find(|p| p.is_file()); + if hermes_cli_path.is_none() && hermes_root.is_dir() { + warnings.push("hermes-cli-missing".into()); + } + + Some(PortableContext { + root: root.to_path_buf(), + panel_config_path: data_dir.join("clawpanel").join("clawpanel.json"), + openclaw_dir: data_dir.join("openclaw"), + hermes_home: data_dir.join("hermes"), + data_dir, + node_dir, + engines_openclaw_dir: cli_root, + engines_hermes_dir: hermes_root, + openclaw_cli_path, + hermes_cli_path, + warnings, + }) +} + +fn node_bin_name() -> &'static str { + if cfg!(windows) { + "node.exe" + } else { + "node" + } +} + +fn portable_cli_candidates(cli_root: &Path) -> Vec { + vec![ + cli_root.join("openclaw.cmd"), + cli_root.join("openclaw.exe"), + cli_root.join("openclaw.bat"), + cli_root.join("openclaw.js"), + cli_root.join("openclaw"), + ] +} + +fn portable_hermes_cli_candidates(hermes_root: &Path) -> Vec { + let bin_dir = hermes_root.join("bin"); + let tool_dir = hermes_root.join("hermes-agent"); + let mut candidates = vec![ + bin_dir.join("hermes.cmd"), + bin_dir.join("hermes.exe"), + bin_dir.join("hermes.bat"), + bin_dir.join("hermes"), + ]; + if cfg!(windows) { + candidates.push(tool_dir.join("Scripts").join("hermes.exe")); + candidates.push(tool_dir.join("Scripts").join("hermes.cmd")); + } else { + candidates.push(tool_dir.join("bin").join("hermes")); + } + candidates +} + +fn current_portable_cli_path(ctx: &PortableContext) -> Option { + ctx.openclaw_cli_path + .as_ref() + .filter(|p| p.is_file()) + .cloned() + .or_else(|| { + portable_cli_candidates(&ctx.engines_openclaw_dir) + .into_iter() + .find(|p| p.is_file()) + }) +} + +fn current_portable_hermes_cli_path(ctx: &PortableContext) -> Option { + ctx.hermes_cli_path + .as_ref() + .filter(|p| p.is_file()) + .cloned() + .or_else(|| { + portable_hermes_cli_candidates(&ctx.engines_hermes_dir) + .into_iter() + .find(|p| p.is_file()) + }) +} + +fn current_portable_node_dir(ctx: &PortableContext) -> Option { + ctx.node_dir + .as_ref() + .filter(|p| p.join(node_bin_name()).is_file()) + .cloned() + .or_else(|| { + ctx.engines_openclaw_dir + .join(node_bin_name()) + .is_file() + .then(|| ctx.engines_openclaw_dir.clone()) + }) +} + +fn clean_status_path(path: &Path) -> String { + let cleaned: PathBuf = path + .components() + .filter(|component| !matches!(component, std::path::Component::CurDir)) + .collect(); + cleaned.to_string_lossy().into_owned() +} + +fn status_snapshot(ctx: &PortableContext) -> Value { + let node_dir = current_portable_node_dir(ctx); + let cli_path = current_portable_cli_path(ctx); + let hermes_cli_path = current_portable_hermes_cli_path(ctx); + let warnings: Vec = ctx + .warnings + .iter() + .filter(|warning| { + !(warning.as_str() == "node-missing" && node_dir.is_some() + || warning.as_str() == "cli-missing" && cli_path.is_some() + || warning.as_str() == "hermes-cli-missing" && hermes_cli_path.is_some()) + }) + .cloned() + .collect(); + + json!({ + "enabled": true, + "root": clean_status_path(&ctx.root), + "dataDir": clean_status_path(&ctx.data_dir), + "openclawDir": clean_status_path(&ctx.openclaw_dir), + "hermesHome": clean_status_path(&ctx.hermes_home), + "nodeDir": node_dir.as_ref().map(|p| clean_status_path(p)), + "enginesOpenclawDir": clean_status_path(&ctx.engines_openclaw_dir), + "enginesHermesDir": clean_status_path(&ctx.engines_hermes_dir), + "openclawCliPath": cli_path.as_ref().map(|p| clean_status_path(p)), + "hermesCliPath": hermes_cli_path.as_ref().map(|p| clean_status_path(p)), + "uvBinPath": clean_status_path(&portable_uv_bin_path(&ctx.root)), + "warnings": warnings, + }) +} + +fn portable_uv_bin_path(root: &Path) -> PathBuf { + let bin = root.join("runtimes").join("uv").join("bin"); + if cfg!(windows) { + bin.join("uv.exe") + } else { + bin.join("uv") + } +} + +/// 只读状态查询,供前端展示"当前为便携模式" +#[tauri::command] +pub fn get_portable_status() -> Result { + match portable_context() { + Some(ctx) => Ok(status_snapshot(ctx)), + None => Ok(json!({ "enabled": false })), + } +} + +fn portable_manifest() -> Value { + json!({ + "mode": "portable", + "dataDir": "./data", + "enginesDir": "./engines", + "runtimesDir": "./runtimes" + }) +} + +fn sanitized_panel_config(config: Option) -> (Value, Vec) { + let mut config = config.unwrap_or_else(|| json!({})); + if !config.is_object() { + config = json!({}); + } + + let mut removed = Vec::new(); + if let Some(obj) = config.as_object_mut() { + for key in [ + "openclawDir", + "openclawCliPath", + "openclawSearchPaths", + "nodePath", + "gitPath", + ] { + if obj.remove(key).is_some() { + removed.push(key.to_string()); + } + } + } + (config, removed) +} + +fn path_is_inside_or_same(path: &Path, base: &Path) -> bool { + crate::utils::path_is_inside_or_same(path, base) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { + if !src.is_dir() { + return Err(format!("源目录不存在: {}", src.display())); + } + std::fs::create_dir_all(dst).map_err(|e| format!("创建目录 {} 失败: {e}", dst.display()))?; + for entry in + std::fs::read_dir(src).map_err(|e| format!("读取目录 {} 失败: {e}", src.display()))? + { + let entry = entry.map_err(|e| format!("读取目录项失败: {e}"))?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + let meta = entry + .metadata() + .map_err(|e| format!("读取元数据 {} 失败: {e}", src_path.display()))?; + if meta.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else if meta.is_file() { + if let Some(parent) = dst_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("创建目录 {} 失败: {e}", parent.display()))?; + } + std::fs::copy(&src_path, &dst_path).map_err(|e| { + format!( + "复制文件 {} -> {} 失败: {e}", + src_path.display(), + dst_path.display() + ) + })?; + } + } + Ok(()) +} + +fn migrate_to_portable_impl( + target_root: &Path, + panel_config: Option, + source_openclaw_dir: &Path, + source_engine_dir: Option<&Path>, + source_hermes_home: Option<&Path>, +) -> Result { + if target_root.as_os_str().is_empty() { + return Err("请选择便携模式目标目录".into()); + } + if target_root.is_file() { + return Err("目标路径是文件,请选择目录".into()); + } + + let portable_json = target_root.join("portable.json"); + if portable_json.exists() { + return Err("目标目录已存在 portable.json,请选择空目录或新的便携目录".into()); + } + + let data_dir = target_root.join("data"); + let panel_dir = data_dir.join("clawpanel"); + let portable_panel_config = panel_dir.join("clawpanel.json"); + let portable_openclaw_dir = data_dir.join("openclaw"); + let portable_hermes_home = data_dir.join("hermes"); + let engines_openclaw_dir = target_root.join("engines").join("openclaw"); + let engines_hermes_dir = target_root.join("engines").join("hermes"); + let runtimes_node_dir = target_root.join("runtimes").join("node"); + let runtimes_uv_dir = target_root.join("runtimes").join("uv"); + + if source_openclaw_dir.is_dir() + && path_is_inside_or_same(&portable_openclaw_dir, source_openclaw_dir) + { + return Err("目标目录不能放在当前 OpenClaw 配置目录内部,避免递归复制".into()); + } + if let Some(engine_dir) = source_engine_dir { + if engine_dir.is_dir() && path_is_inside_or_same(&engines_openclaw_dir, engine_dir) { + return Err("目标目录不能放在当前 OpenClaw 引擎目录内部,避免递归复制".into()); + } + } + if let Some(hermes_home) = source_hermes_home { + if hermes_home.is_dir() && path_is_inside_or_same(&portable_hermes_home, hermes_home) { + return Err("目标目录不能放在当前 Hermes 数据目录内部,避免递归复制".into()); + } + } + + for dir in [ + target_root, + &data_dir, + &panel_dir, + &portable_openclaw_dir, + &portable_hermes_home, + &engines_openclaw_dir, + &engines_hermes_dir, + &runtimes_node_dir, + &runtimes_uv_dir, + ] { + std::fs::create_dir_all(dir) + .map_err(|e| format!("创建目录 {} 失败: {e}", dir.display()))?; + } + + let manifest = portable_manifest(); + let manifest_text = serde_json::to_string_pretty(&manifest) + .map_err(|e| format!("序列化 portable.json 失败: {e}"))?; + std::fs::write(&portable_json, manifest_text) + .map_err(|e| format!("写入 portable.json 失败: {e}"))?; + + let (portable_panel, removed_panel_keys) = sanitized_panel_config(panel_config); + let panel_text = serde_json::to_string_pretty(&portable_panel) + .map_err(|e| format!("序列化 clawpanel.json 失败: {e}"))?; + std::fs::write(&portable_panel_config, panel_text) + .map_err(|e| format!("写入 clawpanel.json 失败: {e}"))?; + + let mut warnings = Vec::new(); + let copied_openclaw = if source_openclaw_dir.is_dir() { + copy_dir_recursive(source_openclaw_dir, &portable_openclaw_dir)?; + true + } else { + warnings.push("openclaw-source-missing".to_string()); + false + }; + + let mut copied_engine = false; + if let Some(engine_dir) = source_engine_dir { + if engine_dir.is_dir() { + copy_dir_recursive(engine_dir, &engines_openclaw_dir)?; + copied_engine = true; + } + } + + let mut copied_hermes_home = false; + if let Some(hermes_home) = source_hermes_home { + if hermes_home.is_dir() { + copy_dir_recursive(hermes_home, &portable_hermes_home)?; + copied_hermes_home = true; + } + } + + Ok(json!({ + "root": target_root.to_string_lossy(), + "portableJson": portable_json.to_string_lossy(), + "panelConfigPath": portable_panel_config.to_string_lossy(), + "openclawDir": portable_openclaw_dir.to_string_lossy(), + "hermesHome": portable_hermes_home.to_string_lossy(), + "enginesOpenclawDir": engines_openclaw_dir.to_string_lossy(), + "enginesHermesDir": engines_hermes_dir.to_string_lossy(), + "copiedOpenclaw": copied_openclaw, + "copiedEngine": copied_engine, + "copiedHermesHome": copied_hermes_home, + "needsOpenclawInstall": !copied_engine, + "needsHermesInstall": true, + "removedPanelKeys": removed_panel_keys, + "warnings": warnings, + })) +} + +fn active_standalone_engine_dir() -> Option { + let cli_path = crate::utils::resolve_openclaw_cli_path()?; + if crate::utils::classify_cli_source(&cli_path) != "standalone" { + return None; + } + let path = PathBuf::from(cli_path); + if path.is_file() { + return path.parent().map(Path::to_path_buf); + } + None +} + +fn copy_current_app_binary(target_root: &Path) -> Result { + let exe = std::env::current_exe().map_err(|e| format!("读取当前程序路径失败: {e}"))?; + let file_name = exe + .file_name() + .ok_or_else(|| "当前程序路径无文件名".to_string())?; + let target = target_root.join(file_name); + if exe != target { + std::fs::copy(&exe, &target).map_err(|e| { + format!( + "复制当前程序 {} -> {} 失败: {e}", + exe.display(), + target.display() + ) + })?; + } + Ok(target) +} + +/// 将当前本机配置复制到一个新的便携目录。当前进程不会切换到便携模式; +/// 用户需要从目标目录里的程序重新启动,启动期才会读取 portable.json。 +#[tauri::command] +pub fn migrate_to_portable(target_root: String) -> Result { + if portable_context().is_some() { + return Err("当前已经处于便携模式,无需迁移".into()); + } + let target_root = target_root.trim(); + if target_root.is_empty() { + return Err("请选择便携模式目标目录".into()); + } + let target_root = PathBuf::from(target_root); + let source_openclaw_dir = crate::commands::openclaw_dir(); + let engine_dir = active_standalone_engine_dir(); + let mut report = migrate_to_portable_impl( + &target_root, + crate::commands::read_panel_config_value(), + &source_openclaw_dir, + engine_dir.as_deref(), + Some(&crate::commands::hermes::hermes_home_path()), + )?; + + match copy_current_app_binary(&target_root) { + Ok(path) => { + if let Some(obj) = report.as_object_mut() { + obj.insert("appCopied".into(), Value::Bool(true)); + obj.insert( + "portableAppPath".into(), + Value::String(path.to_string_lossy().to_string()), + ); + } + } + Err(err) => { + if let Some(obj) = report.as_object_mut() { + obj.insert("appCopied".into(), Value::Bool(false)); + obj.insert("portableAppPath".into(), Value::Null); + if let Some(warnings) = obj.get_mut("warnings").and_then(|v| v.as_array_mut()) { + warnings.push(Value::String(format!("app-copy-failed:{err}"))); + } + } + } + } + + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_root(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "clawpanel-portable-test-{tag}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn resolves_relative_paths_against_root() { + let root = temp_root("rel"); + let manifest: Value = serde_json::from_str( + r#"{ "mode": "portable", "dataDir": "./data", "enginesDir": "./engines", "runtimesDir": "./runtimes" }"#, + ) + .unwrap(); + let ctx = resolve_portable_paths(&manifest, &root).unwrap(); + assert_eq!(ctx.data_dir, root.join("data")); + assert_eq!( + ctx.data_dir.to_string_lossy(), + root.join("data").to_string_lossy() + ); + assert_eq!( + ctx.panel_config_path, + root.join("data").join("clawpanel").join("clawpanel.json") + ); + assert_eq!(ctx.openclaw_dir, root.join("data").join("openclaw")); + assert_eq!(ctx.hermes_home, root.join("data").join("hermes")); + assert_eq!( + ctx.engines_openclaw_dir, + root.join("engines").join("openclaw") + ); + assert_eq!(ctx.engines_hermes_dir, root.join("engines").join("hermes")); + // runtimes/engines 目录不存在 → 静默回退,无告警 + assert!(ctx.node_dir.is_none()); + assert!(ctx.openclaw_cli_path.is_none()); + assert!(ctx.hermes_cli_path.is_none()); + assert!(ctx.warnings.is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn defaults_apply_when_fields_missing() { + let root = temp_root("default"); + let manifest: Value = serde_json::from_str(r#"{ "mode": "portable" }"#).unwrap(); + let ctx = resolve_portable_paths(&manifest, &root).unwrap(); + assert_eq!(ctx.data_dir, root.join("data")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn absolute_path_accepted_with_warning() { + let root = temp_root("abs"); + let abs = std::env::temp_dir().join("clawpanel-abs-data"); + let manifest = json!({ "mode": "portable", "dataDir": abs.to_string_lossy() }); + let ctx = resolve_portable_paths(&manifest, &root).unwrap(); + assert_eq!(ctx.data_dir, abs); + assert!(ctx.warnings.iter().any(|w| w == "absolute-path:dataDir")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn non_portable_mode_disables() { + let root = temp_root("mode"); + for raw in [r#"{}"#, r#"{ "mode": "normal" }"#] { + let manifest: Value = serde_json::from_str(raw).unwrap(); + assert!(resolve_portable_paths(&manifest, &root).is_none()); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn half_copied_runtime_and_engine_warn() { + let root = temp_root("half"); + std::fs::create_dir_all(root.join("runtimes").join("node")).unwrap(); + std::fs::create_dir_all(root.join("engines").join("openclaw")).unwrap(); + let manifest: Value = serde_json::from_str(r#"{ "mode": "portable" }"#).unwrap(); + let ctx = resolve_portable_paths(&manifest, &root).unwrap(); + assert!(ctx.node_dir.is_none()); + assert!(ctx.openclaw_cli_path.is_none()); + assert!(ctx.warnings.iter().any(|w| w == "node-missing")); + assert!(ctx.warnings.iter().any(|w| w == "cli-missing")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn status_snapshot_refreshes_cli_and_bundled_node_after_install() { + let root = temp_root("status-refresh"); + std::fs::create_dir_all(root.join("runtimes").join("node")).unwrap(); + let cli_dir = root.join("engines").join("openclaw"); + let hermes_dir = root.join("engines").join("hermes"); + std::fs::create_dir_all(&cli_dir).unwrap(); + std::fs::create_dir_all(&hermes_dir).unwrap(); + let manifest: Value = serde_json::from_str(r#"{ "mode": "portable" }"#).unwrap(); + let ctx = resolve_portable_paths(&manifest, &root).unwrap(); + assert!(ctx.warnings.iter().any(|w| w == "node-missing")); + assert!(ctx.warnings.iter().any(|w| w == "cli-missing")); + assert!(ctx.warnings.iter().any(|w| w == "hermes-cli-missing")); + + let node_bin = if cfg!(windows) { "node.exe" } else { "node" }; + std::fs::write(cli_dir.join(node_bin), b"").unwrap(); + std::fs::write(cli_dir.join("openclaw.cmd"), b"").unwrap(); + std::fs::create_dir_all(hermes_dir.join("bin")).unwrap(); + std::fs::write(hermes_dir.join("bin").join("hermes.cmd"), b"").unwrap(); + + let snapshot = status_snapshot(&ctx); + let warnings = snapshot + .get("warnings") + .and_then(|v| v.as_array()) + .expect("warnings"); + assert!(!warnings.iter().any(|w| w == "node-missing")); + assert!(!warnings.iter().any(|w| w == "cli-missing")); + assert!(!warnings.iter().any(|w| w == "hermes-cli-missing")); + assert_eq!( + snapshot.get("nodeDir").and_then(|v| v.as_str()), + Some(cli_dir.to_string_lossy().as_ref()) + ); + assert_eq!( + snapshot.get("openclawCliPath").and_then(|v| v.as_str()), + Some(cli_dir.join("openclaw.cmd").to_string_lossy().as_ref()) + ); + assert_eq!( + snapshot.get("hermesCliPath").and_then(|v| v.as_str()), + Some( + hermes_dir + .join("bin") + .join("hermes.cmd") + .to_string_lossy() + .as_ref() + ) + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn detect_at_full_flow_creates_data_dirs() { + let root = temp_root("flow"); + std::fs::write( + root.join("portable.json"), + // 带 UTF-8 BOM,模拟记事本保存 + [&[0xEF_u8, 0xBB, 0xBF][..], br#"{ "mode": "portable" }"#].concat(), + ) + .unwrap(); + let ctx = detect_at(&root).expect("portable mode should enable"); + assert!(root.join("data").join("clawpanel").is_dir()); + assert!(root.join("data").join("openclaw").is_dir()); + assert!(root.join("data").join("hermes").is_dir()); + assert_eq!(ctx.openclaw_dir, root.join("data").join("openclaw")); + assert_eq!(ctx.hermes_home, root.join("data").join("hermes")); + // manifest 缺失 → 普通模式 + let empty_root = temp_root("flow-empty"); + assert!(detect_at(&empty_root).is_none()); + // manifest 损坏 → 普通模式(不 panic) + std::fs::write(empty_root.join("portable.json"), b"{ not json").unwrap(); + assert!(detect_at(&empty_root).is_none()); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&empty_root); + } + + #[test] + fn detects_cli_and_node_when_present() { + let root = temp_root("full"); + let node_dir = root.join("runtimes").join("node"); + std::fs::create_dir_all(&node_dir).unwrap(); + let node_bin = if cfg!(windows) { "node.exe" } else { "node" }; + std::fs::write(node_dir.join(node_bin), b"").unwrap(); + let cli_dir = root.join("engines").join("openclaw"); + std::fs::create_dir_all(&cli_dir).unwrap(); + std::fs::write(cli_dir.join("openclaw.cmd"), b"").unwrap(); + let hermes_bin = root.join("engines").join("hermes").join("bin"); + std::fs::create_dir_all(&hermes_bin).unwrap(); + std::fs::write(hermes_bin.join("hermes.cmd"), b"").unwrap(); + let manifest: Value = serde_json::from_str(r#"{ "mode": "portable" }"#).unwrap(); + let ctx = resolve_portable_paths(&manifest, &root).unwrap(); + assert_eq!(ctx.node_dir.as_deref(), Some(node_dir.as_path())); + assert_eq!( + ctx.openclaw_cli_path.as_deref(), + Some(cli_dir.join("openclaw.cmd").as_path()) + ); + assert_eq!( + ctx.hermes_cli_path.as_deref(), + Some(hermes_bin.join("hermes.cmd").as_path()) + ); + assert!(ctx.warnings.is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn migration_creates_portable_layout_and_sanitizes_host_paths() { + let target = temp_root("migrate-target"); + let source = temp_root("migrate-source"); + std::fs::write( + source.join("openclaw.json"), + br#"{ "gateway": { "port": 18789 } }"#, + ) + .unwrap(); + std::fs::create_dir_all(source.join("agents").join("main")).unwrap(); + std::fs::write( + source.join("agents").join("main").join("agent.json"), + br#"{}"#, + ) + .unwrap(); + + let panel = json!({ + "accessPassword": "secret", + "networkProxy": { "url": "http://127.0.0.1:7897" }, + "openclawDir": "C:\\Users\\demo\\.openclaw", + "openclawCliPath": "C:\\Users\\demo\\AppData\\Roaming\\npm\\openclaw.cmd", + "openclawSearchPaths": ["C:\\Tools\\OpenClaw"], + "nodePath": "C:\\Program Files\\nodejs", + "gitPath": "C:\\Program Files\\Git\\cmd\\git.exe" + }); + + let hermes_source = temp_root("migrate-hermes-source"); + std::fs::write(hermes_source.join("config.yaml"), b"model: test\n").unwrap(); + + let report = + migrate_to_portable_impl(&target, Some(panel), &source, None, Some(&hermes_source)) + .unwrap(); + + assert!(target.join("portable.json").is_file()); + assert!(target + .join("data") + .join("clawpanel") + .join("clawpanel.json") + .is_file()); + assert!(target + .join("data") + .join("openclaw") + .join("openclaw.json") + .is_file()); + assert!(target + .join("data") + .join("openclaw") + .join("agents") + .join("main") + .join("agent.json") + .is_file()); + assert!(target + .join("data") + .join("hermes") + .join("config.yaml") + .is_file()); + + let migrated_panel: Value = serde_json::from_str( + &std::fs::read_to_string(target.join("data").join("clawpanel").join("clawpanel.json")) + .unwrap(), + ) + .unwrap(); + assert_eq!(migrated_panel["accessPassword"], "secret"); + assert_eq!( + migrated_panel["networkProxy"]["url"], + "http://127.0.0.1:7897" + ); + for key in [ + "openclawDir", + "openclawCliPath", + "openclawSearchPaths", + "nodePath", + "gitPath", + ] { + assert!(migrated_panel.get(key).is_none(), "{key} should be removed"); + } + assert_eq!(report["copiedOpenclaw"], true); + assert_eq!(report["copiedHermesHome"], true); + assert_eq!(report["needsOpenclawInstall"], true); + assert_eq!(report["needsHermesInstall"], true); + + let _ = std::fs::remove_dir_all(&target); + let _ = std::fs::remove_dir_all(&source); + let _ = std::fs::remove_dir_all(&hermes_source); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c9ba02..d75f13c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,10 +5,14 @@ mod utils; use commands::{ agent, assistant, cli_conflict, config, device, diagnose, extensions, hermes, hermes_providers, - logs, memory, messaging, pairing, service, site_api, skills, update, + logs, media, memory, messaging, pairing, portable, service, site_api, skills, update, }; pub fn run() { + // 便携模式检测必须最先执行:下一行 openclaw_dir() 已经依赖它, + // 且 portable context 需在 ENHANCED_PATH_CACHE 等缓存填充之前就绪 + commands::portable::init(); + let hot_update_dir = commands::openclaw_dir() .join("clawpanel") .join("web-update"); @@ -109,6 +113,8 @@ pub fn run() { config::get_openclaw_dir, config::read_panel_config, config::write_panel_config, + portable::get_portable_status, + portable::migrate_to_portable, config::test_proxy, config::get_npm_registry, config::set_npm_registry, @@ -194,6 +200,20 @@ pub fn run() { assistant::assistant_save_image, assistant::assistant_load_image, assistant::assistant_delete_image, + // 云端媒体生成 + media::read_media_config, + media::write_media_config, + media::test_media_provider, + media::fetch_media_models, + media::generate_image, + media::create_video_task, + media::poll_video_task, + media::cancel_media_job, + media::list_media_jobs, + media::delete_media_job, + media::reveal_media_asset, + media::reveal_media_output_dir, + media::load_media_asset, // 消息渠道管理 messaging::read_platform_config, messaging::save_messaging_platform, diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 8db45ae..7a3751b 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -190,6 +190,14 @@ pub fn is_rejected_cli_path(cli_path: &str) -> bool { lower.contains("/.cherrystudio/") || lower.contains("cherry-studio") } +/// 便携模式下 U 盘 engines/openclaw 里的 CLI(存在才返回),优先级最高; +/// 缺失时正常回退本机链路,支持"数据便携 + 本机引擎"混合形态 +fn portable_cli_path() -> Option { + crate::commands::portable::portable_context()? + .openclaw_cli_path + .clone() +} + /// 读取 clawpanel.json 中用户绑定的 CLI 路径 fn bound_cli_path() -> Option { let config = crate::commands::read_panel_config_value()?; @@ -230,6 +238,10 @@ fn configured_cli_candidates() -> Vec { /// "\"node\"" is not recognized 错误 #[cfg(target_os = "windows")] fn find_openclaw_cmd() -> Option { + // 便携模式:U 盘引擎优先 + if let Some(portable) = portable_cli_path() { + return Some(portable); + } // 优先使用用户绑定的路径 if let Some(bound) = bound_cli_path() { return Some(bound); @@ -263,6 +275,10 @@ fn common_non_windows_cli_candidates() -> Vec { /// 解析当前实际使用的 openclaw CLI 完整路径(跨平台) pub fn resolve_openclaw_cli_path() -> Option { + // 便携模式:U 盘引擎优先 + if let Some(portable) = portable_cli_path() { + return Some(portable.to_string_lossy().to_string()); + } // 优先使用用户绑定的路径 if let Some(bound) = bound_cli_path() { return Some(bound.to_string_lossy().to_string()); @@ -298,9 +314,33 @@ pub fn resolve_openclaw_cli_path() -> Option { } } +/// 跨平台路径比较 key:统一分隔符;Windows / macOS 文件系统默认大小写不敏感, +/// 故仅在这两个平台小写化(Linux 大小写敏感,不能小写化,否则不同目录会被误判相等) +pub fn path_compare_key(path: &std::path::Path) -> String { + let raw = path.to_string_lossy().replace('\\', "/"); + if cfg!(any(windows, target_os = "macos")) { + raw.to_ascii_lowercase() + } else { + raw + } +} + +/// path 是否等于 base 或位于 base 之下(带路径分隔符边界,media 不会误匹配 media-evil) +pub fn path_is_inside_or_same(path: &std::path::Path, base: &std::path::Path) -> bool { + let path_key = path_compare_key(path); + let base_key = path_compare_key(base).trim_end_matches('/').to_string(); + !base_key.is_empty() && (path_key == base_key || path_key.starts_with(&format!("{base_key}/"))) +} + /// 根据 CLI 路径判断安装来源 pub fn classify_cli_source(cli_path: &str) -> String { let lower = cli_path.replace('\\', "/").to_lowercase(); + // 便携模式:CLI 位于 U 盘 portable root 之下 + if let Some(ctx) = crate::commands::portable::portable_context() { + if path_is_inside_or_same(std::path::Path::new(cli_path), &ctx.root) { + return "portable".into(); + } + } // standalone 安装 if lower.contains("/programs/openclaw/") || lower.contains("/openclaw-bin/") diff --git a/src/components/sidebar.js b/src/components/sidebar.js index a3cf316..c0a6390 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -20,6 +20,26 @@ let _kernelPolicyInfo = null let _kernelPolicyFetchedAt = 0 let _kernelPolicyLoading = false +// 便携模式状态在进程生命周期内不变,只需请求一次;缓存后每次渲染侧边栏直接同步取用, +// 避免用户看到设置向导之外的任何页面(一旦 OpenClaw 已配置,向导会被跳过)时 +// 完全没有线索判断当前连的是便携盘还是本机安装 +let _portableStatus = null +let _portableStatusPromise = null +function _ensurePortableStatusLoaded(onReady) { + if (_portableStatus) return + if (!_portableStatusPromise) { + _portableStatusPromise = api.getPortableStatus().then(st => { _portableStatus = st || { enabled: false } }).catch(() => { _portableStatus = { enabled: false } }) + } + _portableStatusPromise.then(onReady) +} + +function _renderPortableBadge() { + if (!_portableStatus || !_portableStatus.enabled) return '' + const root = String(_portableStatus.root || '') + const title = `${t('setup.portableBadge')} · ${root}`.replace(/"/g, '"') + return `${t('setup.portableBadge')}` +} + function NAV_ITEMS_FULL() { return [ { section: t('sidebar.sectionMonitor'), @@ -104,6 +124,19 @@ function NAV_ITEMS_ENGINE_SELECT() { return [ } ] } +function COMMON_NAV_ITEMS() { return [{ + section: t('sidebar.sectionCommon'), + items: [ + { route: '/media', label: t('sidebar.media'), icon: 'media' }, + ], +}] } + +function withCommonNavItems(navItems) { + const items = Array.isArray(navItems) ? navItems : [] + const hasMedia = items.some(section => section.items?.some(item => item.route === '/media')) + return hasMedia ? items : [...items, ...COMMON_NAV_ITEMS()] +} + const ICONS = { setup: '', dashboard: '', @@ -112,7 +145,7 @@ const ICONS = { logs: '', models: '', agents: '', - gateway: '', + media: '', gateway: '', memory: '', inbox: '', folder: '', @@ -195,6 +228,7 @@ function _setDesktopSidebarCollapsed(collapsed) { export function renderSidebar(el) { const current = getCurrentRoute() + el.classList.remove('lang-menu-open') const collapsed = _isDesktopSidebarCollapsed() let html = ` @@ -206,15 +240,17 @@ export function renderSidebar(el) { + ${_renderEngineSwitcher()}