From ad73e76c1c030a2683b6c55075558fad42db1500 Mon Sep 17 00:00:00 2001 From: SaladDay <92240037+SaladDay@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:50:25 +0000 Subject: [PATCH 1/3] feat: add Pi support --- README.md | 24 +- README_ZH.md | 26 +- src-tauri/src/app_config.rs | 52 +- src-tauri/src/cli/commands/app_targets.rs | 34 +- src-tauri/src/cli/commands/config_common.rs | 32 +- src-tauri/src/cli/commands/env.rs | 2 +- src-tauri/src/cli/commands/failover.rs | 2 +- src-tauri/src/cli/commands/mcp.rs | 31 + src-tauri/src/cli/commands/prompts.rs | 240 ++- src-tauri/src/cli/commands/provider.rs | 104 +- src-tauri/src/cli/commands/provider_input.rs | 176 +- .../src/cli/commands/provider_inspect.rs | 129 +- .../src/cli/commands/provider_usage_query.rs | 27 +- src-tauri/src/cli/commands/proxy.rs | 1 + src-tauri/src/cli/commands/sessions.rs | 5 +- src-tauri/src/cli/commands/settings.rs | 8 + src-tauri/src/cli/commands/skills.rs | 7 +- src-tauri/src/cli/failover_policy.rs | 2 +- src-tauri/src/cli/i18n.rs | 67 +- src-tauri/src/cli/tui/app/app_state.rs | 25 +- src-tauri/src/cli/tui/app/content_config.rs | 12 + src-tauri/src/cli/tui/app/content_entities.rs | 211 ++- src-tauri/src/cli/tui/app/content_skills.rs | 4 +- src-tauri/src/cli/tui/app/editor_state.rs | 11 + .../src/cli/tui/app/form_handlers/provider.rs | 91 +- src-tauri/src/cli/tui/app/helpers.rs | 19 + src-tauri/src/cli/tui/app/menu.rs | 6 + .../cli/tui/app/overlay_handlers/dialogs.rs | 56 + .../cli/tui/app/overlay_handlers/pickers.rs | 17 +- src-tauri/src/cli/tui/app/tests.rs | 284 ++- src-tauri/src/cli/tui/app/types.rs | 15 + src-tauri/src/cli/tui/data.rs | 80 + src-tauri/src/cli/tui/form.rs | 1 + src-tauri/src/cli/tui/form/provider_json.rs | 257 ++- src-tauri/src/cli/tui/form/provider_state.rs | 66 +- .../cli/tui/form/provider_state_loading.rs | 3 + .../src/cli/tui/form/provider_templates.rs | 5 +- src-tauri/src/cli/tui/form/tests.rs | 176 ++ src-tauri/src/cli/tui/help.rs | 27 +- src-tauri/src/cli/tui/mod.rs | 6 +- src-tauri/src/cli/tui/route.rs | 34 + .../tui/runtime_actions/claude_temp_launch.rs | 1 + .../tui/runtime_actions/codex_temp_launch.rs | 1 + .../src/cli/tui/runtime_actions/editor.rs | 133 +- .../src/cli/tui/runtime_actions/helpers.rs | 1 + src-tauri/src/cli/tui/runtime_actions/mod.rs | 60 + .../src/cli/tui/runtime_actions/prompts.rs | 56 + .../src/cli/tui/runtime_actions/providers.rs | 8 +- .../src/cli/tui/runtime_actions/settings.rs | 62 + .../src/cli/tui/runtime_systems/types.rs | 23 +- .../src/cli/tui/runtime_systems/workers.rs | 34 +- src-tauri/src/cli/tui/tests.rs | 22 + src-tauri/src/cli/tui/theme.rs | 2 + src-tauri/src/cli/tui/ui.rs | 4 + src-tauri/src/cli/tui/ui/chrome.rs | 4 + src-tauri/src/cli/tui/ui/config.rs | 10 +- src-tauri/src/cli/tui/ui/forms/provider.rs | 4 +- src-tauri/src/cli/tui/ui/header_tests.rs | 2 + src-tauri/src/cli/tui/ui/overlay/pickers.rs | 2 + src-tauri/src/cli/tui/ui/prompts.rs | 156 ++ src-tauri/src/cli/tui/ui/providers.rs | 2 +- src-tauri/src/cli/tui/ui/skills/helpers.rs | 3 + src-tauri/src/cli/tui/ui/skills/installed.rs | 5 + src-tauri/src/cli/tui/ui/tests.rs | 44 +- src-tauri/src/cli/ui/colors.rs | 2 + src-tauri/src/config.rs | 23 +- src-tauri/src/database/dao/skills.rs | 2 + src-tauri/src/deeplink/parser.rs | 4 +- src-tauri/src/deeplink/provider.rs | 5 + src-tauri/src/error.rs | 3 + src-tauri/src/lib.rs | 1 + src-tauri/src/pi_config/mod.rs | 645 +++++++ src-tauri/src/prompt_files.rs | 2 + src-tauri/src/provider.rs | 5 +- src-tauri/src/provider_preset_sponsors.rs | 1 + src-tauri/src/proxy/forwarder.rs | 4 +- .../src/proxy/forwarder/request_builder.rs | 7 +- src-tauri/src/proxy/providers/mod.rs | 11 +- src-tauri/src/services/config.rs | 1 + src-tauri/src/services/local_env_check.rs | 11 +- src-tauri/src/services/mcp.rs | 2 + src-tauri/src/services/mod.rs | 3 + src-tauri/src/services/pi_prompt_files.rs | 521 ++++++ src-tauri/src/services/pi_state.rs | 138 ++ src-tauri/src/services/prompt.rs | 444 ++++- .../src/services/provider/common_config.rs | 37 +- src-tauri/src/services/provider/live.rs | 3 + src-tauri/src/services/provider/mod.rs | 123 +- src-tauri/src/services/provider/pi.rs | 883 ++++++++++ src-tauri/src/services/provider/usage.rs | 8 + src-tauri/src/services/proxy.rs | 17 +- src-tauri/src/services/session_usage.rs | 5 + src-tauri/src/services/session_usage_pi.rs | 1496 ++++++++++++++++ src-tauri/src/services/skill.rs | 482 ++++- .../services/stream_check/provider_extract.rs | 2 + src-tauri/src/services/usage_stats.rs | 1 + src-tauri/src/services/visible_apps.rs | 7 +- src-tauri/src/session_manager/mod.rs | 22 +- .../src/session_manager/providers/mod.rs | 1 + src-tauri/src/session_manager/providers/pi.rs | 1548 +++++++++++++++++ src-tauri/src/session_manager/terminal/mod.rs | 15 +- src-tauri/src/settings.rs | 55 +- src-tauri/src/store.rs | 19 + src-tauri/src/sync_policy.rs | 2 + src-tauri/tests/deeplink_import.rs | 16 + src-tauri/tests/settings_current_provider.rs | 68 + src-tauri/tests/settings_visible_apps.rs | 82 +- 107 files changed, 9474 insertions(+), 267 deletions(-) create mode 100644 src-tauri/src/pi_config/mod.rs create mode 100644 src-tauri/src/services/pi_prompt_files.rs create mode 100644 src-tauri/src/services/pi_state.rs create mode 100644 src-tauri/src/services/provider/pi.rs create mode 100644 src-tauri/src/services/session_usage_pi.rs create mode 100644 src-tauri/src/session_manager/providers/pi.rs diff --git a/README.md b/README.md index 0a4cf4660..473627e71 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## CC-Switch CLI -**Manage Claude Code, Codex, Gemini, OpenCode, Hermes, and OpenClaw from one interactive TUI or scriptable CLI.** +**Manage Claude Code, Codex, Gemini, OpenCode, Hermes, OpenClaw, and Pi from one interactive TUI or scriptable CLI.** [![Version](https://img.shields.io/badge/version-5.10.3-blue.svg)](https://github.com/saladday/cc-switch-cli/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/saladday/cc-switch-cli/releases) @@ -171,8 +171,9 @@ cc-switch --app codex mcp sync # Sync Codex MCP servers cc-switch --app gemini prompts list # List Gemini prompts cc-switch --app hermes provider list # Manage Hermes providers cc-switch --app openclaw provider list # Manage OpenClaw providers +cc-switch --app pi provider list # Manage Pi providers -# Supported apps: `claude` (default), `codex`, `gemini`, `opencode`, `hermes`, `openclaw` +# Supported apps: `claude` (default), `codex`, `gemini`, `opencode`, `hermes`, `openclaw`, `pi` ``` Use `cc-switch start` when you want different providers in multiple terminals. It only affects the Claude or Codex session launched by that command; `provider switch` and `use` still change the global provider. In the TUI, select a provider on the Providers page and press `o` for the same behavior. @@ -300,7 +301,10 @@ copy target\release\cc-switch.exe C:\Windows\System32\ ### 🔌 Provider Management -Manage API configurations for **Claude Code**, **Codex**, **Gemini**, **OpenCode**, **Hermes**, and **OpenClaw**. +Manage API configurations for **Claude Code**, **Codex**, **Gemini**, **OpenCode**, **Hermes**, **OpenClaw**, and **Pi**. + +Pi provider management follows Pi's native additive model: membership comes from `models.json.providers`. CC-Switch does not modify Pi login credentials or its global default provider/model. +The Pi TUI keeps the same table/form/shortcut conventions as the other apps and exposes Presets, System Prompts, and Prompt Templates as separate pages. **Features:** One-click switching, standalone Claude settings export, multi-endpoint support, API key management, remote model discovery, and per-app diagnostics such as speed testing or stream health checks where supported. @@ -356,7 +360,7 @@ cc-switch mcp import --app claude # Import from live config Manage system prompt presets for AI coding assistants. -**Cross-app support:** Claude (`CLAUDE.md`), Codex (`AGENTS.md`), Gemini (`GEMINI.md`), OpenCode (`AGENTS.md`), Hermes (`AGENTS.md`), OpenClaw (`AGENTS.md`). +**Cross-app support:** Claude (`CLAUDE.md`), Codex (`AGENTS.md`), Gemini (`GEMINI.md`), OpenCode (`AGENTS.md`), Hermes (`AGENTS.md`), OpenClaw (`AGENTS.md`), Pi (`AGENTS.md`, native system prompts, and prompt templates). ```bash cc-switch prompts list # List prompt presets @@ -368,11 +372,13 @@ cc-switch prompts rename [name] # Rename prompt preset, interactive if name cc-switch prompts edit # Edit prompt preset cc-switch prompts show # Display full content cc-switch prompts delete # Delete prompt +cc-switch --app pi prompts system edit append # Edit APPEND_SYSTEM.md +cc-switch --app pi prompts templates list # List Pi prompt templates ``` ### 🎯 Skills Management -Manage and extend Claude Code/Codex/Gemini/OpenCode/Hermes capabilities with community skills. +Manage and extend Claude Code/Codex/Gemini/OpenCode/Hermes/Pi capabilities with community skills. **Features:** SSOT-based skills store, multi-app enable/disable, sync to app directories, manual update checks/updates, unmanaged scan/import, repo discovery, skills.sh marketplace search. @@ -407,7 +413,7 @@ The TUI home page shows a responsive 30-day view by app and model, including tok Review saved assistant sessions, resume a session with one command, delete old records, and import local session logs into token/cost statistics. -**Features:** complete paged history, cross-app scanning, message preview, copyable resume commands, safe deletion, JSON output, visible-page token/cost details, and usage sync for Claude, Codex, Gemini, and OpenCode. Hermes cost is shown when available. +**Features:** complete paged history, cross-app scanning, message preview, copyable resume commands, safe deletion, JSON output, visible-page token/cost details, and usage sync for Claude, Codex, Gemini, OpenCode, and Pi. Hermes cost is shown when available. ```bash cc-switch sessions list --all # List saved sessions across supported apps @@ -494,7 +500,7 @@ Inspect environment conflicts and whether required local CLIs are installed. ```bash cc-switch env check # Check environment conflicts cc-switch env list # List relevant environment variables -cc-switch env tools # Check Claude/Codex/Gemini/OpenCode/Hermes/OpenClaw CLIs +cc-switch env tools # Check Claude/Codex/Gemini/OpenCode/Hermes/OpenClaw/Pi CLIs ``` ### 🌐 Multi-language Support @@ -560,6 +566,7 @@ When `CC_SWITCH_CONFIG_DIR` is set, CC-Switch uses that directory as its config - OpenCode: `~/.config/opencode/opencode.json` (providers + MCP + runtime config), `~/.config/opencode/AGENTS.md` (prompts) - Hermes: `~/.hermes/config.yaml` (providers + MCP + memory settings), `~/.hermes/AGENTS.md` (prompts), `~/.hermes/skills/` (skills), `~/.hermes/memories/` (memory) - OpenClaw: `~/.openclaw/openclaw.json` (providers + env/tools/agents defaults), `~/.openclaw/AGENTS.md` (prompts) +- Pi: `~/.pi/agent/models.json` (additive providers), `~/.pi/agent/settings.json` (read-only defaults/session location), `~/.pi/agent/AGENTS.md`, `SYSTEM.md`, `APPEND_SYSTEM.md`, `prompts/`, `skills/`, and `sessions/` --- @@ -650,13 +657,14 @@ cc-switch
-CC-Switch currently supports six AI coding assistants: +CC-Switch currently supports seven AI coding assistants: - **Claude Code** (`--app claude`, default) - **Codex** (`--app codex`) - **Gemini** (`--app gemini`) - **OpenCode** (`--app opencode`) - **Hermes** (`--app hermes`) - **OpenClaw** (`--app openclaw`) +- **Pi** (`--app pi`) Use the global `--app` flag to specify which app to manage: ```bash diff --git a/README_ZH.md b/README_ZH.md index 5e9516e89..c9b7565d0 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -4,7 +4,7 @@ ## CC-Switch CLI -**通过交互式 TUI 或脚本化 CLI,统一管理 Claude Code、Codex、Gemini、OpenCode、Hermes 和 OpenClaw。** +**通过交互式 TUI 或脚本化 CLI,统一管理 Claude Code、Codex、Gemini、OpenCode、Hermes、OpenClaw 和 Pi。** [![Version](https://img.shields.io/badge/version-5.10.3-blue.svg)](https://github.com/saladday/cc-switch-cli/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/saladday/cc-switch-cli/releases) @@ -170,8 +170,9 @@ cc-switch --app codex mcp sync # 同步 Codex MCP 服务器 cc-switch --app gemini prompts list # 列出 Gemini 提示词 cc-switch --app hermes provider list # 管理 Hermes 供应商 cc-switch --app openclaw provider list # 管理 OpenClaw 供应商 +cc-switch --app pi provider list # 管理 Pi 供应商 -# 支持的应用:`claude`(默认)、`codex`、`gemini`、`opencode`、`hermes`、`openclaw` +# 支持的应用:`claude`(默认)、`codex`、`gemini`、`opencode`、`hermes`、`openclaw`、`pi` ``` 需要在多个终端同时使用不同供应商时,请使用 `cc-switch start`。它只影响由该命令启动的 Claude 或 Codex 会话;`provider switch` 和 `use` 仍会切换全局供应商。在 TUI 的供应商页选中供应商后按 `o`,效果相同。 @@ -303,7 +304,10 @@ copy target\release\cc-switch.exe C:\Windows\System32\ ### 🔌 供应商管理 -管理 **Claude Code**、**Codex**、**Gemini**、**OpenCode**、**Hermes** 与 **OpenClaw** 的 API 配置。 +管理 **Claude Code**、**Codex**、**Gemini**、**OpenCode**、**Hermes**、**OpenClaw** 与 **Pi** 的 API 配置。 + +Pi 供应商遵循原生的增量管理模型:是否启用完全取决于 `models.json.providers` 中的成员关系。CC-Switch 不会修改 Pi 的登录凭据或全局默认供应商/模型。 +Pi TUI 延续其他应用的表格、表单与快捷键交互,并将预设、系统提示词和 Prompt Templates 分为独立页面。 **功能:** 一键切换、Claude 独立 settings 导出、多端点支持、API 密钥管理、远端模型发现,以及按应用提供的速度测试、流式健康检查等诊断能力。 @@ -359,7 +363,7 @@ cc-switch mcp import --app claude # 从实时配置导入 管理 AI 编码助手的系统提示词预设。 -**跨应用支持:** Claude (`CLAUDE.md`)、Codex (`AGENTS.md`)、Gemini (`GEMINI.md`)、OpenCode (`AGENTS.md`)、Hermes (`AGENTS.md`)、OpenClaw (`AGENTS.md`)。 +**跨应用支持:** Claude (`CLAUDE.md`)、Codex (`AGENTS.md`)、Gemini (`GEMINI.md`)、OpenCode (`AGENTS.md`)、Hermes (`AGENTS.md`)、OpenClaw (`AGENTS.md`)、Pi(`AGENTS.md`、原生系统提示词和 prompt templates)。 ```bash cc-switch prompts list # 列出提示词预设 @@ -371,11 +375,13 @@ cc-switch prompts rename [name] # 重命名提示词预设,不传名称 cc-switch prompts edit # 编辑提示词预设 cc-switch prompts show # 显示完整内容 cc-switch prompts delete # 删除提示词 +cc-switch --app pi prompts system edit append # 编辑 APPEND_SYSTEM.md +cc-switch --app pi prompts templates list # 列出 Pi prompt templates ``` ### 🎯 Skills 管理 -通过社区技能扩展 Claude Code/Codex/Gemini/OpenCode/Hermes 的能力。 +通过社区技能扩展 Claude Code/Codex/Gemini/OpenCode/Hermes/Pi 的能力。 **功能:** SSOT 技能仓库、多应用启用/禁用、同步到应用目录、手动检查/执行更新、扫描/导入未管理技能、仓库发现。 @@ -409,7 +415,7 @@ TUI 首页按应用与模型展示响应式的 30 天视图,包括 token/cost 查看历史会话,一键 resume,删除旧会话,并将本地会话日志导入 token / cost 统计,方便管理用量。 -**功能:** 完整历史分页、跨应用扫描、消息预览、可复制的 resume 命令、安全删除、JSON 输出、当前页 token/cost,以及 Claude、Codex、Gemini、OpenCode 的用量同步。Hermes cost 可用时也会显示。 +**功能:** 完整历史分页、跨应用扫描、消息预览、可复制的 resume 命令、安全删除、JSON 输出、当前页 token/cost,以及 Claude、Codex、Gemini、OpenCode、Pi 的用量同步。Hermes cost 可用时也会显示。 ```bash cc-switch sessions list --all # 列出支持应用的历史会话 @@ -491,12 +497,12 @@ cc-switch proxy serve --takeover claude # 前台调试模式;存在 ### 🧪 环境与本地工具 -检查环境变量冲突,以及 Claude/Codex/Gemini/OpenCode/Hermes/OpenClaw CLI 是否已经装好。 +检查环境变量冲突,以及 Claude/Codex/Gemini/OpenCode/Hermes/OpenClaw/Pi CLI 是否已经装好。 ```bash cc-switch env check # 检查环境变量冲突 cc-switch env list # 列出相关环境变量 -cc-switch env tools # 检查 Claude/Codex/Gemini/OpenCode/Hermes/OpenClaw CLI +cc-switch env tools # 检查 Claude/Codex/Gemini/OpenCode/Hermes/OpenClaw/Pi CLI ``` ### 🌐 多语言支持 @@ -562,6 +568,7 @@ cc-switch update --version vX.Y.Z # 更新到指定版本 - OpenCode: `~/.config/opencode/opencode.json`(供应商 + MCP + 运行时配置), `~/.config/opencode/AGENTS.md`(提示词) - Hermes: `~/.hermes/config.yaml`(供应商 + MCP + 记忆设置), `~/.hermes/AGENTS.md`(提示词), `~/.hermes/skills/`(技能), `~/.hermes/memories/`(记忆) - OpenClaw: `~/.openclaw/openclaw.json`(供应商 + Env/Tools/Agents Defaults), `~/.openclaw/AGENTS.md`(提示词) +- Pi: `~/.pi/agent/models.json`(增量供应商), `~/.pi/agent/settings.json`(只读默认项 / 会话位置), `~/.pi/agent/AGENTS.md`、`SYSTEM.md`、`APPEND_SYSTEM.md`、`prompts/`、`skills/` 与 `sessions/` --- @@ -652,13 +659,14 @@ cc-switch
-CC-Switch 目前支持六个 AI 编程助手: +CC-Switch 目前支持七个 AI 编程助手: - **Claude Code** (`--app claude`,默认) - **Codex** (`--app codex`) - **Gemini** (`--app gemini`) - **OpenCode** (`--app opencode`) - **Hermes** (`--app hermes`) - **OpenClaw** (`--app openclaw`) +- **Pi** (`--app pi`) 使用全局 `--app` 参数指定要管理的应用: ```bash diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index bcd5a7092..b5eeb47ad 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -29,6 +29,7 @@ impl McpApps { AppType::OpenCode => self.opencode, AppType::Hermes => self.hermes, AppType::OpenClaw => false, + AppType::Pi => false, } } @@ -41,6 +42,7 @@ impl McpApps { AppType::OpenCode => self.opencode = enabled, AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} + AppType::Pi => {} } } @@ -84,6 +86,8 @@ pub struct SkillApps { pub opencode: bool, #[serde(default)] pub hermes: bool, + #[serde(default)] + pub pi: bool, } impl SkillApps { @@ -95,6 +99,7 @@ impl SkillApps { AppType::OpenCode => self.opencode, AppType::Hermes => self.hermes, AppType::OpenClaw => false, + AppType::Pi => self.pi, } } @@ -106,11 +111,12 @@ impl SkillApps { AppType::OpenCode => self.opencode = enabled, AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} + AppType::Pi => self.pi = enabled, } } pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes && !self.pi } pub fn only(app: &AppType) -> Self { @@ -135,6 +141,7 @@ impl SkillApps { self.gemini |= other.gemini; self.opencode |= other.opencode; self.hermes |= other.hermes; + self.pi |= other.pi; } } @@ -245,6 +252,8 @@ pub struct McpRoot { pub hermes: McpConfig, #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub openclaw: McpConfig, + #[serde(skip)] + pub pi: McpConfig, } impl Default for McpRoot { @@ -259,6 +268,7 @@ impl Default for McpRoot { opencode: McpConfig::default(), hermes: McpConfig::default(), openclaw: McpConfig::default(), + pi: McpConfig::default(), } } } @@ -285,6 +295,8 @@ pub struct PromptRoot { pub hermes: PromptConfig, #[serde(default)] pub openclaw: PromptConfig, + #[serde(default)] + pub pi: PromptConfig, } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; @@ -302,6 +314,7 @@ pub enum AppType { OpenCode, Hermes, OpenClaw, + Pi, } impl AppType { @@ -313,13 +326,14 @@ impl AppType { AppType::OpenCode => "opencode", AppType::Hermes => "hermes", AppType::OpenClaw => "openclaw", + AppType::Pi => "pi", } } pub fn is_additive_mode(&self) -> bool { matches!( self, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi ) } @@ -335,6 +349,7 @@ impl AppType { AppType::OpenCode, AppType::Hermes, AppType::OpenClaw, + AppType::Pi, ] .into_iter() } @@ -358,13 +373,14 @@ impl FromStr for AppType { "opencode" => Ok(AppType::OpenCode), "hermes" => Ok(AppType::Hermes), "openclaw" => Ok(AppType::OpenClaw), + "pi" => Ok(AppType::Pi), other => Err(AppError::localized( "unsupported_app", format!( - "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw。" + "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw, pi。" ), format!( - "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw." + "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw, pi." ), )), } @@ -403,6 +419,7 @@ impl CommonConfigSnippets { AppType::OpenCode => self.opencode.as_ref(), AppType::Hermes => self.hermes.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), + AppType::Pi => None, } } @@ -415,6 +432,7 @@ impl CommonConfigSnippets { AppType::OpenCode => self.opencode = snippet, AppType::Hermes => self.hermes = snippet, AppType::OpenClaw => self.openclaw = snippet, + AppType::Pi => {} } } } @@ -457,6 +475,7 @@ impl Default for MultiAppConfig { apps.insert("opencode".to_string(), ProviderManager::default()); apps.insert("hermes".to_string(), ProviderManager::default()); apps.insert("openclaw".to_string(), ProviderManager::default()); + apps.insert("pi".to_string(), ProviderManager::default()); Self { version: 2, @@ -564,6 +583,13 @@ impl MultiAppConfig { updated = true; } + if !config.apps.contains_key("pi") { + config + .apps + .insert("pi".to_string(), ProviderManager::default()); + updated = true; + } + // 执行 MCP 迁移(v3.6.x → v3.7.0) let migrated = config.migrate_mcp_to_unified()?; if migrated { @@ -630,6 +656,7 @@ impl MultiAppConfig { AppType::OpenCode => &self.mcp.opencode, AppType::Hermes => &self.mcp.hermes, AppType::OpenClaw => &self.mcp.openclaw, + AppType::Pi => &self.mcp.pi, } } @@ -642,6 +669,7 @@ impl MultiAppConfig { AppType::OpenCode => &mut self.mcp.opencode, AppType::Hermes => &mut self.mcp.hermes, AppType::OpenClaw => &mut self.mcp.openclaw, + AppType::Pi => &mut self.mcp.pi, } } @@ -679,6 +707,7 @@ impl MultiAppConfig { AppType::OpenCode => &self.mcp.opencode.servers, AppType::Hermes => &self.mcp.hermes.servers, AppType::OpenClaw => continue, + AppType::Pi => continue, }; for (id, entry) in old_servers { @@ -1021,4 +1050,19 @@ mod tests { "OpenClaw legacy MCP entries should be preserved" ); } + + #[test] + fn pi_mcp_bucket_is_never_serialized_or_imported() { + let mut root = McpRoot::default(); + root.pi.servers.insert("ignored".to_string(), json!({})); + let serialized = serde_json::to_value(&root).expect("serialize MCP root"); + assert!(serialized.get("pi").is_none()); + + let parsed: McpRoot = serde_json::from_value(json!({ + "servers": {}, + "pi": {"servers": {"ignored": {"command": "tool"}}} + })) + .expect("deserialize MCP root"); + assert!(parsed.pi.is_empty()); + } } diff --git a/src-tauri/src/cli/commands/app_targets.rs b/src-tauri/src/cli/commands/app_targets.rs index 18c2b9e17..7f5c9fee0 100644 --- a/src-tauri/src/cli/commands/app_targets.rs +++ b/src-tauri/src/cli/commands/app_targets.rs @@ -4,7 +4,15 @@ use crate::app_config::AppType; use crate::error::AppError; pub(crate) fn supported_app_target_labels() -> &'static str { - "claude, codex, gemini, opencode, hermes" + "claude, codex, gemini, opencode, hermes, pi" +} + +fn supported_app_target_labels_for(feature: &str) -> &'static str { + if feature.eq_ignore_ascii_case("MCP") { + "claude, codex, gemini, opencode, hermes" + } else { + supported_app_target_labels() + } } pub(crate) fn app_targets_or_default( @@ -42,7 +50,7 @@ pub(crate) fn parse_app_targets( if targets.is_empty() { return Err(AppError::InvalidInput(format!( "Please provide at least one app. Supported apps: {}", - supported_app_target_labels() + supported_app_target_labels_for(feature) ))); } @@ -54,14 +62,21 @@ fn parse_app_target(value: &str, feature: &str) -> Result { let app = AppType::from_str(&normalized).map_err(|_| { AppError::InvalidInput(format!( "Unsupported app id: '{value}'. Supported apps: {}", - supported_app_target_labels() + supported_app_target_labels_for(feature) )) })?; if matches!(app, AppType::OpenClaw) { return Err(AppError::InvalidInput(format!( "{feature} does not support openclaw yet. Supported apps: {}", - supported_app_target_labels() + supported_app_target_labels_for(feature) + ))); + } + + if matches!(app, AppType::Pi) && feature.eq_ignore_ascii_case("MCP") { + return Err(AppError::InvalidInput(format!( + "{feature} does not support pi. Supported apps: {}", + supported_app_target_labels_for(feature) ))); } @@ -111,4 +126,15 @@ mod tests { "unexpected error: {err}" ); } + + #[test] + fn parse_app_targets_accepts_pi_for_skills_but_not_mcp() { + assert_eq!( + parse_app_targets(&["pi".to_string()], "Skills").expect("Pi skills target"), + vec![AppType::Pi] + ); + let error = parse_app_targets(&["pi".to_string()], "MCP") + .expect_err("Pi must not be an MCP target"); + assert!(error.to_string().contains("does not support pi")); + } } diff --git a/src-tauri/src/cli/commands/config_common.rs b/src-tauri/src/cli/commands/config_common.rs index b06e93a82..dc0720ac3 100644 --- a/src-tauri/src/cli/commands/config_common.rs +++ b/src-tauri/src/cli/commands/config_common.rs @@ -173,6 +173,15 @@ fn read_required_text( } } +fn ensure_common_config_mutation_supported(app_type: &AppType) -> Result<(), AppError> { + if matches!(app_type, AppType::Pi) { + return Err(AppError::InvalidInput( + "Pi does not support common config snippets".to_string(), + )); + } + Ok(()) +} + fn canonical_common_snippet(app_type: AppType, raw: &str) -> Result, AppError> { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -184,7 +193,8 @@ fn canonical_common_snippet(app_type: AppType, raw: &str) -> Result { + | AppType::OpenClaw + | AppType::Pi => { let value: serde_json::Value = serde_json::from_str(raw).map_err(|e| { AppError::InvalidInput(texts::tui_toast_invalid_json(&e.to_string())) })?; @@ -229,6 +239,7 @@ fn set( file: Option<&Path>, _apply: bool, ) -> Result<(), AppError> { + ensure_common_config_mutation_supported(&app_type)?; let raw = read_required_text( snippet_text, file, @@ -267,6 +278,9 @@ fn extract( file: Option<&Path>, save: bool, ) -> Result<(), AppError> { + if save { + ensure_common_config_mutation_supported(&app_type)?; + } let state = get_state()?; let extracted = if settings_config_text.is_some() || file.is_some() { let raw = read_required_text( @@ -316,6 +330,7 @@ fn extract( } fn clear(app_type: AppType, _apply: bool) -> Result<(), AppError> { + ensure_common_config_mutation_supported(&app_type)?; let state = get_state()?; ProviderService::set_common_config_snippet(&state, app_type.clone(), None)?; @@ -681,4 +696,19 @@ mod tests { fn follow_up_message_is_omitted_for_additive_apps() { assert!(follow_up_message(AppType::OpenCode, CommonConfigSnippetAction::Set, "").is_none()); } + + #[test] + fn pi_common_config_mutations_are_rejected_before_state_access() { + for result in [ + set(AppType::Pi, Some("{}"), None, false), + extract(AppType::Pi, None, Some("{}"), None, true), + clear(AppType::Pi, false), + ] { + assert!(matches!( + result, + Err(AppError::InvalidInput(message)) + if message == "Pi does not support common config snippets" + )); + } + } } diff --git a/src-tauri/src/cli/commands/env.rs b/src-tauri/src/cli/commands/env.rs index 93fd3d918..e056d5293 100644 --- a/src-tauri/src/cli/commands/env.rs +++ b/src-tauri/src/cli/commands/env.rs @@ -96,7 +96,7 @@ fn run_app_doctor(app_type: &AppType) -> Result<(), AppError> { AppType::Claude => check_claude_doctor(), AppType::Codex => check_codex_doctor(), AppType::Gemini => check_gemini_doctor(), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { println!( "{}", info(&format!( diff --git a/src-tauri/src/cli/commands/failover.rs b/src-tauri/src/cli/commands/failover.rs index f14cd3798..efc9d246f 100644 --- a/src-tauri/src/cli/commands/failover.rs +++ b/src-tauri/src/cli/commands/failover.rs @@ -409,7 +409,7 @@ fn takeover_enabled_for(takeovers: &ProxyTakeoverStatus, app_type: &AppType) -> AppType::Claude => takeovers.claude, AppType::Codex => takeovers.codex, AppType::Gemini => takeovers.gemini, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, } } diff --git a/src-tauri/src/cli/commands/mcp.rs b/src-tauri/src/cli/commands/mcp.rs index 09adb076e..f0ded37e1 100644 --- a/src-tauri/src/cli/commands/mcp.rs +++ b/src-tauri/src/cli/commands/mcp.rs @@ -68,6 +68,11 @@ pub enum McpCommand { pub fn execute(cmd: McpCommand, app: Option) -> Result<(), AppError> { let app_type = app.unwrap_or(AppType::Claude); + if matches!(app_type, AppType::Pi) { + return Err(AppError::InvalidInput( + "Pi does not support MCP management".to_string(), + )); + } match cmd { McpCommand::List => list_servers(app_type), @@ -448,3 +453,29 @@ fn validate_command(command: &str) -> Result<(), AppError> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn global_pi_app_rejects_mcp_before_dispatch() { + let commands = [ + McpCommand::List, + McpCommand::Add, + McpCommand::Sync, + McpCommand::Import, + McpCommand::Validate { + command: "unused".to_string(), + }, + ]; + + for command in commands { + let error = execute(command, Some(AppType::Pi)).expect_err("Pi MCP must be rejected"); + assert!(matches!( + error, + AppError::InvalidInput(message) if message == "Pi does not support MCP management" + )); + } + } +} diff --git a/src-tauri/src/cli/commands/prompts.rs b/src-tauri/src/cli/commands/prompts.rs index b064d4759..9af98c843 100644 --- a/src-tauri/src/cli/commands/prompts.rs +++ b/src-tauri/src/cli/commands/prompts.rs @@ -1,8 +1,11 @@ -use clap::{ArgAction, Subcommand}; +use clap::{ArgAction, Subcommand, ValueEnum}; use crate::app_config::AppType; use crate::cli::ui::{create_table, highlight, info, success}; use crate::error::AppError; +use crate::services::pi_prompt_files::{ + PiPromptFileKind, PiPromptFileService, PiPromptTemplateService, +}; use crate::services::PromptService; use crate::store::AppState; @@ -68,6 +71,61 @@ pub enum PromptsCommand { /// Prompt preset ID id: String, }, + /// Manage Pi's native SYSTEM.md or APPEND_SYSTEM.md + System { + #[command(subcommand)] + command: PiSystemPromptCommand, + }, + /// Manage Pi's native slash-command prompt templates + Templates { + #[command(subcommand)] + command: PiPromptTemplatesCommand, + }, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum PiSystemPromptKind { + /// APPEND_SYSTEM.md (recommended) + Append, + /// SYSTEM.md (replaces Pi's built-in system prompt) + Override, +} + +impl From for PiPromptFileKind { + fn from(value: PiSystemPromptKind) -> Self { + match value { + PiSystemPromptKind::Append => Self::SystemAppend, + PiSystemPromptKind::Override => Self::SystemOverride, + } + } +} + +#[derive(Subcommand)] +pub enum PiSystemPromptCommand { + /// Show a native Pi system prompt file + Show { kind: PiSystemPromptKind }, + /// Edit a native Pi system prompt file + Edit { kind: PiSystemPromptKind }, + /// Delete a native Pi system prompt file + Delete { kind: PiSystemPromptKind }, +} + +#[derive(Subcommand)] +pub enum PiPromptTemplatesCommand { + /// List Pi prompt templates + List, + /// Show a Pi prompt template + Show { slug: String }, + /// Create a Pi prompt template + Create { slug: String }, + /// Edit or rename a Pi prompt template + Edit { + slug: String, + #[arg(long)] + new_slug: Option, + }, + /// Delete a Pi prompt template + Delete { slug: String }, } pub fn execute(cmd: PromptsCommand, app: Option) -> Result<(), AppError> { @@ -96,7 +154,187 @@ pub fn execute(cmd: PromptsCommand, app: Option) -> Result<(), AppError } => rename_prompt(app_type, &id, new_id, named.or(name), description), PromptsCommand::Delete { id } => delete_prompt(app_type, &id), PromptsCommand::Show { id } => show_prompt(app_type, &id), + PromptsCommand::System { command } => manage_pi_system_prompt(app_type, command), + PromptsCommand::Templates { command } => manage_pi_prompt_templates(app_type, command), + } +} + +fn require_pi(app_type: &AppType, resource: &str) -> Result<(), AppError> { + if matches!(app_type, AppType::Pi) { + Ok(()) + } else { + Err(AppError::InvalidInput(format!( + "{resource} is only available with --app pi" + ))) + } +} + +fn manage_pi_system_prompt( + app_type: AppType, + command: PiSystemPromptCommand, +) -> Result<(), AppError> { + require_pi(&app_type, "Pi native system prompts")?; + let (kind, action) = match command { + PiSystemPromptCommand::Show { kind } => (kind, "show"), + PiSystemPromptCommand::Edit { kind } => (kind, "edit"), + PiSystemPromptCommand::Delete { kind } => (kind, "delete"), + }; + let filename = match kind { + PiSystemPromptKind::Append => "APPEND_SYSTEM.md", + PiSystemPromptKind::Override => "SYSTEM.md", + }; + let native_kind = kind.into(); + + match action { + "show" => { + let snapshot = PiPromptFileService::read(native_kind)?; + if snapshot.exists { + println!("{}", highlight(filename)); + println!("{}", snapshot.content); + } else { + println!("{}", info(&format!("{filename} does not exist."))); + } + } + "edit" => { + let snapshot = PiPromptFileService::read(native_kind)?; + if matches!(kind, PiSystemPromptKind::Override) && !snapshot.exists { + println!( + "{}", + info("SYSTEM.md replaces Pi's built-in system prompt; APPEND_SYSTEM.md is recommended for normal additions.") + ); + let confirmed = inquire::Confirm::new("Create SYSTEM.md?") + .with_default(false) + .prompt() + .map_err(|error| AppError::Message(format!("Prompt failed: {error}")))?; + if !confirmed { + println!("{}", info("Cancelled.")); + return Ok(()); + } + } + let initial = if snapshot.exists { + snapshot.content.as_str() + } else { + "# Write the Pi system prompt here\n" + }; + let edited = crate::cli::editor::open_external_editor(initial)?; + if snapshot.exists && edited == snapshot.content { + println!("{}", info("No changes detected.")); + return Ok(()); + } + PiPromptFileService::replace(native_kind, &snapshot.revision, &edited)?; + println!("{}", success(&format!("✓ Saved {filename}"))); + } + "delete" => { + let snapshot = PiPromptFileService::read(native_kind)?; + if !snapshot.exists { + println!("{}", info(&format!("{filename} does not exist."))); + return Ok(()); + } + let confirmed = inquire::Confirm::new(&format!("Delete {filename}?")) + .with_default(false) + .prompt() + .map_err(|error| AppError::Message(format!("Prompt failed: {error}")))?; + if !confirmed { + println!("{}", info("Cancelled.")); + return Ok(()); + } + PiPromptFileService::delete(native_kind, &snapshot.revision)?; + println!("{}", success(&format!("✓ Deleted {filename}"))); + } + _ => unreachable!(), + } + Ok(()) +} + +fn manage_pi_prompt_templates( + app_type: AppType, + command: PiPromptTemplatesCommand, +) -> Result<(), AppError> { + require_pi(&app_type, "Pi prompt templates")?; + let templates = PiPromptTemplateService::list()?; + match command { + PiPromptTemplatesCommand::List => { + if templates.is_empty() { + println!("{}", info("No Pi prompt templates found.")); + } else { + let mut table = create_table(); + table.set_header(vec!["Slug", "Lines", "Size"]); + for template in templates { + table.add_row(vec![ + template.slug, + template.content.lines().count().to_string(), + format!("{} bytes", template.content.len()), + ]); + } + println!("{table}"); + } + } + PiPromptTemplatesCommand::Show { slug } => { + let template = templates + .into_iter() + .find(|template| template.slug == slug) + .ok_or_else(|| { + AppError::InvalidInput(format!("Pi prompt template '{slug}' not found")) + })?; + println!("{}", highlight(&template.slug)); + println!("{}", template.content); + } + PiPromptTemplatesCommand::Create { slug } => { + if templates.iter().any(|template| template.slug == slug) { + return Err(AppError::InvalidInput(format!( + "Pi prompt template '{slug}' already exists" + ))); + } + let edited = + crate::cli::editor::open_external_editor("# Write the Pi prompt template here\n")?; + PiPromptTemplateService::upsert(&slug, None, "missing", &edited)?; + println!( + "{}", + success(&format!("✓ Created Pi prompt template '{slug}'")) + ); + } + PiPromptTemplatesCommand::Edit { slug, new_slug } => { + let template = templates + .into_iter() + .find(|template| template.slug == slug) + .ok_or_else(|| { + AppError::InvalidInput(format!("Pi prompt template '{slug}' not found")) + })?; + let target_slug = new_slug.as_deref().unwrap_or(&slug); + let edited = crate::cli::editor::open_external_editor(&template.content)?; + if target_slug == slug && edited == template.content { + println!("{}", info("No changes detected.")); + return Ok(()); + } + PiPromptTemplateService::upsert(target_slug, Some(&slug), &template.revision, &edited)?; + println!( + "{}", + success(&format!("✓ Saved Pi prompt template '{target_slug}'")) + ); + } + PiPromptTemplatesCommand::Delete { slug } => { + let template = templates + .into_iter() + .find(|template| template.slug == slug) + .ok_or_else(|| { + AppError::InvalidInput(format!("Pi prompt template '{slug}' not found")) + })?; + let confirmed = inquire::Confirm::new(&format!("Delete Pi prompt template '{slug}'?")) + .with_default(false) + .prompt() + .map_err(|error| AppError::Message(format!("Prompt failed: {error}")))?; + if !confirmed { + println!("{}", info("Cancelled.")); + return Ok(()); + } + PiPromptTemplateService::delete(&slug, &template.revision)?; + println!( + "{}", + success(&format!("✓ Deleted Pi prompt template '{slug}'")) + ); + } } + Ok(()) } fn get_state() -> Result { diff --git a/src-tauri/src/cli/commands/provider.rs b/src-tauri/src/cli/commands/provider.rs index d0e74f0aa..a3c863ecf 100644 --- a/src-tauri/src/cli/commands/provider.rs +++ b/src-tauri/src/cli/commands/provider.rs @@ -498,7 +498,9 @@ fn prompt_and_apply_provider_api_format( match app_type { AppType::Claude => prompt_and_apply_claude_api_format(app_type, provider), AppType::Codex => prompt_and_apply_codex_api_format(app_type, provider), - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(()), + AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + Ok(()) + } } } @@ -990,7 +992,8 @@ fn resolve_provider_for_switch( fn switch_provider(app_type: AppType, id: &str) -> Result<(), AppError> { let state = get_state()?; let app_str = app_type.as_str().to_string(); - let skip_live_sync = !crate::sync_policy::should_sync_live(&app_type); + let skip_live_sync = + !matches!(app_type, AppType::Pi) && !crate::sync_policy::should_sync_live(&app_type); // 检查 provider 是否存在(支持按 id 或名称解析) let providers = ProviderService::list(&state, app_type.clone())?; @@ -1040,6 +1043,21 @@ fn delete_provider(app_type: AppType, id: &str) -> Result<(), AppError> { )); } + if matches!(app_type, AppType::Pi) + && crate::services::pi_state::PiStateService::current(&state) + .ok() + .and_then(|current| current.default_provider_id) + .as_deref() + == Some(id) + { + println!( + "{}", + warning( + "Pi references this provider as the global default; deletion will not rewrite Pi's default setting." + ) + ); + } + // 确认删除 let confirm = inquire::Confirm::new(&format!( "Are you sure you want to delete provider '{}'?", @@ -1387,6 +1405,35 @@ fn build_add_settings_config( } None => Ok(build_gemini_oauth_settings_config(current)), }, + AppType::Pi => { + let base_url = non_empty(args.base_url.clone()) + .ok_or_else(|| add_missing_field_error("--base-url"))?; + if !crate::pi_config::is_valid_request_url(&base_url) { + return Err(AppError::InvalidInput( + "Pi --base-url must be an absolute HTTP(S) URL".to_string(), + )); + } + let model = + non_empty(args.model.clone()).ok_or_else(|| add_missing_field_error("--model"))?; + let api = non_empty(args.api_format.clone()) + .unwrap_or_else(|| "openai-completions".to_string()); + if !crate::openclaw_config::OPENCLAW_API_PROTOCOLS.contains(&api.as_str()) { + return Err(add_invalid_api_format_error( + &api, + &crate::openclaw_config::OPENCLAW_API_PROTOCOLS.join("|"), + )); + } + let mut settings = serde_json::json!({ + "name": provider_name, + "baseUrl": base_url, + "api": api, + "models": [{ "id": model }], + }); + if let Some(api_key) = non_empty(args.api_key.clone()) { + settings["apiKey"] = serde_json::json!(api_key); + } + Ok(settings) + } AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => { let current = current.ok_or_else(|| add_additive_requires_config_error(app_type))?; let api_key = non_empty(args.api_key.clone()); @@ -1474,7 +1521,8 @@ fn apply_add_provider_api_format( }; apply_codex_api_format(provider, format); } - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => {} + AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + } } Ok(()) } @@ -1701,6 +1749,13 @@ fn edit_provider(app_type: AppType, id: &str) -> Result<(), AppError> { OptionalFields::from_provider(&original) }; + let pi_native_name_follows_display = matches!(app_type, AppType::Pi) + && original + .settings_config + .get("name") + .and_then(serde_json::Value::as_str) + .is_some_and(|native| native.trim() == original.name.trim()); + // 6. 构建更新后的 Provider(保留 meta 和 created_at) let mut updated = Provider { id: id.to_string(), @@ -1716,6 +1771,9 @@ fn edit_provider(app_type: AppType, id: &str) -> Result<(), AppError> { meta: original.meta, // 保留元数据 in_failover_queue: original.in_failover_queue, // 保留故障转移状态 }; + if pi_native_name_follows_display { + updated.settings_config["name"] = serde_json::Value::String(updated.name.clone()); + } apply_settings_prompt_result_metadata(&app_type, &mut updated, settings_prompt_result.as_ref()); prompt_and_apply_provider_api_format(&app_type, &mut updated)?; prompt_and_apply_codex_oauth_provider_options(&app_type, &mut updated)?; @@ -1947,6 +2005,20 @@ fn import_live_config(app_type: AppType) -> Result<(), AppError> { fn remove_from_config(app_type: AppType, id: &str) -> Result<(), AppError> { let state = get_state()?; + if matches!(app_type, AppType::Pi) + && crate::services::pi_state::PiStateService::current(&state) + .ok() + .and_then(|current| current.default_provider_id) + .as_deref() + == Some(id) + { + println!( + "{}", + warning( + "Pi references this provider as the global default; removal will not rewrite Pi's default setting." + ) + ); + } ProviderService::remove_from_live_config(&state, app_type.clone(), id)?; println!( "{}", @@ -2152,6 +2224,32 @@ mod tests { ); } + #[test] + fn noninteractive_pi_add_validates_transport_fields() { + let build = |base_url: &str, api_format: &str| { + build_add_settings_config( + &AppType::Pi, + &AddProviderArgs { + base_url: Some(base_url.to_string()), + model: Some("model-a".to_string()), + api_format: Some(api_format.to_string()), + ..Default::default() + }, + None, + None, + "Custom Pi", + &mut None, + ) + }; + + assert!(build("relative/path", "openai-completions").is_err()); + assert!(build("https://pi.example/v1", "unknown-protocol").is_err()); + let settings = + build("https://pi.example/v1", "anthropic-messages").expect("valid Pi transport"); + assert_eq!(settings["api"], "anthropic-messages"); + assert_eq!(settings["models"][0]["id"], "model-a"); + } + #[test] fn claude_api_format_effective_value_prefers_meta_over_legacy_settings() { let mut provider = claude_provider(json!({ diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index fc4bd5868..b2258d365 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -154,7 +154,7 @@ pub fn common_snippet_has_effective_config( .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|obj| !obj.is_empty()), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, } } @@ -219,7 +219,7 @@ pub fn provider_add_template_choices(app_type: &AppType) -> Vec { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { vec![ProviderAddTemplateChoice { template: ProviderAddTemplate::Custom, label: "Custom", @@ -669,6 +669,7 @@ fn build_sponsor_template_settings_config( }) } } + AppType::Pi => Err(unsupported_template_error(ProviderAddTemplate::Custom)), } } @@ -724,6 +725,22 @@ pub fn apply_additive_template_field_overrides( models_with_primary_override(current, model), ) } + AppType::Pi => { + let mut updated = current.clone(); + let object = updated.as_object_mut().ok_or_else(|| { + AppError::InvalidInput("Pi provider configuration must be an object".to_string()) + })?; + if let Some(api_key) = api_key { + object.insert("apiKey".to_string(), Value::String(api_key.to_string())); + } + if let Some(base_url) = base_url { + object.insert("baseUrl".to_string(), Value::String(base_url.to_string())); + } + if let Some(model) = model { + object.insert("models".to_string(), json!([{ "id": model }])); + } + Ok(updated) + } AppType::Claude | AppType::Codex | AppType::Gemini => Err(AppError::InvalidInput(format!( "{} does not use additive provider settings", app_type.as_str() @@ -2466,6 +2483,29 @@ requires_openai_auth = true assert!(defaults.user_agent_enabled); assert!(defaults.models_json.contains("existing-model")); } + + #[test] + fn pi_prompt_url_validation_allows_only_unchanged_legacy_values() { + let invalid = json!({ + "baseUrl": "not-a-url", + "models": [{ "id": "model" }] + }); + let changed_invalid = json!({ + "baseUrl": "still-not-a-url", + "models": [{ "id": "model" }] + }); + let valid_model_url = json!({ + "models": [{ + "id": "model", + "baseUrl": "https://pi.example/v1" + }] + }); + + assert!(validate_pi_prompt_request_url(None, &invalid).is_err()); + assert!(validate_pi_prompt_request_url(Some(&invalid), &invalid).is_ok()); + assert!(validate_pi_prompt_request_url(Some(&invalid), &changed_invalid).is_err()); + assert!(validate_pi_prompt_request_url(None, &valid_model_url).is_ok()); + } } fn build_codex_settings_config( @@ -3700,6 +3740,108 @@ pub fn prompt_basic_fields( Ok((name, website_url)) } +fn prompt_pi_config(current: Option<&Value>) -> Result { + let mut config = current.cloned().unwrap_or_else(|| json!({})); + let object = config.as_object_mut().ok_or_else(|| { + AppError::InvalidInput("Pi provider configuration must be an object".to_string()) + })?; + + let current_base_url = object + .get("baseUrl") + .and_then(Value::as_str) + .unwrap_or_default(); + let base_url = Text::new("Base URL:") + .with_initial_value(current_base_url) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; + if base_url.trim().is_empty() { + object.remove("baseUrl"); + } else { + object.insert( + "baseUrl".to_string(), + Value::String(base_url.trim().to_string()), + ); + } + + let current_api_key = object + .get("apiKey") + .and_then(Value::as_str) + .unwrap_or_default(); + let api_key = Text::new("API Key:") + .with_initial_value(current_api_key) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; + if api_key.trim().is_empty() { + object.remove("apiKey"); + } else { + object.insert( + "apiKey".to_string(), + Value::String(api_key.trim().to_string()), + ); + } + + let current_api = object + .get("api") + .and_then(Value::as_str) + .unwrap_or(if current.is_none() { + "openai-completions" + } else { + "" + }); + let api = Text::new("API protocol:") + .with_initial_value(current_api) + .with_help_message("For example: openai-completions or anthropic-messages") + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; + if current.is_none() && !crate::openclaw_config::OPENCLAW_API_PROTOCOLS.contains(&api.trim()) { + return Err(AppError::InvalidInput(format!( + "Unsupported Pi API protocol: {}", + api.trim() + ))); + } + if api.trim().is_empty() { + object.remove("api"); + } else { + object.insert("api".to_string(), Value::String(api.trim().to_string())); + } + + if current.is_none() { + let model = Text::new("Model ID:") + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; + if model.trim().is_empty() { + return Err(AppError::InvalidInput( + "A custom Pi provider requires at least one model ID".to_string(), + )); + } + object.insert("models".to_string(), json!([{ "id": model.trim() }])); + } + + validate_pi_prompt_request_url(current, &config)?; + + Ok(config) +} + +fn validate_pi_prompt_request_url(current: Option<&Value>, edited: &Value) -> Result<(), AppError> { + let request_url = |config: &Value| { + crate::pi_config::provider_base_url(config) + .ok() + .map(|url| url.trim().to_string()) + }; + let original_request_url = current.and_then(request_url); + let edited_request_url = request_url(edited); + if (current.is_none() || edited_request_url != original_request_url) + && edited_request_url + .as_deref() + .is_none_or(|url| !crate::pi_config::is_valid_request_url(url)) + { + return Err(AppError::InvalidInput( + "Pi Base URL must be an absolute HTTP(S) URL".to_string(), + )); + } + Ok(()) +} + /// 根据应用类型收集 settings_config pub fn prompt_settings_config( app_type: &AppType, @@ -3741,6 +3883,13 @@ pub fn prompt_settings_config( AppType::OpenCode => prompt_opencode_config(current).map(SettingsConfigPromptResult::new), AppType::Hermes => prompt_hermes_config(current).map(SettingsConfigPromptResult::new), AppType::OpenClaw => prompt_openclaw_config(current).map(SettingsConfigPromptResult::new), + AppType::Pi => { + let mut config = prompt_pi_config(current)?; + if current.is_none() { + config["name"] = Value::String(provider_name.trim().to_string()); + } + Ok(SettingsConfigPromptResult::new(config)) + } } } @@ -4475,6 +4624,29 @@ pub fn display_provider_summary(provider: &Provider, app_type: &AppType) { println!(" {}: {}", texts::model_label(), models.len()); } } + AppType::Pi => { + if provider.configured_api_key(app_type).is_some() { + println!( + " {}: {}", + texts::api_key_display_label(), + crate::t!("configured", "已配置") + ); + } + if let Some(base_url) = provider + .settings_config + .get("baseUrl") + .and_then(Value::as_str) + { + println!(" {}: {}", texts::base_url_display_label(), base_url); + } + if let Some(models) = provider + .settings_config + .get("models") + .and_then(Value::as_array) + { + println!(" {}: {}", texts::model_label(), models.len()); + } + } } // 可选字段 diff --git a/src-tauri/src/cli/commands/provider_inspect.rs b/src-tauri/src/cli/commands/provider_inspect.rs index cde079bed..b1d37f2d9 100644 --- a/src-tauri/src/cli/commands/provider_inspect.rs +++ b/src-tauri/src/cli/commands/provider_inspect.rs @@ -1,5 +1,6 @@ use serde::Serialize; use serde_json::Value; +use std::collections::BTreeMap; use crate::app_config::AppType; use crate::cli::i18n::texts; @@ -36,6 +37,7 @@ struct ModelFetchTarget { is_full_url: bool, auth_value: Option, custom_user_agent: Option, + request_headers: Option>, strategy: ProviderModelFetchStrategy, } @@ -73,6 +75,22 @@ pub(crate) fn list_providers(app_type: AppType) -> Result<(), AppError> { let app_str = app_type.as_str().to_string(); let providers = ProviderService::list(&state, app_type.clone())?; let current_id = ProviderService::current(&state, app_type.clone())?; + let pi_state = if matches!(app_type, AppType::Pi) { + match crate::services::pi_state::PiStateService::current(&state) { + Ok(current) => Some(current), + Err(error) => { + println!( + "{}", + warning(&format!( + "Pi models.json could not be read; enabled state is unavailable: {error}" + )) + ); + None + } + } + } else { + None + }; if providers.is_empty() { println!("{}", info("No providers found.")); @@ -92,7 +110,17 @@ pub(crate) fn list_providers(app_type: AppType) -> Result<(), AppError> { }); for (id, provider) in provider_list { - let current_marker = if id == current_id { "✓" } else { " " }; + let current_marker = if pi_state.as_ref().is_some_and(|state| { + state + .enabled_provider_ids + .iter() + .any(|enabled| enabled == &id) + }) || (!matches!(app_type, AppType::Pi) && id == current_id) + { + "✓" + } else { + " " + }; let api_url = extract_api_url(&provider, &app_type).unwrap_or_else(|| "N/A".to_string()); table.add_row(vec![current_marker.to_string(), id, provider.name, api_url]); @@ -100,12 +128,22 @@ pub(crate) fn list_providers(app_type: AppType) -> Result<(), AppError> { println!("{}", table); println!("\n{} Application: {}", info("ℹ"), app_str); - println!("{} Current: {}", info("→"), highlight(¤t_id)); + if matches!(app_type, AppType::Pi) { + println!("{} ✓ = present in Pi models.json", info("→")); + } else { + println!("{} Current: {}", info("→"), highlight(¤t_id)); + } Ok(()) } pub(crate) fn show_current(app_type: AppType) -> Result<(), AppError> { + if matches!(app_type, AppType::Pi) { + return Err(AppError::InvalidInput( + "Pi owns the current/default provider; CC Switch only manages explicit models.json entries" + .to_string(), + )); + } let state = get_state()?; let current_id = ProviderService::current(&state, app_type.clone())?; let providers = ProviderService::list(&state, app_type.clone())?; @@ -352,6 +390,7 @@ fn fetch_models_from_source(source: &ModelFetchSource) -> Result, Ap target.auth_value.as_deref(), target.custom_user_agent.as_deref(), to_tui_strategy(target.strategy), + target.request_headers.as_ref(), ) .await .map_err(AppError::Message) @@ -806,6 +845,7 @@ fn model_fetch_target( is_full_url, auth_value: Some(auth_value), custom_user_agent, + request_headers: None, strategy, }) } @@ -817,6 +857,7 @@ fn model_fetch_target( || AppError::Message(format!("Missing API key for provider '{}'", provider.id)), )?), custom_user_agent, + request_headers: None, strategy: ProviderModelFetchStrategy::Bearer, }) } @@ -827,6 +868,7 @@ fn model_fetch_target( is_full_url, auth_value: Some(auth_value), custom_user_agent, + request_headers: None, strategy, }) } @@ -847,6 +889,7 @@ fn model_fetch_target( })?, ), custom_user_agent, + request_headers: None, strategy: ProviderModelFetchStrategy::Bearer, }), AppType::Hermes => Ok(ModelFetchTarget { @@ -866,6 +909,7 @@ fn model_fetch_target( })?, ), custom_user_agent, + request_headers: None, strategy: ProviderModelFetchStrategy::Bearer, }), AppType::OpenClaw => Ok(ModelFetchTarget { @@ -884,8 +928,58 @@ fn model_fetch_target( })?, ), custom_user_agent, + request_headers: None, strategy: ProviderModelFetchStrategy::Bearer, }), + AppType::Pi => { + let api = provider.settings_config.get("api").and_then(Value::as_str); + let strategy = match api { + Some("anthropic-messages") => ProviderModelFetchStrategy::Anthropic, + Some("google-generative-ai") => ProviderModelFetchStrategy::GoogleApiKey, + _ => ProviderModelFetchStrategy::Bearer, + }; + let mut request_headers = provider + .settings_config + .get("headers") + .and_then(Value::as_object) + .map(|headers| { + headers + .iter() + .filter_map(|(name, value)| { + value + .as_str() + .map(|value| (name.clone(), value.to_string())) + }) + .collect::>() + }) + .filter(|headers| !headers.is_empty()); + let api_key = provider + .settings_config + .get("apiKey") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let auth_value = if matches!(api, Some("anthropic-messages")) { + if let Some(api_key) = api_key.as_ref() { + request_headers + .get_or_insert_with(BTreeMap::new) + .entry("x-api-key".to_string()) + .or_insert_with(|| api_key.clone()); + } + None + } else { + api_key + }; + Ok(ModelFetchTarget { + base_url, + is_full_url, + auth_value, + custom_user_agent, + request_headers, + strategy, + }) + } } } @@ -912,6 +1006,7 @@ fn one_off_model_fetch_target( is_full_url: false, auth_value, custom_user_agent: None, + request_headers: None, strategy, }) } @@ -920,7 +1015,7 @@ fn default_one_off_model_fetch_strategy(app_type: &AppType) -> ProviderModelFetc match app_type { AppType::Claude => ProviderModelFetchStrategy::Anthropic, AppType::Gemini => ProviderModelFetchStrategy::GoogleApiKey, - AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => { + AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { ProviderModelFetchStrategy::Bearer } } @@ -1359,6 +1454,34 @@ base_url = "https://current.example.com/v1" assert!(target.is_full_url); } + #[test] + fn pi_model_fetch_uses_native_api_auth_and_headers() { + let provider = Provider::with_id( + "pi-demo".to_string(), + "Pi Demo".to_string(), + json!({ + "baseUrl": "https://pi.example.com/v1", + "api": "anthropic-messages", + "apiKey": "pi-secret", + "headers": { "x-client": "cc-switch" }, + "models": [{ "id": "model-a" }] + }), + None, + ); + + let target = model_fetch_target(&provider, &AppType::Pi) + .expect("Pi provider should resolve fetch target"); + assert_eq!(target.strategy, ProviderModelFetchStrategy::Anthropic); + assert_eq!(target.auth_value, None); + assert_eq!( + target.request_headers, + Some(BTreeMap::from([ + ("x-api-key".to_string(), "pi-secret".to_string()), + ("x-client".to_string(), "cc-switch".to_string()), + ])) + ); + } + #[test] fn model_fetch_target_for_claude_supports_openrouter_bearer_mode() { let provider = Provider::with_id( diff --git a/src-tauri/src/cli/commands/provider_usage_query.rs b/src-tauri/src/cli/commands/provider_usage_query.rs index c306b8d95..95439650e 100644 --- a/src-tauri/src/cli/commands/provider_usage_query.rs +++ b/src-tauri/src/cli/commands/provider_usage_query.rs @@ -1,4 +1,5 @@ use clap::{Args, Subcommand, ValueEnum}; +use serde_json::Value; use std::fmt; use crate::app_config::AppType; @@ -289,11 +290,15 @@ fn set(app_type: AppType, command: ProviderUsageQuerySetCommand) -> Result<(), A apply_template_credentials(&mut script, &template, &command); validate_usage_script_for_save(&script)?; - provider - .meta - .get_or_insert_with(ProviderMeta::default) - .usage_script = Some(script); - ProviderService::update(&state, app_type, provider)?; + if app_type == AppType::Pi { + ProviderService::update_pi_usage_script(&state, &provider.id, script)?; + } else { + provider + .meta + .get_or_insert_with(ProviderMeta::default) + .usage_script = Some(script); + ProviderService::update(&state, app_type, provider)?; + } println!("{}", success("✓ Usage Query configuration updated")); Ok(()) @@ -328,6 +333,11 @@ fn validate_usage_template_compatibility( fn clear(app_type: AppType, id: &str) -> Result<(), AppError> { let state = get_state()?; + if app_type == AppType::Pi { + ProviderService::clear_pi_usage_script(&state, id)?; + println!("{}", success("✓ Usage Query configuration cleared")); + return Ok(()); + } let mut provider = find_provider(&state, &app_type, id)?; if let Some(meta) = provider.meta.as_mut() { meta.usage_script = None; @@ -732,6 +742,13 @@ fn provider_comment_credentials<'a>( .map(str::to_string), settings.get("apiKey").and_then(|value| value.as_str()), ), + AppType::Pi => ( + settings + .get("baseUrl") + .and_then(Value::as_str) + .map(str::to_string), + settings.get("apiKey").and_then(Value::as_str), + ), } } diff --git a/src-tauri/src/cli/commands/proxy.rs b/src-tauri/src/cli/commands/proxy.rs index bce1ce383..3fbfb3f58 100644 --- a/src-tauri/src/cli/commands/proxy.rs +++ b/src-tauri/src/cli/commands/proxy.rs @@ -507,6 +507,7 @@ fn build_proxy_overview_lines( takeovers: &crate::proxy::types::ProxyTakeoverStatus, ) -> Vec { let current_providers = AppType::all() + .filter(|app| !matches!(app, AppType::Pi)) .map(|app| { let current = state .db diff --git a/src-tauri/src/cli/commands/sessions.rs b/src-tauri/src/cli/commands/sessions.rs index 68f6dfd35..756432be4 100644 --- a/src-tauri/src/cli/commands/sessions.rs +++ b/src-tauri/src/cli/commands/sessions.rs @@ -587,8 +587,9 @@ fn sync_usage_for_provider( AppType::Codex => crate::services::session_usage_codex::sync_codex_usage(db), AppType::Gemini => crate::services::session_usage_gemini::sync_gemini_usage(db), AppType::OpenCode => crate::services::session_usage_opencode::sync_opencode_usage(db), + AppType::Pi => crate::services::session_usage_pi::sync_pi_usage(db), other => Err(AppError::InvalidInput(format!( - "session usage sync is only supported for claude, codex, gemini, and opencode; got {}", + "session usage sync is only supported for claude, codex, gemini, opencode, and pi; got {}", other.as_str() ))), } @@ -769,7 +770,7 @@ fn parse_scoped_selector(selector: &str) -> Option<(String, String)> { fn parse_session_provider(value: &str) -> Result { app_type_from_provider_id(value).ok_or_else(|| { - format!("unsupported provider '{value}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes") + format!("unsupported provider '{value}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes, pi") }) } diff --git a/src-tauri/src/cli/commands/settings.rs b/src-tauri/src/cli/commands/settings.rs index 82e99f233..dfacaede4 100644 --- a/src-tauri/src/cli/commands/settings.rs +++ b/src-tauri/src/cli/commands/settings.rs @@ -375,6 +375,7 @@ fn show_settings(json_output: bool) -> Result<(), AppError> { "unifyCodexMigrateExisting": settings.unify_codex_migrate_existing.unwrap_or(false), "hasCodexHistoryUnifyBackup": crate::codex_history_migration::has_codex_official_history_unify_backup(), "openclawConfigDir": settings.openclaw_config_dir, + "piConfigDir": settings.pi_config_dir, "preferredEditor": settings.preferred_editor, }); println!( @@ -410,6 +411,10 @@ fn show_settings(json_output: bool) -> Result<(), AppError> { .as_deref() .unwrap_or("(default)") ); + println!( + "Pi config dir: {}", + settings.pi_config_dir.as_deref().unwrap_or("(default)") + ); println!( "Preferred editor: {}", settings.preferred_editor.as_deref().unwrap_or("(not set)") @@ -507,6 +512,7 @@ fn set_visible_apps_list(apps: Vec) -> Result<(), AppError> { opencode: false, hermes: false, openclaw: false, + pi: false, }; for app in apps { visible_apps.set_enabled_for(&app, true); @@ -882,6 +888,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }) .expect("save manual visible apps"); @@ -905,6 +912,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }) .expect_err("empty visible apps should be rejected"); diff --git a/src-tauri/src/cli/commands/skills.rs b/src-tauri/src/cli/commands/skills.rs index f5ca6444a..56f4ee497 100644 --- a/src-tauri/src/cli/commands/skills.rs +++ b/src-tauri/src/cli/commands/skills.rs @@ -242,6 +242,7 @@ fn list_installed() -> Result<(), AppError> { "Gemini", "OpenCode", "Hermes", + "Pi", ]); for skill in skills { table.add_row(vec![ @@ -252,6 +253,7 @@ fn list_installed() -> Result<(), AppError> { if skill.apps.gemini { "✓" } else { " " }.to_string(), if skill.apps.opencode { "✓" } else { " " }.to_string(), if skill.apps.hermes { "✓" } else { " " }.to_string(), + if skill.apps.pi { "✓" } else { " " }.to_string(), ]); } @@ -525,12 +527,13 @@ fn show_skill_info(spec: &str) -> Result<(), AppError> { println!("Desc: {}", desc); } println!( - "Enabled: claude={} codex={} gemini={} opencode={} hermes={}", + "Enabled: claude={} codex={} gemini={} opencode={} hermes={} pi={}", record.apps.claude, record.apps.codex, record.apps.gemini, record.apps.opencode, - record.apps.hermes + record.apps.hermes, + record.apps.pi ); Ok(()) diff --git a/src-tauri/src/cli/failover_policy.rs b/src-tauri/src/cli/failover_policy.rs index 21e789ca2..1b7edf2c5 100644 --- a/src-tauri/src/cli/failover_policy.rs +++ b/src-tauri/src/cli/failover_policy.rs @@ -85,6 +85,6 @@ fn takeover_enabled_for(takeover: &ProxyTakeoverStatus, app_type: &AppType) -> b AppType::Claude => takeover.claude, AppType::Codex => takeover.codex, AppType::Gemini => takeover.gemini, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, } } diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index 985ad0e62..2128ac728 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -5198,6 +5198,26 @@ pub mod texts { "Default (~/.openclaw)" } + pub fn tui_settings_pi_config_dir_label() -> &'static str { + if is_chinese() { + "Pi 配置目录" + } else { + "Pi Config Directory" + } + } + + pub fn tui_settings_pi_config_dir_prompt() -> &'static str { + if is_chinese() { + "输入 Pi agent 配置目录;留空恢复默认 ~/.pi/agent" + } else { + "Enter the Pi agent config directory; leave empty to use ~/.pi/agent" + } + } + + pub fn tui_settings_pi_config_dir_default_value() -> &'static str { + "Default (~/.pi/agent)" + } + pub fn tui_settings_preferred_editor_label() -> &'static str { if is_chinese() { "外部编辑器" @@ -5308,6 +5328,22 @@ pub mod texts { } } + pub fn tui_toast_pi_config_dir_saved() -> &'static str { + if is_chinese() { + "Pi 配置目录已保存。" + } else { + "Pi config directory saved." + } + } + + pub fn tui_toast_pi_config_dir_import_failed(err: &str) -> String { + if is_chinese() { + format!("Pi 配置目录已保存,但导入原生供应商失败: {err}") + } else { + format!("Pi config directory saved, but native provider import failed: {err}") + } + } + pub fn tui_toast_preferred_editor_saved() -> &'static str { if is_chinese() { "外部编辑器设置已保存。" @@ -5742,14 +5778,15 @@ pub mod texts { gemini: usize, opencode: usize, hermes: usize, + pi: usize, ) -> String { if is_chinese() { format!( - "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" + "Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes} · Pi: {pi}" ) } else { format!( - "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" + "Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes} · Pi: {pi}" ) } } @@ -9494,6 +9531,32 @@ pub mod texts { ("💬 Prompts", "💬 提示词") } + pub fn menu_pi_system_prompts() -> &'static str { + let (en, zh) = menu_pi_system_prompts_variants(); + if is_chinese() { + zh + } else { + en + } + } + + pub fn menu_pi_system_prompts_variants() -> (&'static str, &'static str) { + ("🧠 System Prompts", "🧠 系统提示词") + } + + pub fn menu_pi_prompt_templates() -> &'static str { + let (en, zh) = menu_pi_prompt_templates_variants(); + if is_chinese() { + zh + } else { + en + } + } + + pub fn menu_pi_prompt_templates_variants() -> (&'static str, &'static str) { + ("📝 Prompt Templates", "📝 提示词模板") + } + pub fn menu_manage_config() -> &'static str { let (en, zh) = menu_manage_config_variants(); if is_chinese() { diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs index 4945a5d15..8a779da4a 100644 --- a/src-tauri/src/cli/tui/app/app_state.rs +++ b/src-tauri/src/cli/tui/app/app_state.rs @@ -132,6 +132,8 @@ pub enum Action { is_full_url: bool, api_key: Option, custom_user_agent: Option, + api_protocol: Option, + request_headers: Option>, codex_oauth: bool, codex_oauth_account_id: Option, field: ProviderAddField, @@ -200,6 +202,20 @@ pub enum Action { PromptDelete { id: String, }, + PiSystemPromptDelete { + kind: crate::services::pi_prompt_files::PiPromptFileKind, + expected_revision: String, + }, + PiPromptTemplateDelete { + slug: String, + expected_revision: String, + }, + PiPromptTemplateRename { + original_slug: String, + new_slug: String, + expected_revision: String, + content: String, + }, PromptFormOpenExternal, PromptOpenImportCandidate { filename: String, @@ -326,6 +342,9 @@ pub enum Action { SetOpenClawConfigDir { path: Option, }, + SetPiConfigDir { + path: Option, + }, SetPreferredEditor { command: Option, }, @@ -506,6 +525,7 @@ pub enum SettingsItem { SkillsStorageLocation, SkillsSyncMethod, OpenClawConfigDir, + PiConfigDir, ManagedAccounts, SkipClaudeOnboarding, ClaudePluginIntegration, @@ -517,7 +537,7 @@ pub enum SettingsItem { } impl SettingsItem { - pub const ALL: [SettingsItem; 17] = [ + pub const ALL: [SettingsItem; 18] = [ SettingsItem::ManagedAccounts, SettingsItem::Language, SettingsItem::Theme, @@ -528,6 +548,7 @@ impl SettingsItem { SettingsItem::SkillsStorageLocation, SettingsItem::SkillsSyncMethod, SettingsItem::OpenClawConfigDir, + SettingsItem::PiConfigDir, SettingsItem::SkipClaudeOnboarding, SettingsItem::ClaudePluginIntegration, SettingsItem::PreserveCodexOfficialAuth, @@ -749,6 +770,8 @@ pub struct App { pub provider_idx: usize, pub mcp_idx: usize, pub prompt_idx: usize, + pub pi_system_prompt_idx: usize, + pub pi_prompt_template_idx: usize, pub skills_idx: usize, pub skills_discover_idx: usize, pub skills_repo_idx: usize, diff --git a/src-tauri/src/cli/tui/app/content_config.rs b/src-tauri/src/cli/tui/app/content_config.rs index 5dc1f96bd..1a3b18eff 100644 --- a/src-tauri/src/cli/tui/app/content_config.rs +++ b/src-tauri/src/cli/tui/app/content_config.rs @@ -955,6 +955,18 @@ impl App { }); Action::None } + Some(SettingsItem::PiConfigDir) => { + let buffer = crate::settings::get_settings() + .pi_config_dir + .unwrap_or_default(); + self.overlay = Overlay::TextInput(TextInputState { + title: texts::tui_settings_pi_config_dir_label().to_string(), + prompt: texts::tui_settings_pi_config_dir_prompt().to_string(), + input: TextInput::new(buffer), + submit: TextSubmit::SettingsPiConfigDir, + }); + Action::None + } Some(SettingsItem::ManagedAccounts) => { let action = self.push_route_and_switch(Route::SettingsManagedAccounts); if self.managed_auth_status.is_none() { diff --git a/src-tauri/src/cli/tui/app/content_entities.rs b/src-tauri/src/cli/tui/app/content_entities.rs index 98cb6183e..8b0fbb8ba 100644 --- a/src-tauri/src/cli/tui/app/content_entities.rs +++ b/src-tauri/src/cli/tui/app/content_entities.rs @@ -91,22 +91,36 @@ impl App { } fn open_provider_delete_confirm(&mut self, row: &super::data::ProviderRow) { + let mut message = texts::tui_confirm_delete_provider_message( + &super::data::provider_display_name(&self.app_type, row), + &row.id, + ); + if matches!(self.app_type, AppType::Pi) && row.is_default_model { + message.push_str(crate::t!( + "\n\nPi currently references this provider as its global default. Deleting it will not rewrite Pi's default setting.", + "\n\nPi 当前将此供应商作为全局默认项;删除不会改写 Pi 的默认设置。" + )); + } self.overlay = Overlay::Confirm(ConfirmOverlay { title: texts::tui_confirm_delete_provider_title().to_string(), - message: texts::tui_confirm_delete_provider_message( - &super::data::provider_display_name(&self.app_type, row), - &row.id, - ), + message, action: ConfirmAction::ProviderDelete { id: row.id.clone() }, }); } fn open_provider_remove_confirm(&mut self, row: &super::data::ProviderRow) { + let mut message = texts::tui_confirm_remove_provider_message( + &super::data::provider_display_name(&self.app_type, row), + ); + if matches!(self.app_type, AppType::Pi) && row.is_default_model { + message.push_str(crate::t!( + "\n\nPi currently references this provider as its global default. Removing it will not rewrite Pi's default setting.", + "\n\nPi 当前将此供应商作为全局默认项;移除不会改写 Pi 的默认设置。" + )); + } self.overlay = Overlay::Confirm(ConfirmOverlay { title: texts::tui_confirm_remove_provider_title().to_string(), - message: texts::tui_confirm_remove_provider_message( - &super::data::provider_display_name(&self.app_type, row), - ), + message, action: ConfirmAction::ProviderRemoveFromConfig { id: row.id.clone() }, }); } @@ -224,6 +238,23 @@ impl App { return Action::None; }; + if matches!(self.app_type, AppType::Pi) + && data.providers.pi_membership_unknown + && matches!( + intent, + Intent::Primary | Intent::Add | Intent::Edit | Intent::Switch | Intent::Delete + ) + { + self.push_toast( + crate::t!( + "Pi models.json could not be read. Fix or reload it before changing providers.", + "无法读取 Pi models.json;请修复或重新加载后再修改供应商。" + ), + ToastKind::Warning, + ); + return Action::None; + } + match intent { Intent::Primary => { if data.providers.rows.is_empty() { @@ -497,6 +528,163 @@ impl App { } } + pub(crate) fn on_pi_system_prompts_key(&mut self, key: KeyEvent, data: &UiData) -> Action { + let rows = &data.pi_prompts.system_files; + match key.code { + KeyCode::Up => { + self.pi_system_prompt_idx = self.pi_system_prompt_idx.saturating_sub(1); + Action::None + } + KeyCode::Down => { + if !rows.is_empty() { + self.pi_system_prompt_idx = (self.pi_system_prompt_idx + 1).min(rows.len() - 1); + } + Action::None + } + KeyCode::Enter | KeyCode::Char('v') => { + let Some((kind, snapshot)) = rows.get(self.pi_system_prompt_idx) else { + return Action::None; + }; + self.overlay = Overlay::TextView(TextViewState { + title: pi_system_prompt_filename(*kind).to_string(), + lines: snapshot.content.lines().map(str::to_string).collect(), + scroll: 0, + action: None, + }); + Action::None + } + KeyCode::Char('e') => { + let Some((kind, snapshot)) = rows.get(self.pi_system_prompt_idx) else { + return Action::None; + }; + self.open_editor( + pi_system_prompt_filename(*kind), + EditorKind::Plain, + snapshot.content.clone(), + EditorSubmit::PiSystemPrompt { + kind: *kind, + expected_revision: snapshot.revision.clone(), + }, + ); + Action::None + } + KeyCode::Char('d') => { + let Some((kind, snapshot)) = rows.get(self.pi_system_prompt_idx) else { + return Action::None; + }; + if !snapshot.exists { + return Action::None; + } + self.overlay = Overlay::Confirm(ConfirmOverlay { + title: texts::tui_confirm_delete_prompt_title().to_string(), + message: crate::t!( + format!("Delete {}?", pi_system_prompt_filename(*kind)), + format!("删除 {}?", pi_system_prompt_filename(*kind)) + ), + action: ConfirmAction::PiSystemPromptDelete { + kind: *kind, + expected_revision: snapshot.revision.clone(), + }, + }); + Action::None + } + _ => Action::None, + } + } + + pub(crate) fn on_pi_prompt_templates_key(&mut self, key: KeyEvent, data: &UiData) -> Action { + let rows = &data.pi_prompts.templates; + match key.code { + KeyCode::Up => { + self.pi_prompt_template_idx = self.pi_prompt_template_idx.saturating_sub(1); + Action::None + } + KeyCode::Down => { + if !rows.is_empty() { + self.pi_prompt_template_idx = + (self.pi_prompt_template_idx + 1).min(rows.len() - 1); + } + Action::None + } + KeyCode::Char('a') => { + self.overlay = Overlay::TextInput(TextInputState { + title: texts::menu_pi_prompt_templates().to_string(), + prompt: if crate::cli::i18n::is_chinese() { + "模板名称(不含 .md)" + } else { + "Template slug (without .md)" + } + .to_string(), + input: TextInput::new(""), + submit: TextSubmit::PiPromptTemplateCreate, + }); + Action::None + } + KeyCode::Enter | KeyCode::Char('v') => { + let Some(template) = rows.get(self.pi_prompt_template_idx) else { + return Action::None; + }; + self.overlay = Overlay::TextView(TextViewState { + title: format!("/{}", template.slug), + lines: template.content.lines().map(str::to_string).collect(), + scroll: 0, + action: None, + }); + Action::None + } + KeyCode::Char('e') => { + let Some(template) = rows.get(self.pi_prompt_template_idx) else { + return Action::None; + }; + self.open_editor( + format!("/{}", template.slug), + EditorKind::Plain, + template.content.clone(), + EditorSubmit::PiPromptTemplate { + slug: template.slug.clone(), + original_slug: Some(template.slug.clone()), + expected_revision: template.revision.clone(), + }, + ); + Action::None + } + KeyCode::Char('r') => { + let Some(template) = rows.get(self.pi_prompt_template_idx) else { + return Action::None; + }; + self.overlay = Overlay::TextInput(TextInputState { + title: texts::menu_pi_prompt_templates().to_string(), + prompt: crate::t!("New template slug", "新的模板名称").to_string(), + input: TextInput::new(template.slug.clone()), + submit: TextSubmit::PiPromptTemplateRename { + original_slug: template.slug.clone(), + expected_revision: template.revision.clone(), + content: template.content.clone(), + }, + }); + Action::None + } + KeyCode::Char('d') => { + let Some(template) = rows.get(self.pi_prompt_template_idx) else { + return Action::None; + }; + self.overlay = Overlay::Confirm(ConfirmOverlay { + title: texts::tui_confirm_delete_prompt_title().to_string(), + message: crate::t!( + format!("Delete /{}?", template.slug), + format!("删除 /{}?", template.slug) + ), + action: ConfirmAction::PiPromptTemplateDelete { + slug: template.slug.clone(), + expected_revision: template.revision.clone(), + }, + }); + Action::None + } + _ => Action::None, + } + } + pub(crate) fn on_sessions_key(&mut self, key: KeyEvent, data: &UiData) -> Action { use super::paged_list::PageDirection; use crate::cli::tui::keymap::sessions::Intent; @@ -1109,6 +1297,15 @@ impl App { } } +fn pi_system_prompt_filename( + kind: crate::services::pi_prompt_files::PiPromptFileKind, +) -> &'static str { + match kind { + crate::services::pi_prompt_files::PiPromptFileKind::SystemAppend => "APPEND_SYSTEM.md", + crate::services::pi_prompt_files::PiPromptFileKind::SystemOverride => "SYSTEM.md", + } +} + fn bounded_session_message_overlay_lines(content: &str) -> Vec { const MAX_LINES: usize = 512; diff --git a/src-tauri/src/cli/tui/app/content_skills.rs b/src-tauri/src/cli/tui/app/content_skills.rs index faccc5c33..43bddf349 100644 --- a/src-tauri/src/cli/tui/app/content_skills.rs +++ b/src-tauri/src/cli/tui/app/content_skills.rs @@ -66,7 +66,7 @@ impl App { self.overlay = Overlay::SkillsAppsPicker { directory: skill.directory.clone(), name: skill.name.clone(), - selected: four_app_picker_index(&self.app_type), + selected: skills_app_picker_index(&self.app_type), apps: skill.apps.clone(), }; Action::None @@ -266,7 +266,7 @@ impl App { self.overlay = Overlay::SkillsAppsPicker { directory: skill.directory.clone(), name: skill.name.clone(), - selected: four_app_picker_index(&self.app_type), + selected: skills_app_picker_index(&self.app_type), apps: skill.apps.clone(), }; Action::None diff --git a/src-tauri/src/cli/tui/app/editor_state.rs b/src-tauri/src/cli/tui/app/editor_state.rs index ec5e545fd..78a9033e4 100644 --- a/src-tauri/src/cli/tui/app/editor_state.rs +++ b/src-tauri/src/cli/tui/app/editor_state.rs @@ -1,4 +1,5 @@ use super::*; +use serde_json::Value; /// Exact soft wrapping is useful for ordinary configuration and prompt lines, /// but it must not turn a pathological single physical line into an O(n) @@ -24,6 +25,15 @@ pub enum EditorSubmit { PromptEdit { id: String, }, + PiSystemPrompt { + kind: crate::services::pi_prompt_files::PiPromptFileKind, + expected_revision: String, + }, + PiPromptTemplate { + slug: String, + original_slug: Option, + expected_revision: String, + }, ProviderFormApplyJson, ProviderFormApplyOpenClawModels, ProviderFormApplyLocalProxyHeaders, @@ -34,6 +44,7 @@ pub enum EditorSubmit { ProviderAdd, ProviderEdit { id: String, + expected_pi_settings_config: Option, }, PricingEdit { model_id: String, diff --git a/src-tauri/src/cli/tui/app/form_handlers/provider.rs b/src-tauri/src/cli/tui/app/form_handlers/provider.rs index a287a81bf..0d74528d6 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/provider.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/provider.rs @@ -1,5 +1,6 @@ use super::*; use crate::ProviderService; +use serde_json::Value; use url::Url; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -84,6 +85,36 @@ impl App { texts::tui_toast_provider_add_missing_fields().to_string() }, )) + } else if matches!(provider.app_type, crate::app_config::AppType::Pi) + && !provider.mode.is_edit() + && !crate::pi_config::is_valid_request_url(&provider.current_provider_base_url()) + { + Some(( + ProviderValidationTarget::Main(ProviderAddField::OpenCodeBaseUrl), + texts::base_url_empty_error().to_string(), + )) + } else if matches!(provider.app_type, crate::app_config::AppType::Pi) + && !provider.mode.is_edit() + && !crate::openclaw_config::OPENCLAW_API_PROTOCOLS + .contains(&provider.opencode_npm_package.value.trim()) + { + Some(( + ProviderValidationTarget::Main(ProviderAddField::OpenClawApiProtocol), + texts::tui_toast_provider_add_missing_fields().to_string(), + )) + } else if matches!(provider.app_type, crate::app_config::AppType::Pi) + && !provider.mode.is_edit() + && !provider.openclaw_models.iter().any(|model| { + model + .get("id") + .and_then(serde_json::Value::as_str) + .is_some_and(|id| !id.trim().is_empty()) + }) + { + Some(( + ProviderValidationTarget::Main(ProviderAddField::OpenClawModels), + texts::tui_toast_provider_add_missing_fields().to_string(), + )) } else if matches!(provider.app_type, crate::app_config::AppType::Hermes) && !is_valid_hermes_rate_limit_delay(&provider.hermes_rate_limit_delay.value) { @@ -183,7 +214,10 @@ impl App { Action::EditorSubmit { submit: match &provider.mode { FormMode::Add => EditorSubmit::ProviderAdd, - FormMode::Edit { id } => EditorSubmit::ProviderEdit { id: id.clone() }, + FormMode::Edit { id } => EditorSubmit::ProviderEdit { + id: id.clone(), + expected_pi_settings_config: provider.initial_pi_settings_config(), + }, }, content, } @@ -289,6 +323,12 @@ impl App { { Some(self.handle_provider_model_fetch(selected)) } + KeyCode::Char('f') if selected == ProviderAddField::OpenClawModels => { + let is_pi = self.form.as_ref().is_some_and(|form| { + matches!(form, FormState::ProviderAdd(provider) if provider.app_type == AppType::Pi) + }); + is_pi.then(|| self.handle_provider_model_fetch(selected)) + } KeyCode::Char('f') if matches!( selected, @@ -1192,6 +1232,8 @@ impl App { .then(|| provider.codex_api_key.value.clone()), custom_user_agent: (!provider.custom_user_agent.value.trim().is_empty()) .then(|| provider.custom_user_agent.value.clone()), + api_protocol: None, + request_headers: None, codex_oauth: false, codex_oauth_account_id: None, field: ProviderAddField::CodexLocalRouting, @@ -1440,6 +1482,8 @@ impl App { api_key: Some(provider.hermes_api_key.value.clone()), custom_user_agent: (!provider.custom_user_agent.value.trim().is_empty()) .then(|| provider.custom_user_agent.value.clone()), + api_protocol: None, + request_headers: None, codex_oauth: false, codex_oauth_account_id: None, field: ProviderAddField::HermesModels, @@ -1468,7 +1512,7 @@ impl App { let Some(FormState::ProviderAdd(provider)) = self.form.as_ref() else { return Action::None; }; - let api_key = match selected { + let mut api_key = match selected { ProviderAddField::CodexModel => (!provider.codex_api_key.value.trim().is_empty()) .then(|| provider.codex_api_key.value.clone()), ProviderAddField::GeminiModel => (!provider.gemini_api_key.value.trim().is_empty()) @@ -1479,6 +1523,10 @@ impl App { } ProviderAddField::HermesModels => (!provider.hermes_api_key.value.trim().is_empty()) .then(|| provider.hermes_api_key.value.clone()), + ProviderAddField::OpenClawModels => { + (!provider.opencode_api_key.value.trim().is_empty()) + .then(|| provider.opencode_api_key.value.clone()) + } _ => None, }; let base_url = match selected { @@ -1486,14 +1534,53 @@ impl App { ProviderAddField::GeminiModel => provider.gemini_base_url.value.clone(), ProviderAddField::OpenCodeModelId => provider.opencode_base_url.value.clone(), ProviderAddField::HermesModels => provider.hermes_base_url.value.clone(), + ProviderAddField::OpenClawModels if matches!(provider.app_type, AppType::Pi) => { + provider.current_provider_base_url() + } + ProviderAddField::OpenClawModels => provider.opencode_base_url.value.clone(), _ => String::new(), }; + let (api_protocol, mut request_headers) = + if selected == ProviderAddField::OpenClawModels && provider.app_type == AppType::Pi { + let settings = provider.to_provider_json_value()["settingsConfig"].clone(); + let protocol = settings + .get("api") + .and_then(Value::as_str) + .map(str::to_string); + let headers = settings + .get("headers") + .and_then(Value::as_object) + .map(|headers| { + headers + .iter() + .filter_map(|(name, value)| { + value + .as_str() + .map(|value| (name.clone(), value.to_string())) + }) + .collect::>() + }) + .filter(|headers| !headers.is_empty()); + (protocol, headers) + } else { + (None, None) + }; + if api_protocol.as_deref() == Some("anthropic-messages") { + if let Some(key) = api_key.take() { + request_headers + .get_or_insert_with(std::collections::BTreeMap::new) + .entry("x-api-key".to_string()) + .or_insert(key); + } + } Action::ProviderModelFetch { base_url, is_full_url: provider.is_full_url && matches!(selected, ProviderAddField::CodexModel), api_key, custom_user_agent: (!provider.custom_user_agent.value.trim().is_empty()) .then(|| provider.custom_user_agent.value.clone()), + api_protocol, + request_headers, codex_oauth: false, codex_oauth_account_id: None, field: selected, diff --git a/src-tauri/src/cli/tui/app/helpers.rs b/src-tauri/src/cli/tui/app/helpers.rs index d8b079bac..da5f05104 100644 --- a/src-tauri/src/cli/tui/app/helpers.rs +++ b/src-tauri/src/cli/tui/app/helpers.rs @@ -693,6 +693,8 @@ pub(crate) fn route_has_content_list(route: &Route) -> bool { | Route::Sessions | Route::Mcp | Route::Prompts + | Route::PiSystemPrompts + | Route::PiPromptTemplates | Route::HermesMemory | Route::Config | Route::ConfigOpenClawWorkspace @@ -1758,6 +1760,7 @@ pub(crate) fn app_type_picker_index(app_type: &AppType) -> usize { AppType::OpenCode => 3, AppType::Hermes => 4, AppType::OpenClaw => 5, + AppType::Pi => 6, } } @@ -1765,6 +1768,21 @@ pub(crate) fn four_app_picker_index(app_type: &AppType) -> usize { app_type_picker_index(app_type).min(4) } +pub(crate) fn skills_app_picker_index(app_type: &AppType) -> usize { + match app_type { + AppType::Pi | AppType::OpenClaw => 5, + _ => app_type_picker_index(app_type), + } +} + +pub(crate) fn skill_app_type_for_picker_index(index: usize) -> AppType { + if index == 5 { + AppType::Pi + } else { + app_type_for_picker_index(index) + } +} + pub(crate) fn app_type_for_picker_index(index: usize) -> AppType { match index { 1 => AppType::Codex, @@ -1772,6 +1790,7 @@ pub(crate) fn app_type_for_picker_index(index: usize) -> AppType { 3 => AppType::OpenCode, 4 => AppType::Hermes, 5 => AppType::OpenClaw, + 6 => AppType::Pi, _ => AppType::Claude, } } diff --git a/src-tauri/src/cli/tui/app/menu.rs b/src-tauri/src/cli/tui/app/menu.rs index fe89e21a4..cb0ec346d 100644 --- a/src-tauri/src/cli/tui/app/menu.rs +++ b/src-tauri/src/cli/tui/app/menu.rs @@ -100,6 +100,8 @@ impl App { provider_idx: 0, mcp_idx: 0, prompt_idx: 0, + pi_system_prompt_idx: 0, + pi_prompt_template_idx: 0, skills_idx: 0, skills_discover_idx: 0, skills_repo_idx: 0, @@ -186,6 +188,8 @@ impl App { Route::Sessions => NavItem::Sessions, Route::Mcp => NavItem::Mcp, Route::Prompts => NavItem::Prompts, + Route::PiSystemPrompts => NavItem::PiSystemPrompts, + Route::PiPromptTemplates => NavItem::PiPromptTemplates, Route::HermesMemory => NavItem::HermesMemory, Route::Config => NavItem::Config, Route::ConfigOpenClawWorkspace | Route::ConfigOpenClawDailyMemory => { @@ -1108,6 +1112,8 @@ impl App { Route::Sessions => self.on_sessions_key(key, data), Route::Mcp => self.on_mcp_key(key, data), Route::Prompts => self.on_prompts_key(key, data), + Route::PiSystemPrompts => self.on_pi_system_prompts_key(key, data), + Route::PiPromptTemplates => self.on_pi_prompt_templates_key(key, data), Route::HermesMemory => self.on_hermes_memory_key(key, data), Route::Config => self.on_config_key(key, data), Route::ConfigOpenClawWorkspace => self.on_config_openclaw_workspace_key(key, data), diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs index e115cb47d..36fa48229 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs @@ -97,6 +97,20 @@ impl App { } ConfirmAction::McpDelete { id } => Action::McpDelete { id: id.clone() }, ConfirmAction::PromptDelete { id } => Action::PromptDelete { id: id.clone() }, + ConfirmAction::PiSystemPromptDelete { + kind, + expected_revision, + } => Action::PiSystemPromptDelete { + kind: *kind, + expected_revision: expected_revision.clone(), + }, + ConfirmAction::PiPromptTemplateDelete { + slug, + expected_revision, + } => Action::PiPromptTemplateDelete { + slug: slug.clone(), + expected_revision: expected_revision.clone(), + }, ConfirmAction::PricingDelete { model_id } => Action::PricingDelete { model_id: model_id.clone(), }, @@ -430,6 +444,15 @@ impl App { }; Action::SetOpenClawConfigDir { path } } + TextSubmit::SettingsPiConfigDir => { + let trimmed = raw.trim().to_string(); + let path = if trimmed.is_empty() { + None + } else { + Some(trimmed) + }; + Action::SetPiConfigDir { path } + } TextSubmit::SettingsPreferredEditor => { let trimmed = raw.trim().to_string(); let command = if trimmed.is_empty() { @@ -451,6 +474,39 @@ impl App { }; Action::SetPreferredEditor { command } } + TextSubmit::PiPromptTemplateCreate => { + if let Err(error) = crate::services::pi_prompt_files::validate_template_slug(&raw) { + self.push_toast(error.to_string(), ToastKind::Warning); + return Action::None; + } + self.open_editor( + format!("/{raw}"), + EditorKind::Plain, + "# Prompt template\n", + EditorSubmit::PiPromptTemplate { + slug: raw, + original_slug: None, + expected_revision: "missing".to_string(), + }, + ); + Action::None + } + TextSubmit::PiPromptTemplateRename { + original_slug, + expected_revision, + content, + } => { + if let Err(error) = crate::services::pi_prompt_files::validate_template_slug(&raw) { + self.push_toast(error.to_string(), ToastKind::Warning); + return Action::None; + } + Action::PiPromptTemplateRename { + original_slug, + new_slug: raw, + expected_revision, + content, + } + } TextSubmit::SkillsInstallSpec => { if raw.is_empty() { self.push_toast(texts::tui_toast_skill_spec_empty(), ToastKind::Warning); diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs index 6e41040f5..8c5b2673c 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs @@ -1,4 +1,5 @@ use super::*; +use serde_json::Value; fn model_fetch_model_index( models_len: usize, @@ -1000,6 +1001,8 @@ impl App { .then(|| provider.claude_api_key.value.clone()), custom_user_agent: (!provider.custom_user_agent.value.trim().is_empty()) .then(|| provider.custom_user_agent.value.clone()), + api_protocol: None, + request_headers: None, codex_oauth, codex_oauth_account_id, field: ProviderAddField::ClaudeModelConfig, @@ -1140,6 +1143,14 @@ impl App { } } else if field == ProviderAddField::HermesModels { provider.set_selected_hermes_model_id_from_picker(&selected_model); + } else if field == ProviderAddField::OpenClawModels { + if !provider.openclaw_models.iter().any(|model| { + model.get("id").and_then(Value::as_str) == Some(selected_model.as_str()) + }) { + provider + .openclaw_models + .push(serde_json::json!({ "id": selected_model })); + } } else if field == ProviderAddField::CodexLocalRouting { provider.upsert_codex_model_catalog_model(&selected_model); } else if let Some(input_field) = provider.input_mut(field) { @@ -1548,7 +1559,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(5); + *selected = (*selected + 1).min(6); Action::None } KeyCode::Char(' ') => { @@ -1617,11 +1628,11 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(4); + *selected = (*selected + 1).min(5); Action::None } KeyCode::Char(' ') => { - let app_type = app_type_for_picker_index(*selected); + let app_type = skill_app_type_for_picker_index(*selected); let enabled = apps.is_enabled_for(&app_type); apps.set_enabled_for(&app_type, !enabled); Action::None diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 6fe261b3e..1b7babcb9 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -921,7 +921,7 @@ mod tests { } #[test] - fn skills_apps_picker_from_openclaw_targets_hermes_last_visible_row() { + fn skills_apps_picker_from_openclaw_targets_pi_last_visible_row() { let mut app = App::new(Some(AppType::OpenClaw)); app.route = Route::Skills; app.focus = Focus::Content; @@ -935,7 +935,7 @@ mod tests { assert!(matches!(action, Action::None)); assert!(matches!( &app.overlay, - Overlay::SkillsAppsPicker { selected, .. } if *selected == 4 + Overlay::SkillsAppsPicker { selected, .. } if *selected == 5 )); let action = app.on_key(key(KeyCode::Char(' ')), &data); @@ -943,12 +943,45 @@ mod tests { assert!(matches!( &app.overlay, Overlay::SkillsAppsPicker { selected, apps, .. } - if *selected == 4 + if *selected == 5 && !apps.claude && !apps.codex && !apps.gemini && !apps.opencode - && apps.hermes + && !apps.hermes + && apps.pi + )); + } + + #[test] + #[serial(home_settings)] + fn visible_apps_picker_can_toggle_pi() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + crate::settings::set_visible_apps_mode(crate::settings::VisibleAppsMode::Manual) + .expect("save visible apps mode"); + let mut app = App::new(Some(AppType::Pi)); + app.overlay = Overlay::VisibleAppsPicker { + selected: app_type_picker_index(&AppType::Pi), + apps: crate::settings::VisibleApps { + claude: true, + codex: false, + gemini: false, + opencode: false, + hermes: false, + openclaw: false, + pi: false, + }, + }; + + assert!(matches!( + app.on_key(key(KeyCode::Char(' ')), &UiData::default()), + Action::None + )); + assert!(matches!( + &app.overlay, + Overlay::VisibleAppsPicker { selected, apps } + if *selected == 6 && apps.pi )); } @@ -1132,6 +1165,7 @@ mod tests { opencode: true, hermes: false, openclaw: true, + pi: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Claude)); @@ -1157,6 +1191,7 @@ mod tests { opencode: true, hermes: false, openclaw: true, + pi: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Claude)); @@ -1190,6 +1225,7 @@ mod tests { opencode: true, hermes: false, openclaw: true, + pi: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Gemini)); @@ -1231,6 +1267,7 @@ mod tests { opencode: true, hermes: false, openclaw: true, + pi: false, }) .expect("save visible apps"); @@ -1254,6 +1291,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }) .expect("save visible apps"); @@ -1281,6 +1319,7 @@ mod tests { opencode: false, hermes: false, openclaw: true, + pi: false, }) .expect("save visible apps"); @@ -1304,6 +1343,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }) .expect("save visible apps"); @@ -2303,6 +2343,8 @@ mod tests { is_full_url: false, api_key: Some(api_key), custom_user_agent: Some(custom_user_agent), + api_protocol: None, + request_headers: None, codex_oauth: false, codex_oauth_account_id: None, field: ProviderAddField::HermesModels, @@ -5315,6 +5357,165 @@ mod tests { } } + #[test] + fn provider_pi_models_fetch_uses_native_protocol_and_headers() { + let mut app = App::new(Some(AppType::Pi)); + app.route = Route::Providers; + app.focus = Focus::Content; + let provider = Provider::with_id( + "pi-native".to_string(), + "Pi Native".to_string(), + json!({ + "apiKey": "pi-secret", + "api": "anthropic-messages", + "headers": { "X-Custom": "custom-value" }, + "models": [{ + "id": "existing", + "baseUrl": "https://pi.example.test/v1" + }] + }), + None, + ); + let mut form = ProviderAddFormState::from_provider(AppType::Pi, &provider); + form.focus = FormFocus::Fields; + form.field_idx = form + .fields() + .iter() + .position(|field| *field == ProviderAddField::OpenClawModels) + .expect("Pi models field"); + app.form = Some(FormState::ProviderAdd(form)); + + let action = app.on_key(key(KeyCode::Char('f')), &data()); + let Action::ProviderModelFetch { + base_url, + api_key, + api_protocol, + request_headers, + field, + .. + } = action + else { + panic!("expected Pi model fetch action"); + }; + assert_eq!(base_url, "https://pi.example.test/v1"); + assert_eq!(api_key, None, "Anthropic auth is carried by x-api-key"); + assert_eq!(api_protocol.as_deref(), Some("anthropic-messages")); + assert_eq!(field, ProviderAddField::OpenClawModels); + let headers = request_headers.expect("native request headers"); + assert_eq!( + headers.get("X-Custom").map(String::as_str), + Some("custom-value") + ); + assert_eq!( + headers.get("x-api-key").map(String::as_str), + Some("pi-secret") + ); + } + + #[test] + fn provider_pi_model_fetch_selection_adds_a_model_entry() { + let mut app = App::new(Some(AppType::Pi)); + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Pi, + ))); + app.overlay = Overlay::ModelFetchPicker { + request_id: 1, + field: ProviderAddField::OpenClawModels, + claude_idx: None, + input: TextInput::new(""), + query: String::new(), + fetching: false, + models: vec!["fetched-model".to_string()], + filtered_indices: None, + filter_incomplete: false, + error: None, + selected_idx: 0, + selection_active: false, + }; + + assert!(matches!( + app.on_key(key(KeyCode::Enter), &data()), + Action::None + )); + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected provider form"); + }; + assert_eq!(form.openclaw_models, vec![json!({ "id": "fetched-model" })]); + } + + #[test] + fn pi_system_prompt_edit_uses_the_loaded_native_revision() { + let mut app = App::new(Some(AppType::Pi)); + let mut data = UiData::default(); + data.pi_prompts.system_files.push(( + crate::services::pi_prompt_files::PiPromptFileKind::SystemAppend, + crate::services::pi_prompt_files::PiPromptFileSnapshot { + exists: true, + revision: "rev-1".to_string(), + content: "Keep answers concise.".to_string(), + }, + )); + + assert!(matches!( + app.on_pi_system_prompts_key(key(KeyCode::Char('e')), &data), + Action::None + )); + assert!(matches!( + app.editor.as_ref(), + Some(EditorState { + initial_text, + submit: EditorSubmit::PiSystemPrompt { + kind: crate::services::pi_prompt_files::PiPromptFileKind::SystemAppend, + expected_revision, + }, + .. + }) if initial_text == "Keep answers concise." && expected_revision == "rev-1" + )); + } + + #[test] + fn pi_prompt_template_actions_follow_the_family_editor_flow() { + let mut app = App::new(Some(AppType::Pi)); + let mut data = UiData::default(); + data.pi_prompts + .templates + .push(crate::services::pi_prompt_files::PiPromptTemplate { + slug: "review".to_string(), + content: "Review this change.".to_string(), + revision: "rev-2".to_string(), + }); + + assert!(matches!( + app.on_pi_prompt_templates_key(key(KeyCode::Char('e')), &data), + Action::None + )); + assert!(matches!( + app.editor.as_ref(), + Some(EditorState { + initial_text, + submit: EditorSubmit::PiPromptTemplate { + slug, + original_slug: Some(original_slug), + expected_revision, + }, + .. + }) if initial_text == "Review this change." + && slug == "review" + && original_slug == "review" + && expected_revision == "rev-2" + )); + + app.editor = None; + app.on_pi_prompt_templates_key(key(KeyCode::Char('a')), &data); + assert!(matches!( + app.overlay, + Overlay::TextInput(TextInputState { + submit: TextSubmit::PiPromptTemplateCreate, + .. + }) + )); + } + #[test] fn provider_add_form_codex_preview_tab_then_enter_opens_config_editor() { let mut app = App::new(Some(AppType::Codex)); @@ -11024,6 +11225,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }) .expect("save visible apps"); crate::settings::set_visible_apps_mode(crate::settings::VisibleAppsMode::Manual) @@ -11074,6 +11276,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }) .expect("save visible apps"); @@ -11111,6 +11314,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -11149,6 +11353,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -11186,6 +11391,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }; let mut settings = crate::settings::get_settings(); settings.visible_apps = initial.clone(); @@ -12737,6 +12943,28 @@ mod tests { )); } + #[test] + fn provider_add_form_pi_requires_a_native_model_before_submit() { + let mut app = App::new(Some(AppType::Pi)); + app.route = Route::Providers; + app.focus = Focus::Content; + let mut form = ProviderAddFormState::new(AppType::Pi); + form.focus = FormFocus::Fields; + form.id.set("pi-provider"); + form.name.set("Pi Provider"); + form.opencode_base_url.set("https://pi.example.test/v1"); + app.form = Some(FormState::ProviderAdd(form)); + + let submit = app.on_key(ctrl(KeyCode::Char('s')), &UiData::default()); + assert!(matches!(submit, Action::None)); + assert!(matches!( + app.form, + Some(FormState::ProviderAdd(ref form)) + if form.fields().get(form.field_idx) == Some(&ProviderAddField::OpenClawModels) + && form.main_field_error(ProviderAddField::OpenClawModels).is_some() + )); + } + #[test] fn provider_add_form_codex_rejects_invalid_anthropic_max_output_tokens() { let mut app = App::new(Some(AppType::Codex)); @@ -13499,7 +13727,7 @@ mod tests { assert!(matches!( action, Action::EditorSubmit { - submit: EditorSubmit::ProviderEdit { id }, + submit: EditorSubmit::ProviderEdit { id, .. }, content } if id == "p1" && content.contains("\"name\": \"Provider Renamed\"") )); @@ -15754,12 +15982,56 @@ mod tests { assert!(matches!( submit, Action::EditorSubmit { - submit: EditorSubmit::ProviderEdit { id }, + submit: EditorSubmit::ProviderEdit { id, .. }, content, } if id == "p1" && content.contains("Provider One") )); } + #[test] + fn pi_provider_edit_submit_carries_the_opened_native_snapshot() { + let mut app = App::new(Some(AppType::Pi)); + app.route = Route::Providers; + app.focus = Focus::Content; + let settings = json!({ + "name": "Pi Provider", + "api": "anthropic-messages", + "baseUrl": "https://example.com", + "models": [{ "id": "model-a" }], + "futureField": { "preserve": true } + }); + let mut data = UiData::default(); + data.providers.rows.push(super::super::data::ProviderRow { + id: "pi-provider".to_string(), + provider: crate::provider::Provider::with_id( + "pi-provider".to_string(), + "Pi Provider".to_string(), + settings.clone(), + None, + ), + api_url: Some("https://example.com".to_string()), + is_current: false, + is_in_config: true, + is_saved: true, + is_default_model: false, + primary_model_id: Some("model-a".to_string()), + default_model_id: None, + }); + + app.on_key(key(KeyCode::Char('e')), &data); + let submit = app.on_key(ctrl(KeyCode::Char('s')), &data); + assert!(matches!( + submit, + Action::EditorSubmit { + submit: EditorSubmit::ProviderEdit { + id, + expected_pi_settings_config: Some(expected), + }, + .. + } if id == "pi-provider" && expected == settings + )); + } + #[test] fn provider_form_jk_navigates_fields() { let mut app = open_provider_fields_form(AppType::Claude); diff --git a/src-tauri/src/cli/tui/app/types.rs b/src-tauri/src/cli/tui/app/types.rs index 9af072e01..54c06f363 100644 --- a/src-tauri/src/cli/tui/app/types.rs +++ b/src-tauri/src/cli/tui/app/types.rs @@ -4363,6 +4363,14 @@ pub enum ConfirmAction { PromptDelete { id: String, }, + PiSystemPromptDelete { + kind: crate::services::pi_prompt_files::PiPromptFileKind, + expected_revision: String, + }, + PiPromptTemplateDelete { + slug: String, + expected_revision: String, + }, PricingDelete { model_id: String, }, @@ -4495,7 +4503,14 @@ pub enum TextSubmit { SettingsOutboundProxyUsername, SettingsOutboundProxyPassword, SettingsOpenClawConfigDir, + SettingsPiConfigDir, SettingsPreferredEditor, + PiPromptTemplateCreate, + PiPromptTemplateRename { + original_slug: String, + expected_revision: String, + content: String, + }, #[allow(dead_code)] SkillsInstallSpec, SkillsDiscoverQuery, diff --git a/src-tauri/src/cli/tui/data.rs b/src-tauri/src/cli/tui/data.rs index 64d92d967..a6794aef0 100644 --- a/src-tauri/src/cli/tui/data.rs +++ b/src-tauri/src/cli/tui/data.rs @@ -24,6 +24,10 @@ use crate::prompt::Prompt; use crate::prompt_files::prompt_file_path; use crate::provider::Provider; use crate::services::config::BackupInfo; +use crate::services::pi_prompt_files::{ + PiPromptFileKind, PiPromptFileService, PiPromptFileSnapshot, PiPromptTemplate, + PiPromptTemplateService, +}; use crate::services::{ConfigService, McpService, PromptService, ProviderService, SkillService}; use crate::store::AppState; @@ -219,6 +223,9 @@ pub struct ProvidersSnapshot { pub current_id: String, pub rows: Vec, pub live_ids: HashSet, + /// Pi provider membership could not be read from models.json. Mutating + /// membership actions must fail closed while this is true. + pub pi_membership_unknown: bool, /// True only for the transient projection shown while a cold-switched app's /// real data is still loading. Lets the renderer show a "loading" state /// instead of the "no providers / import config" empty CTA, so a freshly @@ -255,6 +262,12 @@ pub struct PromptsSnapshot { pub import_candidate: Option, } +#[derive(Debug, Clone, Default)] +pub struct PiPromptsSnapshot { + pub system_files: Vec<(PiPromptFileKind, PiPromptFileSnapshot)>, + pub templates: Vec, +} + #[derive(Debug, Clone, Default)] pub struct ConfigSnapshot { pub config_path: PathBuf, @@ -395,6 +408,7 @@ impl ProxySnapshot { AppType::OpenCode => None, AppType::Hermes => None, AppType::OpenClaw => None, + AppType::Pi => None, } } @@ -1172,6 +1186,7 @@ pub struct UiData { pub providers: ProvidersSnapshot, pub mcp: McpSnapshot, pub prompts: PromptsSnapshot, + pub pi_prompts: PiPromptsSnapshot, pub config: ConfigSnapshot, pub skills: SkillsSnapshot, pub proxy: ProxySnapshot, @@ -1261,6 +1276,7 @@ impl UiData { let providers = load_providers_with_mode(state, app_type, provider_load_mode)?; let mcp = load_mcp(state)?; let prompts = load_prompts(state, app_type)?; + let pi_prompts = load_pi_prompts(app_type)?; let config = load_config_snapshot(state, app_type)?; let skills = match provider_load_mode { ProviderLoadMode::SyncLive => load_skills_snapshot()?, @@ -1272,6 +1288,7 @@ impl UiData { providers, mcp, prompts, + pi_prompts, config, skills, proxy, @@ -1296,6 +1313,7 @@ impl UiData { }, mcp: self.mcp.clone(), prompts: PromptsSnapshot::default(), + pi_prompts: PiPromptsSnapshot::default(), config: self.config.loading_projection(app_type), skills: self.skills.clone(), proxy, @@ -1505,6 +1523,30 @@ fn load_providers_with_mode( } else { None }; + let (pi_state, pi_membership_unknown) = if matches!(app_type, AppType::Pi) { + match crate::services::pi_state::PiStateService::current(state) { + Ok(current) => (Some(current), false), + Err(error) => { + log::warn!("Failed to read Pi provider membership: {error}"); + (None, true) + } + } + } else { + (None, false) + }; + let pi_live_ids = pi_state + .as_ref() + .map(|current| { + current + .enabled_provider_ids + .iter() + .cloned() + .collect::>() + }) + .unwrap_or_default(); + let pi_default_provider_id = pi_state + .as_ref() + .and_then(|current| current.default_provider_id.clone()); let openclaw_default_model = if matches!(app_type, AppType::OpenClaw) { crate::openclaw_config::get_default_model()? } else { @@ -1535,11 +1577,13 @@ fn load_providers_with_mode( AppType::OpenCode => opencode_live_ids.contains(&id), AppType::Hermes => hermes_live_ids.contains(&id), AppType::OpenClaw => openclaw_live_ids.contains(&id), + AppType::Pi => pi_live_ids.contains(&id), _ => true, }, is_saved: true, is_default_model: match app_type { AppType::Hermes => hermes_current_provider_id.as_deref() == Some(id.as_str()), + AppType::Pi => pi_default_provider_id.as_deref() == Some(id.as_str()), _ => openclaw_primary_default_provider_id.as_deref() == Some(id.as_str()), }, primary_model_id: extract_primary_model_id( @@ -1603,6 +1647,7 @@ fn load_providers_with_mode( AppType::OpenCode => opencode_live_ids, AppType::Hermes => hermes_live_ids, AppType::OpenClaw => openclaw_live_providers.keys().cloned().collect(), + AppType::Pi => pi_live_ids, _ => HashSet::new(), }; @@ -1610,6 +1655,7 @@ fn load_providers_with_mode( current_id, rows, live_ids, + pi_membership_unknown, loading: false, }) } @@ -1695,6 +1741,7 @@ fn extract_api_url(settings_config: &Value, app_type: &AppType) -> Option crate::pi_config::provider_base_url(settings_config).ok(), } } @@ -1822,6 +1869,24 @@ fn load_prompts(state: &AppState, app_type: &AppType) -> Result Result { + if !matches!(app_type, AppType::Pi) { + return Ok(PiPromptsSnapshot::default()); + } + let system_files = [ + PiPromptFileKind::SystemAppend, + PiPromptFileKind::SystemOverride, + ] + .into_iter() + .map(|kind| PiPromptFileService::read(kind).map(|snapshot| (kind, snapshot))) + .collect::, _>>()?; + let templates = PiPromptTemplateService::list()?; + Ok(PiPromptsSnapshot { + system_files, + templates, + }) +} + fn sort_prompt_rows(rows: &mut [PromptRow]) { rows.sort_by(|a, b| { a.prompt @@ -5766,6 +5831,21 @@ base_url = "https://current.example.com/v1" ); } + #[test] + fn extract_api_url_pi_reads_a_model_level_base_url() { + let settings = json!({ + "models": [{ + "id": "pi-model", + "baseUrl": "https://pi-model.example/v1" + }] + }); + + assert_eq!( + extract_api_url(&settings, &AppType::Pi), + Some("https://pi-model.example/v1".to_string()) + ); + } + #[test] #[serial] fn load_providers_opencode_marks_live_config_membership() { diff --git a/src-tauri/src/cli/tui/form.rs b/src-tauri/src/cli/tui/form.rs index f54218e16..952accdc9 100644 --- a/src-tauri/src/cli/tui/form.rs +++ b/src-tauri/src/cli/tui/form.rs @@ -638,6 +638,7 @@ pub struct ProviderAddFormState { pub hermes_model_input: TextInput, pub hermes_rate_limit_delay: TextInput, initial_snapshot: Value, + initial_pi_settings_config: Option, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs index 1ecfd2121..f94bac77a 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -77,6 +77,17 @@ fn sanitize_claude_common_config_value_for_form(value: Value) -> Value { } } +fn pi_native_string_field_changed( + original: Option<&serde_json::Map>, + key: &str, + current: &str, +) -> bool { + match original.and_then(|settings| settings.get(key)) { + Some(Value::String(value)) => value != current, + Some(_) | None => !current.is_empty(), + } +} + pub(crate) fn sanitize_claude_common_config_for_form(snippet: &str) -> Result { let value = serde_json::from_str::(snippet) .map_err(|error| texts::common_config_snippet_invalid_json(&error.to_string()))?; @@ -670,116 +681,188 @@ impl ProviderAddFormState { &self.hermes_rate_limit_delay.value, ); } - AppType::OpenClaw => { - settings_obj.remove("npm"); - settings_obj.remove("options"); - settings_obj.remove("api_key"); - settings_obj.remove("base_url"); + AppType::OpenClaw | AppType::Pi => { + let is_pi = matches!(self.app_type, AppType::Pi); + let original_pi_settings = is_pi + .then(|| { + self.extra + .pointer("/settingsConfig") + .and_then(Value::as_object) + }) + .flatten(); + if is_pi { + let original_native_name = original_pi_settings + .and_then(|settings| settings.get("name")) + .and_then(Value::as_str); + let original_display_name = self.extra.get("name").and_then(Value::as_str); + let native_name_follows_display = !self.mode.is_edit() + || matches!( + (original_native_name, original_display_name), + (Some(native), Some(display)) if native.trim() == display.trim() + ); + if native_name_follows_display { + settings_obj.insert( + "name".to_string(), + Value::String(self.name.value.trim().to_string()), + ); + } + } + if !is_pi { + settings_obj.remove("npm"); + settings_obj.remove("options"); + settings_obj.remove("api_key"); + settings_obj.remove("base_url"); + } - set_or_remove_trimmed(settings_obj, "apiKey", &self.opencode_api_key.value); - set_or_remove_trimmed(settings_obj, "baseUrl", &self.opencode_base_url.value); + if !is_pi + || pi_native_string_field_changed( + original_pi_settings, + "apiKey", + &self.opencode_api_key.value, + ) + { + set_or_remove_trimmed(settings_obj, "apiKey", &self.opencode_api_key.value); + } + if !is_pi + || pi_native_string_field_changed( + original_pi_settings, + "baseUrl", + &self.opencode_base_url.value, + ) + { + set_or_remove_trimmed(settings_obj, "baseUrl", &self.opencode_base_url.value); + } let api_value = self.opencode_npm_package.value.trim(); - settings_obj.insert( - "api".to_string(), - json!(if api_value.is_empty() { - OPENCLAW_DEFAULT_API_PROTOCOL + if !is_pi + || pi_native_string_field_changed( + original_pi_settings, + "api", + &self.opencode_npm_package.value, + ) + { + if is_pi && api_value.is_empty() { + settings_obj.remove("api"); } else { - api_value - }), - ); - - let mut headers_obj = match settings_obj.remove("headers") { - Some(Value::Object(map)) => map, - _ => serde_json::Map::new(), - }; - if self.openclaw_user_agent { - headers_obj - .entry("User-Agent".to_string()) - .or_insert_with(|| json!(OPENCLAW_DEFAULT_USER_AGENT)); - } else { - headers_obj.remove("User-Agent"); + settings_obj.insert( + "api".to_string(), + json!(if api_value.is_empty() { + OPENCLAW_DEFAULT_API_PROTOCOL + } else { + api_value + }), + ); + } } - if headers_obj.is_empty() { - settings_obj.remove("headers"); - } else { - settings_obj.insert("headers".to_string(), Value::Object(headers_obj)); + + if !is_pi { + let mut headers_obj = match settings_obj.remove("headers") { + Some(Value::Object(map)) => map, + _ => serde_json::Map::new(), + }; + if self.openclaw_user_agent { + headers_obj + .entry("User-Agent".to_string()) + .or_insert_with(|| json!(OPENCLAW_DEFAULT_USER_AGENT)); + } else { + headers_obj.remove("User-Agent"); + } + if headers_obj.is_empty() { + settings_obj.remove("headers"); + } else { + settings_obj.insert("headers".to_string(), Value::Object(headers_obj)); + } } - let mut models = if self.openclaw_models.is_empty() { - match settings_obj.remove("models") { - Some(Value::Array(items)) => items, - _ => Vec::new(), + if is_pi { + let models_changed = + match original_pi_settings.and_then(|settings| settings.get("models")) { + Some(Value::Array(models)) => models != &self.openclaw_models, + Some(_) | None => !self.openclaw_models.is_empty(), + }; + if models_changed { + settings_obj.insert( + "models".to_string(), + Value::Array(self.openclaw_models.clone()), + ); } } else { - self.openclaw_models.clone() - }; + let mut models = if self.openclaw_models.is_empty() { + match settings_obj.remove("models") { + Some(Value::Array(items)) => items, + _ => Vec::new(), + } + } else { + self.openclaw_models.clone() + }; - let model_id = self.openclaw_primary_model_id(); - match model_id { - Some(model_id) => { - let mut original_index = self - .opencode_model_original_id - .as_deref() - .and_then(|original_id| openclaw_model_index(&models, original_id)); - - if let Some(existing_index) = openclaw_model_index(&models, &model_id) { - if Some(existing_index) != original_index { - models.remove(existing_index); - if let Some(index) = original_index.as_mut() { - if existing_index < *index { - *index = index.saturating_sub(1); + let model_id = self.openclaw_primary_model_id(); + match model_id { + Some(model_id) => { + let mut original_index = self + .opencode_model_original_id + .as_deref() + .and_then(|original_id| openclaw_model_index(&models, original_id)); + + if let Some(existing_index) = openclaw_model_index(&models, &model_id) { + if Some(existing_index) != original_index { + models.remove(existing_index); + if let Some(index) = original_index.as_mut() { + if existing_index < *index { + *index = index.saturating_sub(1); + } } } } - } - let target_index = - original_index.or_else(|| openclaw_model_index(&models, &model_id)); + let target_index = + original_index.or_else(|| openclaw_model_index(&models, &model_id)); - let mut model_obj = target_index - .and_then(|index| models.get(index).cloned()) - .and_then(|value| value.as_object().cloned()) - .unwrap_or_default(); + let mut model_obj = target_index + .and_then(|index| models.get(index).cloned()) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); - model_obj.insert("id".to_string(), json!(model_id.clone())); + model_obj.insert("id".to_string(), json!(model_id.clone())); - let model_name = self.opencode_model_name.value.trim(); - if model_name.is_empty() { - model_obj.remove("name"); - } else { - model_obj.insert("name".to_string(), json!(model_name)); - } + let model_name = self.opencode_model_name.value.trim(); + if model_name.is_empty() { + model_obj.remove("name"); + } else { + model_obj.insert("name".to_string(), json!(model_name)); + } - let context_value = self.opencode_model_context_limit.value.trim(); - if context_value.is_empty() { - model_obj.remove("contextWindow"); - model_obj.remove("context_window"); - } else if let Ok(context_window) = context_value.parse::() { - model_obj.remove("context_window"); - model_obj.insert("contextWindow".to_string(), json!(context_window)); - } + let context_value = self.opencode_model_context_limit.value.trim(); + if context_value.is_empty() { + model_obj.remove("contextWindow"); + model_obj.remove("context_window"); + } else if let Ok(context_window) = context_value.parse::() { + model_obj.remove("context_window"); + model_obj + .insert("contextWindow".to_string(), json!(context_window)); + } - let updated_model = Value::Object(model_obj); - if let Some(index) = target_index { - models[index] = updated_model; - } else { - models.push(updated_model); + let updated_model = Value::Object(model_obj); + if let Some(index) = target_index { + models[index] = updated_model; + } else { + models.push(updated_model); + } } - } - None => { - if let Some(original_id) = self.opencode_model_original_id.as_deref() { - if let Some(index) = openclaw_model_index(&models, original_id) { - models.remove(index); + None => { + if let Some(original_id) = self.opencode_model_original_id.as_deref() { + if let Some(index) = openclaw_model_index(&models, original_id) { + models.remove(index); + } } } } - } - if models.is_empty() { - settings_obj.remove("models"); - } else { - settings_obj.insert("models".to_string(), Value::Array(models)); + if models.is_empty() { + settings_obj.remove("models"); + } else { + settings_obj.insert("models".to_string(), Value::Array(models)); + } } } } @@ -1402,7 +1485,7 @@ pub(crate) fn strip_common_config_from_settings( ) .map_err(|e| e.to_string())?; } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => {} + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => {} AppType::Codex => { *settings_value = ProviderService::remove_common_config_from_settings_for_preview( app_type, diff --git a/src-tauri/src/cli/tui/form/provider_state.rs b/src-tauri/src/cli/tui/form/provider_state.rs index 65e2a9517..839b5fcac 100644 --- a/src-tauri/src/cli/tui/form/provider_state.rs +++ b/src-tauri/src/cli/tui/form/provider_state.rs @@ -121,7 +121,7 @@ impl ProviderAddFormState { let is_codex = matches!(app_type, AppType::Codex); let is_gemini = matches!(app_type, AppType::Gemini); let openclaw_api_default = match app_type { - AppType::OpenClaw => OPENCLAW_DEFAULT_API_PROTOCOL, + AppType::OpenClaw | AppType::Pi => OPENCLAW_DEFAULT_API_PROTOCOL, _ => "@ai-sdk/openai-compatible", }; @@ -250,6 +250,7 @@ impl ProviderAddFormState { hermes_model_input: TextInput::new(""), hermes_rate_limit_delay: TextInput::new(""), initial_snapshot: Value::Null, + initial_pi_settings_config: None, }; let _ = form.refresh_quick_config_from_common_snippet(common_snippet); form.capture_initial_snapshot(); @@ -271,6 +272,9 @@ impl ProviderAddFormState { }; form.focus = FormFocus::Fields; form.extra = serde_json::to_value(provider).unwrap_or_else(|_| json!({})); + if matches!(app_type, AppType::Pi) { + form.initial_pi_settings_config = Some(provider.settings_config.clone()); + } form.id.set(provider.id.clone()); form.id_is_manual = true; @@ -326,6 +330,7 @@ impl ProviderAddFormState { ) -> Self { let mut form = Self::from_provider_with_common_snippet(app_type, provider, common_snippet); form.mode = FormMode::Add; + form.initial_pi_settings_config = None; form.copy_source_id = Some(provider.id.clone()); form.id_is_manual = false; form.name.set(format!("{} copy", provider.name.trim())); @@ -370,7 +375,7 @@ impl ProviderAddFormState { .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|env| !env.is_empty()), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, } } @@ -426,7 +431,7 @@ impl ProviderAddFormState { app_type, settings, &snippet, ) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, } } @@ -450,6 +455,12 @@ impl ProviderAddFormState { self.to_provider_json_value() != self.initial_snapshot } + pub fn initial_pi_settings_config(&self) -> Option { + (matches!(self.app_type, AppType::Pi) && self.mode.is_edit()) + .then(|| self.initial_pi_settings_config.clone()) + .flatten() + } + pub fn is_id_editable(&self) -> bool { !self.mode.is_edit() && self.copy_source_id.is_none() } @@ -478,8 +489,10 @@ impl ProviderAddFormState { ProviderAddField::Notes, ]; - if matches!(self.app_type, AppType::Hermes | AppType::OpenClaw) - && self.copy_source_id.is_none() + if matches!( + self.app_type, + AppType::Hermes | AppType::OpenClaw | AppType::Pi + ) && self.copy_source_id.is_none() { fields.insert(0, ProviderAddField::Id); } @@ -560,6 +573,12 @@ impl ProviderAddFormState { fields.push(ProviderAddField::OpenClawUserAgent); fields.push(ProviderAddField::OpenClawModels); } + AppType::Pi => { + fields.push(ProviderAddField::OpenClawApiProtocol); + fields.push(ProviderAddField::OpenCodeApiKey); + fields.push(ProviderAddField::OpenCodeBaseUrl); + fields.push(ProviderAddField::OpenClawModels); + } } if Self::supports_common_config(&self.app_type) { @@ -1170,7 +1189,11 @@ impl ProviderAddFormState { crate::codex_config::is_codex_remote_compaction_enabled(config); } } - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => {} + AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi => {} } Ok(()) } @@ -1398,7 +1421,11 @@ impl ProviderAddFormState { !self.is_claude_official_provider() && !self.is_claude_github_copilot_provider() } AppType::Codex => !self.is_codex_official_provider(), - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, + AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi => false, } } @@ -2181,6 +2208,10 @@ impl ProviderAddFormState { AppType::Gemini => self.gemini_base_url.value.clone(), AppType::Hermes => self.hermes_base_url.value.clone(), AppType::OpenCode | AppType::OpenClaw => self.opencode_base_url.value.clone(), + AppType::Pi => { + let provider = self.to_provider_json_value(); + crate::pi_config::provider_base_url(&provider["settingsConfig"]).unwrap_or_default() + } } } @@ -2192,6 +2223,14 @@ impl ProviderAddFormState { ); } + if matches!(self.app_type, AppType::Pi) { + let base_url = self.current_provider_base_url(); + return ( + Self::usage_query_comment_value(&self.opencode_api_key.value), + Self::usage_query_comment_value(&base_url), + ); + } + let (api_key, base_url) = match self.app_type { AppType::Claude => (&self.claude_api_key.value, &self.claude_base_url.value), AppType::Codex => (&self.codex_api_key.value, &self.codex_base_url.value), @@ -2200,6 +2239,7 @@ impl ProviderAddFormState { AppType::OpenCode | AppType::OpenClaw => { (&self.opencode_api_key.value, &self.opencode_base_url.value) } + AppType::Pi => unreachable!("Pi credentials are resolved above"), }; ( Self::usage_query_comment_value(api_key), @@ -2449,7 +2489,11 @@ impl ProviderAddFormState { !self.is_claude_official_provider() && !self.is_claude_codex_oauth_provider() } AppType::Codex => !self.is_codex_official_provider(), - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, + AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi => false, } } @@ -2586,6 +2630,7 @@ impl ProviderAddFormState { let previous_include_common_config_touched = self.include_common_config_touched; let previous_extra = self.extra.clone(); let previous_initial_snapshot = self.initial_snapshot.clone(); + let previous_initial_pi_settings_config = self.initial_pi_settings_config.clone(); let mut next = Self::from_provider(self.app_type.clone(), provider); let overlay = serde_json::to_value(provider).unwrap_or_else(|_| json!({})); @@ -2657,6 +2702,7 @@ impl ProviderAddFormState { next.id_is_manual = true; } next.initial_snapshot = previous_initial_snapshot; + next.initial_pi_settings_config = previous_initial_pi_settings_config; *self = next; } @@ -2685,6 +2731,7 @@ impl ProviderAddFormState { let previous_include_common_config = self.include_common_config; let previous_include_common_config_touched = self.include_common_config_touched; let previous_initial_snapshot = self.initial_snapshot.clone(); + let previous_initial_pi_settings_config = self.initial_pi_settings_config.clone(); let current_value = self.to_provider_json_value(); if let (Some(current_obj), Some(edited_obj)) = @@ -2765,6 +2812,7 @@ impl ProviderAddFormState { next.id_is_manual = true; } next.initial_snapshot = previous_initial_snapshot; + next.initial_pi_settings_config = previous_initial_pi_settings_config; *self = next; Ok(()) @@ -3003,7 +3051,7 @@ impl ProviderAddFormState { } pub fn apply_openclaw_models_value(&mut self, models_value: Value) -> Result<(), String> { - if !matches!(self.app_type, AppType::OpenClaw) { + if !matches!(self.app_type, AppType::OpenClaw | AppType::Pi) { return Ok(()); } if !models_value.is_array() { diff --git a/src-tauri/src/cli/tui/form/provider_state_loading.rs b/src-tauri/src/cli/tui/form/provider_state_loading.rs index 6ecad104b..f27fecb5d 100644 --- a/src-tauri/src/cli/tui/form/provider_state_loading.rs +++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs @@ -28,6 +28,7 @@ pub(super) fn populate_form_from_provider( AppType::OpenCode => populate_opencode_form(form, provider), AppType::Hermes => populate_hermes_form(form, provider), AppType::OpenClaw => populate_openclaw_form(form, provider), + AppType::Pi => populate_openclaw_form(form, provider), } form.is_full_url = form.supports_full_url_mode() && provider @@ -458,6 +459,8 @@ fn populate_openclaw_form(form: &mut ProviderAddFormState, provider: &Provider) .and_then(|value| value.as_str()) { form.opencode_npm_package.set(api); + } else if matches!(form.app_type, AppType::Pi) { + form.opencode_npm_package.set(""); } else { form.opencode_npm_package.set(OPENCLAW_DEFAULT_API_PROTOCOL); } diff --git a/src-tauri/src/cli/tui/form/provider_templates.rs b/src-tauri/src/cli/tui/form/provider_templates.rs index d5f230823..366c159e1 100644 --- a/src-tauri/src/cli/tui/form/provider_templates.rs +++ b/src-tauri/src/cli/tui/form/provider_templates.rs @@ -189,6 +189,7 @@ pub(super) fn provider_builtin_template_defs(app_type: &AppType) -> &'static [Pr AppType::OpenCode => &PROVIDER_TEMPLATE_DEFS_OPENCODE, AppType::Hermes => &PROVIDER_TEMPLATE_DEFS_HERMES, AppType::OpenClaw => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, + AppType::Pi => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, } } @@ -205,7 +206,8 @@ pub(super) fn provider_after_sponsor_template_defs( | AppType::Gemini | AppType::OpenCode | AppType::Hermes - | AppType::OpenClaw => &[], + | AppType::OpenClaw + | AppType::Pi => &[], } } @@ -749,6 +751,7 @@ impl ProviderAddFormState { self.opencode_model_original_id = None; } } + AppType::Pi => {} } if matches!(self.app_type, AppType::Codex) { diff --git a/src-tauri/src/cli/tui/form/tests.rs b/src-tauri/src/cli/tui/form/tests.rs index ebaf55ef5..162165406 100644 --- a/src-tauri/src/cli/tui/form/tests.rs +++ b/src-tauri/src/cli/tui/form/tests.rs @@ -6623,6 +6623,36 @@ fn provider_add_form_openclaw_ignores_legacy_api_aliases_when_loading() { assert_eq!(roundtrip["settingsConfig"]["headers"]["X-Test"], "1"); } +#[test] +fn provider_add_form_pi_uses_model_url_without_materializing_provider_url() { + let provider = Provider::with_id( + "pi-model-url".to_string(), + "Pi Model URL".to_string(), + json!({ + "api": "openai-completions", + "models": [{ + "id": "pi-model", + "baseUrl": "https://pi-model.example/v1" + }] + }), + None, + ); + + let form = ProviderAddFormState::from_provider(AppType::Pi, &provider); + assert!(form.opencode_base_url.value.is_empty()); + assert_eq!( + form.current_provider_base_url(), + "https://pi-model.example/v1" + ); + + let roundtrip = form.to_provider_json_value(); + assert!(roundtrip["settingsConfig"].get("baseUrl").is_none()); + assert_eq!( + roundtrip["settingsConfig"]["models"][0]["baseUrl"], + "https://pi-model.example/v1" + ); +} + #[test] fn provider_add_form_openclaw_ignores_legacy_context_window_alias_when_loading() { let provider = Provider::with_id( @@ -7451,3 +7481,149 @@ fn provider_add_form_usage_query_numeric_fields_match_upstream_normalization() { assert_eq!(script["timeout"], 10); assert_eq!(script["autoQueryInterval"], 0); } + +#[test] +fn provider_pi_models_editor_value_round_trips_through_form() { + let mut form = ProviderAddFormState::new(AppType::Pi); + let models = json!([ + { + "id": "primary-model", + "name": "Primary Model", + "contextWindow": 128000, + "futureCapability": { "preserve": true } + }, + { "id": "fallback-model", "name": "Fallback Model" } + ]); + + form.apply_openclaw_models_value(models.clone()) + .expect("Pi models array should apply"); + + assert_eq!( + form.to_provider_json_value()["settingsConfig"]["models"], + models + ); +} + +#[test] +fn provider_add_form_pi_uses_native_api_default() { + let mut form = ProviderAddFormState::new(AppType::Pi); + form.name.set("Custom Pi"); + + assert_eq!( + form.opencode_npm_package.value, + crate::openclaw_config::OPENCLAW_DEFAULT_API_PROTOCOL + ); + assert_eq!( + form.to_provider_json_value()["settingsConfig"]["api"], + "openai-completions" + ); + assert_eq!( + form.to_provider_json_value()["settingsConfig"]["name"], + "Custom Pi" + ); +} + +#[test] +fn provider_edit_form_pi_updates_only_a_native_name_that_followed_the_display_name() { + let following = Provider::with_id( + "following".to_string(), + "Original".to_string(), + json!({ + "name": "Original", + "api": "openai-completions", + "models": [{ "id": "model" }] + }), + None, + ); + let mut following_form = ProviderAddFormState::from_provider(AppType::Pi, &following); + following_form.name.set("Renamed"); + assert_eq!( + following_form.to_provider_json_value()["settingsConfig"]["name"], + "Renamed" + ); + + let independent = Provider::with_id( + "independent".to_string(), + "Catalog label".to_string(), + json!({ + "name": "Native label", + "api": "openai-completions", + "models": [{ "id": "model" }] + }), + None, + ); + let mut independent_form = ProviderAddFormState::from_provider(AppType::Pi, &independent); + independent_form.name.set("Renamed catalog label"); + assert_eq!( + independent_form.to_provider_json_value()["settingsConfig"]["name"], + "Native label" + ); +} + +#[test] +fn provider_add_form_pi_preserves_missing_api_on_existing_partial_node() { + let provider = Provider::with_id( + "anthropic".to_string(), + "Anthropic override".to_string(), + json!({ + "futureField": { "preserve": true }, + "models": [{ "id": "claude-sonnet" }] + }), + None, + ); + + let form = ProviderAddFormState::from_provider(AppType::Pi, &provider); + assert!(form.opencode_npm_package.value.is_empty()); + let roundtrip = form.to_provider_json_value(); + assert!(roundtrip["settingsConfig"].get("api").is_none()); + assert_eq!( + roundtrip["settingsConfig"]["futureField"], + json!({ "preserve": true }) + ); +} + +#[test] +fn provider_add_form_pi_preserves_raw_native_settings_and_names_a_copy() { + let settings = json!({ + "api": "openai-completions", + "apiKey": " key-with-native-spacing ", + "baseUrl": "https://pi.example/v1", + "models": [ + { + "id": "float-context", + "contextWindow": 1000000.0, + "compat": { "future": true } + }, + { + "id": "alias-context", + "context_window": 64000, + "extension": [1, 2, 3] + } + ], + "unknownNested": { "keep": { "exact": true } } + }); + let provider = Provider::with_id( + "native-shape".to_string(), + "Native Shape".to_string(), + settings.clone(), + None, + ); + + let form = ProviderAddFormState::from_provider(AppType::Pi, &provider); + assert_eq!(form.initial_pi_settings_config(), Some(settings.clone())); + assert_eq!(form.to_provider_json_value()["settingsConfig"], settings); + + let copy = ProviderAddFormState::copy_from_provider_with_common_snippet( + AppType::Pi, + &provider, + "", + &[provider.id.clone()], + ); + assert_eq!(copy.initial_pi_settings_config(), None); + let mut expected_copy = settings; + expected_copy["name"] = json!("Native Shape copy"); + assert_eq!( + copy.to_provider_json_value()["settingsConfig"], + expected_copy + ); +} diff --git a/src-tauri/src/cli/tui/help.rs b/src-tauri/src/cli/tui/help.rs index 9042f1f72..6a189d3b6 100644 --- a/src-tauri/src/cli/tui/help.rs +++ b/src-tauri/src/cli/tui/help.rs @@ -50,6 +50,8 @@ impl HelpContent { enum HelpTarget { Global, Sessions, + PiSystemPrompts, + PiPromptTemplates, FailoverQueue, PreferredEditor, SkillStorageLocation, @@ -150,8 +152,11 @@ fn current_help_target(app: &App) -> HelpTarget { return HelpTarget::Empty; } - if matches!(app.route, super::route::Route::Sessions) { - return HelpTarget::Sessions; + match app.route { + super::route::Route::Sessions => return HelpTarget::Sessions, + super::route::Route::PiSystemPrompts => return HelpTarget::PiSystemPrompts, + super::route::Route::PiPromptTemplates => return HelpTarget::PiPromptTemplates, + _ => {} } if matches!(app.route, super::route::Route::SettingsOutboundProxy) { @@ -328,6 +333,20 @@ fn help_for_target(target: HelpTarget, app: &App, data: &UiData) -> HelpContent "Sessions always show the current app; results combine Project scope × / Search.\nUse ←/→ to switch between the list and details; h/l are aliases. Use ↑/↓ to move one item and PgUp/PgDn to move by a page. Press p to choose a project; Home/End jumps to either end of the active session list or message history, Shift+←/→ reveals the complete directory, and Shift+Home/End jumps to either path end.\nDetail messages are paged on demand across the complete logical history. To stay responsive, an unusually long individual body is shown as a bounded preview. In details, / filters the current message page only; PgUp/PgDn/Home/End still browse other history pages while the filter is retained.\nFor Claude, Codex, Gemini, and OpenCode, Cost and tokens come from locally available usage rows that remain in proxy_request_logs after effective deduplication and can be deterministically attributed to this session. Hermes uses estimates supplied by its state.db. Cost is a best-effort estimate based on these local records and model pricing; it is not a bill and does not represent every historical charge. \"-\" means there is no attributable usage, the identity is ambiguous, the query is unavailable, or at least one token-bearing row cannot be priced reliably. A Codex root session excludes costs from independent subagent threads.\nDeleted or archived logs, source write failures, malformed rows, unattributable proxy Generated IDs, live rows awaiting a requery, and floating-point summation can all make the estimate differ from actual charges.\nUnknown directory is last and contains only legacy sessions without a project directory; exact projects match the complete lexically normalized directory.", ), ), + HelpTarget::PiSystemPrompts => HelpContent::new( + texts::menu_pi_system_prompts(), + help_lines( + "这里直接管理 Pi agent 目录中的原生系统提示词文件。APPEND_SYSTEM.md 在 Pi 内置系统提示词后追加内容;SYSTEM.md 完全替换内置系统提示词。Enter 查看,e 编辑,d 删除。\n保存会校验打开编辑器时的文件版本;若 Pi 或其他编辑器已改动文件,本次保存会报冲突,不会覆盖外部修改。删除只影响当前选中的文件。", + "This page directly manages Pi's native system-prompt files in the agent directory. APPEND_SYSTEM.md appends to Pi's built-in system prompt; SYSTEM.md replaces it completely. Press Enter to view, e to edit, and d to delete.\nSaving checks the file revision captured when the editor opened. If Pi or another editor changed the file, the save reports a conflict instead of overwriting it. Delete affects only the selected file.", + ), + ), + HelpTarget::PiPromptTemplates => HelpContent::new( + texts::menu_pi_prompt_templates(), + help_lines( + "这里管理 Pi agent 目录 prompts/*.md 中的原生斜杠命令模板;文件名就是 /template 名称。a 新建,Enter 查看,e 编辑,r 重命名,d 删除。\n模板名只能使用安全的单个文件名,不能包含路径分隔符或路径穿越。保存和重命名会校验打开时的文件版本;检测到外部修改时会报冲突。", + "This page manages Pi's native slash-command templates under prompts/*.md; the filename is the /template name. Press a to create, Enter to view, e to edit, r to rename, and d to delete.\nTemplate names must be safe single filenames without path separators or traversal. Save and rename check the revision captured on open and report a conflict when an external edit is detected.", + ), + ), HelpTarget::FailoverQueue => HelpContent::new( crate::t!("Failover Queue", "故障转移队列"), help_lines( @@ -863,8 +882,8 @@ fn provider_field_help(app_type: AppType, field: ProviderAddField) -> HelpConten ProviderAddField::OpenClawModels => HelpContent::new( texts::tui_label_openclaw_models(), help_lines( - "编辑 OpenClaw 模型列表。这里保存的是该供应商暴露给客户端选择的模型配置。", - "Edits OpenClaw model entries. These are the models exposed to the client for this provider.", + "编辑 OpenClaw/Pi 模型列表。Pi 表单中可按 f 从原生端点拉取模型。", + "Edits OpenClaw/Pi model entries. In a Pi form, press f to fetch from the native endpoint.", ), ), ProviderAddField::OpenCodeModelContextLimit => HelpContent::new( diff --git a/src-tauri/src/cli/tui/mod.rs b/src-tauri/src/cli/tui/mod.rs index a8ca2f512..1894e473f 100644 --- a/src-tauri/src/cli/tui/mod.rs +++ b/src-tauri/src/cli/tui/mod.rs @@ -2307,6 +2307,9 @@ fn cache_invalidation_for_action(action: &Action) -> CacheInvalidation { | Action::PromptUpdateMetadata { .. } | Action::PromptSave { .. } | Action::PromptDelete { .. } + | Action::PiSystemPromptDelete { .. } + | Action::PiPromptTemplateDelete { .. } + | Action::PiPromptTemplateRename { .. } | Action::ConfigBackup { .. } | Action::ConfigWebDavReset | Action::ConfigWebDavSave { .. } @@ -2324,7 +2327,8 @@ fn cache_invalidation_for_action(action: &Action) -> CacheInvalidation { | Action::SetGlobalOutboundProxy { .. } | Action::SetProxyAutoFailover { .. } | Action::EnableProxyAndAutoFailover { .. } - | Action::SetOpenClawConfigDir { .. } => CacheInvalidation::DataReloaded, + | Action::SetOpenClawConfigDir { .. } + | Action::SetPiConfigDir { .. } => CacheInvalidation::DataReloaded, } } diff --git a/src-tauri/src/cli/tui/route.rs b/src-tauri/src/cli/tui/route.rs index de68093d5..6a91aa9ce 100644 --- a/src-tauri/src/cli/tui/route.rs +++ b/src-tauri/src/cli/tui/route.rs @@ -11,6 +11,8 @@ pub enum Route { Sessions, Mcp, Prompts, + PiSystemPrompts, + PiPromptTemplates, HermesMemory, Config, ConfigOpenClawWorkspace, @@ -39,6 +41,8 @@ pub enum NavItem { Sessions, Mcp, Prompts, + PiSystemPrompts, + PiPromptTemplates, HermesMemory, Config, Skills, @@ -91,10 +95,24 @@ impl NavItem { NavItem::Exit, ]; + pub const PI_ALL: [NavItem; 10] = [ + NavItem::Main, + NavItem::Providers, + NavItem::Skills, + NavItem::Sessions, + NavItem::Prompts, + NavItem::PiSystemPrompts, + NavItem::PiPromptTemplates, + NavItem::Usage, + NavItem::Settings, + NavItem::Exit, + ]; + pub fn all_for_app(app_type: &AppType) -> &'static [NavItem] { match app_type { AppType::OpenClaw => &Self::OPENCLAW_ALL, AppType::Hermes => &Self::HERMES_ALL, + AppType::Pi => &Self::PI_ALL, _ => &Self::ALL, } } @@ -107,6 +125,8 @@ impl NavItem { NavItem::Sessions => Some(Route::Sessions), NavItem::Mcp => Some(Route::Mcp), NavItem::Prompts => Some(Route::Prompts), + NavItem::PiSystemPrompts => Some(Route::PiSystemPrompts), + NavItem::PiPromptTemplates => Some(Route::PiPromptTemplates), NavItem::HermesMemory => Some(Route::HermesMemory), NavItem::Config => Some(Route::Config), NavItem::Skills => Some(Route::Skills), @@ -187,6 +207,7 @@ mod tests { NavItem::ALL.as_slice(), NavItem::OPENCLAW_ALL.as_slice(), NavItem::HERMES_ALL.as_slice(), + NavItem::PI_ALL.as_slice(), ] { assert!(nav_items .iter() @@ -194,6 +215,19 @@ mod tests { } } + #[test] + fn pi_nav_exposes_native_prompt_pages_without_mcp_or_generic_config() { + assert!(NavItem::PI_ALL + .iter() + .any(|item| matches!(item, NavItem::PiSystemPrompts))); + assert!(NavItem::PI_ALL + .iter() + .any(|item| matches!(item, NavItem::PiPromptTemplates))); + assert!(!NavItem::PI_ALL + .iter() + .any(|item| matches!(item, NavItem::Mcp | NavItem::Config))); + } + #[test] fn hermes_nav_uses_memory_instead_of_prompts() { assert!(NavItem::HERMES_ALL diff --git a/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs b/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs index 52a1ff852..f9c76869b 100644 --- a/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs +++ b/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs @@ -139,6 +139,7 @@ mod tests { current_id: current_id.to_string(), rows, live_ids: Default::default(), + pi_membership_unknown: false, loading: false, }, ..UiData::default() diff --git a/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs b/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs index 69e9001cf..52142a529 100644 --- a/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs +++ b/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs @@ -138,6 +138,7 @@ mod tests { current_id: current_id.to_string(), rows, live_ids: Default::default(), + pi_membership_unknown: false, loading: false, }, ..UiData::default() diff --git a/src-tauri/src/cli/tui/runtime_actions/editor.rs b/src-tauri/src/cli/tui/runtime_actions/editor.rs index 117f0c4ad..188481d56 100644 --- a/src-tauri/src/cli/tui/runtime_actions/editor.rs +++ b/src-tauri/src/cli/tui/runtime_actions/editor.rs @@ -27,6 +27,7 @@ fn validate_provider_submit( app_type: &AppType, provider: &Provider, is_edit: bool, + expected_pi_settings: Option<&Value>, ) -> Option<&'static str> { if provider.name.trim().is_empty() { return Some(if is_edit { @@ -53,6 +54,42 @@ fn validate_provider_submit( } } + if matches!(app_type, AppType::Pi) { + let settings = &provider.settings_config; + let request_url = crate::pi_config::provider_base_url(settings).ok(); + let unchanged_legacy_url = is_edit + && expected_pi_settings + .and_then(|expected| crate::pi_config::provider_base_url(expected).ok()) + == request_url; + let valid_base_url = request_url + .as_deref() + .is_some_and(crate::pi_config::is_valid_request_url); + let missing_existing_url = is_edit && request_url.is_none(); + if !valid_base_url && !unchanged_legacy_url && !missing_existing_url { + return Some(texts::base_url_empty_error()); + } + if is_edit { + return None; + } + let api = settings.get("api").and_then(Value::as_str).map(str::trim); + let has_model = settings + .get("models") + .and_then(Value::as_array) + .is_some_and(|models| { + models.iter().any(|model| { + model + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| !id.trim().is_empty()) + }) + }); + if api.is_none_or(|value| !crate::openclaw_config::OPENCLAW_API_PROTOCOLS.contains(&value)) + || !has_model + { + return Some(texts::tui_toast_provider_add_missing_fields()); + } + } + None } @@ -258,6 +295,15 @@ pub(super) fn submit( description, } => submit_prompt_create(ctx, id, name, description, content), EditorSubmit::PromptEdit { id } => submit_prompt_edit(ctx, id, content), + EditorSubmit::PiSystemPrompt { + kind, + expected_revision, + } => submit_pi_system_prompt(ctx, kind, expected_revision, content), + EditorSubmit::PiPromptTemplate { + slug, + original_slug, + expected_revision, + } => submit_pi_prompt_template(ctx, slug, original_slug, expected_revision, content), EditorSubmit::ProviderFormApplyJson => submit_provider_form_apply_json(ctx, content), EditorSubmit::ProviderFormApplyOpenClawModels => { submit_provider_form_apply_openclaw_models(ctx, content) @@ -278,7 +324,10 @@ pub(super) fn submit( submit_provider_form_apply_codex_config_toml(ctx, content) } EditorSubmit::ProviderAdd => submit_provider_add(ctx, content), - EditorSubmit::ProviderEdit { id } => submit_provider_edit(ctx, id, content), + EditorSubmit::ProviderEdit { + id, + expected_pi_settings_config, + } => submit_provider_edit(ctx, id, expected_pi_settings_config, content), EditorSubmit::PricingEdit { model_id } => submit_pricing_edit(ctx, model_id, content), EditorSubmit::McpAdd => submit_mcp_add(ctx, content), EditorSubmit::McpEdit { id } => submit_mcp_edit(ctx, id, content), @@ -298,6 +347,53 @@ pub(super) fn submit( } } +fn submit_pi_system_prompt( + ctx: &mut RuntimeActionContext<'_>, + kind: crate::services::pi_prompt_files::PiPromptFileKind, + expected_revision: String, + content: String, +) -> Result<(), AppError> { + crate::services::pi_prompt_files::PiPromptFileService::replace( + kind, + &expected_revision, + &content, + )?; + ctx.app.editor = None; + ctx.app + .push_toast(texts::tui_toast_prompt_edit_finished(), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + Ok(()) +} + +fn submit_pi_prompt_template( + ctx: &mut RuntimeActionContext<'_>, + slug: String, + original_slug: Option, + expected_revision: String, + content: String, +) -> Result<(), AppError> { + crate::services::pi_prompt_files::PiPromptTemplateService::upsert( + &slug, + original_slug.as_deref(), + &expected_revision, + &content, + )?; + ctx.app.editor = None; + ctx.app + .push_toast(texts::tui_toast_prompt_edit_finished(), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + if let Some(index) = ctx + .data + .pi_prompts + .templates + .iter() + .position(|template| template.slug == slug) + { + ctx.app.pi_prompt_template_idx = index; + } + Ok(()) +} + fn submit_hermes_memory( ctx: &mut RuntimeActionContext<'_>, kind: crate::hermes_config::MemoryKind, @@ -915,7 +1011,7 @@ fn submit_provider_add( _ => None, }; - if let Some(message) = validate_provider_submit(&ctx.app.app_type, &provider, false) { + if let Some(message) = validate_provider_submit(&ctx.app.app_type, &provider, false, None) { ctx.app.push_toast(message, ToastKind::Warning); return Ok(()); } @@ -977,6 +1073,7 @@ fn submit_provider_add( fn submit_provider_edit( ctx: &mut RuntimeActionContext<'_>, id: String, + expected_pi_settings_config: Option, content: String, ) -> Result<(), AppError> { let mut provider: Provider = match serde_json::from_str(&content) { @@ -991,7 +1088,12 @@ fn submit_provider_edit( }; provider.id = id.clone(); - if let Some(message) = validate_provider_submit(&ctx.app.app_type, &provider, true) { + if let Some(message) = validate_provider_submit( + &ctx.app.app_type, + &provider, + true, + expected_pi_settings_config.as_ref(), + ) { ctx.app.push_toast(message, ToastKind::Warning); return Ok(()); } @@ -1340,6 +1442,27 @@ mod tests { )); } + #[test] + fn pi_edit_accepts_upstream_partial_native_provider_nodes() { + let provider: Provider = serde_json::from_value(json!({ + "id": "openai", + "name": "OpenAI", + "settingsConfig": { "futureField": true } + })) + .expect("provider"); + + assert_eq!( + validate_provider_submit( + &AppType::Pi, + &provider, + true, + Some(&provider.settings_config), + ), + None + ); + assert!(validate_provider_submit(&AppType::Pi, &provider, false, None).is_some()); + } + struct EnvGuard { _lock: TestHomeSettingsLock, old_home: Option, @@ -2762,6 +2885,7 @@ mod tests { submit_provider_edit( &mut ctx, "live-only".to_string(), + None, r#"{ "id": "live-only", "name": "Live Only Imported", @@ -2845,6 +2969,7 @@ mod tests { submit_provider_edit( &mut ctx, "codex-provider".to_string(), + None, r#"{ "id": "codex-provider", "name": "Codex Provider", @@ -2944,6 +3069,7 @@ mod tests { submit_provider_edit( &mut ctx, "live-only".to_string(), + None, r#"{ "id": "live-only", "name": "Live Only Custom", @@ -3124,6 +3250,7 @@ mod tests { submit_provider_edit( &mut ctx, "keep".to_string(), + None, r#"{ "id": "keep", "name": "Keep Invalid", diff --git a/src-tauri/src/cli/tui/runtime_actions/helpers.rs b/src-tauri/src/cli/tui/runtime_actions/helpers.rs index 2b4f1d11a..679300648 100644 --- a/src-tauri/src/cli/tui/runtime_actions/helpers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/helpers.rs @@ -67,6 +67,7 @@ pub(crate) fn app_display_name(app_type: &AppType) -> &'static str { AppType::OpenCode => "OpenCode", AppType::Hermes => "Hermes", AppType::OpenClaw => "OpenClaw", + AppType::Pi => "Pi", } } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index 362a7cbd7..898f8936e 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -73,6 +73,26 @@ fn normalize_route_for_app(app_type: &AppType, route: &super::route::Route) -> s | super::route::Route::SettingsManagedAccounts => route.clone(), _ => super::route::Route::Main, }, + AppType::Pi => match route { + super::route::Route::Main + | super::route::Route::Providers + | super::route::Route::Usage + | super::route::Route::UsageLogs + | super::route::Route::UsageLogDetail { .. } + | super::route::Route::Pricing + | super::route::Route::Sessions + | super::route::Route::Prompts + | super::route::Route::PiSystemPrompts + | super::route::Route::PiPromptTemplates + | super::route::Route::Skills + | super::route::Route::SkillsDiscover + | super::route::Route::SkillsRepos + | super::route::Route::SkillDetail { .. } + | super::route::Route::Settings + | super::route::Route::SettingsProxy + | super::route::Route::SettingsManagedAccounts => route.clone(), + _ => super::route::Route::Main, + }, _ => match route { super::route::Route::ConfigOpenClawWorkspace | super::route::Route::ConfigOpenClawDailyMemory @@ -80,6 +100,9 @@ fn normalize_route_for_app(app_type: &AppType, route: &super::route::Route) -> s | super::route::Route::ConfigOpenClawTools | super::route::Route::ConfigOpenClawAgents => super::route::Route::Config, super::route::Route::HermesMemory => super::route::Route::Main, + super::route::Route::PiSystemPrompts | super::route::Route::PiPromptTemplates => { + super::route::Route::Main + } _ => route.clone(), }, } @@ -910,6 +933,8 @@ pub(crate) fn handle_action( is_full_url, api_key, custom_user_agent, + api_protocol, + request_headers, codex_oauth, codex_oauth_account_id, field, @@ -920,6 +945,8 @@ pub(crate) fn handle_action( is_full_url, api_key, custom_user_agent, + api_protocol, + request_headers, codex_oauth, codex_oauth_account_id, field, @@ -947,6 +974,26 @@ pub(crate) fn handle_action( content, } => prompts::save(&mut ctx, old_id, new_id, name, description, content), Action::PromptDelete { id } => prompts::delete(&mut ctx, id), + Action::PiSystemPromptDelete { + kind, + expected_revision, + } => prompts::delete_pi_system_prompt(&mut ctx, kind, expected_revision), + Action::PiPromptTemplateDelete { + slug, + expected_revision, + } => prompts::delete_pi_prompt_template(&mut ctx, slug, expected_revision), + Action::PiPromptTemplateRename { + original_slug, + new_slug, + expected_revision, + content, + } => prompts::rename_pi_prompt_template( + &mut ctx, + original_slug, + new_slug, + expected_revision, + content, + ), Action::PromptFormOpenExternal => prompts::open_form_external(&mut ctx), Action::PromptOpenImportCandidate { filename, content } => { prompts::open_import_candidate(&mut ctx, filename, content) @@ -1051,6 +1098,7 @@ pub(crate) fn handle_action( settings::enable_proxy_and_auto_failover(&mut ctx, app_type) } Action::SetOpenClawConfigDir { path } => settings::set_openclaw_config_dir(&mut ctx, path), + Action::SetPiConfigDir { path } => settings::set_pi_config_dir(&mut ctx, path), Action::SetPreferredEditor { command } => settings::set_preferred_editor(&mut ctx, command), Action::SetManagedProxyForCurrentApp { app_type, enabled } => queue_managed_proxy_action( ctx.app, @@ -1819,6 +1867,7 @@ mod tests { opencode: true, hermes: false, openclaw: true, + pi: false, }) .expect("save initial visible apps"); @@ -1829,6 +1878,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }; let mut app = App::new(Some(AppType::OpenClaw)); app.route = Route::ConfigOpenClawTools; @@ -1889,6 +1939,7 @@ mod tests { opencode: true, hermes: false, openclaw: true, + pi: false, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) .expect("save initial visible apps"); @@ -1911,6 +1962,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }, }, ) @@ -1939,6 +1991,7 @@ mod tests { opencode: true, hermes: false, openclaw: true, + pi: false, }) .expect("save initial visible apps"); write_invalid_legacy_config(temp_home.path()); @@ -1950,6 +2003,7 @@ mod tests { opencode: true, hermes: false, openclaw: false, + pi: false, }; let mut app = App::new(Some(AppType::Claude)); let mut data = UiData::default(); @@ -1987,6 +2041,7 @@ mod tests { opencode: true, hermes: false, openclaw: true, + pi: false, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) .expect("save initial visible apps"); @@ -2006,6 +2061,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }, }, ) @@ -2035,6 +2091,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -2047,6 +2104,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }; let mut app = App::new(Some(AppType::Claude)); let mut data = UiData::default(); @@ -2093,6 +2151,7 @@ mod tests { opencode: false, hermes: false, openclaw: true, + pi: false, }; let mut settings = crate::settings::get_settings(); settings.visible_apps = initial_visible_apps.clone(); @@ -2119,6 +2178,7 @@ mod tests { opencode: false, hermes: false, openclaw: false, + pi: false, }, selected: 5, }, diff --git a/src-tauri/src/cli/tui/runtime_actions/prompts.rs b/src-tauri/src/cli/tui/runtime_actions/prompts.rs index d65066d1f..1d103d309 100644 --- a/src-tauri/src/cli/tui/runtime_actions/prompts.rs +++ b/src-tauri/src/cli/tui/runtime_actions/prompts.rs @@ -143,3 +143,59 @@ pub(super) fn delete(ctx: &mut RuntimeActionContext<'_>, id: String) -> Result<( *ctx.data = UiData::load(&ctx.app.app_type)?; Ok(()) } + +pub(super) fn delete_pi_system_prompt( + ctx: &mut RuntimeActionContext<'_>, + kind: crate::services::pi_prompt_files::PiPromptFileKind, + expected_revision: String, +) -> Result<(), AppError> { + crate::services::pi_prompt_files::PiPromptFileService::delete(kind, &expected_revision)?; + ctx.app + .push_toast(texts::tui_toast_prompt_deleted(), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + Ok(()) +} + +pub(super) fn delete_pi_prompt_template( + ctx: &mut RuntimeActionContext<'_>, + slug: String, + expected_revision: String, +) -> Result<(), AppError> { + crate::services::pi_prompt_files::PiPromptTemplateService::delete(&slug, &expected_revision)?; + ctx.app + .push_toast(texts::tui_toast_prompt_deleted(), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + ctx.app.pi_prompt_template_idx = ctx + .app + .pi_prompt_template_idx + .min(ctx.data.pi_prompts.templates.len().saturating_sub(1)); + Ok(()) +} + +pub(super) fn rename_pi_prompt_template( + ctx: &mut RuntimeActionContext<'_>, + original_slug: String, + new_slug: String, + expected_revision: String, + content: String, +) -> Result<(), AppError> { + crate::services::pi_prompt_files::PiPromptTemplateService::upsert( + &new_slug, + Some(&original_slug), + &expected_revision, + &content, + )?; + ctx.app + .push_toast(texts::tui_toast_prompt_renamed(), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + if let Some(index) = ctx + .data + .pi_prompts + .templates + .iter() + .position(|template| template.slug == new_slug) + { + ctx.app.pi_prompt_template_idx = index; + } + Ok(()) +} diff --git a/src-tauri/src/cli/tui/runtime_actions/providers.rs b/src-tauri/src/cli/tui/runtime_actions/providers.rs index 29cada54d..892b64417 100644 --- a/src-tauri/src/cli/tui/runtime_actions/providers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/providers.rs @@ -426,7 +426,9 @@ pub(super) fn remove_from_config( refresh_provider_data_after_write(ctx, &state)?; Ok(()) } - crate::app_config::AppType::OpenCode | crate::app_config::AppType::Hermes => { + crate::app_config::AppType::OpenCode + | crate::app_config::AppType::Hermes + | crate::app_config::AppType::Pi => { let state = load_state()?; ProviderService::remove_from_live_config(&state, ctx.app.app_type.clone(), &id)?; ctx.app.push_toast( @@ -526,6 +528,8 @@ pub(super) fn model_fetch( is_full_url: bool, api_key: Option, custom_user_agent: Option, + api_protocol: Option, + request_headers: Option>, codex_oauth: bool, codex_oauth_account_id: Option, field: ProviderAddField, @@ -564,6 +568,8 @@ pub(super) fn model_fetch( is_full_url, api_key, custom_user_agent, + api_protocol, + request_headers, codex_oauth, codex_oauth_account_id, field, diff --git a/src-tauri/src/cli/tui/runtime_actions/settings.rs b/src-tauri/src/cli/tui/runtime_actions/settings.rs index 929c9f5bb..5a166fbb4 100644 --- a/src-tauri/src/cli/tui/runtime_actions/settings.rs +++ b/src-tauri/src/cli/tui/runtime_actions/settings.rs @@ -235,6 +235,68 @@ pub(super) fn set_openclaw_config_dir( Ok(()) } +pub(super) fn set_pi_config_dir( + ctx: &mut RuntimeActionContext<'_>, + path: Option, +) -> Result<(), AppError> { + let path = validate_pi_config_dir(path)?; + let mut settings = crate::settings::get_settings(); + settings.pi_config_dir = path; + crate::settings::update_settings(settings)?; + + let state = load_state()?; + let import_result = + crate::services::ProviderService::import_pi_providers_from_live(&state).err(); + + *ctx.data = UiData::load(&ctx.app.app_type)?; + ctx.app.push_toast( + texts::tui_toast_pi_config_dir_saved(), + super::super::app::ToastKind::Success, + ); + if let Some(err) = import_result { + ctx.app.push_toast( + texts::tui_toast_pi_config_dir_import_failed(&err.to_string()), + super::super::app::ToastKind::Warning, + ); + } + + Ok(()) +} + +fn validate_pi_config_dir(path: Option) -> Result, AppError> { + let path = path + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if let Some(raw) = path.as_deref() { + let resolved = crate::settings::resolve_override_path(raw); + if !resolved.is_absolute() { + return Err(AppError::InvalidInput(format!( + "Pi config directory must resolve to an absolute path: {}", + resolved.display() + ))); + } + } + Ok(path) +} + +#[cfg(test)] +mod pi_config_dir_tests { + use super::validate_pi_config_dir; + + #[test] + fn pi_config_dir_is_validated_before_persistence() { + assert!(validate_pi_config_dir(Some("relative/pi".to_string())).is_err()); + assert_eq!( + validate_pi_config_dir(Some(" /tmp/pi-agent ".to_string())).unwrap(), + Some("/tmp/pi-agent".to_string()) + ); + assert_eq!( + validate_pi_config_dir(Some(" ".to_string())).unwrap(), + None + ); + } +} + pub(super) fn set_preferred_editor( ctx: &mut RuntimeActionContext<'_>, command: Option, diff --git a/src-tauri/src/cli/tui/runtime_systems/types.rs b/src-tauri/src/cli/tui/runtime_systems/types.rs index 18b983a48..65f94bbbb 100644 --- a/src-tauri/src/cli/tui/runtime_systems/types.rs +++ b/src-tauri/src/cli/tui/runtime_systems/types.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; use std::time::Duration; @@ -810,6 +810,8 @@ pub(crate) enum ModelFetchReq { is_full_url: bool, api_key: Option, custom_user_agent: Option, + api_protocol: Option, + request_headers: Option>, codex_oauth: bool, codex_oauth_account_id: Option, field: ProviderAddField, @@ -967,6 +969,7 @@ pub(crate) async fn fetch_provider_models_for_tui( api_key: Option<&str>, custom_user_agent: Option<&str>, strategy: ModelFetchStrategy, + request_headers: Option<&BTreeMap>, ) -> Result, String> { let candidate_urls = build_model_fetch_candidate_urls(base_url, strategy, is_full_url); if candidate_urls.is_empty() { @@ -983,6 +986,12 @@ pub(crate) async fn fetch_provider_models_for_tui( let custom_user_agent = crate::provider::parse_custom_user_agent(custom_user_agent) .ok() .flatten(); + if key.is_none() && request_headers.is_none_or(BTreeMap::is_empty) { + return Err("API Key or request headers are required to fetch models".to_string()); + } + if request_headers.is_some_and(|headers| headers.len() > 64) { + return Err("Too many model-fetch request headers (maximum 64)".to_string()); + } let mut last_err = String::from("unknown error"); for url in candidate_urls { @@ -1000,6 +1009,18 @@ pub(crate) async fn fetch_provider_models_for_tui( if let Some(user_agent) = &custom_user_agent { req = req.header(reqwest::header::USER_AGENT, user_agent.clone()); } + if let Some(request_headers) = request_headers { + for (raw_name, raw_value) in request_headers { + let name = reqwest::header::HeaderName::from_bytes(raw_name.trim().as_bytes()) + .map_err(|error| { + format!("Invalid model-fetch header name {raw_name}: {error}") + })?; + let value = reqwest::header::HeaderValue::from_str(raw_value).map_err(|error| { + format!("Invalid model-fetch header value for {name}: {error}") + })?; + req = req.header(name, value); + } + } match req.send().await { Ok(resp) => { diff --git a/src-tauri/src/cli/tui/runtime_systems/workers.rs b/src-tauri/src/cli/tui/runtime_systems/workers.rs index fe2878ca5..6efb91f28 100644 --- a/src-tauri/src/cli/tui/runtime_systems/workers.rs +++ b/src-tauri/src/cli/tui/runtime_systems/workers.rs @@ -21,13 +21,13 @@ use super::types::{ fetch_provider_models_for_tui, model_fetch_strategy_for_field, AppDataLoadKind, AppDataMsg, AppDataReq, AppDataSystem, CodexHistoryMsg, CodexHistoryReq, CodexHistorySystem, LoadedMessagePage, LocalEnvMsg, LocalEnvReq, LocalEnvSystem, ManagedAuthMsg, ManagedAuthReq, - ManagedAuthSystem, ManagedSessionOutcome, ModelFetchMsg, ModelFetchReq, ModelFetchSystem, - ProxyMsg, ProxyReq, ProxySystem, QuotaMsg, QuotaReq, QuotaSystem, RefreshedMessagePages, - SessionMsg, SessionReq, SessionSystem, SessionUsageSyncMsg, SessionUsageSyncReq, - SessionUsageSyncSystem, SkillsMsg, SkillsReq, SkillsSystem, SpeedtestMsg, SpeedtestSystem, - StreamCheckMsg, StreamCheckReq, StreamCheckSystem, UpdateMsg, UpdateReq, UpdateSystem, - UsageLogLoadError, UsagePricingLoadError, UsagePricingMsg, UsagePricingReq, UsagePricingSystem, - WebDavDone, WebDavErr, WebDavMsg, WebDavReq, WebDavReqKind, WebDavSystem, + ManagedAuthSystem, ManagedSessionOutcome, ModelFetchMsg, ModelFetchReq, ModelFetchStrategy, + ModelFetchSystem, ProxyMsg, ProxyReq, ProxySystem, QuotaMsg, QuotaReq, QuotaSystem, + RefreshedMessagePages, SessionMsg, SessionReq, SessionSystem, SessionUsageSyncMsg, + SessionUsageSyncReq, SessionUsageSyncSystem, SkillsMsg, SkillsReq, SkillsSystem, SpeedtestMsg, + SpeedtestSystem, StreamCheckMsg, StreamCheckReq, StreamCheckSystem, UpdateMsg, UpdateReq, + UpdateSystem, UsageLogLoadError, UsagePricingLoadError, UsagePricingMsg, UsagePricingReq, + UsagePricingSystem, WebDavDone, WebDavErr, WebDavMsg, WebDavReq, WebDavReqKind, WebDavSystem, }; static SESSION_SCAN_GENERATION: AtomicU64 = AtomicU64::new(0); @@ -632,6 +632,8 @@ fn model_fetch_worker_loop(rx: mpsc::Receiver, tx: mpsc::Sender, tx: mpsc::Sender ModelFetchStrategy::Anthropic, + Some("google-generative-ai") => ModelFetchStrategy::GoogleApiKey, + _ => model_fetch_strategy_for_field(field), + }; rt.block_on(async { fetch_provider_models_for_tui( &base_url, @@ -652,6 +658,7 @@ fn model_fetch_worker_loop(rx: mpsc::Receiver, tx: mpsc::Sender, ) { + if provider_id == "pi" { + if let Some(error) = crate::session_manager::providers::pi::session_discovery_error() { + let _ = tx.send(SessionMsg::ManifestPublished { + request_id, + scope_epoch, + scope: provider_id, + result: Err(error), + }); + return; + } + } let manifest_store = match crate::session_manager::paged_manifest::PagedManifestStore::open() { Ok(store) => store, Err(error) => { diff --git a/src-tauri/src/cli/tui/tests.rs b/src-tauri/src/cli/tui/tests.rs index 6c73ff491..3682832a2 100644 --- a/src-tauri/src/cli/tui/tests.rs +++ b/src-tauri/src/cli/tui/tests.rs @@ -255,6 +255,25 @@ fn sessions_help_describes_costs_as_local_estimates_without_ui_prefixes() { assert!(!chinese.contains('~'), "{chinese}"); } +#[test] +fn pi_native_prompt_pages_put_behavioral_guidance_in_context_help() { + let mut app = App::new(Some(AppType::Pi)); + let data = UiData::default(); + let _english = crate::cli::i18n::use_test_language(crate::cli::i18n::Language::English); + + app.route = route::Route::PiSystemPrompts; + let system = help::context_help_for_app(&app, &data).lines.join("\n"); + assert!(system.contains("APPEND_SYSTEM.md"), "{system}"); + assert!(system.contains("replaces"), "{system}"); + assert!(system.contains("conflict"), "{system}"); + + app.route = route::Route::PiPromptTemplates; + let templates = help::context_help_for_app(&app, &data).lines.join("\n"); + assert!(templates.contains("prompts/*.md"), "{templates}"); + assert!(templates.contains("slash-command"), "{templates}"); + assert!(templates.contains("traversal"), "{templates}"); +} + struct EnvGuard { _lock: TestHomeSettingsLock, old_home: Option, @@ -5363,6 +5382,7 @@ async fn model_fetch_full_url_reports_when_models_endpoint_cannot_be_derived() { None, None, ModelFetchStrategy::Bearer, + None, ) .await .expect_err("origin-only full URL should not invent a models endpoint"); @@ -5410,6 +5430,7 @@ async fn model_fetch_sends_trimmed_custom_user_agent() { Some("sk-test"), Some(" cc-switch-model-fetch/test "), ModelFetchStrategy::Bearer, + None, ) .await .expect("model fetch should succeed"); @@ -5437,6 +5458,7 @@ fn startup_hidden_requested_app_bootstrap_uses_visible_app_normalization_before_ opencode: true, hermes: false, openclaw: true, + pi: false, }) .expect("save visible apps"); diff --git a/src-tauri/src/cli/tui/theme.rs b/src-tauri/src/cli/tui/theme.rs index 1e9b10032..a1fe08440 100644 --- a/src-tauri/src/cli/tui/theme.rs +++ b/src-tauri/src/cli/tui/theme.rs @@ -252,6 +252,7 @@ fn accent_rgb(app: &AppType, light: bool) -> (u8, u8, u8) { AppType::OpenCode => LIGHT_ORANGE, AppType::Hermes => LIGHT_YELLOW, AppType::OpenClaw => LIGHT_CORAL, + AppType::Pi => LIGHT_CYAN, }; } @@ -262,6 +263,7 @@ fn accent_rgb(app: &AppType, light: bool) -> (u8, u8, u8) { AppType::OpenCode => DRACULA_ORANGE, AppType::Hermes => DRACULA_YELLOW, AppType::OpenClaw => OPENCLAW_CORAL, + AppType::Pi => DRACULA_CYAN, } } diff --git a/src-tauri/src/cli/tui/ui.rs b/src-tauri/src/cli/tui/ui.rs index 2f8ede2f6..e99f205c4 100644 --- a/src-tauri/src/cli/tui/ui.rs +++ b/src-tauri/src/cli/tui/ui.rs @@ -169,6 +169,10 @@ fn render_content( Route::Sessions => render_sessions(frame, app, data, content_area, theme), Route::Mcp => render_mcp(frame, app, data, content_area, theme), Route::Prompts => render_prompts(frame, app, data, content_area, theme), + Route::PiSystemPrompts => render_pi_system_prompts(frame, app, data, content_area, theme), + Route::PiPromptTemplates => { + render_pi_prompt_templates(frame, app, data, content_area, theme) + } Route::HermesMemory => render_hermes_memory(frame, app, data, content_area, theme), Route::Config => render_config(frame, app, data, content_area, theme), Route::ConfigOpenClawWorkspace | Route::ConfigOpenClawDailyMemory => { diff --git a/src-tauri/src/cli/tui/ui/chrome.rs b/src-tauri/src/cli/tui/ui/chrome.rs index a71ec4d7b..456c5d216 100644 --- a/src-tauri/src/cli/tui/ui/chrome.rs +++ b/src-tauri/src/cli/tui/ui/chrome.rs @@ -258,6 +258,8 @@ pub(super) fn nav_label(item: NavItem) -> &'static str { NavItem::Sessions => texts::menu_manage_sessions(), NavItem::Mcp => texts::menu_manage_mcp(), NavItem::Prompts => texts::menu_manage_prompts(), + NavItem::PiSystemPrompts => texts::menu_pi_system_prompts(), + NavItem::PiPromptTemplates => texts::menu_pi_prompt_templates(), NavItem::HermesMemory => texts::menu_hermes_memory(), NavItem::Config => texts::menu_manage_config(), NavItem::Skills => texts::menu_manage_skills(), @@ -278,6 +280,8 @@ pub(super) fn nav_label_variants(item: NavItem) -> (&'static str, &'static str) NavItem::Sessions => texts::menu_manage_sessions_variants(), NavItem::Mcp => texts::menu_manage_mcp_variants(), NavItem::Prompts => texts::menu_manage_prompts_variants(), + NavItem::PiSystemPrompts => texts::menu_pi_system_prompts_variants(), + NavItem::PiPromptTemplates => texts::menu_pi_prompt_templates_variants(), NavItem::HermesMemory => texts::menu_hermes_memory_variants(), NavItem::Config => texts::menu_manage_config_variants(), NavItem::Skills => texts::menu_manage_skills_variants(), diff --git a/src-tauri/src/cli/tui/ui/config.rs b/src-tauri/src/cli/tui/ui/config.rs index 9d7f35491..e05eae144 100644 --- a/src-tauri/src/cli/tui/ui/config.rs +++ b/src-tauri/src/cli/tui/ui/config.rs @@ -22,7 +22,8 @@ fn settings_section(item: SettingsItem) -> SettingsSection { | SettingsItem::VisibleApps | SettingsItem::SkillsStorageLocation | SettingsItem::SkillsSyncMethod - | SettingsItem::OpenClawConfigDir => SettingsSection::Applications, + | SettingsItem::OpenClawConfigDir + | SettingsItem::PiConfigDir => SettingsSection::Applications, SettingsItem::SkipClaudeOnboarding | SettingsItem::ClaudePluginIntegration | SettingsItem::PreserveCodexOfficialAuth @@ -3433,6 +3434,7 @@ pub(super) fn render_settings( let visible_apps = crate::settings::get_visible_apps(); let visible_apps_mode = crate::settings::get_visible_apps_settings().mode; let openclaw_config_dir = crate::settings::get_settings().openclaw_config_dir; + let pi_config_dir = crate::settings::get_settings().pi_config_dir; let skip_claude_onboarding = crate::settings::get_skip_claude_onboarding(); let claude_plugin_integration = crate::settings::get_enable_claude_plugin_integration(); let preserve_codex_official_auth = crate::settings::preserve_codex_official_auth_on_switch(); @@ -3498,6 +3500,12 @@ pub(super) fn render_settings( texts::tui_settings_openclaw_config_dir_default_value().to_string() }), ), + super::app::SettingsItem::PiConfigDir => ( + texts::tui_settings_pi_config_dir_label().to_string(), + pi_config_dir.clone().unwrap_or_else(|| { + texts::tui_settings_pi_config_dir_default_value().to_string() + }), + ), super::app::SettingsItem::ManagedAccounts => ( texts::tui_settings_managed_accounts_title().to_string(), managed_accounts_summary(app), diff --git a/src-tauri/src/cli/tui/ui/forms/provider.rs b/src-tauri/src/cli/tui/ui/forms/provider.rs index fb1bf842c..08141a7b2 100644 --- a/src-tauri/src/cli/tui/ui/forms/provider.rs +++ b/src-tauri/src/cli/tui/ui/forms/provider.rs @@ -236,7 +236,9 @@ fn common_json_preview_value(app_type: &AppType, common_snippet: &str) -> Option AppType::Gemini => serde_json::from_str::(common_snippet) .ok() .map(|env| json!({ "env": env })), - AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => None, + AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + None + } } .filter(Value::is_object) } diff --git a/src-tauri/src/cli/tui/ui/header_tests.rs b/src-tauri/src/cli/tui/ui/header_tests.rs index 3ce5d5533..9f98904dd 100644 --- a/src-tauri/src/cli/tui/ui/header_tests.rs +++ b/src-tauri/src/cli/tui/ui/header_tests.rs @@ -196,6 +196,7 @@ fn header_openclaw_sacrifices_tabs_before_losing_the_only_status_badge() { opencode: true, hermes: false, openclaw: true, + pi: false, }); let _lang = use_test_language(Language::English); let _no_color = super::tests::EnvGuard::remove("NO_COLOR"); @@ -236,6 +237,7 @@ fn header_openclaw_truncates_long_default_model_without_fake_proxy_gap() { opencode: true, hermes: false, openclaw: true, + pi: false, }); let _lang = use_test_language(Language::English); let _no_color = super::tests::EnvGuard::remove("NO_COLOR"); diff --git a/src-tauri/src/cli/tui/ui/overlay/pickers.rs b/src-tauri/src/cli/tui/ui/overlay/pickers.rs index 12b617096..518f885a5 100644 --- a/src-tauri/src/cli/tui/ui/overlay/pickers.rs +++ b/src-tauri/src/cli/tui/ui/overlay/pickers.rs @@ -2345,6 +2345,7 @@ pub(super) fn render_visible_apps_picker_overlay( crate::app_config::AppType::OpenCode, crate::app_config::AppType::Hermes, crate::app_config::AppType::OpenClaw, + crate::app_config::AppType::Pi, ], ); } @@ -2371,6 +2372,7 @@ pub(super) fn render_skills_apps_picker_overlay( crate::app_config::AppType::Gemini, crate::app_config::AppType::OpenCode, crate::app_config::AppType::Hermes, + crate::app_config::AppType::Pi, ], ); } diff --git a/src-tauri/src/cli/tui/ui/prompts.rs b/src-tauri/src/cli/tui/ui/prompts.rs index 815febb83..012f3f403 100644 --- a/src-tauri/src/cli/tui/ui/prompts.rs +++ b/src-tauri/src/cli/tui/ui/prompts.rs @@ -95,3 +95,159 @@ fn prompts_summary(data: &UiData) -> String { texts::tui_prompts_summary(count, active) } + +pub(super) fn render_pi_system_prompts( + frame: &mut Frame<'_>, + app: &App, + data: &UiData, + area: Rect, + theme: &super::theme::Theme, +) { + let body = render_page_frame( + frame, + area, + theme, + app, + texts::menu_pi_system_prompts(), + &[ + ("Enter", texts::tui_key_view()), + ("e", texts::tui_key_edit()), + ("d", texts::tui_key_delete()), + ], + Some(crate::t!("2 native prompt files", "2 个原生提示词文件").to_string()), + ); + let rows = data.pi_prompts.system_files.iter().map(|(kind, snapshot)| { + let filename = match kind { + crate::services::pi_prompt_files::PiPromptFileKind::SystemAppend => "APPEND_SYSTEM.md", + crate::services::pi_prompt_files::PiPromptFileKind::SystemOverride => "SYSTEM.md", + }; + let mode = match kind { + crate::services::pi_prompt_files::PiPromptFileKind::SystemAppend => { + if crate::cli::i18n::is_chinese() { + "追加" + } else { + "Append" + } + } + crate::services::pi_prompt_files::PiPromptFileKind::SystemOverride => { + if crate::cli::i18n::is_chinese() { + "覆盖" + } else { + "Override" + } + } + }; + Row::new(vec![ + Cell::from(if snapshot.exists { + texts::tui_marker_active() + } else { + texts::tui_marker_inactive() + }), + Cell::from(filename), + Cell::from(mode), + Cell::from(snapshot.content.chars().count().to_string()), + ]) + }); + let table = Table::new( + rows, + [ + Constraint::Length(2), + Constraint::Length(24), + Constraint::Length(12), + Constraint::Min(8), + ], + ) + .header( + Row::new(vec![ + "", + crate::t!("File", "文件"), + crate::t!("Mode", "模式"), + crate::t!("Characters", "字符数"), + ]) + .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)), + ) + .block(Block::default().borders(Borders::NONE)) + .row_highlight_style(selection_style(theme)) + .highlight_symbol(highlight_symbol(theme)); + let mut state = TableState::default(); + state.select(Some( + app.pi_system_prompt_idx + .min(data.pi_prompts.system_files.len().saturating_sub(1)), + )); + frame.render_stateful_widget(table, inset_left(body, CONTENT_INSET_LEFT), &mut state); +} + +pub(super) fn render_pi_prompt_templates( + frame: &mut Frame<'_>, + app: &App, + data: &UiData, + area: Rect, + theme: &super::theme::Theme, +) { + let body = render_page_frame( + frame, + area, + theme, + app, + texts::menu_pi_prompt_templates(), + &[ + ("a", texts::tui_key_add()), + ("Enter", texts::tui_key_view()), + ("e", texts::tui_key_edit()), + ("r", texts::tui_key_rename()), + ("d", texts::tui_key_delete()), + ], + Some(crate::t!( + format!("{} templates", data.pi_prompts.templates.len()), + format!("{} 个模板", data.pi_prompts.templates.len()) + )), + ); + if data.pi_prompts.templates.is_empty() { + render_empty_state( + frame, + body, + theme, + crate::t!("No Pi prompt templates", "暂无 Pi 提示词模板"), + crate::t!("Press a to create one", "按 a 新建模板"), + ); + return; + } + let rows = data.pi_prompts.templates.iter().map(|template| { + let preview = template + .content + .split_whitespace() + .take(12) + .collect::>() + .join(" "); + Row::new(vec![ + Cell::from(format!("/{}", template.slug)), + Cell::from(template.content.chars().count().to_string()), + Cell::from(preview), + ]) + }); + let table = Table::new( + rows, + [ + Constraint::Length(24), + Constraint::Length(12), + Constraint::Min(10), + ], + ) + .header( + Row::new(vec![ + crate::t!("Template", "模板"), + crate::t!("Characters", "字符数"), + crate::t!("Preview", "预览"), + ]) + .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)), + ) + .block(Block::default().borders(Borders::NONE)) + .row_highlight_style(selection_style(theme)) + .highlight_symbol(highlight_symbol(theme)); + let mut state = TableState::default(); + state.select(Some( + app.pi_prompt_template_idx + .min(data.pi_prompts.templates.len().saturating_sub(1)), + )); + frame.render_stateful_widget(table, inset_left(body, CONTENT_INSET_LEFT), &mut state); +} diff --git a/src-tauri/src/cli/tui/ui/providers.rs b/src-tauri/src/cli/tui/ui/providers.rs index ca4642062..d4cae4e20 100644 --- a/src-tauri/src/cli/tui/ui/providers.rs +++ b/src-tauri/src/cli/tui/ui/providers.rs @@ -194,7 +194,7 @@ pub(super) fn render_providers( } else { String::new() } - } else if matches!(app.app_type, AppType::OpenCode) { + } else if matches!(app.app_type, AppType::OpenCode | AppType::Pi) { if row.is_in_config { "+".to_string() } else { diff --git a/src-tauri/src/cli/tui/ui/skills/helpers.rs b/src-tauri/src/cli/tui/ui/skills/helpers.rs index 408aee256..937614cdb 100644 --- a/src-tauri/src/cli/tui/ui/skills/helpers.rs +++ b/src-tauri/src/cli/tui/ui/skills/helpers.rs @@ -45,6 +45,9 @@ pub(super) fn enabled_skill_apps_text(apps: &crate::app_config::SkillApps) -> St if apps.hermes { enabled.push("Hermes"); } + if apps.pi { + enabled.push("Pi"); + } if enabled.is_empty() { texts::none().to_string() diff --git a/src-tauri/src/cli/tui/ui/skills/installed.rs b/src-tauri/src/cli/tui/ui/skills/installed.rs index d6c19fa7f..2561fe6e6 100644 --- a/src-tauri/src/cli/tui/ui/skills/installed.rs +++ b/src-tauri/src/cli/tui/ui/skills/installed.rs @@ -27,6 +27,7 @@ pub(super) fn render_skills_installed( Cell::from(crate::app_config::AppType::Gemini.as_str()), Cell::from(crate::app_config::AppType::OpenCode.as_str()), Cell::from(crate::app_config::AppType::Hermes.as_str()), + Cell::from(crate::app_config::AppType::Pi.as_str()), ]) .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)); @@ -44,6 +45,7 @@ pub(super) fn render_skills_installed( Cell::from(skill_marker(skill.apps.gemini)), Cell::from(skill_marker(skill.apps.opencode)), Cell::from(skill_marker(skill.apps.hermes)), + Cell::from(skill_marker(skill.apps.pi)), ]) }); @@ -56,6 +58,7 @@ pub(super) fn render_skills_installed( Constraint::Length(8), Constraint::Length(10), Constraint::Length(8), + Constraint::Length(5), ], ) .header(header) @@ -110,6 +113,7 @@ fn installed_summary(app: &App, data: &UiData) -> String { .iter() .filter(|s| s.apps.hermes) .count(); + let enabled_pi = data.skills.installed.iter().filter(|s| s.apps.pi).count(); let counts = texts::tui_skills_installed_counts( enabled_claude, @@ -117,6 +121,7 @@ fn installed_summary(app: &App, data: &UiData) -> String { enabled_gemini, enabled_opencode, enabled_hermes, + enabled_pi, ); if app.skill_updates.is_empty() { counts diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index a5afd8c8c..731077f13 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -3975,10 +3975,12 @@ pub(super) fn minimal_data(_app_type: &AppType) -> UiData { primary_model_id: Some("claude-sonnet-4".to_string()), default_model_id: None, }], + pi_membership_unknown: false, loading: false, }, mcp: McpSnapshot::default(), prompts: PromptsSnapshot::default(), + pi_prompts: Default::default(), config: ConfigSnapshot::default(), skills: SkillsSnapshot::default(), proxy: ProxySnapshot::default(), @@ -4077,6 +4079,7 @@ fn installed_skill(directory: &str, name: &str) -> InstalledSkill { gemini: false, opencode: false, hermes: false, + pi: false, }, installed_at: 1, content_hash: None, @@ -4575,6 +4578,7 @@ fn header_only_renders_selected_visible_apps() { opencode: false, hermes: false, openclaw: true, + pi: false, }) .expect("save visible apps"); @@ -4604,6 +4608,7 @@ fn header_keeps_all_app_tabs_visible_with_proxy_chip() { opencode: true, hermes: false, openclaw: true, + pi: false, }) .expect("save visible apps"); @@ -4633,6 +4638,7 @@ fn settings_page_shows_visible_apps_row_value() { opencode: false, hermes: false, openclaw: true, + pi: false, }) .expect("save visible apps"); @@ -5306,6 +5312,7 @@ fn zero_selection_warning_toast_renders_after_picker_rejection() { opencode: false, hermes: false, openclaw: false, + pi: false, }, }; app.push_toast( @@ -5321,6 +5328,7 @@ fn zero_selection_warning_toast_renders_after_picker_rejection() { ); assert!(all.contains(AppType::Hermes.as_str()), "{all}"); assert!(all.contains(AppType::OpenClaw.as_str()), "{all}"); + assert!(all.contains(AppType::Pi.as_str()), "{all}"); assert!( all.contains(texts::tui_toast_visible_apps_zero_selection_warning()), "{all}" @@ -5344,6 +5352,7 @@ fn visible_apps_picker_uses_space_toggle_key() { opencode: false, hermes: false, openclaw: false, + pi: false, }, }; @@ -5375,6 +5384,7 @@ fn visible_apps_picker_auto_mode_does_not_append_auto_suffix_to_apps() { opencode: true, hermes: true, openclaw: true, + pi: false, }, }; @@ -5532,6 +5542,7 @@ fn header_centers_tabs_when_room_allows() { opencode: true, hermes: true, openclaw: true, + pi: false, }) .expect("save visible apps"); @@ -5577,6 +5588,7 @@ fn header_keeps_title_and_right_badges_visible_without_large_gap_in_chinese() { opencode: true, hermes: true, openclaw: true, + pi: false, }) .expect("save visible apps"); @@ -6095,6 +6107,7 @@ fn home_connection_card_labels_mcp_and_skills_with_active_counts() { gemini: false, opencode: false, hermes: false, + pi: false, }, installed_at: 0, content_hash: None, @@ -7015,13 +7028,14 @@ fn skills_page_renders_sync_method_and_installed_rows() { let buf = render(&app, &data); let all = all_text(&buf); - assert!(all.contains(&texts::tui_skills_installed_counts(1, 0, 0, 0, 0))); + assert!(all.contains(&texts::tui_skills_installed_counts(1, 0, 0, 0, 0, 0))); assert!(!all.contains(texts::tui_header_directory())); assert!(all.contains(AppType::Claude.as_str())); assert!(all.contains(AppType::Codex.as_str())); assert!(all.contains(AppType::Gemini.as_str())); assert!(all.contains(AppType::OpenCode.as_str())); assert!(all.contains(AppType::Hermes.as_str())); + assert!(all.contains(AppType::Pi.as_str())); assert!(!all.contains("hello-skill")); assert!(all.contains("Hello Skill")); } @@ -7074,7 +7088,7 @@ fn skills_page_empty_state_keeps_summary_and_shows_guidance() { // The summary bar stays; the blank table body is replaced with the // shared empty-state guidance (same style as MCP/Prompts/Providers). - assert!(all.contains(&texts::tui_skills_installed_counts(0, 0, 0, 0, 0))); + assert!(all.contains(&texts::tui_skills_installed_counts(0, 0, 0, 0, 0, 0))); assert!(all.contains(texts::tui_skills_empty_title())); assert!(all.contains(texts::tui_skills_empty_subtitle())); } @@ -7134,6 +7148,7 @@ fn skills_page_shows_opencode_summary() { gemini: false, opencode: true, hermes: false, + pi: false, }; data.skills.installed = vec![skill]; @@ -7160,6 +7175,7 @@ fn skills_page_shows_hermes_column_and_summary() { gemini: false, opencode: false, hermes: true, + pi: false, }; data.skills.installed = vec![skill]; @@ -7189,6 +7205,7 @@ fn skill_detail_page_shows_opencode_enabled_state() { gemini: false, opencode: true, hermes: false, + pi: false, }; data.skills.installed = vec![skill]; @@ -7219,6 +7236,7 @@ fn skill_detail_page_shows_hermes_enabled_state() { gemini: false, opencode: false, hermes: true, + pi: false, }; data.skills.installed = vec![skill]; @@ -13125,6 +13143,28 @@ fn opencode_provider_list_marks_rows_in_config_without_current_marker() { assert!(!provider_line.contains("*"), "{provider_line}"); } +#[test] +fn pi_provider_list_shows_membership_without_exposing_global_default() { + let _lock = lock_env(); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Pi)); + app.route = Route::Providers; + app.focus = Focus::Content; + let mut data = minimal_data(&app.app_type); + data.providers.rows[0].is_in_config = true; + data.providers.rows[0].is_default_model = true; + + let buf = render(&app, &data); + let provider_line = (0..buf.area.height) + .map(|y| line_at(&buf, y)) + .find(|line| line.contains("Demo Provider")) + .expect("provider row rendered"); + + assert!(provider_line.contains("+"), "{provider_line}"); + assert!(!provider_line.contains("*"), "{provider_line}"); +} + #[test] fn openclaw_provider_list_key_bar_shows_edit_for_tracked_provider() { let _lock = lock_env(); diff --git a/src-tauri/src/cli/ui/colors.rs b/src-tauri/src/cli/ui/colors.rs index 8d0470585..d9c147e51 100644 --- a/src-tauri/src/cli/ui/colors.rs +++ b/src-tauri/src/cli/ui/colors.rs @@ -36,6 +36,7 @@ fn inquire_color_for_app(app_type: &AppType) -> InquireColor { AppType::OpenCode => InquireColor::LightGreen, AppType::Hermes => InquireColor::LightBlue, AppType::OpenClaw => InquireColor::LightRed, + AppType::Pi => InquireColor::LightCyan, } } @@ -88,6 +89,7 @@ fn highlight_color_for_app(app_type: &AppType) -> Color { AppType::OpenCode => Color::BrightGreen, AppType::Hermes => Color::BrightBlue, AppType::OpenClaw => Color::BrightRed, + AppType::Pi => Color::BrightCyan, } } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 4279fdb40..220b104c1 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -23,6 +23,14 @@ pub(crate) fn home_dir() -> Option { dirs::home_dir() } +/// 获取用户主目录,缺失时回退到当前目录。 +pub fn get_home_dir() -> PathBuf { + home_dir().unwrap_or_else(|| { + log::warn!("无法获取用户主目录,回退到当前目录"); + PathBuf::from(".") + }) +} + /// 获取 Claude Code 配置目录路径 /// /// Priority: `CLAUDE_CONFIG_DIR` env var > cc-switch settings override > `$HOME/.claude` @@ -307,8 +315,21 @@ pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> { /// 原子写入:写入临时文件后 rename 替换,避免半写状态 pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { + atomic_write_with_privacy(path, data, false) +} + +/// 原子写入包含凭据的文件。Unix 上新文件和替换文件始终使用 0600。 +pub fn atomic_write_private(path: &Path, data: &[u8]) -> Result<(), AppError> { + atomic_write_with_privacy(path, data, true) +} + +fn atomic_write_with_privacy( + path: &Path, + data: &[u8], + force_private: bool, +) -> Result<(), AppError> { let managed_write_path = resolve_managed_storage_path(path)?; - let should_restrict_file = should_restrict_sensitive_config_file(path)?; + let should_restrict_file = force_private || should_restrict_sensitive_config_file(path)?; let write_path = managed_write_path.unwrap_or_else(|| path.to_path_buf()); if path != write_path && should_restrict_file { debug_assert!(write_path.is_absolute()); diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 79827523e..7668d9c85 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -45,6 +45,7 @@ impl Database { gemini: row.get(10)?, opencode: row.get(11)?, hermes: row.get(12)?, + pi: false, }, installed_at: row.get(13)?, content_hash: row.get(14)?, @@ -89,6 +90,7 @@ impl Database { gemini: row.get(10)?, opencode: row.get(11)?, hermes: row.get(12)?, + pi: false, }, installed_at: row.get(13)?, content_hash: row.get(14)?, diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index eb1cc89c1..aa195e7c9 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -191,10 +191,10 @@ fn parse_prompt_deeplink( // Validate app type if !matches!( app.as_str(), - "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" | "pi" ) { return Err(AppError::InvalidInput(format!( - "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'" + "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'hermes', or 'pi', got '{app}'" ))); } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 7c1596ceb..77621b7c2 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -142,6 +142,11 @@ fn build_provider_from_request( AppType::OpenCode => build_opencode_settings(request), AppType::Hermes => build_hermes_settings(request), AppType::OpenClaw => build_openclaw_settings(request), + AppType::Pi => { + return Err(AppError::InvalidInput( + "Pi providers must be added from the Pi provider page".to_string(), + )); + } }; let meta = build_provider_meta(request)?; diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index af93daf5b..07982b07c 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -11,6 +11,9 @@ pub enum AppError { Database(String), #[error("无效输入: {0}")] InvalidInput(String), + /// Native files changed after CC Switch last read them. + #[error("并发冲突: {0}")] + Conflict(String), #[error("IO 错误: {path}: {source}")] Io { path: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2815a8ca6..bfe390ac0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -27,6 +27,7 @@ mod mcp; mod model_capabilities; mod openclaw_config; mod opencode_config; +mod pi_config; mod prompt; mod prompt_files; mod provider; diff --git a/src-tauri/src/pi_config/mod.rs b/src-tauri/src/pi_config/mod.rs new file mode 100644 index 000000000..f54a87f24 --- /dev/null +++ b/src-tauri/src/pi_config/mod.rs @@ -0,0 +1,645 @@ +//! Thin adapter for Pi's native files. +//! +//! Pi owns account login and the active provider/model in `settings.json`. +//! CC Switch only manages explicit provider entries in `models.json`. + +use crate::config::{atomic_write_private, get_home_dir}; +use crate::error::AppError; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex, MutexGuard}; + +const MAX_PI_FILE_BYTES: u64 = 1024 * 1024; +const MISSING_MODELS_REVISION: &str = "missing"; +static MODELS_FILE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +#[cfg(test)] +static TEST_AGENT_DIR: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiNativeDefaults { + #[serde(skip_serializing_if = "Option::is_none")] + pub default_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_dir: Option, +} + +pub(crate) fn get_pi_agent_dir() -> Result { + #[cfg(test)] + if let Some(path) = TEST_AGENT_DIR + .lock() + .expect("lock Pi test directory") + .clone() + { + return resolve_pi_agent_dir(Some(path), None, get_home_dir().join(".pi").join("agent")); + } + + resolve_pi_agent_dir( + crate::settings::get_pi_override_dir(), + std::env::var_os("PI_CODING_AGENT_DIR"), + get_home_dir().join(".pi").join("agent"), + ) +} + +fn resolve_pi_agent_dir( + settings_override: Option, + env_override: Option, + default_path: PathBuf, +) -> Result { + let (path, source) = match settings_override { + Some(path) => (path, "Pi settings override"), + None => match env_override { + Some(value) if !value.is_empty() => ( + crate::settings::resolve_override_path(value.to_string_lossy().as_ref()), + "PI_CODING_AGENT_DIR", + ), + _ => (default_path, "Pi default"), + }, + }; + if !path.is_absolute() { + return Err(AppError::InvalidInput(format!( + "{source} must resolve to an absolute directory: {}", + path.display() + ))); + } + Ok(path) +} + +pub(crate) fn get_pi_models_path() -> Result { + Ok(get_pi_agent_dir()?.join("models.json")) +} + +pub(crate) fn get_pi_settings_path() -> Result { + Ok(get_pi_agent_dir()?.join("settings.json")) +} + +pub(crate) fn read_pi_native_defaults() -> Result { + let path = get_pi_settings_path()?; + if !path.exists() { + return Ok(PiNativeDefaults::default()); + } + let value = read_json5_value(&path, "Pi settings")?; + let object = value.as_object().ok_or_else(|| { + AppError::Config(format!( + "Pi settings root must be an object: {}", + path.display() + )) + })?; + Ok(PiNativeDefaults { + default_provider: optional_string(object, "defaultProvider", &path)?, + default_model: optional_string(object, "defaultModel", &path)?, + session_dir: optional_string(object, "sessionDir", &path)?, + }) +} + +pub(crate) fn read_pi_native_providers() -> Result, AppError> { + let _guard = lock_models_file()?; + read_pi_native_providers_locked(&get_pi_models_path()?) +} + +pub(crate) fn read_pi_native_provider(provider_key: &str) -> Result, AppError> { + let _guard = lock_models_file()?; + let path = get_pi_models_path()?; + let document = read_models_document(&path)?; + Ok(providers(&document, &path)?.get(provider_key).cloned()) +} + +pub(crate) fn pi_provider_exists(provider_key: &str) -> Result { + let _guard = lock_models_file()?; + let path = get_pi_models_path()?; + let document = read_models_document(&path)?; + Ok(providers(&document, &path)?.contains_key(provider_key)) +} + +pub(crate) fn insert_pi_provider(provider_key: &str, config: &Value) -> Result { + validate_provider_node(provider_key, config)?; + let _guard = lock_models_file()?; + let path = get_pi_models_path()?; + let (mut document, expected_revision) = read_models_document_with_revision(&path)?; + let providers = providers_mut(&mut document, &path)?; + + match providers.get(provider_key) { + Some(current) if current == config => return Ok(false), + Some(_) => { + return Err(AppError::InvalidInput(format!( + "Pi provider key '{provider_key}' already exists in models.json" + ))) + } + None => {} + } + + providers.insert(provider_key.to_string(), config.clone()); + write_models_document(&path, &document, &expected_revision)?; + Ok(true) +} + +pub(crate) fn replace_pi_provider( + provider_key: &str, + expected: &Value, + replacement: &Value, +) -> Result<(), AppError> { + validate_provider_node(provider_key, replacement)?; + let _guard = lock_models_file()?; + let path = get_pi_models_path()?; + let (mut document, expected_revision) = read_models_document_with_revision(&path)?; + let providers = providers_mut(&mut document, &path)?; + let current = providers.get(provider_key).ok_or_else(|| { + AppError::Conflict(format!( + "Pi provider '{provider_key}' is no longer present in models.json" + )) + })?; + if current != expected { + return Err(AppError::Conflict(format!( + "Pi provider '{provider_key}' changed outside CC Switch" + ))); + } + if current == replacement { + return Ok(()); + } + providers.insert(provider_key.to_string(), replacement.clone()); + write_models_document(&path, &document, &expected_revision) +} + +pub(crate) fn replace_pi_provider_if_present( + provider_key: &str, + replacement: &Value, +) -> Result, AppError> { + validate_provider_node(provider_key, replacement)?; + let _guard = lock_models_file()?; + let path = get_pi_models_path()?; + let (mut document, expected_revision) = read_models_document_with_revision(&path)?; + let providers = providers_mut(&mut document, &path)?; + let Some(current) = providers.get(provider_key).cloned() else { + return Ok(None); + }; + if current == *replacement { + return Ok(Some(current)); + } + providers.insert(provider_key.to_string(), replacement.clone()); + write_models_document(&path, &document, &expected_revision)?; + Ok(Some(current)) +} + +pub(crate) fn remove_pi_provider(provider_key: &str) -> Result, AppError> { + remove_pi_provider_inner(provider_key, None) +} + +pub(crate) fn remove_pi_provider_if_matches( + provider_key: &str, + expected: &Value, +) -> Result { + remove_pi_provider_inner(provider_key, Some(expected)).map(|removed| removed.is_some()) +} + +fn remove_pi_provider_inner( + provider_key: &str, + expected: Option<&Value>, +) -> Result, AppError> { + let _guard = lock_models_file()?; + let path = get_pi_models_path()?; + let (mut document, expected_revision) = read_models_document_with_revision(&path)?; + let providers = providers_mut(&mut document, &path)?; + let Some(current) = providers.get(provider_key).cloned() else { + return Ok(None); + }; + if expected.is_some_and(|expected| current != *expected) { + return Err(AppError::Conflict(format!( + "Pi provider '{provider_key}' changed outside CC Switch" + ))); + } + providers.remove(provider_key); + write_models_document(&path, &document, &expected_revision)?; + Ok(Some(current)) +} + +pub(crate) fn restore_pi_provider_if_missing( + provider_key: &str, + config: &Value, +) -> Result<(), AppError> { + let _guard = lock_models_file()?; + let path = get_pi_models_path()?; + let (mut document, expected_revision) = read_models_document_with_revision(&path)?; + let providers = providers_mut(&mut document, &path)?; + match providers.get(provider_key) { + Some(current) if current == config => Ok(()), + Some(_) => Err(AppError::Conflict(format!( + "cannot restore Pi provider '{provider_key}' because another value now owns the key" + ))), + None => { + providers.insert(provider_key.to_string(), config.clone()); + write_models_document(&path, &document, &expected_revision) + } + } +} + +/// Validate the shape CC Switch can persist as one +/// `models.json.providers.` node. +/// +/// Provider ownership is intentionally source-based: every explicit object in +/// `models.json.providers` is manageable, including keys also built into Pi. +/// Pi's `/login` credentials live in `auth.json` and are never read here. +pub(crate) fn validate_provider_node(provider_key: &str, config: &Value) -> Result<(), AppError> { + if provider_key.trim().is_empty() { + return Err(AppError::InvalidInput( + "Pi provider key cannot be empty".to_string(), + )); + } + config.as_object().ok_or_else(|| { + AppError::InvalidInput("Pi provider configuration must be an object".to_string()) + })?; + Ok(()) +} + +pub(crate) fn provider_base_url(config: &Value) -> Result { + let provider = config.as_object().ok_or_else(|| { + AppError::InvalidInput("Pi provider configuration must be an object".to_string()) + })?; + nonempty_string(provider.get("baseUrl")) + .or_else(|| { + provider + .get("models") + .and_then(Value::as_array) + .and_then(|models| { + models + .iter() + .find_map(|model| nonempty_string(model.get("baseUrl"))) + }) + }) + .map(str::to_string) + .ok_or_else(|| AppError::InvalidInput("Pi provider has no request URL".to_string())) +} + +pub(crate) fn is_valid_request_url(raw: &str) -> bool { + url::Url::parse(raw.trim()) + .is_ok_and(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some()) +} + +fn lock_models_file() -> Result, AppError> { + MODELS_FILE_LOCK + .lock() + .map_err(|error| AppError::Config(format!("Pi models file lock is poisoned: {error}"))) +} + +fn read_pi_native_providers_locked(path: &Path) -> Result, AppError> { + let document = read_models_document(path)?; + let providers = providers(&document, path)?; + Ok(providers + .iter() + .map(|(provider_key, config)| (provider_key.clone(), config.clone())) + .collect()) +} + +fn read_models_document(path: &Path) -> Result { + read_models_document_with_revision(path).map(|(document, _)| document) +} + +fn read_models_document_with_revision(path: &Path) -> Result<(Value, String), AppError> { + if !path.exists() { + return Ok(( + Value::Object(Map::new()), + MISSING_MODELS_REVISION.to_string(), + )); + } + let bytes = read_file_limited(path, "Pi models")?; + let revision = revision(&bytes); + let document = parse_json5_value(path, "Pi models", bytes)?; + Ok((document, revision)) +} + +fn read_json5_value(path: &Path, label: &str) -> Result { + parse_json5_value(path, label, read_file_limited(path, label)?) +} + +fn read_file_limited(path: &Path, label: &str) -> Result, AppError> { + let file = fs::File::open(path).map_err(|error| AppError::io(path, error))?; + let metadata = file.metadata().map_err(|error| AppError::io(path, error))?; + if metadata.len() > MAX_PI_FILE_BYTES { + return Err(AppError::InvalidInput(format!( + "{label} file exceeds the 1 MiB limit: {}", + path.display() + ))); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(MAX_PI_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| AppError::io(path, error))?; + if bytes.len() as u64 > MAX_PI_FILE_BYTES { + return Err(AppError::InvalidInput(format!( + "{label} file exceeds the 1 MiB limit: {}", + path.display() + ))); + } + Ok(bytes) +} + +fn parse_json5_value(path: &Path, label: &str, bytes: Vec) -> Result { + let source = String::from_utf8(bytes).map_err(|error| { + AppError::Config(format!( + "{label} file must be UTF-8 ({}): {error}", + path.display() + )) + })?; + json5::from_str(&source).map_err(|error| { + AppError::Config(format!( + "{label} file is not valid JSON/JSONC ({}): {error}", + path.display() + )) + }) +} + +fn providers<'a>(document: &'a Value, path: &Path) -> Result<&'a Map, AppError> { + let root = document.as_object().ok_or_else(|| { + AppError::Config(format!( + "Pi models root must be an object: {}", + path.display() + )) + })?; + match root.get("providers") { + None => Ok(empty_json_object()), + Some(Value::Object(providers)) => Ok(providers), + Some(_) => Err(AppError::Config(format!( + "Pi models 'providers' must be an object: {}", + path.display() + ))), + } +} + +fn providers_mut<'a>( + document: &'a mut Value, + path: &Path, +) -> Result<&'a mut Map, AppError> { + let root = document.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "Pi models root must be an object: {}", + path.display() + )) + })?; + let value = root + .entry("providers".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + value.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "Pi models 'providers' must be an object: {}", + path.display() + )) + }) +} + +fn empty_json_object() -> &'static Map { + static EMPTY: LazyLock> = LazyLock::new(Map::new); + &EMPTY +} + +fn write_models_document( + path: &Path, + document: &Value, + expected_revision: &str, +) -> Result<(), AppError> { + let mut bytes = + serde_json::to_vec_pretty(document).map_err(|source| AppError::JsonSerialize { source })?; + bytes.push(b'\n'); + ensure_private_models_parent(path)?; + ensure_models_revision(path, expected_revision)?; + atomic_write_private(path, &bytes) +} + +fn ensure_models_revision(path: &Path, expected_revision: &str) -> Result<(), AppError> { + let actual_revision = match fs::File::open(path) { + Ok(_) => revision(&read_file_limited(path, "Pi models")?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + MISSING_MODELS_REVISION.to_string() + } + Err(error) => return Err(AppError::io(path, error)), + }; + if actual_revision == expected_revision { + Ok(()) + } else { + Err(AppError::Conflict(format!( + "Pi models.json changed outside CC Switch: {}", + path.display() + ))) + } +} + +fn revision(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn ensure_private_models_parent(path: &Path) -> Result<(), AppError> { + let parent = path.parent().ok_or_else(|| { + AppError::Config(format!( + "Pi models path has no parent directory: {}", + path.display() + )) + })?; + let created = !parent.exists(); + fs::create_dir_all(parent).map_err(|source| AppError::io(parent, source))?; + + #[cfg(not(unix))] + let _ = created; + + #[cfg(unix)] + if created { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) + .map_err(|source| AppError::io(parent, source))?; + } + + Ok(()) +} + +fn optional_string( + object: &Map, + key: &str, + path: &Path, +) -> Result, AppError> { + match object.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(AppError::Config(format!( + "Pi settings '{key}' must be a string: {}", + path.display() + ))), + } +} + +fn nonempty_string(value: Option<&Value>) -> Option<&str> { + value + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::path::{Path, PathBuf}; + + pub(crate) struct TestAgentDir { + _dir: Option, + previous: Option, + } + + impl TestAgentDir { + pub(crate) fn new() -> Self { + let dir = tempfile::tempdir().expect("create Pi test directory"); + let agent_dir = dir.path().join("agent"); + Self::set(agent_dir, Some(dir)) + } + + pub(crate) fn at(agent_dir: &Path) -> Self { + Self::set(agent_dir.to_path_buf(), None) + } + + fn set(agent_dir: PathBuf, dir: Option) -> Self { + let previous = super::TEST_AGENT_DIR + .lock() + .expect("lock Pi test directory") + .replace(agent_dir); + Self { + _dir: dir, + previous, + } + } + } + + impl Drop for TestAgentDir { + fn drop(&mut self) { + *super::TEST_AGENT_DIR + .lock() + .expect("lock Pi test directory") = self.previous.take(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serial_test::serial; + + fn provider() -> Value { + json!({ + "name": "Example", + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "apiKey": "secret", + "models": [{"id": "example-model"}] + }) + } + + #[test] + fn provider_node_accepts_unknown_native_fields() { + let mut value = provider(); + value["sdkOption"] = json!({"timeout": 30}); + value["models"][0]["compat"] = json!({"supportsDeveloperRole": true}); + validate_provider_node("cc-switch-example", &value).expect("valid provider"); + } + + #[test] + fn provider_node_ownership_depends_on_models_json_membership() { + let mut oauth = provider(); + oauth["oauth"] = json!("anthropic"); + validate_provider_node("cc-switch-example", &oauth) + .expect("an explicit models.json node stays manageable"); + validate_provider_node("anthropic", &json!({})) + .expect("a built-in provider key may be explicitly configured"); + assert!(validate_provider_node("", &json!({})).is_err()); + assert!(validate_provider_node("anthropic", &json!("invalid")).is_err()); + } + + #[test] + fn relative_agent_directory_is_rejected() { + let error = resolve_pi_agent_dir( + None, + Some("relative/pi-agent".into()), + PathBuf::from("default"), + ) + .expect_err("relative Pi directory must be rejected"); + assert!(error.to_string().contains("absolute directory")); + } + + #[test] + fn settings_directory_precedes_the_environment() { + let temp = tempfile::tempdir().expect("tempdir"); + let settings_dir = temp.path().join("settings-agent"); + let env_dir = temp.path().join("env-agent"); + + assert_eq!( + resolve_pi_agent_dir( + Some(settings_dir.clone()), + Some(env_dir.into_os_string()), + temp.path().join("default-agent"), + ) + .expect("resolve Pi directory"), + settings_dir + ); + } + + #[test] + #[serial] + fn duplicate_provider_key_is_validation_not_a_write_conflict() { + let _agent = test_support::TestAgentDir::new(); + insert_pi_provider("duplicate", &provider()).expect("insert provider"); + let mut replacement = provider(); + replacement["name"] = json!("Other"); + + let error = insert_pi_provider("duplicate", &replacement) + .expect_err("duplicate provider key must be rejected"); + assert!(matches!(error, AppError::InvalidInput(_))); + } + + #[cfg(unix)] + #[test] + #[serial] + fn newly_created_models_file_and_agent_directory_are_private() { + use std::os::unix::fs::PermissionsExt; + + let _agent = test_support::TestAgentDir::new(); + insert_pi_provider("cc-switch-private", &provider()).expect("write private models file"); + + let path = get_pi_models_path().expect("models path"); + let file_mode = fs::metadata(&path) + .expect("models metadata") + .permissions() + .mode() + & 0o777; + let directory_mode = fs::metadata(path.parent().expect("agent directory")) + .expect("agent directory metadata") + .permissions() + .mode() + & 0o777; + + assert_eq!(file_mode, 0o600); + assert_eq!(directory_mode, 0o700); + } + + #[test] + #[serial] + fn stale_models_revision_does_not_overwrite_an_external_edit() { + let _agent = test_support::TestAgentDir::new(); + let path = get_pi_models_path().expect("models path"); + ensure_private_models_parent(&path).expect("create agent directory"); + fs::write(&path, r#"{"providers":{"external":{"models":[]}}}"#) + .expect("write initial models"); + let (_, stale_revision) = + read_models_document_with_revision(&path).expect("read models revision"); + + let external = r#"{"providers":{"external":{"models":[]},"pi-added":{"models":[]}}}"#; + fs::write(&path, external).expect("edit models externally"); + + let replacement = json!({"providers": {"cc-switch": provider()}}); + let error = write_models_document(&path, &replacement, &stale_revision) + .expect_err("stale write must fail"); + assert!(matches!(error, AppError::Conflict(_))); + assert_eq!( + fs::read_to_string(path).expect("read external models"), + external + ); + } +} diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 20552b79e..ad4769088 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -17,6 +17,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::OpenCode => get_opencode_dir(), AppType::Hermes => get_hermes_override_dir().unwrap_or_else(default_hermes_dir), AppType::OpenClaw => get_openclaw_override_dir().unwrap_or_else(default_openclaw_dir), + AppType::Pi => crate::pi_config::get_pi_agent_dir()?, }; let filename = match app { @@ -26,6 +27,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::OpenCode => "AGENTS.md", AppType::Hermes => "AGENTS.md", AppType::OpenClaw => "AGENTS.md", + AppType::Pi => "AGENTS.md", }; Ok(base_dir.join(filename)) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 11100c38a..6b71185a1 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -146,6 +146,7 @@ impl Provider { .get("apiKey") .or_else(|| self.settings_config.get("api_key")), AppType::OpenClaw => self.settings_config.get("apiKey"), + AppType::Pi => self.settings_config.get("apiKey"), }; value @@ -252,7 +253,7 @@ impl Provider { AppType::Claude => "claude", AppType::Codex => "codex", AppType::Gemini => "gemini", - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => return None, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => return None, }; if self.category.as_deref() == Some("official") { @@ -292,7 +293,7 @@ impl Provider { .is_none_or(|value| value.trim().is_empty()); api_key_missing && base_url_missing } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, }; is_official.then_some(tool) diff --git a/src-tauri/src/provider_preset_sponsors.rs b/src-tauri/src/provider_preset_sponsors.rs index ff70bfe81..cb53bc10f 100644 --- a/src-tauri/src/provider_preset_sponsors.rs +++ b/src-tauri/src/provider_preset_sponsors.rs @@ -220,6 +220,7 @@ pub(crate) fn sponsor_provider_presets_for_app( AppType::Codex => &CODEX_SPONSOR_PRESETS, AppType::Gemini => &GEMINI_SPONSOR_PRESETS, AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => &ADDITIVE_SPONSOR_PRESETS, + AppType::Pi => &[], } } diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 0050e2b87..4ba69c947 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -287,7 +287,7 @@ impl RequestForwarder { attempted_providers += 1; pending_upstream_response = None; let provider_needs_transform = matches!(app_type, AppType::Claude) - && get_adapter(app_type).needs_transform(&provider); + && get_adapter(app_type).is_some_and(|adapter| adapter.needs_transform(&provider)); match self .send_streaming_request( @@ -532,7 +532,7 @@ impl RequestForwarder { attempted_providers += 1; pending_upstream_response = None; let provider_needs_transform = matches!(app_type, AppType::Claude) - && get_adapter(app_type).needs_transform(&provider); + && get_adapter(app_type).is_some_and(|adapter| adapter.needs_transform(&provider)); match self .send_buffered_request( diff --git a/src-tauri/src/proxy/forwarder/request_builder.rs b/src-tauri/src/proxy/forwarder/request_builder.rs index b5a0deb87..3150c5e12 100644 --- a/src-tauri/src/proxy/forwarder/request_builder.rs +++ b/src-tauri/src/proxy/forwarder/request_builder.rs @@ -133,7 +133,12 @@ impl RequestForwarder { headers: &HeaderMap, options: ForwardOptions, ) -> Result { - let adapter = get_adapter(app_type); + let adapter = get_adapter(app_type).ok_or_else(|| { + ProxyError::ConfigError(format!( + "{} does not support proxy routing", + app_type.as_str() + )) + })?; let is_claude_request = matches!(app_type, AppType::Claude); let mut upstream_endpoint = self.router.upstream_endpoint(app_type, provider, endpoint); let mut base_url = adapter.extract_base_url(provider)?; diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 77f145eb6..329d5ef4b 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -146,7 +146,9 @@ impl ProviderType { } ProviderType::Gemini } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => ProviderType::Codex, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + ProviderType::Codex + } } } @@ -190,15 +192,16 @@ impl std::str::FromStr for ProviderType { } } -pub fn get_adapter(app_type: &AppType) -> Box { - match app_type { +pub fn get_adapter(app_type: &AppType) -> Option> { + Some(match app_type { AppType::Claude => Box::new(ClaudeAdapter::new()), AppType::Codex => Box::new(CodexAdapter::new()), AppType::Gemini => Box::new(GeminiAdapter::new()), AppType::OpenCode => Box::new(CodexAdapter::new()), AppType::Hermes => Box::new(CodexAdapter::new()), AppType::OpenClaw => Box::new(CodexAdapter::new()), - } + AppType::Pi => return None, + }) } #[allow(dead_code)] diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index ac5cf9498..ece915908 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -315,6 +315,7 @@ impl ConfigService { AppType::OpenCode => {} AppType::Hermes => {} AppType::OpenClaw => {} + AppType::Pi => {} } Ok(()) diff --git a/src-tauri/src/services/local_env_check.rs b/src-tauri/src/services/local_env_check.rs index 4ee016410..bcb520841 100644 --- a/src-tauri/src/services/local_env_check.rs +++ b/src-tauri/src/services/local_env_check.rs @@ -28,16 +28,18 @@ pub enum LocalTool { OpenCode, Hermes, OpenClaw, + Pi, } impl LocalTool { - pub const ALL: [LocalTool; 6] = [ + pub const ALL: [LocalTool; 7] = [ LocalTool::Claude, LocalTool::Codex, LocalTool::Gemini, LocalTool::OpenCode, LocalTool::Hermes, LocalTool::OpenClaw, + LocalTool::Pi, ]; pub fn all() -> &'static [LocalTool] { @@ -52,6 +54,7 @@ impl LocalTool { LocalTool::OpenCode => "OpenCode", LocalTool::Hermes => "Hermes", LocalTool::OpenClaw => "OpenClaw", + LocalTool::Pi => "Pi", } } @@ -63,6 +66,7 @@ impl LocalTool { LocalTool::OpenCode => "opencode", LocalTool::Hermes => "hermes", LocalTool::OpenClaw => "openclaw", + LocalTool::Pi => "pi", } } @@ -74,6 +78,7 @@ impl LocalTool { LocalTool::OpenCode => &["--version", "version"], LocalTool::Hermes => &["--version", "version"], LocalTool::OpenClaw => &["--version", "version"], + LocalTool::Pi => &["--version"], } } @@ -100,6 +105,7 @@ impl LocalTool { AppType::OpenCode => LocalTool::OpenCode, AppType::Hermes => LocalTool::Hermes, AppType::OpenClaw => LocalTool::OpenClaw, + AppType::Pi => LocalTool::Pi, } } } @@ -853,10 +859,11 @@ mod tests { assert_eq!( display_names, - vec!["Claude", "Codex", "Gemini", "OpenCode", "Hermes", "OpenClaw"] + vec!["Claude", "Codex", "Gemini", "OpenCode", "Hermes", "OpenClaw", "Pi"] ); assert_eq!(LocalTool::Hermes.binary_name(), "hermes"); assert_eq!(LocalTool::OpenClaw.binary_name(), "openclaw"); + assert_eq!(LocalTool::Pi.binary_name(), "pi"); assert_eq!(LocalTool::Hermes.version_timeout(), Duration::from_secs(10)); assert_eq!(LocalTool::Claude.version_timeout(), Duration::from_secs(5)); } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index b0c7ecb99..b6a6bdf16 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -217,6 +217,7 @@ impl McpService { mcp::sync_single_server_to_hermes(cfg, &server.id, &server.server)?; } AppType::OpenClaw => {} + AppType::Pi => {} } Ok(()) } @@ -242,6 +243,7 @@ impl McpService { AppType::OpenCode => mcp::remove_server_from_opencode(id)?, AppType::Hermes => mcp::remove_server_from_hermes(id)?, AppType::OpenClaw => {} + AppType::Pi => {} } Ok(()) } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 47884bd47..dd7f9f684 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -16,6 +16,8 @@ pub mod global_proxy; pub mod local_env_check; pub mod mcp; pub mod model_fetch; +pub(crate) mod pi_prompt_files; +pub(crate) mod pi_state; pub mod prompt; pub mod provider; pub mod proxy; @@ -27,6 +29,7 @@ pub mod session_usage_codex; pub mod session_usage_driver; pub mod session_usage_gemini; pub mod session_usage_opencode; +pub mod session_usage_pi; pub mod skill; pub mod speedtest; pub mod sql_helpers; diff --git a/src-tauri/src/services/pi_prompt_files.rs b/src-tauri/src/services/pi_prompt_files.rs new file mode 100644 index 000000000..454116c66 --- /dev/null +++ b/src-tauri/src/services/pi_prompt_files.rs @@ -0,0 +1,521 @@ +//! Pi-native instruction files and slash-command templates. + +use crate::config::atomic_write; +use crate::error::AppError; +use crate::pi_config::get_pi_agent_dir; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex, MutexGuard}; + +const MAX_PROMPT_FILE_BYTES: u64 = 1024 * 1024; +const MAX_TEMPLATE_SLUG_BYTES: usize = 128; +const MISSING_REVISION: &str = "missing"; +static PROMPT_FILE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PiAgentsFileSnapshot { + pub content: Option, + pub revision: String, +} + +/// Coordinates every CC Switch read-modify-write operation on Pi's AGENTS.md. +/// +/// Keeping the guard alive across the database update lets callers compare the +/// file revision immediately before an atomic replacement and roll back their +/// database write if Pi or another editor changed the file in the meantime. +pub(crate) struct PiAgentsFileGuard { + _guard: MutexGuard<'static, ()>, + path: PathBuf, +} + +impl PiAgentsFileGuard { + pub(crate) fn acquire() -> Result { + Ok(Self { + _guard: lock_prompt_files()?, + path: get_pi_agent_dir()?.join("AGENTS.md"), + }) + } + + pub(crate) fn read(&self) -> Result { + let (content, file_revision) = match fs::File::open(&self.path) { + Ok(file) => { + let bytes = read_open_file_limited(file, &self.path, "Pi AGENTS.md")?; + let file_revision = revision(&bytes); + let content = String::from_utf8(bytes).map_err(|error| { + AppError::InvalidInput(format!( + "Pi AGENTS.md must be UTF-8 ({}): {error}", + self.path.display() + )) + })?; + (Some(content), file_revision) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + (None, MISSING_REVISION.to_string()) + } + Err(error) => return Err(AppError::io(&self.path, error)), + }; + Ok(PiAgentsFileSnapshot { + content, + revision: file_revision, + }) + } + + pub(crate) fn replace(&self, expected_revision: &str, content: &str) -> Result<(), AppError> { + validate_content_size(content, "Pi AGENTS.md")?; + ensure_revision(&self.path, expected_revision, "Pi AGENTS.md")?; + atomic_write(&self.path, content.as_bytes()) + } + + pub(crate) fn delete(&self, expected_revision: &str) -> Result<(), AppError> { + ensure_revision(&self.path, expected_revision, "Pi AGENTS.md")?; + match fs::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(AppError::io(&self.path, error)), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PiPromptFileKind { + SystemOverride, + SystemAppend, +} + +impl PiPromptFileKind { + fn filename(self) -> &'static str { + match self { + Self::SystemOverride => "SYSTEM.md", + Self::SystemAppend => "APPEND_SYSTEM.md", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiPromptFileSnapshot { + pub exists: bool, + pub revision: String, + pub content: String, +} + +pub struct PiPromptFileService; + +impl PiPromptFileService { + pub fn read(kind: PiPromptFileKind) -> Result { + let _guard = lock_prompt_files()?; + read_prompt_file(&get_pi_agent_dir()?, kind) + } + + pub fn replace( + kind: PiPromptFileKind, + expected_revision: &str, + content: &str, + ) -> Result { + validate_instruction_content(content)?; + + let _guard = lock_prompt_files()?; + let root = get_pi_agent_dir()?; + let path = root.join(kind.filename()); + ensure_revision(&path, expected_revision, "Pi prompt file")?; + atomic_write(&path, content.as_bytes())?; + read_prompt_file(&root, kind) + } + + pub fn delete(kind: PiPromptFileKind, expected_revision: &str) -> Result { + let _guard = lock_prompt_files()?; + let path = get_pi_agent_dir()?.join(kind.filename()); + ensure_revision(&path, expected_revision, "Pi prompt file")?; + match fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(AppError::io(&path, error)), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiPromptTemplate { + pub slug: String, + pub content: String, + pub revision: String, +} + +pub struct PiPromptTemplateService; + +impl PiPromptTemplateService { + pub fn list() -> Result, AppError> { + let _guard = lock_prompt_files()?; + let dir = get_pi_agent_dir()?.join("prompts"); + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(AppError::io(&dir, error)), + }; + + let mut templates = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| AppError::io(&dir, error))?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("md") { + continue; + } + let Some(slug) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + if validate_template_slug(slug).is_err() { + continue; + } + let bytes = read_limited(&path, "Pi prompt template")?; + let content = String::from_utf8(bytes).map_err(|error| { + AppError::InvalidInput(format!( + "Pi prompt template must be UTF-8 ({}): {error}", + path.display() + )) + })?; + templates.push(PiPromptTemplate { + slug: slug.to_string(), + revision: revision(content.as_bytes()), + content, + }); + } + templates.sort_by(|left, right| left.slug.cmp(&right.slug)); + Ok(templates) + } + + pub fn upsert( + slug: &str, + original_slug: Option<&str>, + expected_revision: &str, + content: &str, + ) -> Result { + validate_template_slug(slug)?; + if let Some(original_slug) = original_slug { + validate_template_slug(original_slug)?; + } + validate_content_size(content, "Pi prompt template")?; + let _guard = lock_prompt_files()?; + let dir = get_pi_agent_dir()?.join("prompts"); + let path = template_path(&dir, slug); + + if let Some(original_slug) = original_slug.filter(|value| *value != slug) { + let original_path = template_path(&dir, original_slug); + ensure_revision(&original_path, expected_revision, "Pi prompt template")?; + ensure_revision(&path, MISSING_REVISION, "Pi prompt template")?; + fs::rename(&original_path, &path) + .map_err(|error| AppError::io(&original_path, error))?; + + if let Err(write_error) = atomic_write(&path, content.as_bytes()) { + if let Err(rollback_error) = fs::rename(&path, &original_path) { + return Err(AppError::Message(format!( + "Pi prompt template save failed ({write_error}); rename rollback also failed: {rollback_error}" + ))); + } + return Err(write_error); + } + } else { + ensure_revision(&path, expected_revision, "Pi prompt template")?; + atomic_write(&path, content.as_bytes())?; + } + + Ok(PiPromptTemplate { + slug: slug.to_string(), + content: content.to_string(), + revision: revision(content.as_bytes()), + }) + } + + pub fn delete(slug: &str, expected_revision: &str) -> Result { + validate_template_slug(slug)?; + let _guard = lock_prompt_files()?; + let path = template_path(&get_pi_agent_dir()?.join("prompts"), slug); + ensure_revision(&path, expected_revision, "Pi prompt template")?; + match fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(AppError::io(&path, error)), + } + } +} + +fn lock_prompt_files() -> Result, AppError> { + PROMPT_FILE_LOCK + .lock() + .map_err(|error| AppError::Config(format!("Pi prompt file lock is poisoned: {error}"))) +} + +fn read_prompt_file(root: &Path, kind: PiPromptFileKind) -> Result { + let path = root.join(kind.filename()); + let (exists, content, file_revision) = match fs::File::open(&path) { + Ok(file) => { + let bytes = read_open_file_limited(file, &path, "Pi prompt file")?; + let file_revision = revision(&bytes); + let content = String::from_utf8(bytes).map_err(|error| { + AppError::InvalidInput(format!( + "Pi prompt file must be UTF-8 ({}): {error}", + path.display() + )) + })?; + (true, content, file_revision) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + (false, String::new(), MISSING_REVISION.to_string()) + } + Err(error) => return Err(AppError::io(&path, error)), + }; + Ok(PiPromptFileSnapshot { + exists, + revision: file_revision, + content, + }) +} + +fn ensure_revision(path: &Path, expected: &str, label: &str) -> Result<(), AppError> { + let actual = match fs::File::open(path) { + Ok(file) => revision(&read_open_file_limited(file, path, label)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => MISSING_REVISION.to_string(), + Err(error) => return Err(AppError::io(path, error)), + }; + if actual == expected { + Ok(()) + } else { + Err(AppError::Conflict(format!( + "{label} changed outside CC Switch: {}", + path.display() + ))) + } +} + +fn read_limited(path: &Path, label: &str) -> Result, AppError> { + let file = fs::File::open(path).map_err(|error| AppError::io(path, error))?; + read_open_file_limited(file, path, label) +} + +fn read_open_file_limited(file: fs::File, path: &Path, label: &str) -> Result, AppError> { + let metadata = file.metadata().map_err(|error| AppError::io(path, error))?; + if metadata.len() > MAX_PROMPT_FILE_BYTES { + return Err(AppError::InvalidInput(format!( + "{label} exceeds the 1 MiB limit: {}", + path.display() + ))); + } + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(MAX_PROMPT_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| AppError::io(path, error))?; + if bytes.len() as u64 > MAX_PROMPT_FILE_BYTES { + return Err(AppError::InvalidInput(format!( + "{label} exceeds the 1 MiB limit: {}", + path.display() + ))); + } + Ok(bytes) +} + +fn validate_instruction_content(content: &str) -> Result<(), AppError> { + if content.trim().is_empty() { + return Err(AppError::InvalidInput( + "Pi instruction cannot be blank; remove the file to deactivate it".to_string(), + )); + } + validate_content_size(content, "Pi instruction") +} + +fn validate_content_size(content: &str, label: &str) -> Result<(), AppError> { + if content.len() as u64 > MAX_PROMPT_FILE_BYTES { + return Err(AppError::InvalidInput(format!( + "{label} exceeds the 1 MiB limit" + ))); + } + Ok(()) +} + +fn revision(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn template_path(dir: &Path, slug: &str) -> PathBuf { + dir.join(format!("{slug}.md")) +} + +pub(crate) fn validate_template_slug(slug: &str) -> Result<(), AppError> { + let basename = slug.split('.').next().unwrap_or_default(); + let windows_reserved = matches!( + basename.to_ascii_lowercase().as_str(), + "con" + | "prn" + | "aux" + | "nul" + | "com1" + | "com2" + | "com3" + | "com4" + | "com5" + | "com6" + | "com7" + | "com8" + | "com9" + | "lpt1" + | "lpt2" + | "lpt3" + | "lpt4" + | "lpt5" + | "lpt6" + | "lpt7" + | "lpt8" + | "lpt9" + ); + let valid = !slug.is_empty() + && slug.len() <= MAX_TEMPLATE_SLUG_BYTES + && !slug.starts_with('.') + && !slug.ends_with('.') + && !windows_reserved + && slug.chars().all(|character| { + !character.is_control() + && !character.is_whitespace() + && !matches!( + character, + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' + ) + }); + if valid { + Ok(()) + } else { + Err(AppError::InvalidInput( + "Pi prompt-template name must be one portable slash-command token".to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pi_config::test_support::TestAgentDir; + use serial_test::serial; + + #[test] + fn template_names_are_single_portable_tokens() { + for valid in ["review-pr", "release.v2", "评审"] { + validate_template_slug(valid).expect(valid); + } + for invalid in [ + "", + ".hidden", + "with space", + "a/b", + r"a\b", + "CON", + "lpt1.txt", + ] { + assert!(validate_template_slug(invalid).is_err(), "{invalid}"); + } + validate_content_size("", "Pi prompt template").expect("empty templates are native"); + assert!(validate_instruction_content("").is_err()); + } + + #[test] + #[serial] + fn empty_prompt_template_round_trips() { + let _agent = TestAgentDir::new(); + PiPromptTemplateService::upsert("empty", None, MISSING_REVISION, "") + .expect("create empty template"); + assert_eq!( + PiPromptTemplateService::list().expect("list templates"), + vec![PiPromptTemplate { + slug: "empty".to_string(), + content: String::new(), + revision: revision(b""), + }] + ); + } + + #[test] + #[serial] + fn prompt_template_can_be_renamed_and_updated_together() { + let _agent = TestAgentDir::new(); + let created = PiPromptTemplateService::upsert("draft", None, MISSING_REVISION, "before") + .expect("create template"); + + let renamed = + PiPromptTemplateService::upsert("review", Some("draft"), &created.revision, "after") + .expect("rename template"); + + assert_eq!(renamed.slug, "review"); + assert_eq!(renamed.content, "after"); + assert_eq!( + PiPromptTemplateService::list().expect("list renamed template"), + vec![renamed] + ); + assert!(!get_pi_agent_dir() + .expect("agent directory") + .join("prompts") + .join("draft.md") + .exists()); + } + + #[test] + #[serial] + fn oversized_prompt_template_is_rejected_before_it_is_loaded() { + let _agent = TestAgentDir::new(); + let path = get_pi_agent_dir() + .expect("agent directory") + .join("prompts") + .join("oversized.md"); + fs::create_dir_all(path.parent().expect("prompt directory")).expect("create prompt dir"); + let file = fs::File::create(&path).expect("create prompt"); + file.set_len(MAX_PROMPT_FILE_BYTES + 1) + .expect("make sparse oversized prompt"); + + let error = PiPromptTemplateService::list().expect_err("oversized prompt must be rejected"); + assert!(error.to_string().contains("1 MiB limit")); + } + + #[test] + #[serial] + fn agents_file_revision_rejects_an_external_edit() { + let _agent = TestAgentDir::new(); + let guard = PiAgentsFileGuard::acquire().expect("lock AGENTS.md"); + let snapshot = guard.read().expect("read missing AGENTS.md"); + assert!(snapshot.content.is_none()); + + let path = get_pi_agent_dir() + .expect("agent directory") + .join("AGENTS.md"); + fs::create_dir_all(path.parent().expect("agent directory")).expect("create agent dir"); + fs::write(&path, "external edit").expect("write AGENTS.md externally"); + + let error = guard + .replace(&snapshot.revision, "managed content") + .expect_err("stale revision must not overwrite the external edit"); + assert!(matches!(error, AppError::Conflict(_))); + assert_eq!( + fs::read_to_string(path).expect("read external edit"), + "external edit" + ); + } + + #[test] + #[serial] + fn oversized_agents_file_is_rejected_before_it_is_loaded() { + let _agent = TestAgentDir::new(); + let path = get_pi_agent_dir() + .expect("agent directory") + .join("AGENTS.md"); + fs::create_dir_all(path.parent().expect("agent directory")).expect("create agent dir"); + let file = fs::File::create(&path).expect("create AGENTS.md"); + file.set_len(MAX_PROMPT_FILE_BYTES + 1) + .expect("make sparse oversized AGENTS.md"); + + let guard = PiAgentsFileGuard::acquire().expect("lock AGENTS.md"); + let error = guard + .read() + .expect_err("oversized AGENTS.md must be rejected"); + assert!(error.to_string().contains("1 MiB limit")); + } +} diff --git a/src-tauri/src/services/pi_state.rs b/src-tauri/src/services/pi_state.rs new file mode 100644 index 000000000..260c12354 --- /dev/null +++ b/src-tauri/src/services/pi_state.rs @@ -0,0 +1,138 @@ +//! Read-only Pi provider membership and global default reference. + +use crate::error::AppError; +use crate::pi_config::{read_pi_native_defaults, read_pi_native_providers}; +use crate::store::AppState; +use serde::Serialize; + +const PI_APP: &str = "pi"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiCurrentState { + pub enabled_provider_ids: Vec, + pub default_provider_id: Option, +} + +pub(crate) struct PiStateService; + +impl PiStateService { + pub(crate) fn current(state: &AppState) -> Result { + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app(PI_APP)); + let native = read_pi_native_providers()?; + let enabled_provider_ids = native.keys().cloned().collect::>(); + let default_provider_id = match read_pi_native_defaults() { + Ok(defaults) => defaults.default_provider, + Err(error) => { + log::warn!("Failed to read Pi global default provider for advisory UI: {error}"); + None + } + }; + Ok(PiCurrentState { + enabled_provider_ids, + default_provider_id, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use crate::pi_config::test_support::TestAgentDir; + use serial_test::serial; + use std::fs; + use std::sync::Arc; + + #[test] + #[serial] + fn state_exposes_every_explicit_provider_node() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new( + Database::memory().expect("create in-memory database"), + )); + let models_path = crate::pi_config::get_pi_models_path().expect("models path"); + fs::create_dir_all(models_path.parent().expect("models directory")) + .expect("create models directory"); + fs::write( + models_path, + r#"{ + "providers": { + "cc-switch-managed": { + "name": "Managed", + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "models": [{ "id": "model-a" }] + }, + "native-oauth": { + "oauth": "example", + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "models": [{ "id": "model-b" }] + }, + "anthropic": {}, + "unsupported": { + "futureField": true + } + } + }"#, + ) + .expect("write models"); + let settings_path = crate::pi_config::get_pi_settings_path().expect("settings path"); + fs::write( + settings_path, + r#"{"defaultProvider":"cc-switch-managed","defaultModel":"model-a"}"#, + ) + .expect("write settings"); + + let current = PiStateService::current(&state).expect("read state"); + assert_eq!( + current.enabled_provider_ids, + vec![ + "cc-switch-managed".to_string(), + "native-oauth".to_string(), + "anthropic".to_string(), + "unsupported".to_string(), + ] + ); + assert_eq!( + current.default_provider_id.as_deref(), + Some("cc-switch-managed") + ); + } + + #[test] + #[serial] + fn invalid_global_settings_do_not_hide_provider_membership() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new( + Database::memory().expect("create in-memory database"), + )); + let models_path = crate::pi_config::get_pi_models_path().expect("models path"); + fs::create_dir_all(models_path.parent().expect("models directory")) + .expect("create models directory"); + fs::write( + models_path, + r#"{ + "providers": { + "cc-switch-managed": { + "name": "Managed", + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "models": [{ "id": "model-a" }] + } + } + }"#, + ) + .expect("write models"); + let settings_path = crate::pi_config::get_pi_settings_path().expect("settings path"); + fs::write(settings_path, "[]").expect("write invalid settings"); + + let current = PiStateService::current(&state).expect("read membership"); + assert_eq!( + current.enabled_provider_ids, + vec!["cc-switch-managed".to_string()] + ); + assert_eq!(current.default_provider_id, None); + } +} diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index f9a273e94..76b90b334 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -5,6 +5,7 @@ use crate::config::write_text_file; use crate::error::AppError; use crate::prompt::Prompt; use crate::prompt_files::prompt_file_path; +use crate::services::pi_prompt_files::PiAgentsFileGuard; use crate::store::AppState; fn get_unix_timestamp() -> Result { @@ -73,15 +74,22 @@ impl PromptService { state: &AppState, app: AppType, ) -> Result, AppError> { + if matches!(app, AppType::Pi) { + return get_pi_prompts(state); + } state.db.get_prompts(app.as_str()) } pub fn upsert_prompt( state: &AppState, app: AppType, - _id: &str, + id: &str, prompt: Prompt, ) -> Result<(), AppError> { + if matches!(app, AppType::Pi) { + return upsert_pi_prompt(state, id, prompt); + } + let is_enabled = prompt.enabled; state.db.save_prompt(app.as_str(), &prompt)?; @@ -95,7 +103,10 @@ impl PromptService { } pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> { - let prompts = state.db.get_prompts(app.as_str())?; + if matches!(app, AppType::Pi) { + return delete_pi_prompt(state, id); + } + let prompts = Self::get_prompts(state, app.clone())?; if let Some(prompt) = prompts.get(id) { if prompt.enabled { @@ -113,7 +124,7 @@ impl PromptService { id: &str, name: &str, ) -> Result<(), AppError> { - let prompts = state.db.get_prompts(app.as_str())?; + let prompts = Self::get_prompts(state, app.clone())?; let Some(existing) = prompts.get(id) else { return Err(AppError::InvalidInput(format!("提示词 {id} 不存在"))); }; @@ -149,7 +160,15 @@ impl PromptService { return Err(AppError::InvalidInput("提示词名称不能为空".to_string())); } - let prompts = state.db.get_prompts(app.as_str())?; + // Pi persists `enabled = false` in SQLite because the native AGENTS.md + // file is the source of truth. Hydrate that live state before editing, + // otherwise an ordinary metadata/content edit would look like a + // disable request and remove the active file. + let prompts = if matches!(app, AppType::Pi) { + get_pi_prompts(state)? + } else { + state.db.get_prompts(app.as_str())? + }; if old_id != new_id && prompts.contains_key(new_id) { return Err(AppError::InvalidInput(format!("提示词 ID {new_id} 已存在"))); } @@ -170,9 +189,17 @@ impl PromptService { prompt.content = content.trim_end().to_string(); } prompt.updated_at = Some(get_unix_timestamp()?); - state.db.save_prompt(app.as_str(), &prompt)?; - if old_prompt_id != prompt.id { - state.db.delete_prompt(app.as_str(), &old_prompt_id)?; + if matches!(app, AppType::Pi) { + if old_prompt_id == prompt.id { + upsert_pi_prompt(state, &prompt.id, prompt.clone())?; + } else { + rename_pi_prompt(state, &old_prompt_id, prompt.clone())?; + } + } else { + state.db.save_prompt(app.as_str(), &prompt)?; + if old_prompt_id != prompt.id { + state.db.delete_prompt(app.as_str(), &old_prompt_id)?; + } } Ok(prompt) @@ -231,6 +258,10 @@ impl PromptService { } pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> { + if matches!(app, AppType::Pi) { + return enable_pi_prompt(state, id); + } + let app_key = app.as_str(); let target_path = prompt_file_path(&app)?; @@ -294,6 +325,18 @@ impl PromptService { } pub fn disable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> { + if matches!(app, AppType::Pi) { + let mut prompt = get_pi_prompts(state)? + .get(id) + .cloned() + .ok_or_else(|| AppError::InvalidInput(format!("提示词 {id} 不存在")))?; + if !prompt.enabled { + return Err(AppError::InvalidInput(format!("提示词 {id} 未激活"))); + } + prompt.enabled = false; + return upsert_pi_prompt(state, id, prompt); + } + let app_key = app.as_str(); let mut prompts = state.db.get_prompts(app_key)?; @@ -316,14 +359,18 @@ impl PromptService { } pub fn import_from_file(state: &AppState, app: AppType) -> Result { - let file_path = prompt_file_path(&app)?; - - if !file_path.exists() { - return Err(AppError::Message("提示词文件不存在".to_string())); - } - - let content = - std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?; + let content = if matches!(app, AppType::Pi) { + PiAgentsFileGuard::acquire()? + .read()? + .content + .ok_or_else(|| AppError::Message("提示词文件不存在".to_string()))? + } else { + let file_path = prompt_file_path(&app)?; + if !file_path.exists() { + return Err(AppError::Message("提示词文件不存在".to_string())); + } + std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))? + }; let timestamp = get_unix_timestamp()?; let id = format!("imported-{timestamp}"); @@ -345,6 +392,9 @@ impl PromptService { } pub fn get_current_file_content(app: AppType) -> Result, AppError> { + if matches!(app, AppType::Pi) { + return Ok(PiAgentsFileGuard::acquire()?.read()?.content); + } let file_path = prompt_file_path(&app)?; if !file_path.exists() { return Ok(None); @@ -358,6 +408,9 @@ impl PromptService { let mut active_prompts = Vec::new(); for app in AppType::all() { + if matches!(app, AppType::Pi) { + continue; + } let prompts = state.db.get_prompts(app.as_str())?; if let Some(prompt) = select_active_prompt(&prompts) { active_prompts.push((app, prompt.content)); @@ -386,6 +439,185 @@ impl PromptService { } } +fn pi_active_prompt_id( + prompts: &IndexMap, + live_content: Option<&str>, +) -> Option { + let live_content = live_content?; + prompts + .iter() + .find(|(_, prompt)| prompt.content == live_content) + .map(|(id, _)| id.clone()) +} + +fn unique_pi_backup_id(prompts: &IndexMap, timestamp: i64) -> String { + let base = format!("backup-{timestamp}"); + if !prompts.contains_key(&base) { + return base; + } + for suffix in 2_u64.. { + let candidate = format!("{base}-{suffix}"); + if !prompts.contains_key(&candidate) { + return candidate; + } + } + unreachable!("the backup suffix space is finite only after u64 exhaustion") +} + +fn get_pi_prompts(state: &AppState) -> Result, AppError> { + let guard = PiAgentsFileGuard::acquire()?; + let mut prompts = state.db.get_prompts(AppType::Pi.as_str())?; + let snapshot = guard.read()?; + let active_id = pi_active_prompt_id(&prompts, snapshot.content.as_deref()); + + for (id, prompt) in &mut prompts { + prompt.enabled = active_id.as_ref() == Some(id); + } + Ok(prompts) +} + +fn upsert_pi_prompt(state: &AppState, id: &str, prompt: Prompt) -> Result<(), AppError> { + if prompt.id != id { + return Err(AppError::InvalidInput( + "Pi prompt id does not match the requested id".to_string(), + )); + } + + let guard = PiAgentsFileGuard::acquire()?; + let prompts = state.db.get_prompts(AppType::Pi.as_str())?; + let snapshot = guard.read()?; + let was_active = + pi_active_prompt_id(&prompts, snapshot.content.as_deref()).as_deref() == Some(id); + let previous = prompts.get(id).cloned(); + let requested_active = prompt.enabled; + let mut stored = prompt; + stored.enabled = false; + + if requested_active && !was_active { + return Err(AppError::Conflict( + "Pi AGENTS.md changed outside CC Switch; reload before editing it".to_string(), + )); + } + + persist_pi_prompt_with_native_update(state, id, &stored, previous.as_ref(), || { + if requested_active { + guard.replace(&snapshot.revision, &stored.content) + } else if was_active { + guard.delete(&snapshot.revision) + } else { + Ok(()) + } + }) +} + +fn persist_pi_prompt_with_native_update( + state: &AppState, + id: &str, + stored: &Prompt, + previous: Option<&Prompt>, + update_native: impl FnOnce() -> Result<(), AppError>, +) -> Result<(), AppError> { + state.db.save_prompt(AppType::Pi.as_str(), stored)?; + if let Err(native_error) = update_native() { + let rollback = match previous { + Some(previous) => state.db.save_prompt(AppType::Pi.as_str(), previous), + None => state.db.delete_prompt(AppType::Pi.as_str(), id), + }; + if let Err(rollback_error) = rollback { + return Err(AppError::Message(format!( + "Pi prompt update failed ({native_error}); database rollback also failed: {rollback_error}" + ))); + } + return Err(native_error); + } + Ok(()) +} + +fn rename_pi_prompt(state: &AppState, old_id: &str, prompt: Prompt) -> Result<(), AppError> { + let guard = PiAgentsFileGuard::acquire()?; + let prompts = state.db.get_prompts(AppType::Pi.as_str())?; + let previous = prompts + .get(old_id) + .cloned() + .ok_or_else(|| AppError::InvalidInput(format!("提示词 {old_id} 不存在")))?; + let snapshot = guard.read()?; + let was_active = + pi_active_prompt_id(&prompts, snapshot.content.as_deref()).as_deref() == Some(old_id); + if prompt.enabled && !was_active { + return Err(AppError::Conflict( + "Pi AGENTS.md changed outside CC Switch; reload before editing it".to_string(), + )); + } + + let mut stored = prompt; + stored.enabled = false; + state.db.save_prompt(AppType::Pi.as_str(), &stored)?; + if let Err(error) = state.db.delete_prompt(AppType::Pi.as_str(), old_id) { + let _ = state.db.delete_prompt(AppType::Pi.as_str(), &stored.id); + return Err(error); + } + + let native_result = if was_active { + guard.replace(&snapshot.revision, &stored.content) + } else { + Ok(()) + }; + if let Err(native_error) = native_result { + let restore_old = state.db.save_prompt(AppType::Pi.as_str(), &previous); + let remove_new = state.db.delete_prompt(AppType::Pi.as_str(), &stored.id); + if let Err(rollback_error) = restore_old.and(remove_new) { + return Err(AppError::Message(format!( + "Pi prompt rename failed ({native_error}); database rollback also failed: {rollback_error}" + ))); + } + return Err(native_error); + } + Ok(()) +} + +fn enable_pi_prompt(state: &AppState, id: &str) -> Result<(), AppError> { + let guard = PiAgentsFileGuard::acquire()?; + let prompts = state.db.get_prompts(AppType::Pi.as_str())?; + let target = prompts + .get(id) + .cloned() + .ok_or_else(|| AppError::InvalidInput(format!("提示词 {id} 不存在")))?; + let snapshot = guard.read()?; + + if let Some(content) = snapshot.content.as_ref() { + let already_saved = prompts.values().any(|prompt| prompt.content == *content); + if !content.trim().is_empty() && !already_saved { + let timestamp = get_unix_timestamp()?; + let backup = Prompt { + id: unique_pi_backup_id(&prompts, timestamp), + name: format!( + "原始提示词 {}", + chrono::Local::now().format("%Y-%m-%d %H:%M") + ), + content: content.clone(), + description: Some("自动备份的原始提示词".to_string()), + enabled: false, + created_at: Some(timestamp), + updated_at: Some(timestamp), + }; + state.db.save_prompt(AppType::Pi.as_str(), &backup)?; + } + } + + guard.replace(&snapshot.revision, &target.content) +} + +fn delete_pi_prompt(state: &AppState, id: &str) -> Result<(), AppError> { + let guard = PiAgentsFileGuard::acquire()?; + let prompts = state.db.get_prompts(AppType::Pi.as_str())?; + let snapshot = guard.read()?; + if pi_active_prompt_id(&prompts, snapshot.content.as_deref()).as_deref() == Some(id) { + return Err(AppError::InvalidInput("无法删除已启用的提示词".to_string())); + } + state.db.delete_prompt(AppType::Pi.as_str(), id)?; + Ok(()) +} + fn select_active_prompt(prompts: &IndexMap) -> Option { prompts .values() @@ -695,3 +927,185 @@ mod tests { assert!(err.to_string().contains("已存在")); } } + +#[cfg(test)] +mod pi_prompt_tests { + use super::*; + use crate::database::Database; + use crate::pi_config::test_support::TestAgentDir; + use serial_test::serial; + use std::sync::Arc; + + fn prompt(enabled: bool) -> Prompt { + Prompt { + id: "test-prompt".to_string(), + name: "Test prompt".to_string(), + content: "managed content".to_string(), + description: None, + enabled, + created_at: Some(1), + updated_at: Some(1), + } + } + + #[test] + #[serial] + fn pi_active_prompt_is_derived_from_agents_file() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new( + Database::memory().expect("create in-memory database"), + )); + state + .db + .save_prompt(AppType::Pi.as_str(), &prompt(true)) + .expect("save prompt"); + + let saved = PromptService::get_prompts(&state, AppType::Pi).expect("load prompts"); + assert!(!saved["test-prompt"].enabled); + + let path = prompt_file_path(&AppType::Pi).expect("prompt path"); + write_text_file(&path, "managed content").expect("write AGENTS.md"); + let active = PromptService::get_prompts(&state, AppType::Pi).expect("load prompts"); + assert!(active["test-prompt"].enabled); + + write_text_file(&path, "external edit").expect("edit AGENTS.md externally"); + let drifted = PromptService::get_prompts(&state, AppType::Pi).expect("load prompts"); + assert!(!drifted["test-prompt"].enabled); + assert!( + PromptService::upsert_prompt(&state, AppType::Pi, "test-prompt", prompt(true),) + .is_err() + ); + assert_eq!( + std::fs::read_to_string(&path).expect("read AGENTS.md"), + "external edit" + ); + + write_text_file(&path, "managed content").expect("restore AGENTS.md"); + PromptService::upsert_prompt(&state, AppType::Pi, "test-prompt", prompt(false)) + .expect("disable prompt"); + assert!(!path.exists()); + } + + #[test] + #[serial] + fn generic_prompt_projection_does_not_rewrite_pi_agents_file() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new( + Database::memory().expect("create in-memory database"), + )); + state + .db + .save_prompt(AppType::Pi.as_str(), &prompt(false)) + .expect("save Pi prompt"); + + let path = prompt_file_path(&AppType::Pi).expect("prompt path"); + write_text_file(&path, "native instructions").expect("write AGENTS.md"); + + PromptService::sync_all_active_to_live_best_effort(&state) + .expect("sync prompts without projecting Pi"); + + assert_eq!( + std::fs::read_to_string(path).expect("read AGENTS.md"), + "native instructions" + ); + } + + #[test] + #[serial] + fn editing_an_inactive_duplicate_pi_prompt_preserves_agents_file() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new( + Database::memory().expect("create in-memory database"), + )); + let first = prompt(false); + let mut duplicate = first.clone(); + duplicate.id = "duplicate-prompt".to_string(); + duplicate.name = "Duplicate prompt".to_string(); + duplicate.created_at = Some(2); + state + .db + .save_prompt(AppType::Pi.as_str(), &first) + .expect("save first prompt"); + state + .db + .save_prompt(AppType::Pi.as_str(), &duplicate) + .expect("save duplicate prompt"); + let path = prompt_file_path(&AppType::Pi).expect("prompt path"); + write_text_file(&path, "managed content").expect("write AGENTS.md"); + + let hydrated = PromptService::get_prompts(&state, AppType::Pi).expect("load prompts"); + assert!(hydrated["test-prompt"].enabled); + assert!(!hydrated["duplicate-prompt"].enabled); + + duplicate.content = "edited duplicate".to_string(); + PromptService::upsert_prompt(&state, AppType::Pi, "duplicate-prompt", duplicate) + .expect("edit inactive duplicate"); + + assert_eq!( + std::fs::read_to_string(&path).expect("read AGENTS.md"), + "managed content" + ); + } + + #[test] + #[serial] + fn generic_updates_preserve_the_active_pi_prompt_state() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new( + Database::memory().expect("create in-memory database"), + )); + state + .db + .save_prompt(AppType::Pi.as_str(), &prompt(false)) + .expect("save prompt"); + let path = prompt_file_path(&AppType::Pi).expect("prompt path"); + write_text_file(&path, "managed content").expect("activate prompt"); + + PromptService::update_prompt( + &state, + AppType::Pi, + "test-prompt", + "test-prompt", + "Edited prompt", + None, + Some("edited content".to_string()), + ) + .expect("edit active prompt content"); + assert_eq!( + std::fs::read_to_string(&path).expect("read active prompt"), + "edited content" + ); + + PromptService::update_prompt_metadata( + &state, + AppType::Pi, + "test-prompt", + "test-prompt", + "Renamed display label", + Some("metadata only".to_string()), + ) + .expect("edit active prompt metadata"); + assert_eq!( + std::fs::read_to_string(&path).expect("read active prompt"), + "edited content" + ); + assert!( + PromptService::get_prompts(&state, AppType::Pi).expect("hydrate prompts") + ["test-prompt"] + .enabled + ); + } + + #[test] + fn pi_backup_ids_do_not_replace_an_existing_same_second_backup() { + let mut prompts = IndexMap::new(); + let mut first = prompt(false); + first.id = "backup-42".to_string(); + prompts.insert(first.id.clone(), first); + let mut second = prompt(false); + second.id = "backup-42-2".to_string(); + prompts.insert(second.id.clone(), second); + + assert_eq!(unique_pi_backup_id(&prompts, 42), "backup-42-3"); + } +} diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index 215a51c50..d8b3733aa 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -502,11 +502,13 @@ fn parse_json_object_snippet(app_type: &AppType, snippet: &str) -> Result AppError::localized( - "common_config.opencode.invalid_json", - format!("OpenCode 通用配置片段不是有效的 JSON:{e}"), - format!("OpenCode common config snippet is not valid JSON: {e}"), - ), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppError::localized( + "common_config.opencode.invalid_json", + format!("OpenCode 通用配置片段不是有效的 JSON:{e}"), + format!("OpenCode common config snippet is not valid JSON: {e}"), + ) + } AppType::Codex => AppError::Config(format!("Unexpected JSON common config parse: {e}")), })?; @@ -522,11 +524,13 @@ fn parse_json_object_snippet(app_type: &AppType, snippet: &str) -> Result AppError::localized( - "common_config.opencode.not_object", - "OpenCode 通用配置片段必须是 JSON 对象", - "OpenCode common config snippet must be a JSON object", - ), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppError::localized( + "common_config.opencode.not_object", + "OpenCode 通用配置片段必须是 JSON 对象", + "OpenCode common config snippet must be a JSON object", + ) + } AppType::Codex => AppError::Config("Unexpected JSON common config type".into()), }); } @@ -556,7 +560,8 @@ pub(super) fn validate_common_config_snippet( | AppType::Gemini | AppType::OpenCode | AppType::Hermes - | AppType::OpenClaw => { + | AppType::OpenClaw + | AppType::Pi => { parse_json_object_snippet(app_type, snippet)?; } AppType::Codex => { @@ -612,7 +617,7 @@ pub(super) fn settings_contain_common_config( } _ => false, }, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, } } @@ -677,7 +682,9 @@ pub(super) fn apply_common_config_to_settings( } Ok(result) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + Ok(settings.clone()) + } } } @@ -726,7 +733,9 @@ pub(super) fn remove_common_config_from_settings( } Ok(result) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + Ok(settings.clone()) + } } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index eaa7ba1df..e1a7da2c6 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -174,6 +174,9 @@ pub(super) fn capture_live_snapshot(app_type: &AppType) -> Result Err(AppError::Config( + "Pi providers use the Pi provider service".to_string(), + )), } } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 642999012..357165668 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -12,6 +12,7 @@ mod gemini_auth; mod live; pub(crate) mod live_merge; mod models; +mod pi; #[cfg(test)] mod tests; mod usage; @@ -93,6 +94,13 @@ pub fn reapply_current_codex_official_live(state: &AppState) -> Result, +} + fn active_failover_last_provider_error() -> AppError { AppError::localized( "provider.delete.last_failover_queue_entry", @@ -215,7 +223,7 @@ enum PreparedCodexAuthWrite { impl ProviderService { pub fn is_provider_key_app(app_type: &AppType) -> bool { - matches!(app_type, AppType::OpenClaw | AppType::Hermes) + matches!(app_type, AppType::OpenClaw | AppType::Hermes | AppType::Pi) } pub fn is_valid_provider_key(value: &str) -> bool { @@ -326,6 +334,10 @@ impl ProviderService { .into_iter() .map(|(id, _)| id) .collect(), + AppType::Pi => crate::pi_config::read_pi_native_providers()? + .into_iter() + .map(|(id, _)| id) + .collect(), _ => HashSet::new(), }; Ok(ids) @@ -390,6 +402,18 @@ impl ProviderService { source_id: &str, provider_override: Option, ) -> Result { + if matches!(app_type, AppType::Pi) { + let providers = pi::list(state)?; + let source = providers.get(source_id).ok_or_else(|| { + AppError::InvalidInput(format!("Pi provider '{source_id}' not found")) + })?; + let mut existing_ids = providers.keys().cloned().collect::>(); + existing_ids.extend(Self::live_provider_ids(&app_type)?); + let duplicate = + Self::duplicate_provider_with_overrides(source, provider_override, &existing_ids); + pi::add(state, duplicate.clone(), false)?; + return Ok(duplicate); + } let app_type_clone = app_type.clone(); let source_id = source_id.to_string(); let live_ids = if app_type.is_additive_mode() { @@ -485,6 +509,49 @@ impl ProviderService { }); } + fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) { + let current_credentials = if matches!(app_type, AppType::Pi) { + ( + crate::pi_config::provider_base_url(&provider.settings_config).unwrap_or_default(), + provider + .settings_config + .get("apiKey") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ) + } else { + (String::new(), String::new()) + }; + + let Some(usage_script) = provider + .meta + .as_mut() + .and_then(|meta| meta.usage_script.as_mut()) + else { + return; + }; + if usage_script.template_type.as_deref() == Some("token_plan") { + return; + } + + if usage_script.api_key.as_deref().is_some_and(|candidate| { + let candidate = candidate.trim(); + candidate.is_empty() + || (!current_credentials.1.trim().is_empty() + && current_credentials.1.trim() == candidate) + }) { + usage_script.api_key = None; + } + if usage_script.base_url.as_deref().is_some_and(|candidate| { + let candidate = candidate.trim().trim_end_matches('/'); + let current = current_credentials.0.trim().trim_end_matches('/'); + candidate.is_empty() || (!current.is_empty() && current == candidate) + }) { + usage_script.base_url = None; + } + } + fn is_codex_official_provider(provider: &Provider) -> bool { provider .meta @@ -1213,6 +1280,7 @@ impl ProviderService { } state.save()?; } + AppType::Pi => {} } Ok(()) } @@ -1268,7 +1336,7 @@ impl ProviderService { strict_current_provider_id, old_snippet, ), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => Ok(()), }; match result { @@ -1390,7 +1458,7 @@ impl ProviderService { } AppType::Gemini => live_settings.get("env") != provider_settings.get("env"), AppType::Claude => live_settings != provider_settings, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, } } @@ -1548,6 +1616,7 @@ impl ProviderService { AppType::OpenCode => Self::extract_opencode_common_config(settings_config), AppType::Hermes => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), + AppType::Pi => Ok(String::new()), } } @@ -1882,6 +1951,9 @@ impl ProviderService { state: &AppState, app_type: AppType, ) -> Result, AppError> { + if matches!(app_type, AppType::Pi) { + return pi::list(state); + } let config = state.config.read().map_err(AppError::from)?; let manager = config .get_manager(&app_type) @@ -1909,6 +1981,9 @@ impl ProviderService { /// 新增供应商 pub fn add(state: &AppState, app_type: AppType, provider: Provider) -> Result { + if matches!(app_type, AppType::Pi) { + return pi::add(state, provider, true); + } let mut provider = provider; // 归一化 Claude 模型键 Self::normalize_provider_if_claude(&app_type, &mut provider); @@ -2004,6 +2079,9 @@ impl ProviderService { app_type: AppType, provider: Provider, ) -> Result { + if matches!(app_type, AppType::Pi) { + return pi::update(state, None, provider); + } let mut provider = provider; // 归一化 Claude 模型键 Self::normalize_provider_if_claude(&app_type, &mut provider); @@ -2225,6 +2303,7 @@ impl ProviderService { AppType::OpenCode => unreachable!("additive mode apps are handled earlier"), AppType::Hermes => unreachable!("additive mode apps are handled earlier"), AppType::OpenClaw => unreachable!("additive mode apps are handled earlier"), + AppType::Pi => unreachable!("Pi uses native provider import"), }; let mut provider = Provider::with_id( @@ -2364,6 +2443,9 @@ impl ProviderService { } crate::openclaw_config::read_openclaw_config() } + AppType::Pi => Err(AppError::InvalidInput( + "Pi providers are read from models.json".to_string(), + )), } } @@ -2395,6 +2477,9 @@ impl ProviderService { app_type: AppType, provider_id: &str, ) -> Result<(), AppError> { + if matches!(app_type, AppType::Pi) { + return pi::remove(state, provider_id); + } if !app_type.is_additive_mode() { return Err(AppError::localized( "provider.remove_from_live_config.unsupported", @@ -2485,6 +2570,7 @@ impl ProviderService { AppType::OpenCode => Self::import_opencode_providers_from_live(state), AppType::OpenClaw => Self::import_openclaw_providers_from_live(state), AppType::Hermes => Self::import_hermes_providers_from_live(state), + AppType::Pi => Self::import_pi_providers_from_live(state), _ => Self::import_default_config(state, app_type).map(usize::from), } } @@ -2760,6 +2846,7 @@ impl ProviderService { AppType::OpenCode => unreachable!("additive mode handled above"), AppType::Hermes => unreachable!("additive mode handled above"), AppType::OpenClaw => unreachable!("additive mode handled above"), + AppType::Pi => unreachable!("Pi switch is handled by the native provider service"), }; Ok(PostCommitAction { @@ -2779,6 +2866,9 @@ impl ProviderService { /// 切换指定应用的供应商 pub fn switch(state: &AppState, app_type: AppType, provider_id: &str) -> Result<(), AppError> { + if matches!(app_type, AppType::Pi) { + return pi::enable(state, provider_id).map(|_| ()); + } if !app_type.is_additive_mode() { let providers = state.db.get_all_providers(app_type.as_str())?; providers.get(provider_id).ok_or_else(|| { @@ -2974,6 +3064,7 @@ impl ProviderService { .map_err(Self::normalize_openclaw_live_write_error)?; Ok(PreparedLiveWrite::OpenClaw { models }) } + AppType::Pi => Ok(PreparedLiveWrite::Noop), } } @@ -3212,6 +3303,9 @@ impl ProviderService { AppType::OpenClaw => Err(AppError::Config( "OpenClaw does not support proxy takeover backups".into(), )), + AppType::Pi => Err(AppError::Config( + "Pi does not support proxy takeover backups".into(), + )), } } @@ -3306,6 +3400,9 @@ impl ProviderService { let config = Self::parse_openclaw_provider_settings(&provider.settings_config)?; Self::validate_openclaw_provider_models(&provider.id, &config)?; } + AppType::Pi => { + crate::pi_config::validate_provider_node(&provider.id, &provider.settings_config)? + } } // 🔧 验证并清理 UsageScript 配置(所有应用类型通用) @@ -3383,7 +3480,22 @@ impl ProviderService { ) } + pub(crate) fn update_pi_usage_script( + state: &AppState, + id: &str, + script: UsageScript, + ) -> Result { + pi::update_usage_script(state, id, script) + } + + pub(crate) fn clear_pi_usage_script(state: &AppState, id: &str) -> Result { + pi::clear_usage_script(state, id) + } + pub fn delete(state: &AppState, app_type: AppType, provider_id: &str) -> Result<(), AppError> { + if matches!(app_type, AppType::Pi) { + return pi::delete(state, provider_id); + } let (local_current_provider, stored_current_provider) = if app_type.is_additive_mode() { (None, None) } else { @@ -3491,6 +3603,7 @@ impl ProviderService { AppType::OpenClaw => { let _ = provider_snapshot; } + AppType::Pi => unreachable!("Pi deletion is handled by the native provider service"), } { @@ -3524,6 +3637,10 @@ impl ProviderService { live::import_openclaw_providers_from_live(state) } + pub fn import_pi_providers_from_live(state: &AppState) -> Result { + pi::import_from_live(state) + } + pub fn import_hermes_providers_from_live(state: &AppState) -> Result { live::import_hermes_providers_from_live(state) } diff --git a/src-tauri/src/services/provider/pi.rs b/src-tauri/src/services/provider/pi.rs new file mode 100644 index 000000000..497a2e124 --- /dev/null +++ b/src-tauri/src/services/provider/pi.rs @@ -0,0 +1,883 @@ +use super::{ProviderService, SwitchResult}; +use crate::app_config::AppType; +use crate::error::AppError; +use crate::provider::{Provider, ProviderMeta, UsageScript}; +use crate::store::AppState; +use indexmap::IndexMap; +use serde_json::Value; + +const PI_APP: &str = "pi"; + +pub(super) fn list(state: &AppState) -> Result, AppError> { + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app(PI_APP)); + match crate::pi_config::read_pi_native_providers() { + Ok(native) => { + if let Err(error) = sync_native_locked(state, &native) { + log::warn!("Failed to sync Pi providers from native config: {error}"); + } + } + Err(error) => { + log::warn!("Failed to read Pi providers; showing saved catalog: {error}"); + } + } + state.db.get_all_providers(PI_APP) +} + +pub(super) fn import_from_live(state: &AppState) -> Result { + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app(PI_APP)); + let native = crate::pi_config::read_pi_native_providers()?; + sync_native_locked(state, &native) +} + +pub(super) fn add( + state: &AppState, + mut provider: Provider, + add_to_live: bool, +) -> Result { + let app_type = AppType::Pi; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + strip_unsupported_pi_metadata(&mut provider); + ProviderService::validate_provider_settings(&app_type, &provider)?; + align_native_display_name(&mut provider); + ProviderService::normalize_usage_script_credential_overrides(&app_type, &mut provider); + + if state + .db + .get_provider_by_id(&provider.id, app_type.as_str())? + .is_some() + { + return Err(AppError::InvalidInput(format!( + "Pi provider '{}' already exists", + provider.id + ))); + } + + if !add_to_live && crate::pi_config::pi_provider_exists(&provider.id)? { + return Err(AppError::InvalidInput(format!( + "Pi provider key '{}' already exists in models.json", + provider.id + ))); + } + + let native_inserted = if add_to_live { + crate::pi_config::insert_pi_provider(&provider.id, &provider.settings_config)? + } else { + false + }; + + if let Err(error) = state.db.save_provider(app_type.as_str(), &provider) { + if native_inserted { + if let Err(rollback) = crate::pi_config::remove_pi_provider_if_matches( + &provider.id, + &provider.settings_config, + ) { + return Err(AppError::Config(format!( + "failed to save Pi provider: {error}; native rollback failed: {rollback}" + ))); + } + } + return Err(error); + } + Ok(true) +} + +pub(super) fn update_usage_script( + state: &AppState, + id: &str, + script: UsageScript, +) -> Result { + let app_type = AppType::Pi; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + ProviderService::validate_usage_script(&script)?; + + let mut provider = state + .db + .get_provider_by_id(id, app_type.as_str())? + .ok_or_else(|| AppError::InvalidInput(format!("Pi provider '{id}' not found")))?; + provider + .meta + .get_or_insert_with(ProviderMeta::default) + .usage_script = Some(script); + strip_unsupported_pi_metadata(&mut provider); + ProviderService::normalize_usage_script_credential_overrides(&app_type, &mut provider); + state.db.save_provider(app_type.as_str(), &provider)?; + Ok(true) +} + +pub(super) fn clear_usage_script(state: &AppState, id: &str) -> Result { + let app_type = AppType::Pi; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + + let mut provider = state + .db + .get_provider_by_id(id, app_type.as_str())? + .ok_or_else(|| AppError::InvalidInput(format!("Pi provider '{id}' not found")))?; + if let Some(meta) = provider.meta.as_mut() { + meta.usage_script = None; + } + strip_unsupported_pi_metadata(&mut provider); + state.db.save_provider(app_type.as_str(), &provider)?; + Ok(true) +} + +pub(super) fn update( + state: &AppState, + original_id: Option<&str>, + mut provider: Provider, +) -> Result { + let app_type = AppType::Pi; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + let original_id = original_id.unwrap_or(&provider.id).to_string(); + if original_id != provider.id { + return Err(AppError::InvalidInput( + "Pi provider keys cannot be renamed".to_string(), + )); + } + + state + .db + .get_provider_by_id(&original_id, app_type.as_str())? + .ok_or_else(|| AppError::InvalidInput(format!("Pi provider '{original_id}' not found")))?; + strip_unsupported_pi_metadata(&mut provider); + ProviderService::validate_provider_settings(&app_type, &provider)?; + ProviderService::normalize_usage_script_credential_overrides(&app_type, &mut provider); + + let previous_native = + crate::pi_config::replace_pi_provider_if_present(&original_id, &provider.settings_config)?; + if let Err(error) = state.db.save_provider(app_type.as_str(), &provider) { + if let Some(previous_native) = previous_native.as_ref() { + if let Err(rollback) = crate::pi_config::replace_pi_provider( + &original_id, + &provider.settings_config, + previous_native, + ) { + return Err(AppError::Config(format!( + "failed to save Pi provider: {error}; native rollback failed: {rollback}" + ))); + } + } + return Err(error); + } + Ok(true) +} + +pub(super) fn delete(state: &AppState, id: &str) -> Result<(), AppError> { + let app_type = AppType::Pi; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + let Some(_) = state.db.get_provider_by_id(id, app_type.as_str())? else { + return Ok(()); + }; + // Delete is intentionally keyed by provider ID. Once the user confirms + // deleting the provider itself, supported field edits do not change that + // intent; the latest native value is retained only for rollback. + let removed = crate::pi_config::remove_pi_provider(id)?; + + if let Err(error) = state.db.delete_provider(app_type.as_str(), id) { + if let Some(removed) = removed.as_ref() { + if let Err(rollback) = crate::pi_config::restore_pi_provider_if_missing(id, removed) { + return Err(AppError::Config(format!( + "failed to delete Pi provider: {error}; native rollback failed: {rollback}" + ))); + } + } + return Err(error); + } + Ok(()) +} + +pub(super) fn remove(state: &AppState, id: &str) -> Result<(), AppError> { + let app_type = AppType::Pi; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + let provider = state + .db + .get_provider_by_id(id, app_type.as_str())? + .ok_or_else(|| AppError::InvalidInput(format!("Pi provider '{id}' not found")))?; + let Some(removed) = crate::pi_config::remove_pi_provider(id)? else { + return Ok(()); + }; + let mut synced = provider; + merge_native_config(&mut synced, removed.clone()); + if let Err(error) = state.db.save_provider(app_type.as_str(), &synced) { + if let Err(rollback) = crate::pi_config::restore_pi_provider_if_missing(id, &removed) { + return Err(AppError::Config(format!( + "failed to preserve Pi provider before removal: {error}; native rollback failed: {rollback}" + ))); + } + return Err(error); + } + Ok(()) +} + +pub(super) fn enable(state: &AppState, id: &str) -> Result { + let app_type = AppType::Pi; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + let provider = state + .db + .get_provider_by_id(id, app_type.as_str())? + .ok_or_else(|| AppError::InvalidInput(format!("Pi provider '{id}' not found")))?; + + if let Some(native) = crate::pi_config::read_pi_native_provider(id)? { + let mut synced = provider; + merge_native_config(&mut synced, native); + state.db.save_provider(app_type.as_str(), &synced)?; + return Ok(SwitchResult::default()); + } + + ProviderService::validate_provider_settings(&app_type, &provider)?; + crate::pi_config::insert_pi_provider(id, &provider.settings_config)?; + Ok(SwitchResult::default()) +} + +fn sync_native_locked( + state: &AppState, + native: &IndexMap, +) -> Result { + let saved = state.db.get_all_providers(PI_APP)?; + let mut changed = 0; + + for (id, config) in native { + let mut provider = saved.get(id).cloned().unwrap_or_else(|| { + let name = native_provider_name(config).unwrap_or(id).to_string(); + let mut imported = Provider::with_id(id.clone(), name, config.clone(), None); + imported.category = Some("custom".to_string()); + imported.icon = Some("pi".to_string()); + imported + }); + let is_new = !saved.contains_key(id); + let previous_name = provider.name.clone(); + let previous_config = provider.settings_config.clone(); + merge_native_config(&mut provider, config.clone()); + if !is_new && provider.name == previous_name && provider.settings_config == previous_config + { + continue; + } + + state.db.save_provider(PI_APP, &provider)?; + changed += 1; + } + + Ok(changed) +} + +fn merge_native_config(provider: &mut Provider, config: Value) { + if let Some(name) = native_provider_name(&config) { + provider.name = name.to_string(); + } + provider.settings_config = config; +} + +fn native_provider_name(config: &Value) -> Option<&str> { + config + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) +} + +fn align_native_display_name(provider: &mut Provider) { + let Some(config) = provider.settings_config.as_object_mut() else { + return; + }; + if config.contains_key("name") { + config.insert("name".to_string(), Value::String(provider.name.clone())); + } +} + +fn strip_unsupported_pi_metadata(provider: &mut Provider) { + provider.in_failover_queue = false; + let Some(meta) = provider.meta.take() else { + return; + }; + provider.meta = Some(ProviderMeta { + usage_script: meta.usage_script, + is_partner: meta.is_partner, + partner_promotion_key: meta.partner_promotion_key, + ..ProviderMeta::default() + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use crate::pi_config::test_support::TestAgentDir; + use crate::provider::ProviderMeta; + use serde_json::json; + use serial_test::serial; + use std::fs; + use std::sync::Arc; + + fn state() -> AppState { + AppState::new(Arc::new( + Database::memory().expect("create in-memory database"), + )) + } + + fn input(model_id: &str) -> Provider { + Provider { + id: "cc-switch-test".to_string(), + name: "Test provider".to_string(), + settings_config: json!({ + "name": "Test provider", + "baseUrl": "https://api.example.com/v1", + "apiKey": "secret", + "api": "openai-completions", + "models": [{ "id": model_id }] + }), + website_url: None, + category: Some("custom".to_string()), + created_at: Some(1), + sort_index: None, + notes: None, + meta: Some(ProviderMeta { + endpoint_auto_select: Some(true), + live_config_managed: Some(false), + api_format: Some("openai_chat".to_string()), + custom_user_agent: Some("legacy-route-agent".to_string()), + is_partner: Some(true), + ..ProviderMeta::default() + }), + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + fn usage_script(code: &str) -> UsageScript { + UsageScript { + enabled: true, + language: "javascript".to_string(), + code: code.to_string(), + timeout: Some(5), + api_key: None, + base_url: None, + access_token: None, + user_id: None, + template_type: None, + auto_query_interval: Some(10), + coding_plan_provider: None, + } + } + + #[test] + #[serial] + fn membership_is_derived_only_from_models_json() { + let _agent = TestAgentDir::new(); + let state = state(); + + add(&state, input("model-a"), false).expect("save disabled provider"); + assert!(!crate::pi_config::pi_provider_exists("cc-switch-test").unwrap()); + + let saved = state + .db + .get_provider_by_id("cc-switch-test", "pi") + .unwrap() + .unwrap(); + let meta = saved.meta.unwrap_or_default(); + assert_eq!(meta.live_config_managed, None); + assert_eq!(meta.endpoint_auto_select, None); + assert_eq!(meta.api_format, None); + assert_eq!(meta.custom_user_agent, None); + assert_eq!(meta.is_partner, Some(true)); + + ProviderService::switch(&state, AppType::Pi, "cc-switch-test").expect("enable provider"); + assert!(crate::pi_config::pi_provider_exists("cc-switch-test").unwrap()); + + ProviderService::remove_from_live_config(&state, AppType::Pi, "cc-switch-test") + .expect("remove provider"); + assert!(!crate::pi_config::pi_provider_exists("cc-switch-test").unwrap()); + assert!(state + .db + .get_provider_by_id("cc-switch-test", "pi") + .unwrap() + .is_some()); + } + + #[test] + #[serial] + fn default_selection_does_not_block_membership_changes() { + let _agent = TestAgentDir::new(); + let state = state(); + let original = input("model-a"); + add(&state, original.clone(), true).expect("add provider"); + let settings_path = crate::pi_config::get_pi_settings_path().unwrap(); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + fs::write( + &settings_path, + r#"{"defaultProvider":"cc-switch-test","defaultModel":"model-a"}"#, + ) + .unwrap(); + + update(&state, Some("cc-switch-test"), input("model-b")) + .expect("global default must not block model edits"); + ProviderService::remove_from_live_config(&state, AppType::Pi, "cc-switch-test") + .expect("global default must not block removal"); + assert!(!crate::pi_config::pi_provider_exists("cc-switch-test").unwrap()); + + ProviderService::switch(&state, AppType::Pi, "cc-switch-test").expect("re-enable provider"); + ProviderService::delete(&state, AppType::Pi, "cc-switch-test") + .expect("global default must not block deletion"); + assert!(state + .db + .get_provider_by_id("cc-switch-test", "pi") + .unwrap() + .is_none()); + assert_eq!( + fs::read_to_string(settings_path).unwrap(), + r#"{"defaultProvider":"cc-switch-test","defaultModel":"model-a"}"# + ); + assert!(!crate::pi_config::pi_provider_exists("cc-switch-test").unwrap()); + } + + #[test] + #[serial] + fn provider_membership_never_changes_pi_auth_or_defaults() { + let _agent = TestAgentDir::new(); + let state = state(); + let agent_dir = crate::pi_config::get_pi_agent_dir().expect("agent directory"); + fs::create_dir_all(&agent_dir).expect("create agent directory"); + let auth_path = agent_dir.join("auth.json"); + let settings_path = agent_dir.join("settings.json"); + let auth_contents = br#"{ + "anthropic": {"type":"oauth","refresh":"native-secret"}, + "openai": {"type":"api_key","key":"native-api-key"} + }"#; + let settings_contents = + br#"{"defaultProvider":"anthropic","defaultModel":"claude-opus-4-6"}"#; + fs::write(&auth_path, auth_contents).expect("write auth"); + fs::write(&settings_path, settings_contents).expect("write settings"); + let models_path = agent_dir.join("models.json"); + fs::write( + &models_path, + r#"{"providers":{"anthropic":{"futureField":{"keep":true}}}}"#, + ) + .expect("write explicit provider"); + + ProviderService::list(&state, AppType::Pi).expect("import explicit provider"); + ProviderService::remove_from_live_config(&state, AppType::Pi, "anthropic") + .expect("remove explicit provider"); + ProviderService::switch(&state, AppType::Pi, "anthropic") + .expect("enable explicit provider"); + let mut edited = state + .db + .get_provider_by_id("anthropic", PI_APP) + .expect("read provider") + .expect("provider"); + edited.settings_config["anotherField"] = json!(true); + update(&state, Some("anthropic"), edited).expect("edit explicit provider"); + + assert_eq!(fs::read(auth_path).expect("read auth"), auth_contents); + assert_eq!( + fs::read(settings_path).expect("read settings"), + settings_contents + ); + } + + #[test] + #[serial] + fn failed_duplicate_create_rolls_back_native_insertion() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), false).expect("save DB-only provider"); + + assert!(add(&state, input("model-a"), true).is_err()); + assert!(!crate::pi_config::pi_provider_exists("cc-switch-test").unwrap()); + } + + #[test] + #[serial] + fn native_edits_sync_to_the_saved_provider_and_survive_removal() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), true).expect("add provider"); + let saved = state + .db + .get_provider_by_id("cc-switch-test", "pi") + .unwrap() + .unwrap(); + let mut external = saved.settings_config.clone(); + external["name"] = json!("External edit"); + external["models"][0]["contextWindow"] = json!(1_000_000.0); + crate::pi_config::replace_pi_provider("cc-switch-test", &saved.settings_config, &external) + .expect("edit native provider"); + + let listed = ProviderService::list(&state, AppType::Pi).expect("sync native providers"); + assert_eq!(listed["cc-switch-test"].name, "External edit"); + assert_eq!(listed["cc-switch-test"].settings_config, external); + + ProviderService::remove_from_live_config(&state, AppType::Pi, "cc-switch-test") + .expect("remove externally edited provider"); + assert!(!crate::pi_config::pi_provider_exists("cc-switch-test").unwrap()); + let preserved = state + .db + .get_provider_by_id("cc-switch-test", "pi") + .unwrap() + .unwrap(); + assert_eq!(preserved.name, "External edit"); + assert_eq!(preserved.settings_config, external); + } + + #[test] + #[serial] + fn refreshed_native_config_can_be_edited_without_snapshot_state() { + let _agent = TestAgentDir::new(); + let state = state(); + let baseline = input("model-a"); + add(&state, baseline.clone(), true).expect("add provider"); + + let mut external = baseline.settings_config.clone(); + external["apiKey"] = json!("rotated-outside"); + external["futureField"] = json!({ "preserve": true }); + crate::pi_config::replace_pi_provider( + "cc-switch-test", + &baseline.settings_config, + &external, + ) + .expect("edit native provider"); + + let listed = ProviderService::list(&state, AppType::Pi).expect("refresh native provider"); + let mut local = listed["cc-switch-test"].clone(); + local.name = "Local edit".to_string(); + local.settings_config["name"] = json!("Local edit"); + update(&state, Some("cc-switch-test"), local).expect("edit the refreshed native provider"); + assert_eq!( + crate::pi_config::read_pi_native_provider("cc-switch-test") + .expect("read native provider") + .expect("native provider")["futureField"], + json!({ "preserve": true }) + ); + } + + #[test] + #[serial] + fn enabled_provider_edit_needs_no_special_snapshot_parameter() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), true).expect("add provider"); + + let mut edited = input("model-b"); + edited.settings_config["unknownField"] = json!({ "keep": true }); + update(&state, Some("cc-switch-test"), edited.clone()).expect("edit enabled provider"); + + assert_eq!( + crate::pi_config::read_pi_native_provider("cc-switch-test") + .expect("read native provider") + .expect("native provider"), + edited.settings_config + ); + } + + #[test] + #[serial] + fn native_sync_imports_every_explicit_provider_node() { + let _agent = TestAgentDir::new(); + let state = state(); + let mut stale_oauth = input("stale-model"); + stale_oauth.id = "native-oauth".to_string(); + add(&state, stale_oauth, false).expect("save stale provider"); + let path = crate::pi_config::get_pi_models_path().unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + path, + r#"{ + "providers": { + "native-custom": { + "name": "Native custom", + "baseUrl": "https://api.example.com/v1", + "apiKey": "secret", + "api": "openai-completions", + "models": [{ "id": "model-a" }] + }, + "anthropic": { + "name": "Built in", + "baseUrl": "https://api.anthropic.com", + "api": "anthropic-messages", + "models": [{ "id": "claude" }] + }, + "openai": {}, + "deepseek": { + "futureField": { "preserve": true } + }, + "native-oauth": { + "name": "OAuth", + "oauth": "example", + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "models": [{ "id": "model-b" }] + } + } + }"#, + ) + .unwrap(); + + let providers = ProviderService::list(&state, AppType::Pi).expect("sync providers"); + assert_eq!(providers.len(), 5); + let imported = &providers["native-custom"]; + assert_eq!(imported.name, "Native custom"); + assert_eq!(imported.category.as_deref(), Some("custom")); + assert_eq!(imported.icon.as_deref(), Some("pi")); + assert_eq!(providers["anthropic"].name, "Built in"); + assert_eq!(providers["openai"].settings_config, json!({})); + assert_eq!( + providers["deepseek"].settings_config["futureField"], + json!({ "preserve": true }) + ); + assert_eq!( + providers["native-oauth"].settings_config["oauth"], + json!("example") + ); + } + + #[test] + #[serial] + fn removal_preserves_and_can_restore_a_minimal_native_node() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), true).expect("add provider"); + let minimal = json!({ + "name": "Extension-owned provider", + "extension": { "type": "custom" } + }); + let path = crate::pi_config::get_pi_models_path().expect("models path"); + fs::write( + &path, + serde_json::to_vec(&json!({ + "providers": { + "cc-switch-test": minimal.clone() + } + })) + .expect("serialize models"), + ) + .expect("replace native provider"); + + ProviderService::remove_from_live_config(&state, AppType::Pi, "cc-switch-test") + .expect("remove exact native node"); + assert!(!crate::pi_config::pi_provider_exists("cc-switch-test").unwrap()); + assert_eq!( + state + .db + .get_provider_by_id("cc-switch-test", PI_APP) + .expect("read saved provider") + .expect("saved provider") + .settings_config, + minimal + ); + + ProviderService::switch(&state, AppType::Pi, "cc-switch-test") + .expect("restore the complete native node"); + assert_eq!( + crate::pi_config::read_pi_native_provider("cc-switch-test") + .expect("read restored provider"), + Some(minimal) + ); + } + + #[test] + #[serial] + fn usage_metadata_update_does_not_rewrite_native_provider_settings() { + let _agent = TestAgentDir::new(); + let state = state(); + let baseline = input("model-a"); + add(&state, baseline.clone(), true).expect("add provider"); + + let mut external = baseline.settings_config.clone(); + external["apiKey"] = json!("rotated-outside"); + external["futureField"] = json!({ "preserve": true }); + crate::pi_config::replace_pi_provider( + "cc-switch-test", + &baseline.settings_config, + &external, + ) + .expect("edit native provider"); + + update_usage_script(&state, "cc-switch-test", usage_script("return {}")) + .expect("save usage metadata"); + assert_eq!( + crate::pi_config::read_pi_native_provider("cc-switch-test") + .expect("read native provider") + .expect("native provider"), + external + ); + + let providers = ProviderService::list(&state, AppType::Pi).expect("sync provider"); + let saved = &providers["cc-switch-test"]; + assert_eq!(saved.settings_config, external); + assert_eq!( + saved + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .map(|script| script.code.as_str()), + Some("return {}") + ); + + clear_usage_script(&state, "cc-switch-test").expect("clear usage metadata"); + assert_eq!( + crate::pi_config::read_pi_native_provider("cc-switch-test") + .expect("read native provider"), + Some(external) + ); + assert!(state + .db + .get_provider_by_id("cc-switch-test", PI_APP) + .expect("read saved provider") + .expect("saved provider") + .meta + .is_none_or(|meta| meta.usage_script.is_none())); + } + + #[test] + #[serial] + fn copied_provider_keeps_its_display_name_after_enable_and_sync() { + let _agent = TestAgentDir::new(); + let state = state(); + let mut copy = input("model-a"); + copy.id = "cc-switch-test-copy".to_string(); + copy.name = "Test provider copy".to_string(); + + add(&state, copy, false).expect("save copied provider"); + ProviderService::switch(&state, AppType::Pi, "cc-switch-test-copy") + .expect("enable copied provider"); + let providers = ProviderService::list(&state, AppType::Pi).expect("sync providers"); + + assert_eq!(providers["cc-switch-test-copy"].name, "Test provider copy"); + assert_eq!( + providers["cc-switch-test-copy"].settings_config["name"], + json!("Test provider copy") + ); + } + + #[test] + #[serial] + fn database_only_create_does_not_overwrite_an_unsynced_native_key() { + let _agent = TestAgentDir::new(); + let state = state(); + let path = crate::pi_config::get_pi_models_path().expect("models path"); + fs::create_dir_all(path.parent().expect("models directory")) + .expect("create models directory"); + fs::write( + &path, + r#"{ + "providers": { + "cc-switch-test-copy": { + "name": "Native OAuth", + "oauth": "example", + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "models": [{ "id": "model-a" }] + } + } + }"#, + ) + .expect("write native provider"); + + let mut copy = input("model-a"); + copy.id = "cc-switch-test-copy".to_string(); + let error = add(&state, copy, false) + .expect_err("an unsynced native provider key must stay reserved"); + + assert!(error.to_string().contains("already exists in models.json")); + assert!(state + .db + .get_provider_by_id("cc-switch-test-copy", PI_APP) + .expect("read saved provider") + .is_none()); + assert!(crate::pi_config::pi_provider_exists("cc-switch-test-copy") + .expect("read native provider")); + + let providers = ProviderService::list(&state, AppType::Pi).expect("sync native provider"); + assert_eq!(providers["cc-switch-test-copy"].name, "Native OAuth"); + } + + #[test] + #[serial] + fn malformed_native_file_keeps_the_saved_catalog_visible() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), false).expect("save provider"); + let path = crate::pi_config::get_pi_models_path().expect("models path"); + fs::create_dir_all(path.parent().expect("models directory")) + .expect("create models directory"); + fs::write(path, "{not-json").expect("write malformed models"); + + let providers = ProviderService::list(&state, AppType::Pi).expect("read saved catalog"); + assert!(providers.contains_key("cc-switch-test")); + } + + #[test] + #[serial] + fn numeric_json_representation_does_not_block_removal() { + let _agent = TestAgentDir::new(); + let state = state(); + let mut saved = input("model-a"); + saved.settings_config["models"][0]["contextWindow"] = json!(1_000_000); + add(&state, saved, false).expect("save provider"); + + let path = crate::pi_config::get_pi_models_path().unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + path, + r#"{ + "providers": { + "cc-switch-test": { + "name": "Test provider", + "baseUrl": "https://api.example.com/v1", + "apiKey": "secret", + "api": "openai-completions", + "models": [{ "id": "model-a", "contextWindow": 1000000.0 }] + } + } + }"#, + ) + .unwrap(); + + ProviderService::remove_from_live_config(&state, AppType::Pi, "cc-switch-test") + .expect("remove provider with equivalent numeric representation"); + assert!(!crate::pi_config::pi_provider_exists("cc-switch-test").unwrap()); + assert_eq!( + state + .db + .get_provider_by_id("cc-switch-test", "pi") + .unwrap() + .unwrap() + .settings_config["models"][0]["contextWindow"] + .as_f64(), + Some(1_000_000.0) + ); + } + + #[test] + #[serial] + fn unreadable_selection_does_not_block_membership_changes() { + let _agent = TestAgentDir::new(); + let state = state(); + let settings_path = crate::pi_config::get_pi_settings_path().unwrap(); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + fs::write(&settings_path, "{not-json").unwrap(); + + add(&state, input("model-a"), true).expect("selection is unrelated to adding a provider"); + assert!(state + .db + .get_provider_by_id("cc-switch-test", "pi") + .unwrap() + .is_some()); + assert!(crate::pi_config::pi_provider_exists("cc-switch-test").unwrap()); + + let original = input("model-a"); + update(&state, Some("cc-switch-test"), original.clone()) + .expect("an edit that keeps every model does not need the default selection"); + + ProviderService::remove_from_live_config(&state, AppType::Pi, "cc-switch-test") + .expect("global selection is advisory for removal"); + ProviderService::switch(&state, AppType::Pi, "cc-switch-test").expect("re-enable provider"); + ProviderService::delete(&state, AppType::Pi, "cc-switch-test") + .expect("global selection is advisory for deletion"); + assert!(!crate::pi_config::pi_provider_exists("cc-switch-test").unwrap()); + } +} diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index f15e5e722..9442eaf9d 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -1,5 +1,6 @@ use std::sync::OnceLock; +use serde_json::Value; use tokio::sync::RwLock; use crate::app_config::AppType; @@ -475,6 +476,12 @@ impl ProviderService { ) }) .map(|s| s.to_string()), + AppType::Pi => provider + .settings_config + .get("apiKey") + .and_then(Value::as_str) + .ok_or_else(|| AppError::InvalidInput("Pi provider API key is missing".to_string())) + .map(str::to_string), } } @@ -555,6 +562,7 @@ impl ProviderService { .and_then(|v| v.as_str()) .unwrap_or_default() .to_string()), + AppType::Pi => crate::pi_config::provider_base_url(&provider.settings_config), } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 4fbc564d7..27ef121f4 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -158,6 +158,13 @@ fn proxy_runtime_registry() -> &'static StdMutex tokio::sync::OwnedMutexGuard<()> { + self.switch_locks.lock_for_app(app_type).await + } + fn run_in_blocking_runtime(&self, task: F) -> Result where T: Send + 'static, @@ -1743,7 +1750,11 @@ impl ProxyService { Some(&cached), )?; } - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => {} + AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi => {} } if cached != original { self.save_failover_live_snapshot(app_type, &provider.id, &cached) @@ -2555,7 +2566,9 @@ impl ProxyService { (None, _) => Ok(incoming_snapshot), } } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(backup_snapshot), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + Ok(backup_snapshot) + } } } diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index bc5ad7c8d..62dd2a949 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -377,6 +377,11 @@ pub(crate) fn sync_all_session_usage_unlocked( "OpenCode", crate::services::session_usage_opencode::sync_opencode_usage(db), ); + merge_sync_step( + &mut result, + "Pi", + crate::services::session_usage_pi::sync_pi_usage(db), + ); if result.imported > 0 { crate::usage_events::notify_log_recorded(); } diff --git a/src-tauri/src/services/session_usage_pi.rs b/src-tauri/src/services/session_usage_pi.rs new file mode 100644 index 000000000..2ec875a7e --- /dev/null +++ b/src-tauri/src/services/session_usage_pi.rs @@ -0,0 +1,1496 @@ +//! Pi coding-agent session usage importer. +//! +//! Pi records normalized token and cost data in its session JSONL files. This +//! importer keeps direct (non-proxy) Pi usage visible in the shared dashboard. + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::proxy::usage::calculator::CostCalculator; +use crate::proxy::usage::parser::TokenUsage; +use crate::services::session_usage::{ + metadata_modified_nanos, update_sync_state_conn, SessionSyncResult, +}; +use crate::services::sql_helpers::INPUT_TOKEN_SEMANTICS_FRESH; +use crate::services::usage_stats::find_model_pricing; +use rusqlite::OptionalExtension; +use rust_decimal::Decimal; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +const APP_TYPE: &str = "pi"; +const DATA_SOURCE: &str = "pi_session"; +const PROVIDER_PLACEHOLDER: &str = "_pi_session"; +const UNKNOWN_MODEL: &str = "unknown"; +const MAX_USAGE_LABEL_BYTES: usize = 512; +const MIN_SQLITE_UNIX_MILLIS: i64 = -62_167_219_200_000; +const MAX_SQLITE_UNIX_MILLIS: i64 = 253_402_300_799_999; +const REVISION_TAIL_BYTES: u64 = 4096; +const REVISION_MARKER_SHIFT: u32 = 61; +const REVISION_COMPLETE_SHIFT: u32 = 60; +const REVISION_SIZE_SHIFT: u32 = 32; +const REVISION_MARKER: u64 = 0b101; +const REVISION_SIZE_MASK: u64 = (1 << 28) - 1; + +#[derive(Debug, Clone, Copy, Default)] +struct PiCosts { + input: Decimal, + output: Decimal, + cache_read: Decimal, + cache_write: Decimal, + total: Decimal, +} + +impl PiCosts { + fn reported(self) -> Option<(Decimal, Decimal, Decimal, Decimal, Decimal)> { + let component_total = self.input + self.output + self.cache_read + self.cache_write; + let total = if self.total > Decimal::ZERO { + self.total + } else { + component_total + }; + (total > Decimal::ZERO).then_some(( + self.input, + self.output, + self.cache_read, + self.cache_write, + total, + )) + } +} + +#[derive(Debug)] +struct PiUsageRecord { + request_id: String, + semantic_id: String, + has_entry_id: bool, + provider_id: String, + model: String, + request_model: String, + input_tokens: u32, + output_tokens: u32, + cache_read_tokens: u32, + cache_write_tokens: u32, + costs: PiCosts, + status_code: i64, + error_message: Option, + created_at: i64, + session_id: String, +} + +#[derive(Debug)] +struct ParsedPiFile { + records: Vec, + last_complete_line: i64, + incomplete_tail: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PiFileRevision { + modified_nanos: i64, + file_size: u64, + tail_fingerprint: u32, + complete: bool, +} + +impl PiFileRevision { + fn encoded(self) -> i64 { + ((REVISION_MARKER << REVISION_MARKER_SHIFT) + | (u64::from(self.complete) << REVISION_COMPLETE_SHIFT) + | (self.file_size << REVISION_SIZE_SHIFT) + | u64::from(self.tail_fingerprint)) as i64 + } +} + +#[derive(Debug, Clone, Copy)] +struct PiSyncState { + revision: PiFileRevision, + last_line_offset: i64, +} + +#[derive(Debug)] +struct PiRequestIdentity { + request_id: String, + semantic_id: String, + has_entry_id: bool, +} + +/// Import usage from every Pi session file discoverable by the session +/// browser's current root and layout rules. +pub fn sync_pi_usage(db: &Database) -> Result { + let files = crate::session_manager::providers::pi::session_files() + .map_err(|error| AppError::Config(format!("无法发现 Pi 会话: {error}")))?; + Ok(sync_pi_files(db, &files)) +} + +fn sync_pi_files(db: &Database, files: &[PathBuf]) -> SessionSyncResult { + let mut result = SessionSyncResult { + files_scanned: files.len().min(u32::MAX as usize) as u32, + ..Default::default() + }; + + for file_path in files { + match sync_single_pi_file(db, file_path) { + Ok(file_result) => result.merge(file_result), + Err(error) => { + let message = format!("{}: {error}", file_path.display()); + log::warn!("[PI-SYNC] 会话文件解析失败: {message}"); + result.errors.push(message); + } + } + } + + if result.imported > 0 { + log::info!( + "[PI-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个文件", + result.imported, + result.skipped, + result.files_scanned + ); + } + result +} + +fn sync_single_pi_file(db: &Database, file_path: &Path) -> Result { + let metadata = fs::symlink_metadata(file_path) + .map_err(|error| AppError::Config(format!("无法读取 Pi 会话文件元数据: {error}")))?; + if !metadata.file_type().is_file() + || file_path.extension().and_then(|value| value.to_str()) != Some("jsonl") + { + return Err(AppError::Config( + "Pi 会话路径不是普通 JSONL 文件".to_string(), + )); + } + if metadata.len() > crate::session_manager::providers::pi::MAX_SESSION_BYTES { + return Err(AppError::Config(format!( + "Pi 会话文件超过 {} 字节安全上限", + crate::session_manager::providers::pi::MAX_SESSION_BYTES + ))); + } + + let file_path_string = file_path.to_string_lossy().to_string(); + let modified = metadata_modified_nanos(&metadata); + let revision = pi_file_revision(file_path, &metadata, modified)?; + let previous = get_pi_sync_state(db, &file_path_string)?; + if previous.is_some_and(|state| state.revision == revision) { + return Ok(SessionSyncResult::default()); + } + + // A matching tail at the old EOF identifies Pi's normal append path, so + // active sessions can seek straight to appended JSONL. Any mismatch is a + // rewrite and must rescan from the header; the durable request ledger + // makes that safe. + let (start_after_line, start_at_byte) = match previous { + Some(state) + if state.revision.complete + && revision.file_size > state.revision.file_size + && pi_prefix_tail_matches(file_path, state.revision)? => + { + (state.last_line_offset, Some(state.revision.file_size)) + } + Some(_) | None => (0, None), + }; + let parsed = parse_pi_file( + file_path, + start_after_line, + start_at_byte, + revision.file_size, + modified, + )?; + let conn = lock_conn!(db.conn); + let tx = conn + .unchecked_transaction() + .map_err(|error| AppError::Database(format!("启动 Pi 用量导入事务失败: {error}")))?; + let mut result = SessionSyncResult::default(); + for record in &parsed.records { + if insert_pi_record(&tx, record)? { + result.imported = result.imported.saturating_add(1); + } else { + result.skipped = result.skipped.saturating_add(1); + } + } + + update_pi_sync_state_on_conn(&tx, &file_path_string, revision, parsed.last_complete_line)?; + tx.commit() + .map_err(|error| AppError::Database(format!("提交 Pi 用量导入事务失败: {error}")))?; + if parsed.incomplete_tail { + result.deferred_files = 1; + } + Ok(result) +} + +fn get_pi_sync_state(db: &Database, file_path: &str) -> Result, AppError> { + let conn = lock_conn!(db.conn); + let row = conn + .query_row( + "SELECT last_modified, last_line_offset, last_synced_at + FROM session_log_sync WHERE file_path = ?1", + rusqlite::params![file_path], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }, + ) + .optional() + .map_err(|error| AppError::Database(format!("读取 Pi 会话同步状态失败: {error}")))?; + let Some((modified_nanos, last_line_offset, encoded_revision)) = row else { + return Ok(None); + }; + let encoded_revision = encoded_revision as u64; + if encoded_revision >> REVISION_MARKER_SHIFT != REVISION_MARKER { + return Ok(None); + } + let file_size = (encoded_revision >> REVISION_SIZE_SHIFT) & REVISION_SIZE_MASK; + if file_size > crate::session_manager::providers::pi::MAX_SESSION_BYTES + || last_line_offset < 0 + || last_line_offset > crate::session_manager::providers::pi::MAX_TREE_ENTRIES as i64 + 1 + { + return Ok(None); + } + Ok(Some(PiSyncState { + revision: PiFileRevision { + modified_nanos, + file_size, + tail_fingerprint: encoded_revision as u32, + complete: ((encoded_revision >> REVISION_COMPLETE_SHIFT) & 1) == 1, + }, + last_line_offset, + })) +} + +fn update_pi_sync_state_on_conn( + conn: &rusqlite::Connection, + file_path: &str, + revision: PiFileRevision, + last_line_offset: i64, +) -> Result<(), AppError> { + update_sync_state_conn(conn, file_path, revision.modified_nanos, last_line_offset)?; + // Match upstream: Pi privately tags this field with the append-proof + // revision; no production consumer interprets it as a wall-clock value. + conn.execute( + "UPDATE session_log_sync SET last_synced_at = ?2 WHERE file_path = ?1", + rusqlite::params![file_path, revision.encoded()], + ) + .map_err(|error| AppError::Database(format!("更新 Pi 会话同步状态失败: {error}")))?; + Ok(()) +} + +fn pi_file_revision( + file_path: &Path, + metadata: &fs::Metadata, + modified_nanos: i64, +) -> Result { + let tail_len = metadata.len().min(REVISION_TAIL_BYTES); + let mut tail = vec![0; tail_len as usize]; + if tail_len > 0 { + let mut file = File::open(file_path) + .map_err(|error| AppError::Config(format!("无法打开 Pi 会话文件: {error}")))?; + file.seek(SeekFrom::Start(metadata.len() - tail_len)) + .and_then(|_| file.read_exact(&mut tail)) + .map_err(|error| AppError::Config(format!("无法读取 Pi 会话文件尾部: {error}")))?; + } + + let complete = tail.last() == Some(&b'\n'); + let tail_fingerprint = pi_tail_fingerprint(&tail); + Ok(PiFileRevision { + modified_nanos, + file_size: metadata.len(), + tail_fingerprint, + complete, + }) +} + +fn pi_prefix_tail_matches(file_path: &Path, previous: PiFileRevision) -> Result { + let tail_len = previous.file_size.min(REVISION_TAIL_BYTES); + let mut tail = vec![0; tail_len as usize]; + if tail_len > 0 { + let mut file = File::open(file_path) + .map_err(|error| AppError::Config(format!("无法打开 Pi 会话文件: {error}")))?; + file.seek(SeekFrom::Start(previous.file_size - tail_len)) + .and_then(|_| file.read_exact(&mut tail)) + .map_err(|error| AppError::Config(format!("无法校验 Pi 会话追加边界: {error}")))?; + } + Ok(pi_tail_fingerprint(&tail) == previous.tail_fingerprint) +} + +fn pi_tail_fingerprint(tail: &[u8]) -> u32 { + let mut hasher = Sha256::new(); + hash_field(&mut hasher, b"pi-session-tail-v1"); + hash_field(&mut hasher, tail); + let digest = hasher.finalize(); + u32::from_be_bytes(digest[..4].try_into().unwrap_or_default()) +} + +fn parse_pi_file( + file_path: &Path, + start_after_line: i64, + start_at_byte: Option, + snapshot_size: u64, + file_modified_nanos: i64, +) -> Result { + let file = File::open(file_path) + .map_err(|error| AppError::Config(format!("无法打开 Pi 会话文件: {error}")))?; + let mut reader = BufReader::new(file); + let mut buffer = String::new(); + let mut line_number = 0i64; + let mut bytes_read = 0u64; + let mut session_id = None; + let mut session_timestamp = None; + let mut records = Vec::new(); + let mut incomplete_tail = false; + + loop { + buffer.clear(); + let remaining = snapshot_size.saturating_sub(bytes_read); + if remaining == 0 { + break; + } + let read = Read::by_ref(&mut reader) + .take(remaining) + .read_line(&mut buffer) + .map_err(|error| AppError::Config(format!("无法读取 Pi 会话文件: {error}")))?; + if read == 0 { + return Err(AppError::Config("Pi 会话文件在读取期间被截断".to_string())); + } + bytes_read = bytes_read.saturating_add(read as u64); + if bytes_read > crate::session_manager::providers::pi::MAX_SESSION_BYTES { + return Err(AppError::Config( + "Pi 会话文件读取时超过安全上限".to_string(), + )); + } + let has_newline = buffer.ends_with('\n'); + let line = buffer.trim(); + let value = if line.is_empty() { + None + } else { + match serde_json::from_str::(line) { + Ok(value) => Some(value), + Err(_) if !has_newline => { + incomplete_tail = true; + break; + } + Err(_) => None, + } + }; + line_number = line_number.saturating_add(1); + if line_number > crate::session_manager::providers::pi::MAX_TREE_ENTRIES as i64 + 1 { + return Err(AppError::Config(format!( + "Pi 会话超过 {} 条 entry 安全上限", + crate::session_manager::providers::pi::MAX_TREE_ENTRIES + ))); + } + if session_id.is_some() && line_number <= start_after_line { + continue; + } + let Some(value) = value else { + if !has_newline { + incomplete_tail = true; + break; + } + continue; + }; + + if session_id.is_none() { + if value.get("type").and_then(Value::as_str) != Some("session") { + return Err(AppError::Config( + "Pi 会话的首条有效 JSON 不是 session header".to_string(), + )); + } + session_id = value + .get("id") + .and_then(Value::as_str) + .filter(|id| crate::session_manager::providers::pi::is_valid_tree_id(id)) + .map(str::to_string); + if session_id.is_none() { + return Err(AppError::Config("Pi 会话 header 缺少 id".to_string())); + } + let header_timestamp_millis = value.get("timestamp").and_then(parse_timestamp_millis); + session_timestamp = header_timestamp_millis.map(|timestamp| timestamp / 1000); + if let Some(byte_offset) = start_at_byte.filter(|offset| *offset >= bytes_read) { + reader.seek(SeekFrom::Start(byte_offset)).map_err(|error| { + AppError::Config(format!("无法定位 Pi 会话增量边界: {error}")) + })?; + bytes_read = byte_offset; + line_number = start_after_line; + } + continue; + } + if let Some(record) = parse_usage_record( + &value, + session_id.as_deref().unwrap_or_default(), + session_timestamp, + file_modified_nanos / 1_000_000_000, + ) { + records.push(record); + } + } + + if session_id.is_none() && !incomplete_tail { + return Err(AppError::Config("Pi 会话没有有效 header".to_string())); + } + Ok(ParsedPiFile { + records, + last_complete_line: line_number, + incomplete_tail, + }) +} + +fn parse_usage_record( + entry: &Value, + session_id: &str, + session_timestamp: Option, + file_timestamp: i64, +) -> Option { + let entry_type = entry.get("type").and_then(Value::as_str)?; + let (kind, usage_value, message) = match entry_type { + "message" => { + let message = entry.get("message")?; + match message.get("role").and_then(Value::as_str) { + Some("assistant") => ("assistant", message.get("usage")?, Some(message)), + Some("toolResult") => ("tool_result", message.get("usage")?, Some(message)), + _ => return None, + } + } + "compaction" => ("compaction", entry.get("usage")?, None), + "branch_summary" => ("branch_summary", entry.get("usage")?, None), + _ => return None, + }; + + let event_timestamp_millis = entry + .get("timestamp") + .and_then(parse_timestamp_millis) + .or_else(|| { + message + .and_then(|value| value.get("timestamp")) + .and_then(parse_timestamp_millis) + }); + let input_tokens = token_count(usage_value, "input"); + let output_tokens = token_count(usage_value, "output"); + let cache_read_tokens = token_count(usage_value, "cacheRead"); + let cache_write_tokens = token_count(usage_value, "cacheWrite"); + let costs = parse_costs(usage_value.get("cost")); + let stop_reason = (kind == "assistant") + .then(|| message.and_then(|value| nonempty_string(value.get("stopReason")))) + .flatten(); + let failed = matches!(stop_reason, Some("error" | "aborted")); + if input_tokens == 0 + && output_tokens == 0 + && cache_read_tokens == 0 + && cache_write_tokens == 0 + && costs.reported().is_none() + && !failed + { + return None; + } + + let (provider_id, model, request_model) = if kind == "assistant" { + let message = message?; + let provider = bounded_label(message.get("provider"), PROVIDER_PLACEHOLDER); + let requested = bounded_label(message.get("model"), UNKNOWN_MODEL); + let actual = nonempty_string(message.get("responseModel")) + .map(truncate_usage_label) + .unwrap_or(&requested) + .to_string(); + (provider, actual, requested) + } else { + ( + PROVIDER_PLACEHOLDER.to_string(), + UNKNOWN_MODEL.to_string(), + UNKNOWN_MODEL.to_string(), + ) + }; + + let created_at = event_timestamp_millis + .map(|timestamp| timestamp / 1000) + .or(session_timestamp) + .unwrap_or(file_timestamp) + .clamp(MIN_SQLITE_UNIX_MILLIS / 1000, MAX_SQLITE_UNIX_MILLIS / 1000); + + let (status_code, error_message) = if kind == "assistant" { + match stop_reason { + Some("error") | Some("aborted") => { + let fallback = if stop_reason == Some("aborted") { + "Pi request aborted" + } else { + "Pi request failed" + }; + let error = message + .and_then(|value| nonempty_string(value.get("errorMessage"))) + .unwrap_or(fallback) + .chars() + .take(4096) + .collect(); + ( + if stop_reason == Some("aborted") { + 499 + } else { + 500 + }, + Some(error), + ) + } + _ => (200, None), + } + } else { + (200, None) + }; + + let identity = pi_request_identity(entry, kind, usage_value, message); + + Some(PiUsageRecord { + request_id: identity.request_id, + semantic_id: identity.semantic_id, + has_entry_id: identity.has_entry_id, + provider_id, + model, + request_model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + costs, + status_code, + error_message, + created_at, + session_id: session_id.to_string(), + }) +} + +fn nonempty_string(value: Option<&Value>) -> Option<&str> { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn bounded_label(value: Option<&Value>, fallback: &str) -> String { + truncate_usage_label(nonempty_string(value).unwrap_or(fallback)).to_string() +} + +fn truncate_usage_label(value: &str) -> &str { + if value.len() <= MAX_USAGE_LABEL_BYTES { + return value; + } + let mut end = MAX_USAGE_LABEL_BYTES; + while !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} + +fn token_count(usage: &Value, key: &str) -> u32 { + usage + .get(key) + .and_then(Value::as_u64) + .unwrap_or(0) + .min(u32::MAX as u64) as u32 +} + +fn parse_costs(value: Option<&Value>) -> PiCosts { + let decimal = |key| { + value + .and_then(|cost| cost.get(key)) + .and_then(parse_decimal) + .unwrap_or(Decimal::ZERO) + .max(Decimal::ZERO) + }; + PiCosts { + input: decimal("input"), + output: decimal("output"), + cache_read: decimal("cacheRead"), + cache_write: decimal("cacheWrite"), + total: decimal("total"), + } +} + +fn parse_decimal(value: &Value) -> Option { + let raw = match value { + Value::Number(number) => number.to_string(), + Value::String(value) => value.clone(), + _ => return None, + }; + Decimal::from_str(&raw) + .or_else(|_| Decimal::from_scientific(&raw)) + .ok() +} + +fn parse_timestamp_millis(value: &Value) -> Option { + let timestamp = if let Some(timestamp) = value.as_i64() { + if !(-100_000_000_000..=100_000_000_000).contains(×tamp) { + timestamp + } else { + timestamp.saturating_mul(1000) + } + } else { + value + .as_str() + .and_then(|timestamp| chrono::DateTime::parse_from_rfc3339(timestamp).ok())? + .timestamp_millis() + }; + (MIN_SQLITE_UNIX_MILLIS..=MAX_SQLITE_UNIX_MILLIS) + .contains(×tamp) + .then_some(timestamp) +} + +fn pi_request_identity( + entry: &Value, + kind: &str, + usage: &Value, + message: Option<&Value>, +) -> PiRequestIdentity { + let mut hasher = Sha256::new(); + hash_field(&mut hasher, b"pi-session-semantic-v1"); + hash_field(&mut hasher, kind.as_bytes()); + + for (label, value) in [ + (b"entry_timestamp".as_slice(), entry.get("timestamp")), + ( + b"message_timestamp".as_slice(), + message.and_then(|value| value.get("timestamp")), + ), + ] { + if let Some(value) = value { + hash_field(&mut hasher, label); + hash_json(&mut hasher, value); + } + } + if let Some(message) = message { + for key in [ + "provider", + "model", + "responseModel", + "responseId", + "api", + "toolCallId", + "toolName", + "stopReason", + "errorMessage", + ] { + if let Some(value) = message.get(key) { + hash_field(&mut hasher, key.as_bytes()); + hash_json(&mut hasher, value); + } + } + if let Some(content) = message.get("content") { + hash_field(&mut hasher, b"content"); + hash_json(&mut hasher, content); + } + } else if let Some(summary) = entry.get("summary") { + hash_field(&mut hasher, b"summary"); + hash_json(&mut hasher, summary); + } + hash_field(&mut hasher, b"usage"); + hash_json(&mut hasher, usage); + let semantic_id = format!("pi_session_semantic:{:x}", hasher.finalize()); + let entry_id = nonempty_string(entry.get("id")); + let request_id = if let Some(entry_id) = entry_id { + let mut request_hasher = Sha256::new(); + hash_field(&mut request_hasher, b"pi-session-request-v3"); + hash_field(&mut request_hasher, kind.as_bytes()); + hash_field(&mut request_hasher, entry_id.as_bytes()); + if let Some(timestamp) = entry.get("timestamp") { + hash_json(&mut request_hasher, timestamp); + } + format!("pi_session:{:x}", request_hasher.finalize()) + } else { + semantic_id.clone() + }; + PiRequestIdentity { + request_id, + semantic_id, + has_entry_id: entry_id.is_some(), + } +} + +fn hash_json(hasher: &mut Sha256, value: &Value) { + match value { + Value::Null => hash_field(hasher, b"null"), + Value::Bool(value) => { + hash_field(hasher, b"bool"); + hash_field(hasher, if *value { b"true" } else { b"false" }); + } + Value::Number(value) => { + hash_field(hasher, b"number"); + hash_field(hasher, value.to_string().as_bytes()); + } + Value::String(value) => { + hash_field(hasher, b"string"); + hash_field(hasher, value.as_bytes()); + } + Value::Array(values) => { + hash_field(hasher, b"array"); + hash_field(hasher, &(values.len() as u64).to_be_bytes()); + for value in values { + hash_json(hasher, value); + } + } + Value::Object(values) => { + hash_field(hasher, b"object"); + hash_field(hasher, &(values.len() as u64).to_be_bytes()); + let mut keys: Vec<_> = values.keys().collect(); + keys.sort_unstable(); + for key in keys { + hash_field(hasher, key.as_bytes()); + hash_json(hasher, &values[key]); + } + } + } +} + +fn hash_field(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value); +} + +fn insert_pi_record(conn: &rusqlite::Connection, record: &PiUsageRecord) -> Result { + let already_seen: bool = conn + .query_row( + "SELECT EXISTS( + SELECT 1 FROM session_usage_dedup + WHERE data_source = ?1 AND ( + request_id = ?2 OR + (semantic_id = ?3 AND (?4 = 0 OR has_entry_id = 0)) + ) + )", + rusqlite::params![ + DATA_SOURCE, + record.request_id, + record.semantic_id, + i64::from(record.has_entry_id), + ], + |row| row.get(0), + ) + .map_err(|error| AppError::Database(format!("查询 Pi 用量去重账本失败: {error}")))?; + if already_seen { + return Ok(false); + } + conn.execute( + "INSERT OR IGNORE INTO session_usage_dedup + (data_source, request_id, semantic_id, has_entry_id) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + DATA_SOURCE, + record.request_id, + record.semantic_id, + i64::from(record.has_entry_id), + ], + ) + .map_err(|error| AppError::Database(format!("写入 Pi 用量去重账本失败: {error}")))?; + + let usage = TokenUsage { + input_tokens: record.input_tokens, + output_tokens: record.output_tokens, + cache_read_tokens: record.cache_read_tokens, + cache_creation_tokens: record.cache_write_tokens, + model: Some(record.model.clone()), + message_id: None, + }; + let costs = record.costs.reported().or_else(|| { + find_model_pricing(conn, &record.model).map(|pricing| { + let calculated = + CostCalculator::calculate_for_app(APP_TYPE, &usage, &pricing, Decimal::ONE); + ( + calculated.input_cost, + calculated.output_cost, + calculated.cache_read_cost, + calculated.cache_creation_cost, + calculated.total_cost, + ) + }) + }); + let (input_cost, output_cost, cache_read_cost, cache_write_cost, total_cost) = + costs.unwrap_or(( + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + )); + + conn.execute( + "INSERT OR IGNORE INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, pricing_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_token_semantics, + input_cost_usd, output_cost_usd, cache_read_cost_usd, + cache_creation_cost_usd, total_cost_usd, + latency_ms, first_token_ms, status_code, error_message, session_id, + provider_type, is_streaming, cost_multiplier, created_at, data_source + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, + ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26 + )", + rusqlite::params![ + record.request_id, + record.provider_id, + APP_TYPE, + record.model, + record.request_model, + record.model, + record.input_tokens, + record.output_tokens, + record.cache_read_tokens, + record.cache_write_tokens, + INPUT_TOKEN_SEMANTICS_FRESH, + input_cost.to_string(), + output_cost.to_string(), + cache_read_cost.to_string(), + cache_write_cost.to_string(), + total_cost.to_string(), + 0i64, + Option::::None, + record.status_code, + record.error_message, + record.session_id, + Some(DATA_SOURCE), + 1i64, + "1.0", + record.created_at, + DATA_SOURCE, + ], + ) + .map(|changed| changed > 0) + .map_err(|error| AppError::Database(format!("插入 Pi 会话用量失败: {error}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::FileTimes; + use std::io::Write; + + fn session_path(root: &Path, name: &str) -> PathBuf { + root.join(format!("{name}.jsonl")) + } + + fn write_lines(path: &Path, lines: &[&str]) { + let mut file = File::create(path).expect("create session"); + for line in lines { + writeln!(file, "{line}").expect("write session line"); + } + } + + fn assistant_line(id: &str, timestamp: &str, input: u32) -> String { + format!( + r#"{{"type":"message","id":"{id}","parentId":null,"timestamp":"{timestamp}","message":{{"role":"assistant","content":[{{"type":"text","text":"ok"}}],"provider":"fixture-provider","model":"fixture-model","responseId":"reused-response-id","timestamp":1700000000000,"usage":{{"input":{input},"output":2,"cacheRead":5,"cacheWrite":2,"totalTokens":999,"cost":{{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}}}},"stopReason":"stop"}}}}"# + ) + } + + fn set_modified(path: &Path, modified: std::time::SystemTime) { + File::options() + .write(true) + .open(path) + .expect("open session for timestamp restore") + .set_times(FileTimes::new().set_modified(modified)) + .expect("restore session timestamp"); + } + + #[test] + fn imports_all_pi_usage_carriers_with_source_semantics() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = session_path(temp.path(), "all-carriers"); + write_lines( + &path, + &[ + r#"{"type":"session","version":3,"id":"session-a","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#, + r#"{"type":"message","id":"user","parentId":null,"timestamp":"2023-11-14T22:13:20Z","message":{"role":"user","content":"question"}}"#, + r#"{"type":"message","id":"assistant","parentId":"user","timestamp":"2023-11-14T22:13:21Z","message":{"role":"assistant","content":[{"type":"text","text":"answer"}],"provider":"custom-pi","model":"requested-model","responseModel":"actual-model","responseId":"response-1","timestamp":1700000000000,"usage":{"input":10,"output":7,"cacheRead":5,"cacheWrite":2,"reasoning":3,"totalTokens":23,"cost":{"input":0.00001,"output":0.000014,"cacheRead":5E-7,"cacheWrite":0.000001,"total":0.0000255}},"stopReason":"stop"}}"#, + r#"{"type":"message","id":"tool","parentId":"assistant","timestamp":"2023-11-14T22:13:22Z","message":{"role":"toolResult","toolCallId":"tool-1","toolName":"nested","content":[],"usage":{"input":3,"output":4,"cacheRead":1,"cacheWrite":1,"totalTokens":9,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}}}}"#, + r#"{"type":"compaction","id":"compact","parentId":"tool","timestamp":"2023-11-14T22:13:23Z","summary":"summary","usage":{"input":11,"output":12,"cacheRead":2,"cacheWrite":3,"totalTokens":28,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"details":{"retainedTail":[{"type":"message","message":{"role":"assistant","usage":{"input":999,"output":999,"cacheRead":999,"cacheWrite":999}}}]}}"#, + r#"{"type":"branch_summary","id":"branch","parentId":"compact","timestamp":"2023-11-14T22:13:24Z","summary":"branch summary","usage":{"input":13,"output":14,"cacheRead":4,"cacheWrite":5,"totalTokens":36,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}}}"#, + r#"{"type":"message","id":"empty-error","parentId":"branch","timestamp":"2023-11-14T22:13:25Z","message":{"role":"assistant","provider":"custom-pi","model":"actual-model","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error"}}"#, + ], + ); + + let db = Database::memory()?; + let result = sync_pi_files(&db, std::slice::from_ref(&path)); + assert_eq!(result.imported, 5); + assert!(result.errors.is_empty()); + + { + let conn = lock_conn!(db.conn); + let totals: (i64, i64, i64, i64, i64) = conn.query_row( + "SELECT COUNT(*), SUM(input_tokens), SUM(output_tokens), + SUM(cache_read_tokens), SUM(cache_creation_tokens) + FROM proxy_request_logs WHERE data_source = 'pi_session'", + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + )?; + assert_eq!(totals, (5, 37, 37, 12, 11)); + + let assistant: (String, String, String, String, i64, i64, String) = conn.query_row( + "SELECT provider_id, model, request_model, pricing_model, created_at, + input_token_semantics, total_cost_usd + FROM proxy_request_logs + WHERE provider_id = 'custom-pi' AND status_code = 200", + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }, + )?; + assert_eq!(assistant.0, "custom-pi"); + assert_eq!(assistant.1, "actual-model"); + assert_eq!(assistant.2, "requested-model"); + assert_eq!(assistant.3, "actual-model"); + assert_eq!(assistant.4, 1_700_000_001); + assert_eq!(assistant.5, INPUT_TOKEN_SEMANTICS_FRESH); + assert_eq!( + Decimal::from_str(&assistant.6).expect("reported total"), + Decimal::from_str("0.0000255").expect("expected total") + ); + + let empty_failure: (i64, i64, String) = conn.query_row( + "SELECT status_code, input_tokens, error_message + FROM proxy_request_logs + WHERE provider_id = 'custom-pi' AND status_code = 500", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + assert_eq!(empty_failure, (500, 0, "Pi request failed".to_string())); + } + + let providers = db.get_provider_stats(None, None, Some("pi"))?; + assert!(providers.iter().any(|provider| { + provider.provider_id == PROVIDER_PLACEHOLDER && provider.provider_name == "Pi (Session)" + })); + Ok(()) + } + + #[test] + fn zero_reported_cost_uses_fresh_input_pricing() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = session_path(temp.path(), "pricing"); + let assistant = assistant_line("priced", "2023-11-14T22:13:21Z", 10); + write_lines( + &path, + &[ + r#"{"type":"session","version":3,"id":"session-priced","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#, + &assistant, + ], + ); + + let db = Database::memory()?; + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT OR REPLACE INTO model_pricing ( + model_id, display_name, input_cost_per_million, + output_cost_per_million, cache_read_cost_per_million, + cache_creation_cost_per_million + ) VALUES ('fixture-model', 'Fixture', '1', '2', '0.1', '0.5')", + [], + )?; + } + let result = sync_pi_files(&db, std::slice::from_ref(&path)); + assert_eq!(result.imported, 1); + + let conn = lock_conn!(db.conn); + let total: String = conn.query_row( + "SELECT total_cost_usd FROM proxy_request_logs WHERE data_source = 'pi_session'", + [], + |row| row.get(0), + )?; + // Pi input is already fresh; cache buckets are priced in addition to + // all 10 input tokens rather than subtracted from them. + assert_eq!( + Decimal::from_str(&total).expect("calculated total"), + Decimal::from_str("0.0000155").expect("expected total") + ); + Ok(()) + } + + #[test] + fn distinct_ids_keep_identical_usage_events_separate() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = session_path(temp.path(), "same-payload"); + let first = assistant_line("first", "2023-11-14T22:13:21Z", 1); + let second = assistant_line("second", "2023-11-14T22:13:21Z", 1); + write_lines( + &path, + &[ + r#"{"type":"session","version":3,"id":"session-same","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#, + &first, + &second, + ], + ); + + let db = Database::memory()?; + assert_eq!(sync_pi_files(&db, std::slice::from_ref(&path)).imported, 2); + Ok(()) + } + + #[test] + fn bounds_untrusted_provider_and_model_labels_at_utf8_boundaries() { + let mut entry: Value = + serde_json::from_str(&assistant_line("bounded", "2023-11-14T22:13:21Z", 1)) + .expect("assistant entry"); + let oversized = "界".repeat(MAX_USAGE_LABEL_BYTES); + let message = entry + .get_mut("message") + .and_then(Value::as_object_mut) + .expect("message object"); + message.insert("provider".to_string(), Value::String(oversized.clone())); + message.insert("model".to_string(), Value::String(oversized.clone())); + message.insert("responseModel".to_string(), Value::String(oversized)); + + let record = parse_usage_record(&entry, "session", None, 0).expect("usage record"); + for label in [record.provider_id, record.model, record.request_model] { + assert!(label.len() <= MAX_USAGE_LABEL_BYTES); + assert!(std::str::from_utf8(label.as_bytes()).is_ok()); + } + } + + #[test] + fn rejects_timestamps_outside_sqlite_date_range() { + let mut entry: Value = + serde_json::from_str(&assistant_line("time", "2023-11-14T22:13:21Z", 1)) + .expect("assistant entry"); + entry["timestamp"] = Value::from(i64::MIN); + entry["message"]["timestamp"] = Value::from(i64::MAX); + + let record = + parse_usage_record(&entry, "session", Some(1_700_000_000), 0).expect("usage record"); + assert_eq!(record.created_at, 1_700_000_000); + } + + #[test] + fn forked_history_is_not_imported_twice() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let parent = session_path(temp.path(), "parent"); + let fork = session_path(temp.path(), "fork"); + let first = assistant_line("first", "2023-11-14T22:13:21Z", 1); + let second = assistant_line("second", "2023-11-14T22:13:23Z", 3); + write_lines( + &parent, + &[ + r#"{"type":"session","version":3,"id":"session-parent","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#, + &first, + ], + ); + write_lines( + &fork, + &[ + r#"{"type":"session","version":3,"id":"session-fork","timestamp":"2023-11-14T22:13:22Z","cwd":"/work","parentSession":"parent.jsonl"}"#, + &first, + &second, + ], + ); + + let db = Database::memory()?; + let result = sync_pi_files(&db, &[parent.clone(), fork.clone()]); + assert_eq!(result.imported, 2); + assert_eq!(result.skipped, 1); + + // A fork is also a complete recovery source when its parent file is + // absent from a fresh database. + let orphan_db = Database::memory()?; + assert_eq!( + sync_pi_files(&orphan_db, std::slice::from_ref(&fork)).imported, + 2 + ); + + let second_pass = sync_pi_files(&db, &[parent, fork]); + assert_eq!(second_pass.imported, 0); + let count: i64 = lock_conn!(db.conn).query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'pi_session'", + [], + |row| row.get(0), + )?; + assert_eq!(count, 2); + Ok(()) + } + + #[test] + fn rollup_then_legacy_migration_and_fork_do_not_reimport_history() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let parent = session_path(temp.path(), "legacy-parent"); + let fork = session_path(temp.path(), "migrated-fork"); + let migrated = assistant_line("generated", "2023-11-14T22:13:21Z", 1); + let legacy = migrated.replace("\"id\":\"generated\",\"parentId\":null,", ""); + write_lines( + &parent, + &[ + r#"{"type":"session","version":1,"id":"session-legacy","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#, + &legacy, + ], + ); + + let db = Database::memory()?; + assert_eq!( + sync_pi_files(&db, std::slice::from_ref(&parent)).imported, + 1 + ); + assert_eq!(db.rollup_and_prune(30)?, 1); + + // Pi migrates v1 in place by adding generated entry IDs while keeping + // the same logical rows. The line cursor prevents those rows from + // being treated as new after the rewrite. + write_lines( + &parent, + &[ + r#"{"type":"session","version":3,"id":"session-legacy","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#, + &migrated, + ], + ); + assert_eq!( + sync_pi_files(&db, std::slice::from_ref(&parent)).imported, + 0 + ); + + let new_entry = assistant_line("new-entry", "2023-11-14T22:13:23Z", 2); + write_lines( + &fork, + &[ + r#"{"type":"session","version":3,"id":"session-fork","timestamp":"2023-11-14T22:13:22Z","cwd":"/work","parentSession":"legacy-parent.jsonl"}"#, + &migrated, + &new_entry, + ], + ); + assert_eq!(sync_pi_files(&db, std::slice::from_ref(&fork)).imported, 1); + + let conn = lock_conn!(db.conn); + let rolled_up: i64 = conn.query_row( + "SELECT COALESCE(SUM(request_count), 0) FROM usage_daily_rollups + WHERE app_type = 'pi'", + [], + |row| row.get(0), + )?; + let details: i64 = conn.query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'pi_session'", + [], + |row| row.get(0), + )?; + assert_eq!((rolled_up, details), (1, 1)); + drop(conn); + + // Once the new fork request is rolled up too, a shorter repair forces + // a full rescan. The durable ledger still rejects both old identities. + assert_eq!(db.rollup_and_prune(30)?, 1); + write_lines( + &fork, + &[ + r#"{"type":"session","version":3,"id":"session-fork","timestamp":"2023-11-14T22:13:22Z","cwd":"/work","parentSession":"legacy-parent.jsonl"}"#, + &migrated, + ], + ); + assert_eq!(sync_pi_files(&db, std::slice::from_ref(&fork)).imported, 0); + let conn = lock_conn!(db.conn); + let totals: (i64, i64) = conn.query_row( + "SELECT + (SELECT COALESCE(SUM(request_count), 0) FROM usage_daily_rollups + WHERE app_type = 'pi'), + (SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'pi_session')", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + assert_eq!(totals, (2, 0)); + Ok(()) + } + + #[test] + fn assistant_failures_keep_status_and_error_message() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = session_path(temp.path(), "failures"); + let failed = assistant_line("failed", "2023-11-14T22:13:21Z", 1).replace( + r#""stopReason":"stop""#, + r#""stopReason":"error","errorMessage":"provider failed""#, + ); + let aborted = assistant_line("aborted", "2023-11-14T22:13:22Z", 1) + .replace(r#""stopReason":"stop""#, r#""stopReason":"aborted""#); + write_lines( + &path, + &[ + r#"{"type":"session","version":3,"id":"session-failures","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#, + &failed, + &aborted, + ], + ); + + let db = Database::memory()?; + assert_eq!(sync_pi_files(&db, std::slice::from_ref(&path)).imported, 2); + let conn = lock_conn!(db.conn); + let statuses: Vec<(i64, String)> = conn + .prepare( + "SELECT status_code, error_message FROM proxy_request_logs + WHERE data_source = 'pi_session' ORDER BY status_code", + )? + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + assert_eq!( + statuses, + vec![ + (499, "Pi request aborted".to_string()), + (500, "provider failed".to_string()), + ] + ); + Ok(()) + } + + #[test] + fn parser_stops_at_the_captured_file_size() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = session_path(temp.path(), "snapshot"); + let header = r#"{"type":"session","version":3,"id":"session-snapshot","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#; + let first = assistant_line("first", "2023-11-14T22:13:21Z", 1); + let second = assistant_line("second", "2023-11-14T22:13:22Z", 2); + write_lines(&path, &[header, &first]); + let metadata = fs::metadata(&path).expect("snapshot metadata"); + let snapshot_size = metadata.len(); + let modified = metadata_modified_nanos(&metadata); + { + let mut file = File::options() + .append(true) + .open(&path) + .expect("append after snapshot"); + writeln!(file, "{second}").expect("append second record"); + } + + let parsed = parse_pi_file(&path, 0, None, snapshot_size, modified)?; + assert_eq!(parsed.records.len(), 1); + assert_eq!(parsed.last_complete_line, 2); + Ok(()) + } + + #[test] + fn valid_unterminated_final_record_is_imported() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = session_path(temp.path(), "unterminated"); + let header = r#"{"type":"session","version":3,"id":"session-unterminated","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#; + let assistant = assistant_line("final", "2023-11-14T22:13:21Z", 1); + let mut file = File::create(&path).expect("create session"); + writeln!(file, "{header}").expect("write header"); + file.write_all(assistant.as_bytes()) + .expect("write unterminated record"); + drop(file); + + let db = Database::memory()?; + let first = sync_pi_files(&db, std::slice::from_ref(&path)); + assert_eq!((first.imported, first.deferred_files), (1, 0)); + assert_eq!(sync_pi_files(&db, std::slice::from_ref(&path)).imported, 0); + Ok(()) + } + + #[test] + fn failed_append_prefix_proof_rescans_a_longer_rewrite() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = session_path(temp.path(), "longer-rewrite"); + let header = r#"{"type":"session","version":3,"id":"session-rewrite","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#; + let first = assistant_line("first", "2023-11-14T22:13:22Z", 1); + write_lines(&path, &[header, &first]); + + let db = Database::memory()?; + assert_eq!(sync_pi_files(&db, std::slice::from_ref(&path)).imported, 1); + + let before = assistant_line("before", "2023-11-14T22:13:21Z", 2); + let after = assistant_line("after", "2023-11-14T22:13:23Z", 3); + write_lines(&path, &[header, &before, &first, &after]); + let rewritten = sync_pi_files(&db, std::slice::from_ref(&path)); + assert_eq!((rewritten.imported, rewritten.skipped), (2, 1)); + Ok(()) + } + + #[test] + fn same_size_middle_rewrite_is_detected_by_mtime() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = session_path(temp.path(), "middle-rewrite"); + let header = r#"{"type":"session","version":3,"id":"session-middle","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#; + let zero = assistant_line("middle", "2023-11-14T22:13:21Z", 0) + .replace(r#""output":2"#, r#""output":0"#) + .replace(r#""cacheRead":5"#, r#""cacheRead":0"#) + .replace(r#""cacheWrite":2"#, r#""cacheWrite":0"#); + let one = zero.replacen(r#""input":0,"output":0"#, r#""input":1,"output":0"#, 1); + let filler = serde_json::json!({ + "type": "message", + "id": "filler", + "timestamp": "2023-11-14T22:13:22Z", + "message": {"role": "user", "content": "x".repeat(5000)} + }) + .to_string(); + write_lines(&path, &[header, &zero, &filler]); + let initial_size = fs::metadata(&path).expect("initial metadata").len(); + + let db = Database::memory()?; + assert_eq!(sync_pi_files(&db, std::slice::from_ref(&path)).imported, 0); + let previous_mtime = fs::metadata(&path) + .expect("previous metadata") + .modified() + .expect("previous mtime"); + + write_lines(&path, &[header, &one, &filler]); + assert_eq!( + fs::metadata(&path).expect("rewrite metadata").len(), + initial_size + ); + set_modified(&path, previous_mtime + std::time::Duration::from_secs(2)); + assert_eq!(sync_pi_files(&db, std::slice::from_ref(&path)).imported, 1); + Ok(()) + } + + #[test] + fn stable_entry_id_and_canonical_json_prevent_rewrite_duplicates() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let current = session_path(temp.path(), "current"); + let legacy = session_path(temp.path(), "legacy"); + let header = r#"{"type":"session","version":3,"id":"session-stable","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#; + let ten = assistant_line("stable", "2023-11-14T22:13:21Z", 10); + let eleven = assistant_line("stable", "2023-11-14T22:13:21Z", 11); + write_lines(¤t, &[header, &ten]); + + let db = Database::memory()?; + assert_eq!( + sync_pi_files(&db, std::slice::from_ref(¤t)).imported, + 1 + ); + write_lines(¤t, &[header, &eleven]); + let correction = sync_pi_files(&db, std::slice::from_ref(¤t)); + assert_eq!((correction.imported, correction.skipped), (0, 1)); + let current_count: i64 = lock_conn!(db.conn).query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'pi_session'", + [], + |row| row.get(0), + )?; + assert_eq!(current_count, 1); + + let without_id = ten.replace(r#""id":"stable","parentId":null,"#, ""); + let reordered = without_id.replace( + r#"{"type":"text","text":"ok"}"#, + r#"{"text":"ok","type":"text"}"#, + ); + write_lines(&legacy, &[header, &without_id]); + let legacy_db = Database::memory()?; + assert_eq!( + sync_pi_files(&legacy_db, std::slice::from_ref(&legacy)).imported, + 1 + ); + write_lines(&legacy, &[header, &reordered]); + let reordered_result = sync_pi_files(&legacy_db, std::slice::from_ref(&legacy)); + assert_eq!( + (reordered_result.imported, reordered_result.skipped), + (0, 1) + ); + + let legacy_count: i64 = lock_conn!(legacy_db.conn).query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'pi_session'", + [], + |row| row.get(0), + )?; + assert_eq!(legacy_count, 1); + Ok(()) + } + + #[test] + fn incomplete_tail_and_truncated_rewrite_are_recovered() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = session_path(temp.path(), "active"); + let header = r#"{"type":"session","version":3,"id":"session-active","timestamp":"2023-11-14T22:13:20Z","cwd":"/work"}"#; + let first = assistant_line("first", "2023-11-14T22:13:21Z", 1); + let first_bytes = first.as_bytes(); + let split = first_bytes.len() / 2; + { + let mut file = File::create(&path).expect("create partial session"); + writeln!(file, "{header}").expect("header"); + file.write_all(&first_bytes[..split]).expect("partial row"); + } + + let db = Database::memory()?; + let deferred = sync_pi_files(&db, std::slice::from_ref(&path)); + assert_eq!(deferred.imported, 0); + assert_eq!(deferred.deferred_files, 1); + let deferred_state = + get_pi_sync_state(&db, path.to_string_lossy().as_ref())?.expect("deferred sync state"); + assert!(!deferred_state.revision.complete); + let unchanged = sync_pi_files(&db, std::slice::from_ref(&path)); + assert_eq!((unchanged.imported, unchanged.deferred_files), (0, 0)); + + { + let mut file = fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("append session"); + file.write_all(&first_bytes[split..]).expect("finish row"); + writeln!(file).expect("finish newline"); + } + let completed = sync_pi_files(&db, std::slice::from_ref(&path)); + assert_eq!(completed.imported, 1); + let preserved_mtime = fs::metadata(&path) + .expect("session metadata") + .modified() + .expect("session mtime"); + + let second = assistant_line("second", "2023-11-14T22:13:22Z", 2); + let third = assistant_line("third", "2023-11-14T22:13:23Z", 3); + { + let mut file = fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("append complete row"); + writeln!(file, "{second}").expect("second row"); + } + set_modified(&path, preserved_mtime); + assert_eq!(sync_pi_files(&db, std::slice::from_ref(&path)).imported, 1); + + // Rewrite to fewer lines than the saved cursor. Already billed rows + // remain, while the new record is found even with a preserved mtime. + write_lines(&path, &[header, &third]); + set_modified(&path, preserved_mtime); + assert_eq!(sync_pi_files(&db, std::slice::from_ref(&path)).imported, 1); + + // A same-size, same-mtime repair is also detected by the bounded tail + // fingerprint and rescanned from the header. + let fifth = assistant_line("fifth", "2023-11-14T22:13:24Z", 4); + let repaired_size = fs::metadata(&path).expect("repair metadata").len(); + write_lines(&path, &[header, &fifth]); + assert_eq!( + fs::metadata(&path).expect("replacement metadata").len(), + repaired_size + ); + set_modified(&path, preserved_mtime); + assert_eq!(sync_pi_files(&db, std::slice::from_ref(&path)).imported, 1); + let count: i64 = lock_conn!(db.conn).query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'pi_session'", + [], + |row| row.get(0), + )?; + assert_eq!(count, 4); + Ok(()) + } + + #[test] + fn oversized_file_is_reported_instead_of_silently_skipped() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = session_path(temp.path(), "oversized"); + File::create(&path) + .expect("create sparse session") + .set_len(crate::session_manager::providers::pi::MAX_SESSION_BYTES + 1) + .expect("size sparse session"); + + let db = Database::memory()?; + let result = sync_pi_files(&db, std::slice::from_ref(&path)); + assert_eq!(result.files_scanned, 1); + assert_eq!(result.errors.len(), 1); + assert!(result.errors[0].contains("安全上限")); + Ok(()) + } +} diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 9ed643536..7c254f08d 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -621,6 +621,12 @@ fn merge_repos_from_lock( pub struct SkillService; +#[derive(Debug, Clone)] +enum PiSkillDeployment { + Symlink { expected_target: PathBuf }, + Copy { expected_hash: String }, +} + impl SkillService { fn app_supports_skills(app: &AppType) -> bool { !matches!(app, AppType::OpenClaw) @@ -633,6 +639,7 @@ impl SkillService { AppType::Gemini, AppType::OpenCode, AppType::Hermes, + AppType::Pi, ] .into_iter() } @@ -716,6 +723,7 @@ impl SkillService { AppType::OpenCode => crate::opencode_config::get_opencode_dir().join("skills"), AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir().join("skills"), + AppType::Pi => crate::pi_config::get_pi_agent_dir()?.join("skills"), }) } @@ -833,7 +841,10 @@ impl SkillService { fn load_index_from_db(db: &Database) -> Result { let repos = db.get_skill_repos()?; - let installed = db.get_all_installed_skills()?; + let mut installed = db.get_all_installed_skills()?; + for skill in installed.values_mut() { + skill.apps.pi = Self::skill_exists_in_app(&skill.directory, &AppType::Pi); + } let skills: HashMap = installed .into_values() .map(|skill| (skill.directory.clone(), skill)) @@ -970,7 +981,9 @@ impl SkillService { let mut discovered: HashMap = HashMap::new(); - for app in Self::supported_skill_apps() { + // Pi support did not exist before the SSOT migration. Never claim an + // independently installed Pi skill as a legacy CC Switch deployment. + for app in Self::supported_skill_apps().filter(|app| !matches!(app, AppType::Pi)) { let app_dir = match Self::get_app_skills_dir(&app) { Ok(d) => d, Err(_) => continue, @@ -1085,6 +1098,145 @@ impl SkillService { .unwrap_or(false) } + fn skill_exists_in_app(directory: &str, app: &AppType) -> bool { + let Ok(directory) = Self::require_valid_directory(directory) else { + return false; + }; + let Ok(app_dir) = Self::get_app_skills_dir(app) else { + return false; + }; + app_dir.join(directory).is_dir() + } + + fn compute_pi_deployment_hash(dir: &Path) -> Result { + use sha2::{Digest, Sha256}; + + fn collect(current: &Path, entries: &mut Vec) -> Result<(), AppError> { + for entry in fs::read_dir(current).map_err(|error| AppError::io(current, error))? { + let entry = entry.map_err(|error| AppError::io(current, error))?; + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|error| AppError::io(&path, error))?; + entries.push(path.clone()); + if file_type.is_dir() { + collect(&path, entries)?; + } + } + Ok(()) + } + + if !dir.is_dir() { + return Err(AppError::Message(format!( + "Skill directory not found: {}", + dir.display() + ))); + } + let mut entries = Vec::new(); + collect(dir, &mut entries)?; + entries.sort(); + + let mut hasher = Sha256::new(); + for path in entries { + let relative = path.strip_prefix(dir).unwrap_or(&path); + hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update(b"\0"); + let metadata = + fs::symlink_metadata(&path).map_err(|error| AppError::io(&path, error))?; + let file_type = metadata.file_type(); + if file_type.is_symlink() { + hasher.update(b"link\0"); + hasher.update( + fs::read_link(&path) + .map_err(|error| AppError::io(&path, error))? + .to_string_lossy() + .as_bytes(), + ); + } else if file_type.is_dir() { + hasher.update(b"dir\0"); + } else if file_type.is_file() { + hasher.update(b"file\0"); + hasher.update(fs::read(&path).map_err(|error| AppError::io(&path, error))?); + } else { + hasher.update(b"other\0"); + } + hasher.update(b"\0"); + } + Ok(format!("{:x}", hasher.finalize())) + } + + fn inspect_pi_skill_destination( + source: &Path, + destination: &Path, + ) -> Result, AppError> { + if !destination.exists() && !Self::is_symlink(destination) { + return Ok(None); + } + if Self::is_symlink(destination) { + let target = + fs::read_link(destination).map_err(|error| AppError::io(destination, error))?; + let resolved = if target.is_absolute() { + target + } else { + destination + .parent() + .map(|parent| parent.join(&target)) + .unwrap_or(target) + }; + if matches!( + (resolved.canonicalize(), source.canonicalize()), + (Ok(resolved), Ok(source)) if resolved == source + ) { + return Ok(Some(PiSkillDeployment::Symlink { + expected_target: resolved, + })); + } + } else if destination.is_dir() { + if let (Ok(destination_hash), Ok(source_hash)) = ( + Self::compute_pi_deployment_hash(destination), + Self::compute_pi_deployment_hash(source), + ) { + if destination_hash == source_hash { + return Ok(Some(PiSkillDeployment::Copy { + expected_hash: destination_hash, + })); + } + } + } + Err(AppError::InvalidInput(format!( + "Pi skill destination already exists and is not managed by CC Switch: {}", + destination.display() + ))) + } + + fn preflight_pi_skill_destination( + source: &Path, + directory: &str, + app: &AppType, + ) -> Result<(), AppError> { + if !matches!(app, AppType::Pi) { + return Ok(()); + } + let ssot_dir = Self::get_ssot_dir()?; + let app_dir = Self::get_distinct_app_skills_dir(&ssot_dir, app)?; + let destination = app_dir.join(directory); + if destination.exists() || Self::is_symlink(&destination) { + Self::inspect_pi_skill_destination(source, &destination)?; + } + Ok(()) + } + + fn pi_skill_destination_is_managed(source: &Path, destination: &Path) -> bool { + Self::inspect_pi_skill_destination(source, destination) + .ok() + .flatten() + .is_some() + } + + fn copy_skill_to_app(source: &Path, destination: &Path) -> Result<(), AppError> { + Self::copy_dir_recursive(source, destination) + } + fn remove_path(path: &Path) -> Result<(), AppError> { if Self::is_symlink(path) { #[cfg(unix)] @@ -1122,6 +1274,13 @@ impl SkillService { let dest = app_dir.join(&directory); + // Pi's native Skills directory may contain user-managed entries. Match + // upstream by replacing only a destination that still mirrors the + // CC Switch source; preserve every conflicting entry. + if matches!(app, AppType::Pi) && (dest.exists() || Self::is_symlink(&dest)) { + Self::inspect_pi_skill_destination(&source, &dest)?; + } + match method { SyncMethod::Auto => { if dest.exists() && !Self::is_symlink(&dest) { @@ -1275,6 +1434,15 @@ impl SkillService { let app_dir = Self::get_distinct_app_skills_dir(&ssot_dir, app)?; fs::create_dir_all(&app_dir).map_err(|e| AppError::io(&app_dir, e))?; let dest = app_dir.join(&directory); + if matches!(app, AppType::Pi) + && (dest.exists() || Self::is_symlink(&dest)) + && !Self::pi_skill_destination_is_managed(&source, &dest) + { + return Err(AppError::InvalidInput(format!( + "Pi skill destination changed outside CC Switch and was preserved: {}", + dest.display() + ))); + } if source == dest { return Ok(()); } @@ -1293,11 +1461,11 @@ impl SkillService { log::warn!( "Symlink creation failed during Skill update, falling back to copy: {error}" ); - Self::copy_dir_recursive(&source, &next)?; + Self::copy_skill_to_app(&source, &next)?; } } SyncMethod::Symlink => Self::create_symlink(&source, &next)?, - SyncMethod::Copy => Self::copy_dir_recursive(&source, &next)?, + SyncMethod::Copy => Self::copy_skill_to_app(&source, &next)?, } let had_previous = fs::symlink_metadata(&dest).is_ok(); @@ -1323,6 +1491,66 @@ impl SkillService { Ok(()) } + fn refresh_pi_skill_destination( + source: &Path, + destination: &Path, + deployment: &PiSkillDeployment, + ) -> Result<(), AppError> { + match deployment { + PiSkillDeployment::Symlink { expected_target } => { + if !Self::is_symlink(destination) { + return Err(AppError::InvalidInput(format!( + "Pi skill destination changed during update: {}", + destination.display() + ))); + } + let target = + fs::read_link(destination).map_err(|error| AppError::io(destination, error))?; + let resolved = if target.is_absolute() { + target + } else { + destination + .parent() + .map(|parent| parent.join(&target)) + .unwrap_or(target) + }; + if &resolved != expected_target { + return Err(AppError::InvalidInput(format!( + "Pi skill destination changed during update: {}", + destination.display() + ))); + } + Ok(()) + } + PiSkillDeployment::Copy { expected_hash } => { + if Self::is_symlink(destination) + || !destination.is_dir() + || !matches!( + Self::compute_pi_deployment_hash(destination), + Ok(current_hash) if ¤t_hash == expected_hash + ) + { + return Err(AppError::InvalidInput(format!( + "Pi skill destination changed during update: {}", + destination.display() + ))); + } + + let directory = + destination + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| { + AppError::InvalidInput(format!( + "Invalid Pi Skill destination: {}", + destination.display() + )) + })?; + Self::replace_dest_with_copy(source, destination, directory) + } + } + } + pub fn remove_from_app(directory: &str, app: &AppType) -> Result<(), AppError> { if !Self::app_supports_skills(app) { return Ok(()); @@ -1333,6 +1561,15 @@ impl SkillService { let app_dir = Self::get_distinct_app_skills_dir(&ssot_dir, app)?; let path = app_dir.join(&directory); if path.exists() || Self::is_symlink(&path) { + if matches!(app, AppType::Pi) { + let source = Self::get_ssot_dir()?.join(directory); + if !Self::pi_skill_destination_is_managed(&source, &path) { + return Err(AppError::InvalidInput(format!( + "Pi skill destination changed outside CC Switch and was preserved: {}", + path.display() + ))); + } + } Self::remove_path(&path)?; } Ok(()) @@ -2441,10 +2678,11 @@ impl SkillService { /// Update one repository-backed Skill after an explicit user action. async fn update_skill(&self, skill_id: &str) -> Result { let db = Database::init()?; - let skill = db + let mut skill = db .get_installed_skill(skill_id)? .ok_or_else(|| AppError::Message(format!("Skill not found: {skill_id}")))?; let directory = Self::require_valid_directory(&skill.directory)?; + skill.apps.pi = Self::skill_exists_in_app(&skill.directory, &AppType::Pi); let (owner, name) = match (&skill.repo_owner, &skill.repo_name) { (Some(owner), Some(name)) => (owner.clone(), name.clone()), @@ -2485,9 +2723,10 @@ impl SkillService { // Remote I/O and inspection are complete. Serialize SSOT staging, the // final replacement, metadata write, and app refresh as one mutation. let _state_guard = skill_state_write_guard(); - let current = db.get_installed_skill(skill_id)?.ok_or_else(|| { + let mut current = db.get_installed_skill(skill_id)?.ok_or_else(|| { AppError::Message(format!("Skill was removed during update: {skill_id}")) })?; + current.apps.pi = Self::skill_exists_in_app(¤t.directory, &AppType::Pi); if current.directory != skill.directory || current.repo_owner != skill.repo_owner || current.repo_name != skill.repo_name @@ -2512,6 +2751,12 @@ impl SkillService { drop(temp_dir); let sync_method = Self::get_sync_method()?; let dest = ssot_dir.join(&directory); + let pi_deployment = if current.apps.pi { + let pi_dir = Self::get_distinct_app_skills_dir(&ssot_dir, &AppType::Pi)?; + Self::inspect_pi_skill_destination(&dest, &pi_dir.join(&directory))? + } else { + None + }; let had_previous = fs::symlink_metadata(&dest).is_ok(); if had_previous { fs::rename(&dest, &previous).map_err(|e| AppError::IoContext { @@ -2569,7 +2814,22 @@ impl SkillService { drop(staging); let mut deployment_failures = Vec::new(); + if let Some(deployment) = pi_deployment.as_ref() { + let pi_destination = Self::get_app_skills_dir(&AppType::Pi)?.join(&updated.directory); + if let Err(error) = + Self::refresh_pi_skill_destination(&dest, &pi_destination, deployment) + { + log::warn!( + "Updated Skill {} but failed to sync it to Pi: {error}", + updated.id + ); + deployment_failures.push(format!("Pi: {error}")); + } + } for app in Self::supported_skill_apps() { + if matches!(app, AppType::Pi) { + continue; + } if updated.apps.is_enabled_for(&app) { if let Err(error) = Self::sync_updated_skill_to_app(&updated.directory, &app, sync_method) @@ -2737,6 +2997,8 @@ impl SkillService { app: &AppType, sync_method: SyncMethod, ) -> Result<(), AppError> { + let source = Self::get_ssot_dir()?.join(&installed.directory); + Self::preflight_pi_skill_destination(&source, &installed.directory, app)?; db.save_skill(installed)?; if let Err(error) = Self::sync_to_app_dir(&installed.directory, app, sync_method) { if let Err(rollback_error) = db.delete_skill(&installed.id) { @@ -2833,13 +3095,7 @@ impl SkillService { match Self::require_valid_directory(&record.directory) { Ok(directory) => { // Remove from app dirs (best effort). - for app in [ - AppType::Claude, - AppType::Codex, - AppType::Gemini, - AppType::OpenCode, - AppType::Hermes, - ] { + for app in Self::supported_skill_apps() { if let Err(error) = Self::remove_from_app(&directory, &app) { log::warn!("从 {app:?} 删除 Skill {directory} 失败: {error}"); } @@ -3236,7 +3492,8 @@ impl SkillService { let skill_md = dest.join("SKILL.md"); let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name); - let apps = selection.apps; + let mut apps = selection.apps; + apps.pi = Self::skill_exists_in_app(&dir_name, &AppType::Pi); let (id, repo_owner, repo_name, repo_branch, readme_url) = build_repo_info_from_lock(&agents_lock, &dir_name); @@ -3498,6 +3755,7 @@ impl SkillService { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; #[test] fn skill_state_lock_allows_snapshots_but_excludes_writers() { @@ -3640,6 +3898,202 @@ mod tests { ); } + #[test] + fn pi_deployment_hash_includes_hidden_native_changes() { + let temp = tempfile::tempdir().expect("create Pi hash fixture"); + fs::write(temp.path().join("SKILL.md"), "managed").expect("write manifest"); + let initial = + SkillService::compute_pi_deployment_hash(temp.path()).expect("compute Pi hash"); + fs::write(temp.path().join(".env"), "native edit").expect("write hidden native file"); + assert_ne!( + SkillService::compute_pi_deployment_hash(temp.path()).expect("hash hidden edit"), + initial + ); + } + + #[test] + #[serial] + fn pi_remove_preserves_a_same_name_external_directory() { + let home = tempfile::tempdir().expect("create isolated home"); + let _environment = crate::test_support::TestEnvGuard::isolated(home.path()); + let _pi = crate::pi_config::test_support::TestAgentDir::new(); + let source = SkillService::get_ssot_dir() + .expect("resolve SSOT") + .join("demo"); + fs::create_dir_all(&source).expect("create managed source"); + fs::write(source.join("SKILL.md"), "managed").expect("write managed source"); + let destination = SkillService::get_app_skills_dir(&AppType::Pi) + .expect("resolve Pi skills") + .join("demo"); + fs::create_dir_all(&destination).expect("create external Pi skill"); + fs::write(destination.join("SKILL.md"), "external").expect("write external Pi skill"); + + SkillService::remove_from_app("demo", &AppType::Pi) + .expect_err("external Pi skill must be preserved"); + assert_eq!( + fs::read_to_string(destination.join("SKILL.md")).expect("read preserved skill"), + "external" + ); + } + + #[test] + #[serial] + fn pi_remove_accepts_an_identical_directory() { + let home = tempfile::tempdir().expect("create isolated home"); + let _environment = crate::test_support::TestEnvGuard::isolated(home.path()); + let _pi = crate::pi_config::test_support::TestAgentDir::new(); + let source = SkillService::get_ssot_dir() + .expect("resolve SSOT") + .join("demo"); + fs::create_dir_all(&source).expect("create managed source"); + fs::write(source.join("SKILL.md"), "identical").expect("write managed source"); + let destination = SkillService::get_app_skills_dir(&AppType::Pi) + .expect("resolve Pi skills") + .join("demo"); + fs::create_dir_all(&destination).expect("create external Pi skill"); + fs::write(destination.join("SKILL.md"), "identical").expect("write external Pi skill"); + + SkillService::remove_from_app("demo", &AppType::Pi) + .expect("an identical Pi deployment is safe to remove"); + assert!(!destination.exists()); + } + + #[test] + #[serial] + fn pi_native_directory_is_reported_as_enabled() { + let home = tempfile::tempdir().expect("create isolated home"); + let _environment = crate::test_support::TestEnvGuard::isolated(home.path()); + let _pi = crate::pi_config::test_support::TestAgentDir::new(); + let source = SkillService::get_ssot_dir() + .expect("resolve SSOT") + .join("demo"); + fs::create_dir_all(&source).expect("create managed source"); + fs::write(source.join("SKILL.md"), "identical").expect("write managed source"); + let destination = SkillService::get_app_skills_dir(&AppType::Pi) + .expect("resolve Pi skills") + .join("demo"); + fs::create_dir_all(&destination).expect("create external Pi skill"); + fs::write(destination.join("SKILL.md"), "identical").expect("write external Pi skill"); + + assert!(SkillService::skill_exists_in_app("demo", &AppType::Pi)); + } + + #[test] + #[serial] + fn pi_matching_copy_allows_safe_removal() { + let home = tempfile::tempdir().expect("create isolated home"); + let _environment = crate::test_support::TestEnvGuard::isolated(home.path()); + let _pi = crate::pi_config::test_support::TestAgentDir::new(); + let source = SkillService::get_ssot_dir() + .expect("resolve SSOT") + .join("demo"); + fs::create_dir_all(&source).expect("create managed source"); + fs::write(source.join("SKILL.md"), "managed").expect("write managed source"); + + SkillService::sync_to_app_dir("demo", &AppType::Pi, SyncMethod::Copy) + .expect("deploy managed copy"); + let destination = SkillService::get_app_skills_dir(&AppType::Pi) + .expect("resolve Pi skills") + .join("demo"); + assert_eq!( + fs::read_to_string(destination.join("SKILL.md")).expect("read managed copy"), + "managed" + ); + + SkillService::remove_from_app("demo", &AppType::Pi).expect("remove managed copy"); + assert!(!destination.exists()); + } + + #[test] + #[serial] + fn importing_a_native_pi_skill_keeps_the_matching_directory() { + let home = tempfile::tempdir().expect("create isolated home"); + let _environment = crate::test_support::TestEnvGuard::isolated(home.path()); + let _pi = crate::pi_config::test_support::TestAgentDir::new(); + let destination = SkillService::get_app_skills_dir(&AppType::Pi) + .expect("resolve Pi skills") + .join("demo"); + fs::create_dir_all(&destination).expect("create native Pi skill"); + fs::write(destination.join("SKILL.md"), "native").expect("write native Pi skill"); + + let imported = SkillService::import_from_apps(vec![ImportSkillSelection { + directory: "demo".to_string(), + apps: SkillApps::only(&AppType::Pi), + }]) + .expect("import native Pi skill"); + + assert_eq!(imported.len(), 1); + assert_eq!( + fs::read_to_string(destination.join("SKILL.md")).expect("read native Pi skill"), + "native" + ); + let index = SkillService::load_index().expect("reload imported skill"); + assert!(index.skills["demo"].apps.pi); + } + + #[test] + #[serial] + fn uninstall_preserves_a_modified_pi_copy_and_removes_managed_state() { + let home = tempfile::tempdir().expect("create isolated home"); + let _environment = crate::test_support::TestEnvGuard::isolated(home.path()); + let _pi = crate::pi_config::test_support::TestAgentDir::new(); + let destination = SkillService::get_app_skills_dir(&AppType::Pi) + .expect("resolve Pi skills") + .join("demo"); + fs::create_dir_all(&destination).expect("create native Pi skill"); + fs::write(destination.join("SKILL.md"), "native").expect("write native Pi skill"); + SkillService::import_from_apps(vec![ImportSkillSelection { + directory: "demo".to_string(), + apps: SkillApps::only(&AppType::Pi), + }]) + .expect("import native Pi skill"); + fs::write(destination.join("SKILL.md"), "modified outside CC Switch") + .expect("modify deployed Pi skill"); + + SkillService::uninstall("demo").expect("uninstall managed state"); + + assert!(destination.exists()); + assert!(!SkillService::get_ssot_dir() + .expect("resolve SSOT") + .join("demo") + .exists()); + assert!(!SkillService::load_index() + .expect("reload preserved index") + .skills + .contains_key("demo")); + } + + #[cfg(unix)] + #[test] + #[serial] + fn pi_explicit_sync_method_replaces_the_managed_deployment_type() { + let home = tempfile::tempdir().expect("create isolated home"); + let _environment = crate::test_support::TestEnvGuard::isolated(home.path()); + let _pi = crate::pi_config::test_support::TestAgentDir::new(); + let source = SkillService::get_ssot_dir() + .expect("resolve SSOT") + .join("demo"); + fs::create_dir_all(&source).expect("create managed source"); + fs::write(source.join("SKILL.md"), "managed").expect("write managed source"); + let destination = SkillService::get_app_skills_dir(&AppType::Pi) + .expect("resolve Pi skills") + .join("demo"); + + SkillService::sync_to_app_dir("demo", &AppType::Pi, SyncMethod::Copy) + .expect("deploy managed copy"); + assert!(!SkillService::is_symlink(&destination)); + assert!(destination.join("SKILL.md").exists()); + + SkillService::sync_to_app_dir("demo", &AppType::Pi, SyncMethod::Symlink) + .expect("replace copy with symlink"); + assert!(SkillService::is_symlink(&destination)); + + SkillService::sync_to_app_dir("demo", &AppType::Pi, SyncMethod::Copy) + .expect("replace symlink with copy"); + assert!(!SkillService::is_symlink(&destination)); + assert!(destination.join("SKILL.md").exists()); + } + #[test] fn repository_archive_enforces_entry_and_extracted_size_limits() { let entry_dest = tempfile::tempdir().expect("create entry-limit destination"); diff --git a/src-tauri/src/services/stream_check/provider_extract.rs b/src-tauri/src/services/stream_check/provider_extract.rs index b222fa524..d07986cc5 100644 --- a/src-tauri/src/services/stream_check/provider_extract.rs +++ b/src-tauri/src/services/stream_check/provider_extract.rs @@ -21,7 +21,9 @@ impl StreamCheckService { } AppType::Hermes => Self::extract_hermes_base_url(provider), AppType::OpenClaw => Self::extract_openclaw_base_url(provider), + AppType::Pi => crate::pi_config::provider_base_url(&provider.settings_config), AppType::Claude | AppType::Codex | AppType::Gemini => get_adapter(app_type) + .expect("proxy-capable app must have an adapter") .extract_base_url(provider) .map(|url| url.trim().trim_end_matches('/').to_string()) .map_err(|err| AppError::Message(format!("Failed to extract base_url: {err}"))), diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 4f4c91c70..263aa29f9 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -215,6 +215,7 @@ fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String { WHEN '_codex_session' THEN 'Codex (Session)' \ WHEN '_gemini_session' THEN 'Gemini (Session)' \ WHEN '_opencode_session' THEN 'OpenCode (Session)' \ + WHEN '_pi_session' THEN 'Pi (Session)' \ ELSE {log_alias}.provider_id END)" ) } diff --git a/src-tauri/src/services/visible_apps.rs b/src-tauri/src/services/visible_apps.rs index 0dbeb4577..cb2c4f99e 100644 --- a/src-tauri/src/services/visible_apps.rs +++ b/src-tauri/src/services/visible_apps.rs @@ -34,11 +34,12 @@ pub struct VisibleAppsStartupOutcome { pub should_prompt: bool, } -const CONTROLLED_APPS: [AppType; 4] = [ +const CONTROLLED_APPS: [AppType; 5] = [ AppType::Gemini, AppType::OpenCode, AppType::Hermes, AppType::OpenClaw, + AppType::Pi, ]; pub fn detect_visible_app_installation() -> VisibleAppsDetection { @@ -189,6 +190,7 @@ pub fn app_display_name(app: &AppType) -> &'static str { AppType::OpenCode => "OpenCode", AppType::Hermes => "Hermes", AppType::OpenClaw => "OpenClaw", + AppType::Pi => "Pi", } } @@ -319,6 +321,7 @@ mod tests { opencode: true, hermes: true, openclaw: false, + pi: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Auto, @@ -361,6 +364,7 @@ mod tests { opencode: false, hermes: false, openclaw: true, + pi: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Manual, @@ -428,6 +432,7 @@ mod tests { opencode: true, hermes: true, openclaw: true, + pi: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Auto, diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index d82a1e9ec..e38f3eaa1 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize}; use std::ops::ControlFlow; use std::path::{Path, PathBuf}; -use providers::{claude, codex, gemini, hermes, openclaw, opencode}; +use providers::{claude, codex, gemini, hermes, openclaw, opencode, pi}; use scan_cache_store::ScanCacheStore; /// Session metadata as rendered on the Sessions page. @@ -209,8 +209,8 @@ pub(crate) fn sort_by_recent(sessions: &mut [SessionMeta]) { /// Providers in the stable order used by the "all providers" manifest stream. /// SQLite-only sources (opencode.db / hermes state.db) are queried inside their /// provider module and are intentionally not covered by the file cache. -pub(crate) const CACHED_PROVIDERS: [&str; 6] = [ - "codex", "claude", "opencode", "openclaw", "gemini", "hermes", +pub(crate) const CACHED_PROVIDERS: [&str; 7] = [ + "codex", "claude", "opencode", "openclaw", "gemini", "hermes", "pi", ]; /// Legacy snapshot row cap (one historical 100-row page plus look-ahead). @@ -250,6 +250,8 @@ pub(crate) fn stream_sessions_for_provider_cancellable( .map(|stats| (stats, None)), "hermes" => hermes::stream_sessions_cancellable(store, force, on_session, is_cancelled) .map(|stats| (stats, None)), + "pi" => pi::stream_sessions_cancellable(store, force, on_session, is_cancelled) + .map(|stats| (stats, None)), _ => Ok((cache::StreamScanStats::default(), None)), } } @@ -267,6 +269,11 @@ pub(crate) fn stream_sessions_for_provider_cancellable( pub(crate) fn build_fresh_session_manifest( scope: &str, ) -> Result { + if scope == "pi" { + if let Some(error) = pi::session_discovery_error() { + return Err(error); + } + } let manifest_store = paged_manifest::CliManifestStore::open() .map_err(|error| format!("failed to open isolated session page workspace: {error}"))?; @@ -437,6 +444,9 @@ fn search_provider_cancellable( gemini::search_session_cancellable(meta, needle, is_cancelled) }), "hermes" => hermes::search_sessions_cancellable(metas, needle, is_cancelled), + "pi" => search_file_provider(metas, is_cancelled, |meta| { + pi::search_session_cancellable(meta, needle, is_cancelled) + }), _ => Some(Vec::new()), } } @@ -490,6 +500,7 @@ pub(crate) fn load_messages_cancellable( "openclaw" => openclaw::load_messages_cancellable(path, is_cancelled), "gemini" => gemini::load_messages_cancellable(path, is_cancelled), "hermes" => hermes::load_messages_cancellable(path, is_cancelled), + "pi" => pi::load_messages_cancellable(path, is_cancelled), _ => Err(format!("Unsupported provider: {provider_id}")), } } @@ -567,6 +578,7 @@ fn delete_session_with_root( "openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id), "gemini" => gemini::delete_session(&validated_root, &validated_source, session_id), "hermes" => hermes::delete_session(&validated_root, &validated_source, session_id), + "pi" => pi::delete_session(&validated_root, &validated_source, session_id), _ => Err(format!("Unsupported provider: {provider_id}")), } } @@ -579,6 +591,10 @@ fn provider_root(provider_id: &str) -> Result { "openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"), "gemini" => crate::gemini_config::get_gemini_dir().join("tmp"), "hermes" => crate::hermes_config::get_hermes_dir().join("sessions"), + "pi" => pi::session_roots() + .into_iter() + .next() + .ok_or_else(|| "Pi session root not found".to_string())?, _ => return Err(format!("Unsupported provider: {provider_id}")), }; diff --git a/src-tauri/src/session_manager/providers/mod.rs b/src-tauri/src/session_manager/providers/mod.rs index b4387c8a0..890581d7e 100644 --- a/src-tauri/src/session_manager/providers/mod.rs +++ b/src-tauri/src/session_manager/providers/mod.rs @@ -4,4 +4,5 @@ pub mod gemini; pub mod hermes; pub mod openclaw; pub mod opencode; +pub mod pi; pub(crate) mod utils; diff --git a/src-tauri/src/session_manager/providers/pi.rs b/src-tauri/src/session_manager/providers/pi.rs new file mode 100644 index 000000000..2e83efa78 --- /dev/null +++ b/src-tauri/src/session_manager/providers/pi.rs @@ -0,0 +1,1548 @@ +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader}; +use std::ops::ControlFlow; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::Value; + +use crate::session_manager::cache; +use crate::session_manager::scan_cache_store::ScanCacheStore; +use crate::session_manager::{ + SearchSnippet, SessionMessage, SessionMessageBatch, SessionMessageBatchBuilder, SessionMeta, + SessionSearchHit, +}; + +use super::utils::{ + build_snippet_cancellable, extract_text, parse_timestamp_to_ms, path_basename, + truncate_summary, TITLE_MAX_CHARS, +}; + +const PROVIDER_ID: &str = "pi"; +pub(crate) const MAX_TREE_ENTRIES: usize = 500_000; +const MAX_TREE_ID_BYTES: usize = 256; +pub(crate) const MAX_SESSION_BYTES: u64 = 128 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SessionLayout { + Flat, + ProjectDirectories, +} + +#[derive(Debug, PartialEq, Eq)] +enum SessionRootResolution { + Available { + root: PathBuf, + layout: SessionLayout, + }, + RequiresProjectContext { + configured_path: String, + }, + Unavailable { + reason: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum PiSessionDiscovery { + Available, + RequiresProjectContext { + #[serde(rename = "configuredPath")] + configured_path: String, + }, + Unavailable { + reason: String, + }, +} + +#[derive(Debug)] +struct SessionHeader { + id: String, + cwd: String, + timestamp: Option, + version: u64, +} + +#[derive(Debug)] +struct SessionTree { + header: SessionHeader, + active_entry_indexes: HashSet, + summary: SessionSummary, +} + +#[derive(Debug, Default)] +struct SessionSummary { + first_user_message: Option, + last_message: Option, + explicit_name: Option>, + last_active_at: Option, +} + +/// Pi keeps a relative `sessionDir` relative through SessionManager creation; +/// its file operations therefore depend on the launching process cwd. A global +/// session browser has no authoritative launch cwd, so relative values are +/// deliberately non-enumerable and never fall back to another root. +pub fn session_roots() -> Vec { + match resolve_session_root() { + SessionRootResolution::Available { root, .. } => vec![root], + SessionRootResolution::RequiresProjectContext { .. } + | SessionRootResolution::Unavailable { .. } => Vec::new(), + } +} + +/// Return candidate JSONL files using Pi's active root and layout rules. +/// Oversized files remain candidates so the usage importer can report them +/// instead of silently treating an incomplete import as success. +pub(crate) fn session_files() -> Result, String> { + session_files_from_resolution(resolve_session_root()) +} + +fn session_files_from_resolution( + resolution: SessionRootResolution, +) -> Result, String> { + match resolution { + SessionRootResolution::Available { root, layout } => { + let mut files = Vec::new(); + collect_jsonl_files(&root, layout, &mut files, false); + files.sort(); + Ok(files) + } + SessionRootResolution::RequiresProjectContext { configured_path } => Err(format!( + "Pi sessionDir '{configured_path}' requires a project cwd and cannot be globally enumerated" + )), + SessionRootResolution::Unavailable { reason } => Err(reason), + } +} + +pub fn session_discovery() -> PiSessionDiscovery { + match resolve_session_root() { + SessionRootResolution::Available { .. } => PiSessionDiscovery::Available, + SessionRootResolution::RequiresProjectContext { configured_path } => { + PiSessionDiscovery::RequiresProjectContext { configured_path } + } + SessionRootResolution::Unavailable { reason } => PiSessionDiscovery::Unavailable { reason }, + } +} + +pub fn session_discovery_error() -> Option { + match session_discovery() { + PiSessionDiscovery::Available => None, + PiSessionDiscovery::RequiresProjectContext { configured_path } => Some(format!( + "Pi sessionDir '{configured_path}' requires a project cwd and cannot be globally enumerated" + )), + PiSessionDiscovery::Unavailable { reason } => Some(reason), + } +} + +fn resolve_session_root() -> SessionRootResolution { + let home = crate::config::get_home_dir(); + if let Some(raw) = std::env::var_os("PI_CODING_AGENT_SESSION_DIR") { + if !raw.is_empty() { + return classify_configured_session_dir( + raw.to_string_lossy().as_ref(), + &home, + "environment", + ); + } + } + + match crate::pi_config::read_pi_native_defaults() { + Ok(defaults) => { + if let Some(value) = defaults.session_dir.filter(|value| !value.is_empty()) { + return classify_configured_session_dir(&value, &home, "settings"); + } + } + Err(error) => { + return SessionRootResolution::Unavailable { + reason: error.to_string(), + }; + } + } + + match crate::pi_config::get_pi_agent_dir() { + Ok(agent_dir) => SessionRootResolution::Available { + root: agent_dir.join("sessions"), + layout: SessionLayout::ProjectDirectories, + }, + Err(error) => SessionRootResolution::Unavailable { + reason: error.to_string(), + }, + } +} + +fn classify_configured_session_dir( + value: &str, + home: &Path, + source: &'static str, +) -> SessionRootResolution { + match resolve_global_session_dir(value, home) { + Some(root) => match fs::metadata(&root) { + Ok(metadata) if !metadata.is_dir() => SessionRootResolution::Unavailable { + reason: format!( + "Configured Pi session directory from {source} is not a directory: {}", + root.display() + ), + }, + Ok(_) => match fs::read_dir(&root) { + Ok(_) => SessionRootResolution::Available { + root, + layout: SessionLayout::Flat, + }, + Err(error) => SessionRootResolution::Unavailable { + reason: format!( + "Configured Pi session directory from {source} is not readable ({}): {error}", + root.display() + ), + }, + }, + Err(error) => SessionRootResolution::Unavailable { + reason: format!( + "Configured Pi session directory from {source} is unavailable ({}): {error}", + root.display() + ), + }, + }, + None => SessionRootResolution::RequiresProjectContext { + configured_path: value.to_string(), + }, + } +} + +fn resolve_global_session_dir(value: &str, home: &Path) -> Option { + let path = if value == "~" { + home.to_path_buf() + } else if let Some(suffix) = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")) + { + home.join(suffix) + } else { + PathBuf::from(value) + }; + path.is_absolute().then_some(path) +} + +#[allow( + dead_code, + reason = "retained as the upstream-compatible eager snapshot adapter" +)] +pub fn scan_sessions() -> Vec { + match resolve_session_root() { + SessionRootResolution::Available { root, layout, .. } => { + scan_sessions_in_root(&root, layout) + } + SessionRootResolution::RequiresProjectContext { + configured_path, .. + } => { + log::warn!( + "Pi sessionDir '{configured_path}' requires a project cwd and cannot be globally enumerated" + ); + Vec::new() + } + SessionRootResolution::Unavailable { reason } => { + log::warn!("Pi session discovery unavailable: {reason}"); + Vec::new() + } + } +} + +pub(crate) fn stream_sessions_cancellable( + store: Option<&ScanCacheStore>, + force: bool, + on_session: &mut dyn FnMut(SessionMeta) -> ControlFlow<()>, + is_cancelled: &(dyn Fn() -> bool + Sync), +) -> Result { + stream_sessions_from_resolution_cancellable( + store, + force, + resolve_session_root(), + on_session, + is_cancelled, + ) +} + +fn stream_sessions_from_resolution_cancellable( + store: Option<&ScanCacheStore>, + force: bool, + resolution: SessionRootResolution, + on_session: &mut dyn FnMut(SessionMeta) -> ControlFlow<()>, + is_cancelled: &(dyn Fn() -> bool + Sync), +) -> Result { + let (root, layout) = match resolution { + SessionRootResolution::Available { root, layout } => (root, layout), + SessionRootResolution::RequiresProjectContext { configured_path } => { + log::warn!( + "Pi sessionDir '{configured_path}' requires a project cwd and cannot be globally enumerated" + ); + return Ok(cache::StreamScanStats::default()); + } + SessionRootResolution::Unavailable { reason } => { + log::warn!("Pi session discovery unavailable: {reason}"); + return Ok(cache::StreamScanStats::default()); + } + }; + + cache::stream_file_provider_cancellable( + store, + PROVIDER_ID, + force, + |path| { + if is_cancelled() { + return Err(cache::StreamScanStop::Cancelled); + } + let parsed = match parse_session_cancellable(path, is_cancelled) { + Ok(Some(session)) => Some(session), + Ok(None) => return Err(cache::StreamScanStop::Cancelled), + Err(error) => { + log::debug!("Skipping invalid Pi session {}: {error}", path.display()); + None + } + }; + if is_cancelled() { + Err(cache::StreamScanStop::Cancelled) + } else { + Ok(parsed) + } + }, + |_| true, + cache::stat_target, + move |on_target, cancel| { + visit_session_targets_cancellable(&root, layout, on_target, cancel) + }, + on_session, + is_cancelled, + ) +} + +fn visit_session_targets_cancellable( + root: &Path, + layout: SessionLayout, + on_target: &mut dyn FnMut(cache::FileScanTarget) -> Result<(), cache::StreamScanStop>, + is_cancelled: &(dyn Fn() -> bool + Sync), +) -> Result<(), cache::StreamScanStop> { + if is_cancelled() { + return Err(cache::StreamScanStop::Cancelled); + } + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + log::warn!("Pi session walk failed at {}: {error}", root.display()); + return Err(cache::StreamScanStop::Incomplete); + } + }; + for entry in entries { + if is_cancelled() { + return Err(cache::StreamScanStop::Cancelled); + } + let entry = entry.map_err(|error| { + log::warn!( + "Pi session directory entry failed at {}: {error}", + root.display() + ); + cache::StreamScanStop::Incomplete + })?; + let file_type = entry.file_type().map_err(|error| { + log::warn!( + "Pi session file type failed at {}: {error}", + entry.path().display() + ); + cache::StreamScanStop::Incomplete + })?; + match layout { + SessionLayout::Flat if file_type.is_file() => { + visit_session_target(entry.path(), on_target)?; + } + SessionLayout::ProjectDirectories if file_type.is_dir() => { + let project_path = entry.path(); + let project_entries = fs::read_dir(&project_path).map_err(|error| { + log::warn!( + "Pi project session walk failed at {}: {error}", + project_path.display() + ); + cache::StreamScanStop::Incomplete + })?; + for project_entry in project_entries { + if is_cancelled() { + return Err(cache::StreamScanStop::Cancelled); + } + let project_entry = project_entry.map_err(|error| { + log::warn!( + "Pi project session entry failed at {}: {error}", + project_path.display() + ); + cache::StreamScanStop::Incomplete + })?; + let project_file_type = project_entry.file_type().map_err(|error| { + log::warn!( + "Pi project session file type failed at {}: {error}", + project_entry.path().display() + ); + cache::StreamScanStop::Incomplete + })?; + if project_file_type.is_file() { + visit_session_target(project_entry.path(), on_target)?; + } + } + } + SessionLayout::Flat | SessionLayout::ProjectDirectories => {} + } + } + Ok(()) +} + +fn visit_session_target( + path: PathBuf, + on_target: &mut dyn FnMut(cache::FileScanTarget) -> Result<(), cache::StreamScanStop>, +) -> Result<(), cache::StreamScanStop> { + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + return Ok(()); + } + let target = cache::stat_target_strict(&path).map_err(|error| { + log::warn!("Pi session stat failed at {}: {error}", path.display()); + cache::StreamScanStop::Incomplete + })?; + if let Some(target) = target.filter(|target| target.size <= MAX_SESSION_BYTES as i64) { + on_target(target)?; + } + Ok(()) +} + +fn scan_sessions_in_root(root: &Path, layout: SessionLayout) -> Vec { + let mut files = Vec::new(); + collect_jsonl_files(root, layout, &mut files, true); + files + .into_iter() + .filter_map(|path| match parse_session(&path) { + Ok(session) => Some(session), + Err(error) => { + log::debug!("Skipping invalid Pi session {}: {error}", path.display()); + None + } + }) + .collect() +} + +pub fn load_messages(path: &Path) -> Result, String> { + match resolve_session_root() { + SessionRootResolution::Available { root, layout, .. } => { + load_messages_with_layout(&root, path, layout) + } + SessionRootResolution::RequiresProjectContext { .. } => { + Err("Relative Pi sessionDir cannot be globally resolved".to_string()) + } + SessionRootResolution::Unavailable { reason } => Err(reason), + } +} + +pub(crate) fn load_messages_cancellable( + path: &Path, + is_cancelled: &(dyn Fn() -> bool + Sync), +) -> Result { + match resolve_session_root() { + SessionRootResolution::Available { root, layout, .. } => { + load_messages_with_layout_cancellable(&root, path, layout, is_cancelled) + } + SessionRootResolution::RequiresProjectContext { .. } => { + Err("Relative Pi sessionDir cannot be globally resolved".to_string()) + } + SessionRootResolution::Unavailable { reason } => Err(reason), + } +} + +fn load_messages_with_layout( + root: &Path, + path: &Path, + layout: SessionLayout, +) -> Result, String> { + let (_, source) = validate_source_under_root(root, path, layout)?; + let tree = read_tree(&source)?; + read_active_messages(&source, &tree) +} + +fn load_messages_with_layout_cancellable( + root: &Path, + path: &Path, + layout: SessionLayout, + is_cancelled: &(dyn Fn() -> bool + Sync), +) -> Result { + let (_, source) = validate_source_under_root(root, path, layout)?; + let tree = read_tree_cancellable(&source, is_cancelled)? + .ok_or_else(|| "Session message preview was cancelled".to_string())?; + read_active_message_batch(&source, &tree, is_cancelled) +} + +pub(crate) fn search_session_cancellable( + meta: &SessionMeta, + needle: &str, + is_cancelled: &(dyn Fn() -> bool + Sync), +) -> Option { + if is_cancelled() { + return None; + } + let source_path = meta.source_path.as_deref()?; + let path = Path::new(source_path); + let (root, layout) = match resolve_session_root() { + SessionRootResolution::Available { root, layout, .. } => (root, layout), + SessionRootResolution::RequiresProjectContext { .. } + | SessionRootResolution::Unavailable { .. } => return None, + }; + search_session_with_layout_cancellable(meta, &root, path, layout, needle, is_cancelled) +} + +fn search_session_with_layout_cancellable( + meta: &SessionMeta, + root: &Path, + path: &Path, + layout: SessionLayout, + needle: &str, + is_cancelled: &(dyn Fn() -> bool + Sync), +) -> Option { + let (_, source) = validate_source_under_root(root, path, layout).ok()?; + let tree = read_tree_cancellable(&source, is_cancelled).ok()??; + let mut snippets = Vec::new(); + const MAX_SNIPPETS: usize = 5; + visit_active_messages_cancellable(&source, &tree, is_cancelled, &mut |message| { + match build_snippet_cancellable(&message.content, needle, is_cancelled) { + Ok(Some(snippet)) => { + snippets.push(SearchSnippet { + role: message.role, + snippet, + }); + if snippets.len() >= MAX_SNIPPETS { + return ControlFlow::Break(()); + } + } + Ok(None) => {} + Err(_) => return ControlFlow::Break(()), + } + ControlFlow::Continue(()) + }) + .ok()??; + if is_cancelled() || snippets.is_empty() { + return None; + } + Some(SessionSearchHit { + provider_id: PROVIDER_ID.to_string(), + session_id: meta.session_id.clone(), + source_path: meta.source_path.clone()?, + snippets, + }) +} + +pub fn delete_session(root: &Path, path: &Path, session_id: &str) -> Result { + let layout = layout_for_current_root(root)?; + delete_session_with_layout(root, path, session_id, layout) +} + +fn delete_session_with_layout( + root: &Path, + path: &Path, + session_id: &str, + layout: SessionLayout, +) -> Result { + if !is_valid_tree_id(session_id) { + return Err("Invalid Pi session ID".to_string()); + } + let (_, source) = validate_source_under_root(root, path, layout)?; + let tree = read_tree(&source)?; + if tree.header.id != session_id { + return Err(format!( + "Pi session ID mismatch: expected {session_id}, found {}", + tree.header.id + )); + } + fs::remove_file(&source) + .map_err(|error| format!("Failed to delete Pi session {}: {error}", source.display()))?; + Ok(true) +} + +fn layout_for_current_root(root: &Path) -> Result { + let (configured_root, layout) = match resolve_session_root() { + SessionRootResolution::Available { root, layout, .. } => (root, layout), + SessionRootResolution::RequiresProjectContext { .. } => { + return Err("Relative Pi sessionDir cannot be globally resolved".to_string()); + } + SessionRootResolution::Unavailable { reason } => return Err(reason), + }; + let configured_root = configured_root.canonicalize().map_err(|error| { + format!( + "Failed to resolve Pi session root {}: {error}", + configured_root.display() + ) + })?; + let requested_root = root.canonicalize().map_err(|error| { + format!( + "Failed to resolve Pi session root {}: {error}", + root.display() + ) + })?; + if configured_root != requested_root { + return Err("Pi session root changed before deletion".to_string()); + } + Ok(layout) +} + +fn parse_session(path: &Path) -> Result { + parse_session_cancellable(path, &|| false)? + .ok_or_else(|| "Pi session parse was cancelled".to_string()) +} + +fn parse_session_cancellable( + path: &Path, + is_cancelled: &(dyn Fn() -> bool + Sync), +) -> Result, String> { + if is_cancelled() { + return Ok(None); + } + let source = path + .canonicalize() + .map_err(|error| format!("Failed to resolve Pi session {}: {error}", path.display()))?; + let source_path = source + .to_str() + .ok_or_else(|| "Pi session path is not valid UTF-8".to_string())? + .to_string(); + let SessionTree { + header, summary, .. + } = match read_tree_cancellable(&source, is_cancelled)? { + Some(tree) => tree, + None => return Ok(None), + }; + let title = summary.explicit_name.flatten().or_else(|| { + summary + .first_user_message + .as_deref() + .map(|message| truncate_summary(message, TITLE_MAX_CHARS)) + .filter(|message| !message.is_empty()) + .or_else(|| path_basename(&header.cwd)) + }); + let summary_text = summary + .last_message + .as_deref() + .map(|message| truncate_summary(message, 160)) + .filter(|message| !message.is_empty()); + Ok(Some(SessionMeta { + provider_id: PROVIDER_ID.to_string(), + session_id: header.id, + title, + summary: summary_text, + project_dir: (!header.cwd.trim().is_empty()).then(|| header.cwd.clone()), + created_at: header.timestamp, + last_active_at: summary.last_active_at.or(header.timestamp), + source_path: Some(source_path.clone()), + resume_command: Some(format!( + "pi --session {}", + crate::session_manager::terminal::shell_escape(&source_path) + )), + source_mtime_ns: None, + usage: None, + })) +} + +fn read_tree(path: &Path) -> Result { + read_tree_cancellable(path, &|| false)? + .ok_or_else(|| "Pi session tree read was cancelled".to_string()) +} + +fn read_tree_cancellable( + path: &Path, + is_cancelled: &(dyn Fn() -> bool + Sync), +) -> Result, String> { + if is_cancelled() { + return Ok(None); + } + validate_file_size(path)?; + let reader = BufReader::new( + File::open(path).map_err(|error| format!("Failed to open Pi session: {error}"))?, + ); + let mut header = None; + let mut parents = HashMap::, usize)>::new(); + let mut latest_id = None; + let mut legacy_previous_id = None; + let mut entry_index = 0usize; + let mut summary = SessionSummary::default(); + for line in reader.lines() { + if is_cancelled() { + return Ok(None); + } + let line = line.map_err(|error| format!("Failed to read Pi session: {error}"))?; + if line.trim().is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if header.is_none() { + header = Some(parse_header(&value)?); + continue; + } + entry_index += 1; + if entry_index > MAX_TREE_ENTRIES { + return Err(format!( + "Pi session exceeds the {MAX_TREE_ENTRIES}-entry safety limit" + )); + } + update_session_summary(&mut summary, &value); + let version = header + .as_ref() + .map_or(1, |item: &SessionHeader| item.version); + let Some((id, parent_id)) = + entry_identity(&value, version, entry_index, legacy_previous_id.as_deref()) + else { + continue; + }; + if parents + .insert(id.clone(), (parent_id, entry_index)) + .is_some() + { + log::debug!("Pi session contains duplicate entry ID {id}; using the latest entry"); + } + latest_id = Some(id.clone()); + legacy_previous_id = Some(id); + } + let header = header.ok_or_else(|| "Pi session has no valid header".to_string())?; + let mut active_entry_indexes = HashSet::new(); + let mut visited_ids = HashSet::new(); + let mut current = latest_id; + while let Some(id) = current { + if !visited_ids.insert(id.clone()) { + log::debug!("Pi session tree contains a cycle at entry {id}; stopping traversal"); + break; + } + let Some((parent_id, entry_index)) = parents.get(&id) else { + log::debug!("Pi session tree references missing entry {id}; stopping traversal"); + break; + }; + active_entry_indexes.insert(*entry_index); + current = parent_id.clone(); + } + Ok(Some(SessionTree { + header, + active_entry_indexes, + summary, + })) +} + +fn update_session_summary(summary: &mut SessionSummary, value: &Value) { + if value.get("type").and_then(Value::as_str) == Some("session_info") { + summary.explicit_name = Some( + value + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + ); + return; + } + let Some((role, content)) = value + .get("message") + .filter(|_| value.get("type").and_then(Value::as_str) == Some("message")) + .and_then(parse_message) + else { + return; + }; + if !matches!(role.as_str(), "user" | "assistant") { + return; + } + let timestamp = value + .get("message") + .and_then(|message| message.get("timestamp")) + .and_then(parse_timestamp_to_ms) + .or_else(|| value.get("timestamp").and_then(parse_timestamp_to_ms)); + if let Some(timestamp) = timestamp { + summary.last_active_at = Some( + summary + .last_active_at + .map_or(timestamp, |current| current.max(timestamp)), + ); + } + if role == "user" && summary.first_user_message.is_none() { + summary.first_user_message = Some(content.clone()); + } + summary.last_message = Some(content); +} + +fn read_active_messages(path: &Path, tree: &SessionTree) -> Result, String> { + let mut messages = Vec::new(); + visit_active_messages_cancellable(path, tree, &|| false, &mut |message| { + messages.push(message); + ControlFlow::Continue(()) + })? + .ok_or_else(|| "Pi session message read was cancelled".to_string())?; + Ok(messages) +} + +fn read_active_message_batch( + path: &Path, + tree: &SessionTree, + is_cancelled: &(dyn Fn() -> bool + Sync), +) -> Result { + let mut batch = SessionMessageBatchBuilder::new(); + visit_active_messages_cancellable(path, tree, is_cancelled, &mut |message| { + batch.push(message) + })? + .ok_or_else(|| "Session message preview was cancelled".to_string())?; + Ok(batch.finish()) +} + +fn visit_active_messages_cancellable( + path: &Path, + tree: &SessionTree, + is_cancelled: &(dyn Fn() -> bool + Sync), + on_message: &mut dyn FnMut(SessionMessage) -> ControlFlow<()>, +) -> Result, String> { + if is_cancelled() { + return Ok(None); + } + validate_file_size(path)?; + let reader = BufReader::new( + File::open(path).map_err(|error| format!("Failed to open Pi session: {error}"))?, + ); + let mut saw_header = false; + let mut entry_index = 0usize; + let mut legacy_previous_id = None; + for line in reader.lines() { + if is_cancelled() { + return Ok(None); + } + let line = line.map_err(|error| format!("Failed to read Pi session: {error}"))?; + if line.trim().is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if !saw_header { + if value.get("type").and_then(Value::as_str) == Some("session") { + saw_header = true; + } + continue; + } + entry_index += 1; + if entry_index > MAX_TREE_ENTRIES { + return Err(format!( + "Pi session exceeds the {MAX_TREE_ENTRIES}-entry safety limit" + )); + } + let Some((id, _)) = entry_identity( + &value, + tree.header.version, + entry_index, + legacy_previous_id.as_deref(), + ) else { + continue; + }; + legacy_previous_id = Some(id.clone()); + if !tree.active_entry_indexes.contains(&entry_index) { + continue; + } + let entry_timestamp = value.get("timestamp").and_then(parse_timestamp_to_ms); + let message = match value.get("type").and_then(Value::as_str) { + Some("session_info") => None, + Some("message") => { + let Some((role, content)) = value.get("message").and_then(parse_message) else { + continue; + }; + let timestamp = value + .get("message") + .and_then(|message| message.get("timestamp")) + .and_then(parse_timestamp_to_ms) + .or(entry_timestamp); + Some(SessionMessage { + role, + content, + ts: timestamp, + }) + } + Some("compaction") | Some("branch_summary") => system_message( + value + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default(), + entry_timestamp, + ), + Some("custom_message") + if value.get("display").and_then(Value::as_bool) != Some(false) => + { + system_message( + &value.get("content").map(extract_text).unwrap_or_default(), + entry_timestamp, + ) + } + _ => None, + }; + if let Some(message) = message { + if on_message(message).is_break() { + break; + } + } + } + Ok(Some(())) +} + +fn system_message(content: &str, ts: Option) -> Option { + (!content.trim().is_empty()).then(|| SessionMessage { + role: "system".to_string(), + content: content.to_string(), + ts, + }) +} + +fn parse_header(value: &Value) -> Result { + if value.get("type").and_then(Value::as_str) != Some("session") { + return Err("Pi session header must be the first valid JSON entry".to_string()); + } + let id = value + .get("id") + .and_then(Value::as_str) + .filter(|id| is_valid_tree_id(id)) + .ok_or_else(|| "Pi session header has an invalid ID".to_string())? + .to_string(); + let version = value.get("version").and_then(Value::as_u64).unwrap_or(1); + if version == 0 { + return Err(format!("Unsupported Pi session version: {version}")); + } + Ok(SessionHeader { + id, + cwd: value + .get("cwd") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + timestamp: value.get("timestamp").and_then(parse_timestamp_to_ms), + version, + }) +} + +fn entry_identity( + value: &Value, + version: u64, + entry_index: usize, + legacy_previous_id: Option<&str>, +) -> Option<(String, Option)> { + if version < 2 { + return Some(( + format!("legacy-{entry_index}"), + legacy_previous_id.map(str::to_string), + )); + } + let id = value + .get("id") + .and_then(Value::as_str) + .filter(|id| is_valid_tree_id(id))? + .to_string(); + let parent_id = match value.get("parentId") { + None | Some(Value::Null) => None, + Some(Value::String(parent)) if is_valid_tree_id(parent) => Some(parent.clone()), + _ => return None, + }; + Some((id, parent_id)) +} + +fn parse_message(message: &Value) -> Option<(String, String)> { + let role = message.get("role").and_then(Value::as_str)?; + let (display_role, content) = match role { + "user" | "assistant" => ( + role.to_string(), + message.get("content").map(extract_text).unwrap_or_default(), + ), + "toolResult" => ( + "tool".to_string(), + message.get("content").map(extract_text).unwrap_or_default(), + ), + "bashExecution" => ( + "tool".to_string(), + format!( + "$ {}\n{}", + message + .get("command") + .and_then(Value::as_str) + .unwrap_or_default(), + message + .get("output") + .and_then(Value::as_str) + .unwrap_or_default() + ), + ), + "branchSummary" | "compactionSummary" => ( + "system".to_string(), + message + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ), + _ => return None, + }; + (!content.trim().is_empty()).then_some((display_role, content)) +} + +fn validate_source_under_root( + root: &Path, + path: &Path, + layout: SessionLayout, +) -> Result<(PathBuf, PathBuf), String> { + let root = root.canonicalize().map_err(|error| { + format!( + "Failed to resolve Pi session root {}: {error}", + root.display() + ) + })?; + let source = path + .canonicalize() + .map_err(|error| format!("Failed to resolve Pi session {}: {error}", path.display()))?; + if !source.starts_with(&root) { + return Err(format!( + "Pi session source is outside the session root: {}", + path.display() + )); + } + if !matches_session_layout(&root, &source, layout) { + return Err(format!( + "Pi session source does not match the active directory layout: {}", + path.display() + )); + } + let metadata = fs::symlink_metadata(&source) + .map_err(|error| format!("Failed to inspect Pi session {}: {error}", source.display()))?; + if !metadata.file_type().is_file() + || source.extension().and_then(|value| value.to_str()) != Some("jsonl") + || metadata.len() > MAX_SESSION_BYTES + { + return Err(format!("Invalid Pi session file: {}", source.display())); + } + Ok((root, source)) +} + +fn matches_session_layout(root: &Path, source: &Path, layout: SessionLayout) -> bool { + let Ok(relative) = source.strip_prefix(root) else { + return false; + }; + let depth = relative.components().count(); + match layout { + SessionLayout::Flat => depth == 1, + SessionLayout::ProjectDirectories => depth == 2, + } +} + +fn validate_file_size(path: &Path) -> Result<(), String> { + let metadata = + fs::metadata(path).map_err(|error| format!("Failed to inspect Pi session: {error}"))?; + if metadata.len() > MAX_SESSION_BYTES { + Err(format!( + "Pi session exceeds the {MAX_SESSION_BYTES}-byte safety limit" + )) + } else { + Ok(()) + } +} + +pub(crate) fn is_valid_tree_id(id: &str) -> bool { + let bytes = id.as_bytes(); + !bytes.is_empty() + && bytes.len() <= MAX_TREE_ID_BYTES + && bytes.first().is_some_and(u8::is_ascii_alphanumeric) + && bytes.last().is_some_and(u8::is_ascii_alphanumeric) + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn collect_jsonl_files( + root: &Path, + layout: SessionLayout, + output: &mut Vec, + enforce_size_limit: bool, +) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + match layout { + SessionLayout::Flat if file_type.is_file() => { + push_jsonl_file(&entry, output, enforce_size_limit); + } + SessionLayout::ProjectDirectories if file_type.is_dir() => { + let Ok(project_entries) = fs::read_dir(entry.path()) else { + continue; + }; + for project_entry in project_entries.flatten() { + if project_entry + .file_type() + .is_ok_and(|file_type| file_type.is_file()) + { + push_jsonl_file(&project_entry, output, enforce_size_limit); + } + } + } + SessionLayout::Flat | SessionLayout::ProjectDirectories => {} + } + } +} + +fn push_jsonl_file(entry: &fs::DirEntry, output: &mut Vec, enforce_size_limit: bool) { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) == Some("jsonl") + && (!enforce_size_limit + || entry + .metadata() + .is_ok_and(|metadata| metadata.len() <= MAX_SESSION_BYTES)) + { + output.push(path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_session_header(path: &Path, id: &str) { + fs::create_dir_all(path.parent().expect("session parent")).expect("create session parent"); + fs::write( + path, + format!("{{\"type\":\"session\",\"version\":3,\"id\":\"{id}\",\"cwd\":\"/work\"}}\n"), + ) + .expect("write session"); + } + + #[test] + fn latest_leaf_defines_the_active_branch() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("tree.jsonl"); + fs::write( + &path, + "{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\ + {\"type\":\"message\",\"id\":\"root\",\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":\"question\"}}\n\ + {\"type\":\"message\",\"id\":\"dead\",\"parentId\":\"root\",\"message\":{\"role\":\"assistant\",\"content\":\"abandoned\"}}\n\ + {\"type\":\"message\",\"id\":\"live\",\"parentId\":\"root\",\"message\":{\"role\":\"assistant\",\"content\":\"active\"}}\n", + ) + .expect("session"); + let messages = + load_messages_with_layout(&root, &path, SessionLayout::Flat).expect("messages"); + assert_eq!( + messages + .into_iter() + .map(|message| message.content) + .collect::>(), + vec!["question", "active"] + ); + } + + #[test] + fn message_preview_is_bounded_and_marked_truncated() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("large.jsonl"); + let mut body = + "{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n" + .to_string(); + for index in 0..=crate::session_manager::SESSION_MESSAGE_PREVIEW_MAX_MESSAGES { + let parent = if index == 0 { + "null".to_string() + } else { + format!("\"message-{}\"", index - 1) + }; + body.push_str(&format!( + "{{\"type\":\"message\",\"id\":\"message-{index}\",\"parentId\":{parent},\"message\":{{\"role\":\"assistant\",\"content\":\"message {index}\"}}}}\n" + )); + } + fs::write(&path, body).expect("session"); + + let batch = + load_messages_with_layout_cancellable(&root, &path, SessionLayout::Flat, &|| false) + .expect("bounded preview"); + assert_eq!( + batch.messages.len(), + crate::session_manager::SESSION_MESSAGE_PREVIEW_MAX_MESSAGES + ); + assert!(batch.truncated); + } + + #[test] + fn message_preview_honors_cancellation() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("cancel.jsonl"); + write_session_header(&path, "session-1"); + + let error = + load_messages_with_layout_cancellable(&root, &path, SessionLayout::Flat, &|| true) + .expect_err("cancelled preview"); + assert!(error.contains("cancelled")); + } + + #[test] + fn deep_search_reads_the_active_pi_branch() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("search.jsonl"); + fs::write( + &path, + "{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\ + {\"type\":\"message\",\"id\":\"root\",\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":\"start\"}}\n\ + {\"type\":\"message\",\"id\":\"match\",\"parentId\":\"root\",\"message\":{\"role\":\"assistant\",\"content\":\"hidden pi needle\"}}\n\ + {\"type\":\"message\",\"id\":\"done\",\"parentId\":\"match\",\"message\":{\"role\":\"assistant\",\"content\":\"done\"}}\n", + ) + .expect("session"); + let meta = parse_session(&path).expect("metadata"); + + let hit = search_session_with_layout_cancellable( + &meta, + &root, + &path, + SessionLayout::Flat, + "pi needle", + &|| false, + ) + .expect("search hit"); + assert_eq!(hit.session_id, "session-1"); + assert_eq!(hit.snippets.len(), 1); + assert_eq!(hit.snippets[0].role, "assistant"); + assert!(hit.snippets[0].snippet.contains("pi needle")); + } + + #[test] + fn future_session_versions_are_read_with_current_semantics() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("future.jsonl"); + fs::write( + &path, + "{\"type\":\"session\",\"version\":4,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\ + {\"type\":\"message\",\"id\":\"message-1\",\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":\"future\"}}\n", + ) + .expect("session"); + + let session = parse_session(&path).expect("parse future session"); + assert_eq!(session.summary.as_deref(), Some("future")); + } + + #[test] + fn duplicate_entry_ids_use_the_latest_entry() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("duplicate.jsonl"); + fs::write( + &path, + "{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\ + {\"type\":\"message\",\"id\":\"root\",\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":\"root\"}}\n\ + {\"type\":\"message\",\"id\":\"duplicate\",\"parentId\":\"root\",\"message\":{\"role\":\"assistant\",\"content\":\"stale\"}}\n\ + {\"type\":\"message\",\"id\":\"duplicate\",\"parentId\":\"root\",\"message\":{\"role\":\"assistant\",\"content\":\"latest\"}}\n", + ) + .expect("session"); + + let messages = + load_messages_with_layout(&root, &path, SessionLayout::Flat).expect("messages"); + assert_eq!( + messages + .into_iter() + .map(|message| message.content) + .collect::>(), + vec!["root", "latest"] + ); + } + + #[test] + fn missing_parents_and_cycles_stop_at_the_readable_branch() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + + let missing = root.join("missing.jsonl"); + fs::write( + &missing, + "{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\ + {\"type\":\"message\",\"id\":\"orphan\",\"parentId\":\"missing\",\"message\":{\"role\":\"assistant\",\"content\":\"visible orphan\"}}\n", + ) + .expect("missing-parent session"); + let messages = load_messages_with_layout(&root, &missing, SessionLayout::Flat) + .expect("missing parent is tolerated"); + assert_eq!(messages[0].content, "visible orphan"); + + let cycle = root.join("cycle.jsonl"); + fs::write( + &cycle, + "{\"type\":\"session\",\"version\":3,\"id\":\"session-2\",\"cwd\":\"/work\"}\n\ + {\"type\":\"message\",\"id\":\"a\",\"parentId\":\"b\",\"message\":{\"role\":\"user\",\"content\":\"a\"}}\n\ + {\"type\":\"message\",\"id\":\"b\",\"parentId\":\"a\",\"message\":{\"role\":\"assistant\",\"content\":\"b\"}}\n", + ) + .expect("cyclic session"); + let messages = load_messages_with_layout(&root, &cycle, SessionLayout::Flat) + .expect("cycle is tolerated"); + assert_eq!( + messages + .into_iter() + .map(|message| message.content) + .collect::>(), + vec!["a", "b"] + ); + } + + #[test] + fn global_name_and_malformed_line_follow_pi_semantics() { + // Pi keeps the latest global session_info even when its branch is + // inactive, and skips malformed JSONL lines when opening a session. + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("captured.jsonl"); + fs::write( + &path, + "{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\ + {\"type\":\"message\",\"id\":\"root\",\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":\"root\"}}\n\ + {not valid json\n\ + {\"type\":\"session_info\",\"id\":\"dead-name\",\"parentId\":\"root\",\"name\":\"Abandoned branch name\"}\n\ + {\"type\":\"message\",\"id\":\"dead\",\"parentId\":\"dead-name\",\"message\":{\"role\":\"assistant\",\"content\":\"abandoned\"}}\n\ + {\"type\":\"message\",\"id\":\"live\",\"parentId\":\"root\",\"message\":{\"role\":\"user\",\"content\":\"active branch\"}}\n", + ) + .expect("captured session"); + + let session = parse_session(&path).expect("parse capture semantics"); + assert_eq!(session.title.as_deref(), Some("Abandoned branch name")); + assert_eq!(session.summary.as_deref(), Some("active branch")); + } + + #[test] + fn relative_root_is_explicitly_non_enumerable() { + assert_eq!( + resolve_global_session_dir(".pi/sessions", Path::new("/home/pi")), + None + ); + assert_eq!( + classify_configured_session_dir(".pi/sessions", Path::new("/home/pi"), "settings"), + SessionRootResolution::RequiresProjectContext { + configured_path: ".pi/sessions".to_string(), + } + ); + } + + #[test] + fn unavailable_explicit_roots_are_not_reported_as_empty_history() { + let temp = tempfile::tempdir().expect("tempdir"); + let missing = temp.path().join("missing"); + assert!(matches!( + classify_configured_session_dir( + missing.to_string_lossy().as_ref(), + temp.path(), + "settings" + ), + SessionRootResolution::Unavailable { .. } + )); + + let file = temp.path().join("sessions.jsonl"); + fs::write(&file, b"").expect("session file"); + assert!(matches!( + classify_configured_session_dir( + file.to_string_lossy().as_ref(), + temp.path(), + "settings" + ), + SessionRootResolution::Unavailable { .. } + )); + + let directory = temp.path().join("sessions"); + fs::create_dir(&directory).expect("session directory"); + assert!(matches!( + classify_configured_session_dir( + directory.to_string_lossy().as_ref(), + temp.path(), + "settings" + ), + SessionRootResolution::Available { root, .. } if root == directory + )); + + let unavailable = SessionRootResolution::Unavailable { + reason: "fixture unavailable".to_string(), + }; + assert_eq!( + session_files_from_resolution(unavailable).expect_err("discovery error"), + "fixture unavailable" + ); + let relative = SessionRootResolution::RequiresProjectContext { + configured_path: ".pi/sessions".to_string(), + }; + assert!(session_files_from_resolution(relative) + .expect_err("relative discovery error") + .contains("requires a project cwd")); + } + + #[test] + fn v3_shape_round_trips_all_consumed_fields() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("captured.jsonl"); + fs::write( + &path, + "{\"type\":\"session\",\"version\":3,\"id\":\"cc-switch-capture-session\",\"timestamp\":\"2023-11-14T22:13:20.000Z\",\"cwd\":\"/work/captured\",\"parentSession\":null}\n\ + {\"type\":\"session_info\",\"id\":\"00000000-0000-7000-8000-000000000001\",\"parentId\":null,\"timestamp\":\"2023-11-14T22:13:20.100Z\",\"name\":\"Captured session\"}\n\ + {\"type\":\"message\",\"id\":\"00000000-0000-7000-8000-000000000002\",\"parentId\":\"00000000-0000-7000-8000-000000000001\",\"timestamp\":\"2023-11-14T22:13:20.200Z\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"captured question\"}],\"timestamp\":1700000000000}}\n\ + {\"type\":\"message\",\"id\":\"00000000-0000-7000-8000-000000000003\",\"parentId\":\"00000000-0000-7000-8000-000000000002\",\"timestamp\":\"2023-11-14T22:13:21.200Z\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"captured answer\"}],\"api\":\"openai-responses\",\"provider\":\"capture\",\"model\":\"capture-model\",\"usage\":{\"input\":1,\"output\":1,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":2,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1700000001000}}\n", + ) + .expect("captured session"); + + let session = parse_session(&path).expect("parse capture-generated session"); + assert_eq!(session.session_id, "cc-switch-capture-session"); + assert_eq!(session.title.as_deref(), Some("Captured session")); + assert_eq!(session.summary.as_deref(), Some("captured answer")); + assert_eq!(session.project_dir.as_deref(), Some("/work/captured")); + assert_eq!(session.created_at, Some(1_700_000_000_000)); + // Pi's session picker prefers the message timestamp over the enclosing + // entry timestamp when both are present. + assert_eq!(session.last_active_at, Some(1_700_000_001_000)); + // Pi resumes a session by its exact file path. + assert!(session + .resume_command + .as_deref() + .is_some_and(|command| command.starts_with("pi --session "))); + + let messages = + load_messages_with_layout(&root, &path, SessionLayout::Flat).expect("load messages"); + assert_eq!( + messages + .iter() + .map(|message| (message.role.as_str(), message.content.as_str(), message.ts)) + .collect::>(), + vec![ + ("user", "captured question", Some(1_700_000_000_000)), + ("assistant", "captured answer", Some(1_700_000_001_000)), + ] + ); + } + + #[test] + fn deletion_requires_containment_and_matching_header_id() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("session.jsonl"); + write_session_header(&path, "session-1"); + assert!(delete_session_with_layout(&root, &path, "other", SessionLayout::Flat).is_err()); + assert!(path.exists()); + assert!( + delete_session_with_layout(&root, &path, "session-1", SessionLayout::Flat) + .expect("delete") + ); + } + + #[test] + fn scanning_respects_the_active_directory_layout() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + let flat = root.join("flat.jsonl"); + let project = root.join("project").join("project.jsonl"); + write_session_header(&flat, "flat-session"); + write_session_header(&project, "project-session"); + + let flat_sessions = scan_sessions_in_root(&root, SessionLayout::Flat); + assert_eq!(flat_sessions.len(), 1); + assert_eq!(flat_sessions[0].session_id, "flat-session"); + + let project_sessions = scan_sessions_in_root(&root, SessionLayout::ProjectDirectories); + assert_eq!(project_sessions.len(), 1); + assert_eq!(project_sessions[0].session_id, "project-session"); + } + + #[test] + fn streaming_scan_honors_cancellation_during_discovery() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + for index in 0..200 { + write_session_header( + &root.join(format!("session-{index:03}.jsonl")), + &format!("session-{index:03}"), + ); + } + let checks = AtomicUsize::new(0); + let emitted = AtomicUsize::new(0); + let is_cancelled = || checks.fetch_add(1, Ordering::AcqRel) >= 20; + let mut sink = |_: SessionMeta| { + emitted.fetch_add(1, Ordering::AcqRel); + ControlFlow::Continue(()) + }; + + let result = stream_sessions_from_resolution_cancellable( + None, + false, + SessionRootResolution::Available { + root, + layout: SessionLayout::Flat, + }, + &mut sink, + &is_cancelled, + ); + + assert_eq!(result, Err(cache::StreamScanStop::Cancelled)); + assert_eq!(emitted.load(Ordering::Acquire), 0); + assert!(checks.load(Ordering::Acquire) < 100); + } + + #[test] + fn usage_collection_keeps_oversized_files_for_error_reporting() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("oversized.jsonl"); + File::create(&path) + .expect("create sparse session") + .set_len(MAX_SESSION_BYTES + 1) + .expect("size sparse session"); + + let mut browser_files = Vec::new(); + collect_jsonl_files(&root, SessionLayout::Flat, &mut browser_files, true); + assert!(browser_files.is_empty()); + + let mut usage_files = Vec::new(); + collect_jsonl_files(&root, SessionLayout::Flat, &mut usage_files, false); + assert_eq!(usage_files, vec![path]); + } + + #[test] + fn deletion_rejects_files_outside_the_active_directory_layout() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + let flat = root.join("flat.jsonl"); + let project = root.join("project").join("project.jsonl"); + write_session_header(&flat, "flat-session"); + write_session_header(&project, "project-session"); + + assert!(delete_session_with_layout( + &root, + &project, + "project-session", + SessionLayout::Flat + ) + .is_err()); + assert!(project.exists()); + + assert!(delete_session_with_layout( + &root, + &flat, + "flat-session", + SessionLayout::ProjectDirectories + ) + .is_err()); + assert!(flat.exists()); + + assert!(delete_session_with_layout( + &root, + &project, + "project-session", + SessionLayout::ProjectDirectories + ) + .expect("delete project session")); + } +} diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs index 420eff9ba..5879882f2 100644 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -315,9 +315,8 @@ fn build_shell_command(command: &str, cwd: Option<&str>) -> String { } } -fn shell_escape(value: &str) -> String { - let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); - format!("\"{escaped}\"") +pub(crate) fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', r"'\''")) } fn escape_osascript(value: &str) -> String { @@ -362,6 +361,14 @@ mod tests { ); } + #[test] + fn shell_escape_neutralizes_shell_expansion() { + assert_eq!(shell_escape("/tmp/$(id -un)"), "'/tmp/$(id -un)'"); + assert_eq!(shell_escape("/tmp/`id -un`"), "'/tmp/`id -un`'"); + assert_eq!(shell_escape("/tmp/$HOME"), "'/tmp/$HOME'"); + assert_eq!(shell_escape("/tmp/it's"), r"'/tmp/it'\''s'"); + } + #[test] fn ghostty_uses_working_directory_arg_for_cwd() { // cwd should be passed as --working-directory, not embedded in the shell command string @@ -377,6 +384,6 @@ mod tests { ); // Verify shell_escape works correctly for paths with spaces - assert_eq!(shell_escape(cwd), "\"/tmp/project dir\""); + assert_eq!(shell_escape(cwd), "'/tmp/project dir'"); } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index f926221a6..06fda8899 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -22,6 +22,8 @@ pub struct VisibleApps { pub hermes: bool, #[serde(default = "default_visible_app_openclaw")] pub openclaw: bool, + #[serde(default = "default_visible_app_pi")] + pub pi: bool, } fn default_visible_app_claude() -> bool { @@ -48,6 +50,10 @@ fn default_visible_app_openclaw() -> bool { true } +fn default_visible_app_pi() -> bool { + true +} + pub fn default_visible_apps() -> VisibleApps { VisibleApps { claude: true, @@ -56,6 +62,7 @@ pub fn default_visible_apps() -> VisibleApps { opencode: true, hermes: true, openclaw: true, + pi: true, } } @@ -122,6 +129,7 @@ impl VisibleApps { AppType::OpenCode => self.opencode, AppType::Hermes => self.hermes, AppType::OpenClaw => self.openclaw, + AppType::Pi => self.pi, } } @@ -133,6 +141,7 @@ impl VisibleApps { AppType::OpenCode => self.opencode = enabled, AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => self.openclaw = enabled, + AppType::Pi => self.pi = enabled, } } @@ -153,7 +162,7 @@ impl VisibleApps { } } -fn app_order() -> [AppType; 6] { +fn app_order() -> [AppType; 7] { [ AppType::Claude, AppType::Codex, @@ -161,6 +170,7 @@ fn app_order() -> [AppType; 6] { AppType::OpenCode, AppType::Hermes, AppType::OpenClaw, + AppType::Pi, ] } @@ -531,6 +541,8 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub pi_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_claude: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_codex: Option, @@ -632,6 +644,7 @@ impl Default for AppSettings { opencode_config_dir: None, hermes_config_dir: None, openclaw_config_dir: None, + pi_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, @@ -713,6 +726,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.pi_config_dir = self + .pi_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -812,7 +832,7 @@ pub(crate) fn reload_test_settings() { *guard = AppSettings::load(); } -fn resolve_override_path(raw: &str) -> PathBuf { +pub(crate) fn resolve_override_path(raw: &str) -> PathBuf { if raw == "~" { if let Some(home) = home_dir() { return home; @@ -1104,6 +1124,14 @@ pub fn get_openclaw_override_dir() -> Option { .map(|p| resolve_override_path(p)) } +pub fn get_pi_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .pi_config_dir + .as_ref() + .map(|path| resolve_override_path(path)) +} + pub fn get_current_provider(app_type: &AppType) -> Option { let settings = settings_store().read().ok()?; match app_type { @@ -1113,6 +1141,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::OpenCode => settings.current_provider_opencode.clone(), AppType::Hermes => settings.current_provider_hermes.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), + AppType::Pi => None, } } @@ -1126,6 +1155,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::OpenCode => settings.current_provider_opencode = id.map(|value| value.to_string()), AppType::Hermes => settings.current_provider_hermes = id.map(|value| value.to_string()), AppType::OpenClaw => settings.current_provider_openclaw = id.map(|value| value.to_string()), + AppType::Pi => {} } update_settings(settings) @@ -1394,11 +1424,26 @@ pub fn set_skip_claude_onboarding(enabled: bool) -> Result<(), AppError> { #[cfg(test)] mod tests { use super::{ - get_preferred_editor, get_s3_sync_settings, get_webdav_sync_settings, set_preferred_editor, - set_s3_sync_settings, set_webdav_sync_settings, update_settings, AppSettings, - LocalMigrations, S3SyncSettings, WebDavSyncSettings, + get_pi_override_dir, get_preferred_editor, get_s3_sync_settings, get_settings, + get_webdav_sync_settings, set_preferred_editor, set_s3_sync_settings, + set_webdav_sync_settings, update_settings, AppSettings, LocalMigrations, S3SyncSettings, + WebDavSyncSettings, }; use crate::test_support::TestEnvGuard; + + #[test] + fn pi_override_dir_is_normalized_and_resolved() { + let home = tempfile::tempdir().expect("create isolated home"); + let _environment = TestEnvGuard::isolated(home.path()); + let settings = AppSettings { + pi_config_dir: Some(" ~/custom-pi ".to_string()), + ..Default::default() + }; + update_settings(settings).expect("save isolated settings"); + + assert_eq!(get_pi_override_dir(), Some(home.path().join("custom-pi"))); + assert_eq!(get_settings().pi_config_dir.as_deref(), Some("~/custom-pi")); + } use serde_json::json; use std::sync::RwLock; diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index e02e2fc54..c06acd490 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -13,6 +13,15 @@ pub struct AppState { } impl AppState { + /// Construct state from an existing database handle. + /// + /// This mirrors the upstream constructor used by isolated service tests. + pub fn new(db: Arc) -> Self { + let config = export_db_to_multi_app_config(&db) + .expect("failed to export application state from database"); + Self::from_parts(db, config).expect("failed to construct application state") + } + /// 创建新的应用状态 pub fn try_new() -> Result { let app_config_dir = crate::config::get_app_config_dir(); @@ -252,6 +261,14 @@ impl AppState { Err(error) => log::warn!("✗ Failed to import OpenClaw providers: {error}"), } + match crate::services::provider::ProviderService::import_pi_providers_from_live(self) { + Ok(count) if count > 0 => { + log::info!("✓ Synchronized {count} Pi provider(s) from models.json"); + } + Ok(_) => log::debug!("○ No Pi providers to synchronize"), + Err(error) => log::warn!("✗ Failed to synchronize Pi providers: {error}"), + } + self.refresh_config_from_db() } @@ -515,6 +532,7 @@ fn export_db_to_multi_app_config(db: &Database) -> Result Result config.prompts.opencode.prompts = prompts.into_iter().collect(), AppType::Hermes => config.prompts.hermes.prompts = prompts.into_iter().collect(), AppType::OpenClaw => config.prompts.openclaw.prompts = prompts.into_iter().collect(), + AppType::Pi => config.prompts.pi.prompts = prompts.into_iter().collect(), } // common snippet diff --git a/src-tauri/src/sync_policy.rs b/src-tauri/src/sync_policy.rs index a744d11f6..490518660 100644 --- a/src-tauri/src/sync_policy.rs +++ b/src-tauri/src/sync_policy.rs @@ -24,6 +24,8 @@ pub(crate) fn should_sync_live(app_type: &AppType) -> bool { AppType::Hermes => crate::hermes_config::get_hermes_dir().exists(), // OpenClaw is considered initialized if ~/.openclaw (or override dir) exists. AppType::OpenClaw => get_openclaw_dir().exists(), + // Pi live provider writes are owned by the revision-aware native service. + AppType::Pi => false, } } diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs index caad7f78b..f77b712f2 100644 --- a/src-tauri/tests/deeplink_import.rs +++ b/src-tauri/tests/deeplink_import.rs @@ -504,6 +504,22 @@ fn deeplink_import_prompt_for_non_claude_app() { assert!(!prompt.enabled, "should default to disabled"); } +#[test] +fn deeplink_parser_accepts_pi_prompts_but_not_pi_providers() { + let content_b64 = BASE64_URL_SAFE_NO_PAD.encode(b"pi prompt content"); + let prompt_url = + format!("ccswitch://v1/import?resource=prompt&app=pi&name=PiPrompt&content={content_b64}"); + let prompt = parse_deeplink_url(&prompt_url).expect("parse Pi prompt deeplink"); + assert_eq!(prompt.app.as_deref(), Some("pi")); + + let provider = + parse_deeplink_url("ccswitch://v1/import?resource=provider&app=pi&name=PiProvider"); + assert!( + provider.is_err(), + "Pi provider deeplinks remain unsupported" + ); +} + #[test] fn deeplink_import_skill_repo_persists() { let _guard = lock_test_mutex(); diff --git a/src-tauri/tests/settings_current_provider.rs b/src-tauri/tests/settings_current_provider.rs index 4143feda6..ceb71bd89 100644 --- a/src-tauri/tests/settings_current_provider.rs +++ b/src-tauri/tests/settings_current_provider.rs @@ -13,6 +13,7 @@ mod app_config { OpenCode, OpenClaw, Hermes, + Pi, } impl AppType { @@ -24,6 +25,7 @@ mod app_config { AppType::OpenCode => "opencode", AppType::OpenClaw => "openclaw", AppType::Hermes => "hermes", + AppType::Pi => "pi", } } } @@ -143,6 +145,14 @@ mod error { source, } } + + pub fn localized( + _key: &'static str, + _zh: impl Into, + en: impl Into, + ) -> Self { + Self::InvalidInput(en.into()) + } } impl From> for AppError { @@ -226,6 +236,61 @@ mod services { } } +mod test_support { + use std::ffi::OsString; + use std::path::Path; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + pub(crate) struct TestEnvGuard { + _lock: MutexGuard<'static, ()>, + old_home: Option, + old_userprofile: Option, + old_cc_switch_config_dir: Option, + } + + pub(crate) fn lock_test_env() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + impl TestEnvGuard { + pub(crate) fn isolated(home: &Path) -> Self { + let lock = lock_test_env(); + let old_home = std::env::var_os("HOME"); + let old_userprofile = std::env::var_os("USERPROFILE"); + let old_cc_switch_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR"); + std::env::set_var("HOME", home); + std::env::set_var("USERPROFILE", home); + std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch")); + crate::settings_impl::reload_test_settings(); + Self { + _lock: lock, + old_home, + old_userprofile, + old_cc_switch_config_dir, + } + } + } + + impl Drop for TestEnvGuard { + fn drop(&mut self) { + restore("HOME", &self.old_home); + restore("USERPROFILE", &self.old_userprofile); + restore("CC_SWITCH_CONFIG_DIR", &self.old_cc_switch_config_dir); + crate::settings_impl::reload_test_settings(); + } + } + + fn restore(key: &str, value: &Option) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } +} + #[path = "../src/settings.rs"] mod settings_impl; @@ -234,6 +299,7 @@ use database::Database; use settings_impl::{get_current_provider, get_effective_current_provider, set_current_provider}; struct HomeGuard { + _lock: std::sync::MutexGuard<'static, ()>, _temp: TempDir, old_home: Option, old_userprofile: Option, @@ -242,6 +308,7 @@ struct HomeGuard { impl HomeGuard { fn new() -> Self { + let lock = test_support::lock_test_env(); let temp = tempfile::tempdir().expect("create tempdir"); let old_home = std::env::var_os("HOME"); let old_userprofile = std::env::var_os("USERPROFILE"); @@ -251,6 +318,7 @@ impl HomeGuard { std::env::set_var("CC_SWITCH_CONFIG_DIR", temp.path().join(".cc-switch")); Self { + _lock: lock, _temp: temp, old_home, old_userprofile, diff --git a/src-tauri/tests/settings_visible_apps.rs b/src-tauri/tests/settings_visible_apps.rs index 221c219fc..30b18c5f7 100644 --- a/src-tauri/tests/settings_visible_apps.rs +++ b/src-tauri/tests/settings_visible_apps.rs @@ -16,6 +16,7 @@ mod app_config { OpenCode, OpenClaw, Hermes, + Pi, } impl AppType { @@ -27,6 +28,7 @@ mod app_config { AppType::OpenCode => "opencode", AppType::OpenClaw => "openclaw", AppType::Hermes => "hermes", + AppType::Pi => "pi", } } } @@ -146,6 +148,14 @@ mod error { source, } } + + pub fn localized( + _key: &'static str, + _zh: impl Into, + en: impl Into, + ) -> Self { + Self::InvalidInput(en.into()) + } } impl From> for AppError { @@ -229,6 +239,61 @@ mod services { } } +mod test_support { + use std::ffi::OsString; + use std::path::Path; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + pub(crate) struct TestEnvGuard { + _lock: MutexGuard<'static, ()>, + old_home: Option, + old_userprofile: Option, + old_cc_switch_config_dir: Option, + } + + pub(crate) fn lock_test_env() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + impl TestEnvGuard { + pub(crate) fn isolated(home: &Path) -> Self { + let lock = lock_test_env(); + let old_home = std::env::var_os("HOME"); + let old_userprofile = std::env::var_os("USERPROFILE"); + let old_cc_switch_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR"); + std::env::set_var("HOME", home); + std::env::set_var("USERPROFILE", home); + std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch")); + crate::settings_impl::reload_test_settings(); + Self { + _lock: lock, + old_home, + old_userprofile, + old_cc_switch_config_dir, + } + } + } + + impl Drop for TestEnvGuard { + fn drop(&mut self) { + restore("HOME", &self.old_home); + restore("USERPROFILE", &self.old_userprofile); + restore("CC_SWITCH_CONFIG_DIR", &self.old_cc_switch_config_dir); + crate::settings_impl::reload_test_settings(); + } + } + + fn restore(key: &str, value: &Option) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } +} + #[path = "../src/settings.rs"] mod settings_impl; @@ -240,6 +305,7 @@ use settings_impl::{ }; struct HomeGuard { + _lock: std::sync::MutexGuard<'static, ()>, _temp: TempDir, old_home: Option, old_userprofile: Option, @@ -248,6 +314,7 @@ struct HomeGuard { impl HomeGuard { fn new() -> Self { + let lock = test_support::lock_test_env(); let temp = tempfile::tempdir().expect("create tempdir"); let old_home = std::env::var_os("HOME"); let old_userprofile = std::env::var_os("USERPROFILE"); @@ -258,6 +325,7 @@ impl HomeGuard { reload_test_settings(); Self { + _lock: lock, _temp: temp, old_home, old_userprofile, @@ -328,6 +396,7 @@ fn default_visible_apps_hide_gemini() { AppType::OpenCode, AppType::Hermes, AppType::OpenClaw, + AppType::Pi, ] ); assert!(!visible.is_enabled_for(&AppType::Gemini)); @@ -344,6 +413,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { gemini: true, opencode: false, openclaw: true, + pi: false, hermes: true, }) .expect("persist visible apps"); @@ -362,6 +432,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { "opencode": false, "openclaw": true, "hermes": true, + "pi": false, }) ); } @@ -395,6 +466,7 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { gemini: true, opencode: true, openclaw: false, + pi: true, hermes: true, } ); @@ -404,7 +476,8 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { AppType::Codex, AppType::Gemini, AppType::OpenCode, - AppType::Hermes + AppType::Hermes, + AppType::Pi, ] ); } @@ -432,6 +505,7 @@ fn load_partial_visible_apps_object_uses_defaults_for_missing_keys() { gemini: false, opencode: true, openclaw: true, + pi: true, hermes: true, } ); @@ -507,6 +581,7 @@ fn set_visible_apps_rejects_zero_selection() { gemini: false, opencode: false, openclaw: false, + pi: false, hermes: false, }) .expect_err("zero visible apps should be rejected"); @@ -529,6 +604,7 @@ fn update_settings_rejects_all_false_visible_apps() { gemini: false, opencode: false, openclaw: false, + pi: false, hermes: false, }, ..Default::default() @@ -580,7 +656,8 @@ fn load_normalizes_all_false_visible_apps_to_defaults() { "gemini": false, "opencode": false, "openclaw": false, - "hermes": false + "hermes": false, + "pi": false } }), ); @@ -624,6 +701,7 @@ fn next_visible_app_wraps_and_skips_hidden_entries() { gemini: false, opencode: true, openclaw: true, + pi: false, hermes: true, }; From 1fcf4980737a50a481885d6f69c7caa0a87c2721 Mon Sep 17 00:00:00 2001 From: SaladDay <92240037+SaladDay@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:31:28 +0000 Subject: [PATCH 2/3] feat(pi): add provider presets --- .../src/cli/tui/form/provider_templates.rs | 158 ++++- src-tauri/src/cli/tui/form/tests.rs | 114 ++++ src-tauri/src/lib.rs | 1 + src-tauri/src/provider_preset_pi.rs | 600 ++++++++++++++++++ 4 files changed, 870 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/provider_preset_pi.rs diff --git a/src-tauri/src/cli/tui/form/provider_templates.rs b/src-tauri/src/cli/tui/form/provider_templates.rs index 366c159e1..8fcd8dcc2 100644 --- a/src-tauri/src/cli/tui/form/provider_templates.rs +++ b/src-tauri/src/cli/tui/form/provider_templates.rs @@ -5,7 +5,12 @@ use crate::provider_preset_models::{ sponsor_opencode_settings, SponsorModelFamily, CODEX_DEFAULT_MODEL, CODEX_OAUTH_FAST_MODEL, GEMINI_DEFAULT_MODEL, }; -use crate::provider_preset_sponsors::{sponsor_provider_presets_for_app, SponsorProviderPreset}; +use crate::provider_preset_pi::{ + PiProviderPreset, PI_BUILTIN_PROVIDER_PRESETS, PI_SPONSOR_PROVIDER_PRESETS, +}; +use crate::provider_preset_sponsors::{ + sponsor_provider_preset, sponsor_provider_presets_for_app, SponsorProviderPreset, +}; use serde_json::json; use super::{ @@ -197,6 +202,14 @@ pub(super) fn provider_sponsor_presets(app_type: &AppType) -> &'static [SponsorP sponsor_provider_presets_for_app(app_type) } +fn pi_provider_preset_for_flat_idx(flat_idx: usize) -> Option<&'static PiProviderPreset> { + let preset_idx = flat_idx.checked_sub(1)?; + PI_BUILTIN_PROVIDER_PRESETS.get(preset_idx).or_else(|| { + PI_SPONSOR_PROVIDER_PRESETS + .get(preset_idx.saturating_sub(PI_BUILTIN_PROVIDER_PRESETS.len())) + }) +} + pub(super) fn provider_after_sponsor_template_defs( app_type: &AppType, ) -> &'static [ProviderTemplateDef] { @@ -260,6 +273,9 @@ impl ProviderAddFormState { } pub fn template_count(&self) -> usize { + if matches!(self.app_type, AppType::Pi) { + return 1 + PI_BUILTIN_PROVIDER_PRESETS.len() + PI_SPONSOR_PROVIDER_PRESETS.len(); + } provider_builtin_template_defs(&self.app_type).len() + provider_sponsor_presets(&self.app_type).len() + provider_after_sponsor_template_defs(&self.app_type).len() @@ -272,6 +288,21 @@ impl ProviderAddFormState { /// marker no longer separates sponsors from built-ins the way it did in /// the old chip row (which the MCP form and the CLI chooser still use). pub fn template_labels(&self) -> Vec<&'static str> { + if matches!(self.app_type, AppType::Pi) { + let mut labels = Vec::with_capacity(self.template_count()); + labels.push("Custom"); + labels.extend( + PI_BUILTIN_PROVIDER_PRESETS + .iter() + .map(|preset| preset.label), + ); + labels.extend( + PI_SPONSOR_PROVIDER_PRESETS + .iter() + .map(|preset| preset.label), + ); + return labels; + } let mut labels = provider_builtin_template_defs(&self.app_type) .iter() .map(|def| def.label) @@ -296,6 +327,41 @@ impl ProviderAddFormState { /// headers are only emitted when the app actually has sponsor presets, /// so single-section apps stay a plain list. pub fn template_picker_rows(&self) -> Vec { + if matches!(self.app_type, AppType::Pi) { + let mut rows = Vec::with_capacity(self.template_count() + 2); + rows.push(ProviderTemplateRow::Header( + ProviderTemplateSection::BuiltIn, + )); + rows.push(ProviderTemplateRow::Item { + flat_idx: 0, + label: "Custom", + section: ProviderTemplateSection::BuiltIn, + }); + rows.extend( + PI_BUILTIN_PROVIDER_PRESETS + .iter() + .enumerate() + .map(|(offset, preset)| ProviderTemplateRow::Item { + flat_idx: 1 + offset, + label: preset.label, + section: ProviderTemplateSection::BuiltIn, + }), + ); + rows.push(ProviderTemplateRow::Header( + ProviderTemplateSection::Sponsors, + )); + rows.extend( + PI_SPONSOR_PROVIDER_PRESETS + .iter() + .enumerate() + .map(|(offset, preset)| ProviderTemplateRow::Item { + flat_idx: 1 + PI_BUILTIN_PROVIDER_PRESETS.len() + offset, + label: preset.label, + section: ProviderTemplateSection::Sponsors, + }), + ); + return rows; + } let builtin_defs = provider_builtin_template_defs(&self.app_type); let sponsor_presets = provider_sponsor_presets(&self.app_type); let after_sponsor_defs = provider_after_sponsor_template_defs(&self.app_type); @@ -344,7 +410,7 @@ impl ProviderAddFormState { let builtin_defs = provider_builtin_template_defs(&self.app_type); let sponsor_presets = provider_sponsor_presets(&self.app_type); let after_sponsor_defs = provider_after_sponsor_template_defs(&self.app_type); - let total_templates = builtin_defs.len() + sponsor_presets.len() + after_sponsor_defs.len(); + let total_templates = self.template_count(); let idx = idx.min(total_templates.saturating_sub(1)); self.template_idx = idx; self.field_errors.clear(); @@ -357,7 +423,11 @@ impl ProviderAddFormState { self.codex_prompt_cache_routing = PromptCacheRoutingMode::Auto; } - if idx >= builtin_defs.len() && idx < builtin_defs.len() + sponsor_presets.len() { + if matches!(self.app_type, AppType::Pi) && idx > 0 { + if let Some(preset) = pi_provider_preset_for_flat_idx(idx) { + self.apply_pi_provider_preset(preset); + } + } else if idx >= builtin_defs.len() && idx < builtin_defs.len() + sponsor_presets.len() { let sponsor_idx = idx.saturating_sub(builtin_defs.len()); if let Some(preset) = sponsor_presets.get(sponsor_idx) { self.apply_sponsor_preset(preset); @@ -759,6 +829,88 @@ impl ProviderAddFormState { } } + fn apply_pi_provider_preset(&mut self, preset: &PiProviderPreset) { + let settings = preset.settings_config(); + let sponsor = preset.sponsor_id.and_then(sponsor_provider_preset); + let mut extra = json!({ + "category": preset.category, + "icon": preset.icon, + "settingsConfig": settings.clone(), + }); + + if let Some(icon_color) = preset.icon_color { + extra["iconColor"] = json!(icon_color); + } + if let Some(sponsor) = sponsor { + extra["meta"] = json!({ + "isPartner": true, + "partnerPromotionKey": sponsor.partner_promotion_key, + }); + } else if let Some(partner_promotion_key) = preset.partner_promotion_key { + extra["meta"] = json!({ + "partnerPromotionKey": partner_promotion_key, + }); + } + + self.extra = extra; + self.id.set(preset.provider_key); + self.id_is_manual = true; + self.name.set(preset.label); + self.website_url + .set(sponsor.map_or(preset.website_url, |item| item.website_url)); + self.notes.set(""); + self.openclaw_user_agent = false; + + self.opencode_api_key.set( + settings + .get("apiKey") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + ); + self.opencode_base_url.set( + settings + .get("baseUrl") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + ); + self.opencode_npm_package.set( + settings + .get("api") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + ); + self.openclaw_models = settings + .get("models") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + + self.opencode_model_id.set(""); + self.opencode_model_name.set(""); + self.opencode_model_context_limit.set(""); + self.opencode_model_output_limit.set(""); + self.opencode_model_original_id = None; + if let Some(model) = self.openclaw_models.first() { + if let Some(id) = model.get("id").and_then(serde_json::Value::as_str) { + self.opencode_model_id.set(id); + self.opencode_model_original_id = Some(id.to_string()); + } + if let Some(name) = model.get("name").and_then(serde_json::Value::as_str) { + self.opencode_model_name.set(name); + } + if let Some(context_window) = model + .get("contextWindow") + .and_then(serde_json::Value::as_u64) + { + self.opencode_model_context_limit + .set(context_window.to_string()); + } + if let Some(max_tokens) = model.get("maxTokens").and_then(serde_json::Value::as_u64) { + self.opencode_model_output_limit.set(max_tokens.to_string()); + } + } + } + fn reset_codex_local_routing_state(&mut self) { self.claude_api_format = ClaudeApiFormat::OpenAiResponses; self.claude_api_key_field = ClaudeApiKeyField::AuthToken; diff --git a/src-tauri/src/cli/tui/form/tests.rs b/src-tauri/src/cli/tui/form/tests.rs index 162165406..5ffe3c787 100644 --- a/src-tauri/src/cli/tui/form/tests.rs +++ b/src-tauri/src/cli/tui/form/tests.rs @@ -216,6 +216,120 @@ fn provider_add_form_template_labels_follow_explicit_support_matrix() { ); } +#[test] +fn provider_add_form_pi_exposes_only_the_selected_template_scope() { + let form = ProviderAddFormState::new(AppType::Pi); + assert_eq!( + form.template_labels(), + vec![ + "Custom", + "DeepSeek", + "Zhipu GLM", + "Zhipu GLM en", + "ModelScope", + "MiniMax", + "Xiaomi MiMo", + "Xiaomi MiMo Token Plan (China)", + "OpenCode Go", + "OpenRouter", + "PackyCode", + "AICodeMirror", + "FennoAI", + "RunAPI", + "Qiniu", + "Cubence", + ] + ); +} + +#[test] +fn provider_add_form_pi_picker_uses_family_sections() { + let rows = ProviderAddFormState::new(AppType::Pi).template_picker_rows(); + assert_eq!( + rows.first(), + Some(&ProviderTemplateRow::Header( + ProviderTemplateSection::BuiltIn + )) + ); + assert_eq!( + rows.get(11), + Some(&ProviderTemplateRow::Header( + ProviderTemplateSection::Sponsors + )) + ); + + let items = rows + .iter() + .filter_map(|row| match row { + ProviderTemplateRow::Item { + flat_idx, + label, + section, + } => Some((*flat_idx, *label, *section)), + ProviderTemplateRow::Header(_) => None, + }) + .collect::>(); + assert_eq!(items.len(), 16); + assert_eq!(items[0], (0, "Custom", ProviderTemplateSection::BuiltIn)); + assert_eq!( + items[9], + (9, "OpenRouter", ProviderTemplateSection::BuiltIn) + ); + assert_eq!( + items[10], + (10, "PackyCode", ProviderTemplateSection::Sponsors) + ); + assert_eq!( + items[15], + (15, "Cubence", ProviderTemplateSection::Sponsors) + ); +} + +#[test] +fn provider_add_form_pi_templates_preserve_upstream_native_settings() { + let presets = crate::provider_preset_pi::PI_BUILTIN_PROVIDER_PRESETS + .iter() + .chain(crate::provider_preset_pi::PI_SPONSOR_PROVIDER_PRESETS.iter()); + + for (offset, preset) in presets.enumerate() { + let mut form = ProviderAddFormState::new(AppType::Pi); + form.apply_template(offset + 1, &[]); + let provider = form.to_provider_json_value(); + + assert_eq!(provider["id"], preset.provider_key, "{} id", preset.label); + assert_eq!(provider["name"], preset.label, "{} name", preset.label); + assert_eq!( + provider["category"], preset.category, + "{} category", + preset.label + ); + assert_eq!(provider["icon"], preset.icon, "{} icon", preset.label); + assert_eq!( + provider["settingsConfig"], + preset.settings_config(), + "{} native settings", + preset.label + ); + + if let Some(sponsor_id) = preset.sponsor_id { + let sponsor = crate::provider_preset_sponsors::sponsor_provider_preset(sponsor_id) + .expect("selected Pi sponsor must exist in the CLI sponsor registry"); + assert_eq!(provider["websiteUrl"], sponsor.website_url); + assert_eq!(provider["meta"]["isPartner"], true); + assert_eq!( + provider["meta"]["partnerPromotionKey"], + sponsor.partner_promotion_key + ); + } else { + assert_eq!(provider["websiteUrl"], preset.website_url); + assert!(provider["meta"].get("isPartner").is_none()); + if let Some(promotion_key) = preset.partner_promotion_key { + assert_eq!(provider["meta"]["partnerPromotionKey"], promotion_key); + } + } + } +} + #[test] fn cli_provider_templates_match_tui_serializer_output() { for (app_type, template, label) in [ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bfe390ac0..431c558c7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,6 +33,7 @@ mod prompt_files; mod provider; mod provider_defaults; mod provider_preset_models; +mod provider_preset_pi; mod provider_preset_sponsors; mod proxy; mod services; diff --git a/src-tauri/src/provider_preset_pi.rs b/src-tauri/src/provider_preset_pi.rs new file mode 100644 index 000000000..356f75fcd --- /dev/null +++ b/src-tauri/src/provider_preset_pi.rs @@ -0,0 +1,600 @@ +use serde_json::{json, Value}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct PiProviderPreset { + pub(crate) label: &'static str, + pub(crate) provider_key: &'static str, + pub(crate) website_url: &'static str, + pub(crate) category: &'static str, + pub(crate) icon: &'static str, + pub(crate) icon_color: Option<&'static str>, + pub(crate) partner_promotion_key: Option<&'static str>, + pub(crate) sponsor_id: Option<&'static str>, + settings: fn() -> Value, +} + +impl PiProviderPreset { + pub(crate) fn settings_config(&self) -> Value { + (self.settings)() + } +} + +pub(crate) static PI_BUILTIN_PROVIDER_PRESETS: [PiProviderPreset; 9] = [ + PiProviderPreset { + label: "DeepSeek", + provider_key: "cc-switch-deep-seek", + website_url: "https://platform.deepseek.com", + category: "cn_official", + icon: "deepseek", + icon_color: Some("#1E88E5"), + partner_promotion_key: None, + sponsor_id: None, + settings: deepseek_settings, + }, + PiProviderPreset { + label: "Zhipu GLM", + provider_key: "cc-switch-zhipu-glm", + website_url: "https://open.bigmodel.cn", + category: "cn_official", + icon: "zhipu", + icon_color: Some("#0F62FE"), + partner_promotion_key: None, + sponsor_id: None, + settings: zhipu_glm_settings, + }, + PiProviderPreset { + label: "Zhipu GLM en", + provider_key: "cc-switch-zhipu-glm-en", + website_url: "https://z.ai", + category: "cn_official", + icon: "zhipu", + icon_color: Some("#0F62FE"), + partner_promotion_key: None, + sponsor_id: None, + settings: zhipu_glm_en_settings, + }, + PiProviderPreset { + label: "ModelScope", + provider_key: "cc-switch-model-scope", + website_url: "https://modelscope.cn", + category: "aggregator", + icon: "modelscope", + icon_color: Some("#624AFF"), + partner_promotion_key: None, + sponsor_id: None, + settings: modelscope_settings, + }, + PiProviderPreset { + label: "MiniMax", + provider_key: "cc-switch-mini-max", + website_url: "https://platform.minimaxi.com", + category: "cn_official", + icon: "minimax", + icon_color: Some("#FF6B6B"), + partner_promotion_key: Some("minimax_cn"), + sponsor_id: None, + settings: minimax_settings, + }, + PiProviderPreset { + label: "Xiaomi MiMo", + provider_key: "cc-switch-xiaomi-mi-mo", + website_url: "https://platform.xiaomimimo.com", + category: "cn_official", + icon: "xiaomimimo", + icon_color: Some("#000000"), + partner_promotion_key: None, + sponsor_id: None, + settings: xiaomi_mimo_settings, + }, + PiProviderPreset { + label: "Xiaomi MiMo Token Plan (China)", + provider_key: "cc-switch-xiaomi-mi-mo-token-plan-china", + website_url: "https://platform.xiaomimimo.com/#/token-plan", + category: "cn_official", + icon: "xiaomimimo", + icon_color: Some("#000000"), + partner_promotion_key: None, + sponsor_id: None, + settings: xiaomi_mimo_token_plan_settings, + }, + PiProviderPreset { + label: "OpenCode Go", + provider_key: "cc-switch-open-code-go", + website_url: "https://opencode.ai/go", + category: "third_party", + icon: "opencode", + icon_color: Some("#211E1E"), + partner_promotion_key: Some("opencode_go"), + sponsor_id: None, + settings: opencode_go_settings, + }, + PiProviderPreset { + label: "OpenRouter", + provider_key: "cc-switch-open-router", + website_url: "https://openrouter.ai", + category: "aggregator", + icon: "openrouter", + icon_color: Some("#6566F1"), + partner_promotion_key: None, + sponsor_id: None, + settings: openrouter_settings, + }, +]; + +pub(crate) static PI_SPONSOR_PROVIDER_PRESETS: [PiProviderPreset; 6] = [ + PiProviderPreset { + label: "PackyCode", + provider_key: "cc-switch-packy-code", + website_url: "https://www.packyapi.ai", + category: "third_party", + icon: "packycode", + icon_color: None, + partner_promotion_key: None, + sponsor_id: Some("packycode"), + settings: packycode_settings, + }, + PiProviderPreset { + label: "AICodeMirror", + provider_key: "cc-switch-aicode-mirror", + website_url: "https://www.aicodemirror.ai", + category: "third_party", + icon: "aicodemirror", + icon_color: Some("#000000"), + partner_promotion_key: None, + sponsor_id: Some("aicodemirror"), + settings: aicodemirror_settings, + }, + PiProviderPreset { + label: "FennoAI", + provider_key: "cc-switch-fenno-ai", + website_url: "https://api.fenno.ai", + category: "aggregator", + icon: "fenno", + icon_color: None, + partner_promotion_key: None, + sponsor_id: Some("fenno"), + settings: fenno_settings, + }, + PiProviderPreset { + label: "RunAPI", + provider_key: "cc-switch-run-api", + website_url: "https://runapi.co", + category: "aggregator", + icon: "runapi", + icon_color: None, + partner_promotion_key: None, + sponsor_id: Some("runapi"), + settings: runapi_settings, + }, + PiProviderPreset { + label: "Qiniu", + provider_key: "cc-switch-qiniu", + website_url: "https://s.qiniu.com/nMvAvy", + category: "aggregator", + icon: "qiniu", + icon_color: None, + partner_promotion_key: None, + sponsor_id: Some("qiniu"), + settings: qiniu_settings, + }, + PiProviderPreset { + label: "Cubence", + provider_key: "cc-switch-cubence", + website_url: "https://cubence.com", + category: "third_party", + icon: "cubence", + icon_color: Some("#000000"), + partner_promotion_key: None, + sponsor_id: Some("cubence"), + settings: cubence_settings, + }, +]; + +fn claude_sonnet(id: &str) -> Value { + json!({ + "name": "Claude Sonnet 5", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1_000_000, + "maxTokens": 128_000, + "id": id, + "thinkingLevelMap": { "xhigh": "xhigh", "max": "max" }, + "compat": { "forceAdaptiveThinking": true }, + }) +} + +fn claude_opus(id: &str) -> Value { + json!({ + "name": "Claude Opus 5", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1_000_000, + "maxTokens": 128_000, + "id": id, + "thinkingLevelMap": { "xhigh": "xhigh", "max": "max" }, + "compat": { "forceAdaptiveThinking": true }, + }) +} + +fn gpt_5_6_sol() -> Value { + json!({ + "name": "GPT-5.6 Sol", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 272_000, + "maxTokens": 128_000, + "id": "gpt-5.6-sol", + "thinkingLevelMap": {}, + }) +} + +fn glm_5_1() -> Value { + json!({ + "name": "GLM-5.1", + "reasoning": true, + "input": ["text"], + "contextWindow": 200_000, + "maxTokens": 131_072, + "id": "glm-5.1", + "thinkingLevelMap": {}, + }) +} + +fn anthropic_settings(name: &str, base_url: &str) -> Value { + json!({ + "name": name, + "baseUrl": base_url, + "api": "anthropic-messages", + "apiKey": "", + "models": [ + claude_sonnet("claude-sonnet-5"), + claude_opus("claude-opus-5"), + ], + }) +} + +fn openai_gpt_settings(name: &str, base_url: &str) -> Value { + json!({ + "name": name, + "baseUrl": base_url, + "api": "openai-completions", + "apiKey": "", + "models": [gpt_5_6_sol()], + }) +} + +fn packycode_settings() -> Value { + anthropic_settings("PackyCode", "https://www.packyapi.ai") +} + +fn aicodemirror_settings() -> Value { + anthropic_settings("AICodeMirror", "https://api.aicodemirror.ai/api/claudecode") +} + +fn fenno_settings() -> Value { + openai_gpt_settings("FennoAI", "https://api.fenno.ai/v1") +} + +fn runapi_settings() -> Value { + let mut settings = anthropic_settings("RunAPI", "https://runapi.co"); + settings["models"] + .as_array_mut() + .expect("Pi RunAPI models are an array") + .push(json!({ + "name": "Claude Haiku 4.5 (latest)", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 200_000, + "maxTokens": 64_000, + "id": "claude-haiku-4-5", + "thinkingLevelMap": {}, + })); + settings +} + +fn qiniu_settings() -> Value { + openai_gpt_settings("Qiniu", "https://api.qnaigc.com/v1") +} + +fn cubence_settings() -> Value { + anthropic_settings("Cubence", "https://api.cubence.com") +} + +fn deepseek_model(name: &str, id: &str) -> Value { + json!({ + "name": name, + "reasoning": true, + "input": ["text"], + "contextWindow": 1_000_000, + "maxTokens": 384_000, + "id": id, + "thinkingLevelMap": { + "minimal": null, + "low": null, + "medium": null, + "high": "high", + "max": "max", + }, + }) +} + +fn deepseek_settings() -> Value { + json!({ + "name": "DeepSeek", + "baseUrl": "https://api.deepseek.com/v1", + "api": "openai-completions", + "apiKey": "", + "models": [ + deepseek_model("DeepSeek V4 Pro", "deepseek-v4-pro"), + deepseek_model("DeepSeek V4 Flash", "deepseek-v4-flash"), + ], + }) +} + +fn zhipu_glm_settings() -> Value { + json!({ + "name": "Zhipu GLM", + "baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4", + "api": "openai-completions", + "apiKey": "", + "models": [glm_5_1()], + }) +} + +fn zhipu_glm_en_settings() -> Value { + json!({ + "name": "Zhipu GLM en", + "baseUrl": "https://api.z.ai/api/coding/paas/v4", + "api": "openai-completions", + "apiKey": "", + "models": [glm_5_1()], + }) +} + +fn modelscope_settings() -> Value { + json!({ + "name": "ModelScope", + "baseUrl": "https://api-inference.modelscope.cn/v1", + "api": "openai-completions", + "apiKey": "", + "models": [{ + "name": "GLM-5.2", + "reasoning": true, + "input": ["text"], + "contextWindow": 1_000_000, + "maxTokens": 131_072, + "id": "ZhipuAI/GLM-5.2", + "thinkingLevelMap": {}, + }], + }) +} + +fn minimax_settings() -> Value { + json!({ + "name": "MiniMax", + "baseUrl": "https://api.minimaxi.com/v1", + "api": "openai-completions", + "apiKey": "", + "models": [{ + "name": "MiniMax-M2.7", + "reasoning": true, + "input": ["text"], + "contextWindow": 204_800, + "maxTokens": 131_072, + "id": "MiniMax-M2.7", + "thinkingLevelMap": {}, + }], + }) +} + +fn xiaomi_model(name: &str, id: &str, input: &[&str], compat: bool) -> Value { + let mut model = json!({ + "name": name, + "reasoning": true, + "input": input, + "contextWindow": 1_048_576, + "maxTokens": 131_072, + "id": id, + "thinkingLevelMap": {}, + }); + if compat { + model["compat"] = json!({ + "requiresReasoningContentOnAssistantMessages": true, + "thinkingFormat": "deepseek", + }); + } + model +} + +fn xiaomi_mimo_settings() -> Value { + json!({ + "name": "Xiaomi MiMo", + "baseUrl": "https://api.xiaomimimo.com/v1", + "api": "openai-completions", + "apiKey": "", + "models": [ + xiaomi_model("MiMo-V2.5-Pro", "mimo-v2.5-pro", &["text"], true), + xiaomi_model("MiMo-V2.5", "mimo-v2.5", &["text", "image"], true), + ], + }) +} + +fn xiaomi_mimo_token_plan_settings() -> Value { + json!({ + "name": "Xiaomi MiMo Token Plan (China)", + "baseUrl": "https://token-plan-cn.xiaomimimo.com/v1", + "api": "openai-completions", + "apiKey": "", + "models": [ + xiaomi_model("MiMo-V2.5-Pro", "mimo-v2.5-pro", &["text"], false), + xiaomi_model("MiMo-V2.5", "mimo-v2.5", &["text", "image"], false), + ], + }) +} + +fn opencode_compat(extra: Value) -> Value { + let mut compat = json!({ + "supportsStore": false, + "supportsDeveloperRole": false, + "maxTokensField": "max_tokens", + }); + if let (Some(compat), Some(extra)) = (compat.as_object_mut(), extra.as_object()) { + compat.extend(extra.clone()); + } + compat +} + +fn opencode_go_settings() -> Value { + let thinking_levels = json!({ + "minimal": null, + "low": null, + "medium": null, + "high": "high", + "max": "max", + }); + json!({ + "name": "OpenCode Go", + "baseUrl": "https://opencode.ai/zen/go/v1", + "api": "openai-completions", + "apiKey": "", + "models": [ + { + "name": "GLM 5.2", + "reasoning": true, + "input": ["text"], + "contextWindow": 1_000_000, + "maxTokens": 131_072, + "id": "glm-5.2", + "compat": opencode_compat(json!({})), + "thinkingLevelMap": { + "off": null, + "minimal": null, + "low": null, + "medium": null, + "high": "high", + "xhigh": null, + "max": "max", + }, + }, + { + "name": "Kimi K2.7 Code", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 262_144, + "maxTokens": 262_144, + "id": "kimi-k2.7-code", + "compat": opencode_compat(json!({})), + "thinkingLevelMap": {}, + }, + { + "name": "DeepSeek V4 Pro", + "reasoning": true, + "input": ["text"], + "contextWindow": 1_000_000, + "maxTokens": 384_000, + "id": "deepseek-v4-pro", + "compat": opencode_compat(json!({ + "requiresReasoningContentOnAssistantMessages": true, + "thinkingFormat": "deepseek", + })), + "thinkingLevelMap": thinking_levels.clone(), + }, + { + "name": "DeepSeek V4 Flash", + "reasoning": true, + "input": ["text"], + "contextWindow": 1_000_000, + "maxTokens": 384_000, + "id": "deepseek-v4-flash", + "compat": opencode_compat(json!({ + "requiresReasoningContentOnAssistantMessages": true, + "thinkingFormat": "deepseek", + })), + "thinkingLevelMap": thinking_levels, + }, + { + "name": "MiMo-V2.5-Pro", + "reasoning": true, + "input": ["text"], + "contextWindow": 1_048_576, + "maxTokens": 131_072, + "id": "mimo-v2.5-pro", + "compat": opencode_compat(json!({})), + "thinkingLevelMap": {}, + }, + ], + }) +} + +fn openrouter_settings() -> Value { + json!({ + "name": "OpenRouter", + "baseUrl": "https://openrouter.ai/api", + "api": "anthropic-messages", + "apiKey": "", + "models": [ + claude_sonnet("anthropic/claude-sonnet-5"), + claude_opus("anthropic/claude-opus-5"), + ], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn pi_preset_scope_is_the_selected_upstream_subset() { + assert_eq!( + PI_BUILTIN_PROVIDER_PRESETS + .iter() + .map(|preset| preset.label) + .collect::>(), + [ + "DeepSeek", + "Zhipu GLM", + "Zhipu GLM en", + "ModelScope", + "MiniMax", + "Xiaomi MiMo", + "Xiaomi MiMo Token Plan (China)", + "OpenCode Go", + "OpenRouter", + ] + ); + assert_eq!( + PI_SPONSOR_PROVIDER_PRESETS + .iter() + .map(|preset| preset.sponsor_id.expect("sponsor id")) + .collect::>(), + [ + "packycode", + "aicodemirror", + "fenno", + "runapi", + "qiniu", + "cubence", + ] + ); + } + + #[test] + fn pi_preset_keys_are_unique_and_native_settings_are_complete() { + let presets = PI_BUILTIN_PROVIDER_PRESETS + .iter() + .chain(PI_SPONSOR_PROVIDER_PRESETS.iter()); + let mut keys = HashSet::new(); + for preset in presets { + assert!(keys.insert(preset.provider_key), "duplicate provider key"); + let settings = preset.settings_config(); + assert_eq!(settings["name"], preset.label); + assert!(settings["baseUrl"].is_string()); + assert!(settings["api"].is_string()); + assert_eq!(settings["apiKey"], ""); + assert!(settings["models"] + .as_array() + .is_some_and(|items| !items.is_empty())); + } + } +} From 66aa80131ee2c2bc047df38d8474344bc14d1fc6 Mon Sep 17 00:00:00 2001 From: SaladDay <92240037+SaladDay@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:41:17 +0000 Subject: [PATCH 3/3] feat(providers): add upstream built-in presets --- src-tauri/src/cli/commands/provider_input.rs | 68 +-- src-tauri/src/cli/tui/app/tests.rs | 59 ++- src-tauri/src/cli/tui/form.rs | 2 + src-tauri/src/cli/tui/form/provider_json.rs | 18 + src-tauri/src/cli/tui/form/provider_state.rs | 24 + .../src/cli/tui/form/provider_templates.rs | 207 ++++---- src-tauri/src/cli/tui/form/tests.rs | 311 +++++++++++- src-tauri/src/cli/tui/ui/tests.rs | 2 + src-tauri/src/codex_config.rs | 257 +++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/provider.rs | 4 + src-tauri/src/provider_preset_builtin.rs | 475 ++++++++++++++++++ src-tauri/src/proxy/providers/codex.rs | 84 +++- .../proxy/providers/transform_codex_chat.rs | 74 ++- .../tests/provider_add_noninteractive.rs | 6 +- 15 files changed, 1391 insertions(+), 201 deletions(-) create mode 100644 src-tauri/src/provider_preset_builtin.rs diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index b2258d365..e1b6b1b72 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -9,10 +9,8 @@ use crate::claude_model_config::{ use crate::cli::i18n::texts; use crate::cli::ui::info; use crate::error::AppError; -use crate::provider::{ - AuthBinding, AuthBindingSource, ClaudeApiKeyField, CodexChatReasoningConfig, Provider, - ProviderMeta, -}; +use crate::provider::{AuthBinding, AuthBindingSource, ClaudeApiKeyField, Provider, ProviderMeta}; +use crate::provider_preset_builtin::{builtin_provider_preset_value, BuiltinProviderPresetId}; use crate::provider_preset_models::{ codex_oauth_claude_env, sponsor_hermes_models, sponsor_model_family, sponsor_openclaw_models, sponsor_opencode_settings, SponsorModelFamily, CODEX_DEFAULT_MODEL, GEMINI_DEFAULT_MODEL, @@ -89,17 +87,6 @@ impl ProviderAddTemplate { } } -const DEEPSEEK_CODEX_CONFIG: &str = r#"model_provider = "custom" -model = "deepseek-v4-flash" -model_reasoning_effort = "high" -disable_response_storage = true - -[model_providers.custom] -name = "deepseek" -base_url = "https://api.deepseek.com" -wire_api = "responses" -requires_openai_auth = true"#; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ProviderAddTemplateChoice { pub template: ProviderAddTemplate, @@ -430,15 +417,7 @@ fn template_default_meta( ..Default::default() }), ProviderAddTemplate::Deepseek => Some(ProviderMeta { - api_format: Some("openai_chat".to_string()), - codex_chat_reasoning: Some(CodexChatReasoningConfig { - supports_thinking: Some(true), - supports_effort: Some(true), - thinking_param: Some("thinking".to_string()), - effort_param: Some("reasoning_effort".to_string()), - effort_value_mode: Some("deepseek".to_string()), - output_format: Some("reasoning_content".to_string()), - }), + api_format: Some("openai_responses".to_string()), ..Default::default() }), ProviderAddTemplate::GoogleOauth => Some(ProviderMeta { @@ -541,23 +520,9 @@ fn build_provider_template_settings_config( } fn build_codex_deepseek_settings_config() -> Value { - json!({ - "config": DEEPSEEK_CODEX_CONFIG, - "modelCatalog": { - "models": [ - { - "model": "deepseek-v4-flash", - "displayName": "DeepSeek V4 Flash", - "contextWindow": 1000000, - }, - { - "model": "deepseek-v4-pro", - "displayName": "DeepSeek V4 Pro", - "contextWindow": 1000000, - }, - ], - }, - }) + builtin_provider_preset_value(&AppType::Codex, BuiltinProviderPresetId::DeepSeek) + .and_then(|provider| provider.get("settingsConfig").cloned()) + .unwrap_or_else(|| json!({})) } fn build_sponsor_template_settings_config( @@ -1816,31 +1781,22 @@ requires_openai_auth = true { "model": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash", - "contextWindow": 1000000, + "contextWindow": 1048576, + "reasoningLevels": ["low", "high", "max"], }, { "model": "deepseek-v4-pro", "displayName": "DeepSeek V4 Pro", - "contextWindow": 1000000, + "contextWindow": 1048576, + "reasoningLevels": ["low", "high", "max"], }, ], }) ); let meta = provider.meta.expect("DeepSeek metadata should be present"); - assert_eq!(meta.api_format.as_deref(), Some("openai_chat")); - let reasoning = meta - .codex_chat_reasoning - .expect("DeepSeek reasoning metadata should be present"); - assert_eq!(reasoning.supports_thinking, Some(true)); - assert_eq!(reasoning.supports_effort, Some(true)); - assert_eq!(reasoning.thinking_param.as_deref(), Some("thinking")); - assert_eq!(reasoning.effort_param.as_deref(), Some("reasoning_effort")); - assert_eq!(reasoning.effort_value_mode.as_deref(), Some("deepseek")); - assert_eq!( - reasoning.output_format.as_deref(), - Some("reasoning_content") - ); + assert_eq!(meta.api_format.as_deref(), Some("openai_responses")); + assert!(meta.codex_chat_reasoning.is_none()); } #[test] diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 1b7babcb9..89e23445c 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -13174,6 +13174,20 @@ mod tests { }) .count(); assert!(builtin_count >= 3, "{rows:?}"); + let last_builtin_flat = rows + .iter() + .filter_map(|row| match row { + super::super::form::ProviderTemplateRow::Item { + flat_idx, + section: super::super::form::ProviderTemplateSection::BuiltIn, + .. + } => Some(*flat_idx), + _ => None, + }) + .next_back() + .expect("at least one built-in template"); + let first_sponsor_flat = + first_sponsor_flat_idx(&rows).expect("claude exposes sponsor presets"); assert!(rows.iter().any(|row| matches!( row, super::super::form::ProviderTemplateRow::Header( @@ -13184,20 +13198,20 @@ mod tests { // Walk from the last built-in row into the sponsor section: the // Sponsors header sits between them and must never be selected. app.overlay = Overlay::ProviderTemplatePicker { - selected: builtin_count - 1, + selected: last_builtin_flat, }; let data = UiData::default(); app.on_key(key(KeyCode::Down), &data); let after_down = provider_template_picker_selection(&app); assert_eq!( - after_down, builtin_count, + after_down, first_sponsor_flat, "Down must land on the first sponsor, not the header" ); app.on_key(key(KeyCode::Up), &data); assert_eq!( provider_template_picker_selection(&app), - builtin_count - 1, + last_builtin_flat, "Up must skip back over the header" ); @@ -13223,8 +13237,8 @@ mod tests { ); } - /// Codex's display order diverges from its flat order: DeepSeek has the - /// LAST flat index but renders as the last Built-in row, directly above + /// Codex's display order diverges from its flat order: migrated built-ins + /// have flat indices after the sponsors but render directly above /// the Sponsors header. Navigation must follow display order, so a naive /// flat-index ±1 stepping implementation cannot pass this. #[test] @@ -13234,22 +13248,30 @@ mod tests { let rows = form.template_picker_rows(); let labels = form.template_labels(); - let deepseek_flat = labels + let last_builtin_flat = rows .iter() - .position(|label| *label == "DeepSeek") - .expect("codex exposes DeepSeek"); + .filter_map(|row| match row { + super::super::form::ProviderTemplateRow::Item { + flat_idx, + section: super::super::form::ProviderTemplateSection::BuiltIn, + .. + } => Some(*flat_idx), + _ => None, + }) + .next_back() + .expect("codex exposes built-in presets"); let first_sponsor_flat = first_sponsor_flat_idx(&rows).expect("codex exposes sponsor presets"); // Precondition: flat order and display order really do disagree here. assert_eq!( - deepseek_flat, + last_builtin_flat, labels.len() - 1, - "DeepSeek should hold the last flat index" + "the last built-in should hold the last flat index" ); assert!( - first_sponsor_flat < deepseek_flat, - "sponsors precede DeepSeek in flat order" + first_sponsor_flat < last_builtin_flat, + "sponsors precede migrated built-ins in flat order" ); app.form = Some(FormState::ProviderAdd(form)); @@ -13257,25 +13279,24 @@ mod tests { // Down from the last Built-in row crosses the Sponsors header. app.overlay = Overlay::ProviderTemplatePicker { - selected: deepseek_flat, + selected: last_builtin_flat, }; app.on_key(key(KeyCode::Down), &data); assert_eq!( provider_template_picker_selection(&app), first_sponsor_flat, - "Down from DeepSeek must reach the first sponsor, not flat+1" + "Down from the last built-in must reach the first sponsor, not flat+1" ); - // And Up returns to DeepSeek rather than stepping to flat-1. + // And Up returns to the last built-in rather than stepping to flat-1. app.on_key(key(KeyCode::Up), &data); assert_eq!( provider_template_picker_selection(&app), - deepseek_flat, - "Up from the first sponsor must return to DeepSeek, not flat-1" + last_builtin_flat, + "Up from the first sponsor must return to the last built-in, not flat-1" ); - // DeepSeek is the last display row, so Down past it is a no-op only - // after the sponsors are exhausted, never a wrap to flat order. + // Basic navigation inside the first Built-in rows stays unchanged. app.overlay = Overlay::ProviderTemplatePicker { selected: 0 }; app.on_key(key(KeyCode::Down), &data); assert_eq!( diff --git a/src-tauri/src/cli/tui/form.rs b/src-tauri/src/cli/tui/form.rs index 952accdc9..69557519d 100644 --- a/src-tauri/src/cli/tui/form.rs +++ b/src-tauri/src/cli/tui/form.rs @@ -419,6 +419,8 @@ pub struct CodexModelCatalogRow { pub supports_parallel_tool_calls: Option, pub input_modalities: Vec, pub base_instructions: String, + pub reasoning_levels: Vec, + pub default_reasoning_level: String, } pub(crate) fn parse_codex_model_catalog_context_window(raw: &str) -> Option { diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs index f94bac77a..8c7d22881 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -1334,6 +1334,24 @@ impl ProviderAddFormState { obj.insert("baseInstructions".to_string(), json!(base_instructions)); } + let reasoning_levels = row + .reasoning_levels + .iter() + .map(|level| level.trim()) + .filter(|level| !level.is_empty()) + .collect::>(); + if !reasoning_levels.is_empty() { + obj.insert("reasoningLevels".to_string(), json!(reasoning_levels)); + } + + let default_reasoning_level = row.default_reasoning_level.trim(); + if !default_reasoning_level.is_empty() { + obj.insert( + "defaultReasoningLevel".to_string(), + json!(default_reasoning_level), + ); + } + models.push(Value::Object(obj)); } diff --git a/src-tauri/src/cli/tui/form/provider_state.rs b/src-tauri/src/cli/tui/form/provider_state.rs index 839b5fcac..f1c97b7a5 100644 --- a/src-tauri/src/cli/tui/form/provider_state.rs +++ b/src-tauri/src/cli/tui/form/provider_state.rs @@ -1606,6 +1606,8 @@ impl ProviderAddFormState { supports_parallel_tool_calls: None, input_modalities: Vec::new(), base_instructions: String::new(), + reasoning_levels: Vec::new(), + default_reasoning_level: String::new(), }); self.codex_model_catalog_idx = self.codex_model_catalog.len().saturating_sub(1); true @@ -3128,6 +3130,26 @@ pub(crate) fn codex_model_catalog_row_from_value(value: &Value) -> Option Option &'static [ProviderTemplateDef] { match app_type { + AppType::Claude => &PROVIDER_TEMPLATE_DEFS_CLAUDE_AFTER_SPONSORS, AppType::Codex => &PROVIDER_TEMPLATE_DEFS_CODEX_AFTER_SPONSORS, - AppType::Claude - | AppType::Gemini - | AppType::OpenCode - | AppType::Hermes - | AppType::OpenClaw - | AppType::Pi => &[], + AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + &[] + } } } @@ -587,78 +648,8 @@ impl ProviderAddFormState { self.codex_requires_openai_auth = true; self.codex_env_key.set(""); } - ProviderTemplateId::DeepSeek => { - self.extra = json!({ - "category": "cn_official", - "icon": "deepseek", - "iconColor": "#1E88E5", - "meta": { - "apiFormat": "openai_chat", - "codexChatReasoning": { - "supportsThinking": true, - "supportsEffort": true, - "thinkingParam": "thinking", - "effortParam": "reasoning_effort", - "effortValueMode": "deepseek", - "outputFormat": "reasoning_content", - }, - }, - "settingsConfig": { - "config": DEEPSEEK_CODEX_CONFIG, - "modelCatalog": { - "models": [ - { - "model": "deepseek-v4-flash", - "displayName": "DeepSeek V4 Flash", - "contextWindow": 1000000, - }, - { - "model": "deepseek-v4-pro", - "displayName": "DeepSeek V4 Pro", - "contextWindow": 1000000, - }, - ], - }, - }, - }); - self.name.set("DeepSeek"); - self.website_url.set("https://platform.deepseek.com"); - self.codex_api_key.set(""); - self.codex_base_url.set("https://api.deepseek.com"); - self.codex_model.set("deepseek-v4-flash"); - self.codex_wire_api = CodexWireApi::Responses; - self.codex_requires_openai_auth = true; - self.codex_env_key.set(""); - self.claude_api_format = ClaudeApiFormat::OpenAiChat; - self.codex_chat_reasoning = CodexChatReasoningConfig { - supports_thinking: Some(true), - supports_effort: Some(true), - thinking_param: Some("thinking".to_string()), - effort_param: Some("reasoning_effort".to_string()), - effort_value_mode: Some("deepseek".to_string()), - output_format: Some("reasoning_content".to_string()), - }; - self.codex_model_catalog = vec![ - CodexModelCatalogRow { - model: "deepseek-v4-flash".to_string(), - display_name: "DeepSeek V4 Flash".to_string(), - context_window: "1000000".to_string(), - supports_parallel_tool_calls: None, - input_modalities: Vec::new(), - base_instructions: String::new(), - }, - CodexModelCatalogRow { - model: "deepseek-v4-pro".to_string(), - display_name: "DeepSeek V4 Pro".to_string(), - context_window: "1000000".to_string(), - supports_parallel_tool_calls: None, - input_modalities: Vec::new(), - base_instructions: String::new(), - }, - ]; - self.codex_local_routing_field_idx = 0; - self.codex_model_catalog_idx = 0; - self.codex_model_catalog_field = CodexModelCatalogField::Model; + ProviderTemplateId::Builtin(preset) => { + self.apply_builtin_provider_preset(preset); } ProviderTemplateId::GoogleOAuth => { self.extra = json!({ @@ -690,6 +681,28 @@ impl ProviderAddFormState { } } + fn apply_builtin_provider_preset(&mut self, preset: BuiltinProviderPresetId) { + let app_type = self.app_type.clone(); + let Some(provider_value) = builtin_provider_preset_value(&app_type, preset) else { + return; + }; + let Ok(provider) = serde_json::from_value::(provider_value.clone()) else { + return; + }; + + match app_type { + AppType::Claude => self.reset_claude_template_state(), + AppType::Codex => self.reset_codex_template_state(), + _ => return, + } + self.extra = provider_value; + self.name.set(&provider.name); + self.website_url + .set(provider.website_url.as_deref().unwrap_or_default()); + self.notes.set(""); + populate_form_from_provider(self, &app_type, &provider); + } + fn apply_sponsor_preset(&mut self, preset: &SponsorProviderPreset) { let mut extra = json!({ "meta": { diff --git a/src-tauri/src/cli/tui/form/tests.rs b/src-tauri/src/cli/tui/form/tests.rs index 5ffe3c787..44d4acc60 100644 --- a/src-tauri/src/cli/tui/form/tests.rs +++ b/src-tauri/src/cli/tui/form/tests.rs @@ -127,6 +127,15 @@ fn provider_add_form_template_labels_follow_explicit_support_matrix() { "FennoAI", "PackyCode", "DDS", + "DeepSeek", + "Zhipu GLM", + "Zhipu GLM en", + "ModelScope", + "MiniMax", + "Xiaomi MiMo", + "Xiaomi MiMo Token Plan (China)", + "OpenCode Go", + "OpenRouter", ] ); @@ -145,6 +154,14 @@ fn provider_add_form_template_labels_follow_explicit_support_matrix() { "PackyCode", "DDS", "DeepSeek", + "Zhipu GLM", + "Zhipu GLM en", + "ModelScope", + "MiniMax", + "Xiaomi MiMo", + "Xiaomi MiMo Token Plan (China)", + "OpenCode Go", + "OpenRouter", ] ); @@ -330,6 +347,270 @@ fn provider_add_form_pi_templates_preserve_upstream_native_settings() { } } +#[test] +fn provider_add_form_claude_builtin_presets_match_upstream_fields() { + let cases = [ + ( + "DeepSeek", + "https://api.deepseek.com/anthropic", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v4-pro", + "deepseek-v4-pro", + ClaudeApiKeyField::AuthToken, + ), + ( + "Zhipu GLM", + "https://open.bigmodel.cn/api/anthropic", + "glm-5.1", + "glm-5.1", + "glm-5.1", + "glm-5.1", + ClaudeApiKeyField::AuthToken, + ), + ( + "Zhipu GLM en", + "https://api.z.ai/api/anthropic", + "glm-5.1", + "glm-5.1", + "glm-5.1", + "glm-5.1", + ClaudeApiKeyField::AuthToken, + ), + ( + "ModelScope", + "https://api-inference.modelscope.cn", + "ZhipuAI/GLM-5.2", + "ZhipuAI/GLM-5.2", + "ZhipuAI/GLM-5.2", + "ZhipuAI/GLM-5.2", + ClaudeApiKeyField::AuthToken, + ), + ( + "MiniMax", + "https://api.minimaxi.com/anthropic", + "MiniMax-M2.7", + "MiniMax-M2.7", + "MiniMax-M2.7", + "MiniMax-M2.7", + ClaudeApiKeyField::AuthToken, + ), + ( + "Xiaomi MiMo", + "https://api.xiaomimimo.com/anthropic", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + ClaudeApiKeyField::AuthToken, + ), + ( + "Xiaomi MiMo Token Plan (China)", + "https://token-plan-cn.xiaomimimo.com/anthropic", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + ClaudeApiKeyField::AuthToken, + ), + ( + "OpenCode Go", + "https://opencode.ai/zen/go", + "deepseek-v4-flash", + "deepseek-v4-flash", + "deepseek-v4-flash", + "deepseek-v4-flash", + ClaudeApiKeyField::ApiKey, + ), + ( + "OpenRouter", + "https://openrouter.ai/api", + "anthropic/claude-sonnet-5", + "anthropic/claude-haiku-4.5", + "anthropic/claude-sonnet-5", + "anthropic/claude-opus-5", + ClaudeApiKeyField::AuthToken, + ), + ]; + + for (label, base_url, model, haiku, sonnet, opus, key_field) in cases { + let mut form = ProviderAddFormState::new(AppType::Claude); + form.apply_template(template_index_by_label(AppType::Claude, label), &[]); + assert_eq!(form.claude_api_key_field, key_field, "{label}"); + + let provider = form.to_provider_json_value(); + let env = &provider["settingsConfig"]["env"]; + assert_eq!(provider["name"], label, "{label}"); + assert_eq!(env["ANTHROPIC_BASE_URL"], base_url, "{label}"); + assert_eq!(env["ANTHROPIC_MODEL"], model, "{label}"); + assert_eq!(env["ANTHROPIC_DEFAULT_HAIKU_MODEL"], haiku, "{label}"); + assert_eq!(env["ANTHROPIC_DEFAULT_SONNET_MODEL"], sonnet, "{label}"); + assert_eq!(env["ANTHROPIC_DEFAULT_OPUS_MODEL"], opus, "{label}"); + assert!(provider.get("apiKeyUrl").is_none(), "{label}"); + assert!( + provider["meta"].get("partnerPromotionKey").is_none(), + "{label}" + ); + } + + let mut minimax = ProviderAddFormState::new(AppType::Claude); + minimax.apply_template(template_index_by_label(AppType::Claude, "MiniMax"), &[]); + let minimax = minimax.to_provider_json_value(); + assert_eq!( + minimax["settingsConfig"]["env"]["API_TIMEOUT_MS"], + "3000000" + ); + assert_eq!( + minimax["settingsConfig"]["env"]["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"], + 1 + ); +} + +#[test] +fn provider_add_form_codex_builtin_presets_match_upstream_fields() { + let cases = [ + ( + "DeepSeek", + "deepseek", + "https://api.deepseek.com", + "deepseek-v4-flash", + "openai_responses", + 2, + ), + ( + "Zhipu GLM", + "zhipu_glm", + "https://open.bigmodel.cn/api/coding/paas/v4", + "glm-5.2", + "openai_chat", + 1, + ), + ( + "Zhipu GLM en", + "zhipu_glm_en", + "https://api.z.ai/api/coding/paas/v4", + "glm-5.2", + "openai_chat", + 1, + ), + ( + "ModelScope", + "modelscope", + "https://api-inference.modelscope.cn/v1", + "ZhipuAI/GLM-5.2", + "openai_chat", + 1, + ), + ( + "MiniMax", + "minimax", + "https://api.minimaxi.com/v1", + "MiniMax-M3", + "openai_responses", + 1, + ), + ( + "Xiaomi MiMo", + "xiaomi_mimo", + "https://api.xiaomimimo.com/v1", + "mimo-v2.5-pro", + "openai_responses", + 2, + ), + ( + "Xiaomi MiMo Token Plan (China)", + "xiaomi_mimo_token_plan", + "https://token-plan-cn.xiaomimimo.com/v1", + "mimo-v2.5-pro", + "openai_responses", + 2, + ), + ( + "OpenCode Go", + "opencode_go", + "https://opencode.ai/zen/go/v1", + "glm-5.2", + "openai_chat", + 6, + ), + ( + "OpenRouter", + "openrouter", + "https://openrouter.ai/api/v1", + "gpt-5.6-sol", + "openai_responses", + 0, + ), + ]; + + for (label, provider_name, base_url, model, api_format, catalog_len) in cases { + let mut form = ProviderAddFormState::new(AppType::Codex); + form.apply_template(template_index_by_label(AppType::Codex, label), &[]); + let provider = form.to_provider_json_value(); + let config = provider["settingsConfig"]["config"] + .as_str() + .expect("Codex preset config should be text"); + let models = provider["settingsConfig"]["modelCatalog"]["models"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default(); + + assert_eq!(provider["name"], label, "{label}"); + assert_eq!(provider["meta"]["apiFormat"], api_format, "{label}"); + assert!( + config.contains(&format!("name = \"{provider_name}\"")), + "{label}" + ); + assert!( + config.contains(&format!("base_url = \"{base_url}\"")), + "{label}" + ); + assert!(config.contains(&format!("model = \"{model}\"")), "{label}"); + assert!(config.contains("wire_api = \"responses\""), "{label}"); + assert_eq!(models.len(), catalog_len, "{label}"); + assert!(provider.get("apiKeyUrl").is_none(), "{label}"); + assert!( + provider["meta"].get("partnerPromotionKey").is_none(), + "{label}" + ); + } + + let mut deepseek = ProviderAddFormState::new(AppType::Codex); + deepseek.apply_template(template_index_by_label(AppType::Codex, "DeepSeek"), &[]); + let deepseek = deepseek.to_provider_json_value(); + assert_eq!( + deepseek["settingsConfig"]["modelCatalog"]["models"][0]["reasoningLevels"], + json!(["low", "high", "max"]) + ); + assert_eq!( + deepseek["settingsConfig"]["modelCatalog"]["models"][0]["contextWindow"], + 1_048_576 + ); + + let mut opencode = ProviderAddFormState::new(AppType::Codex); + opencode.apply_template(template_index_by_label(AppType::Codex, "OpenCode Go"), &[]); + let opencode = opencode.to_provider_json_value(); + assert_eq!( + opencode["meta"]["codexChatReasoning"]["effortValueMode"], + "zen" + ); + assert_eq!( + opencode["settingsConfig"]["modelCatalog"]["models"][4]["reasoningLevels"], + json!(["low", "high", "max"]) + ); + + let mut minimax = ProviderAddFormState::new(AppType::Codex); + minimax.apply_template(template_index_by_label(AppType::Codex, "MiniMax"), &[]); + let minimax = minimax.to_provider_json_value(); + let minimax_model = &minimax["settingsConfig"]["modelCatalog"]["models"][0]; + assert_eq!(minimax_model["supportsParallelToolCalls"], true); + assert_eq!(minimax_model["inputModalities"], json!(["text", "image"])); + assert_eq!( + minimax_model["baseInstructions"], + "You are Codex, a coding agent based on MiniMax-M3. You and the user share the same workspace and collaborate to achieve the user's goals." + ); +} + #[test] fn cli_provider_templates_match_tui_serializer_output() { for (app_type, template, label) in [ @@ -435,8 +716,8 @@ fn provider_add_form_codex_deepseek_template_matches_upstream_preset_values() { let labels = ProviderAddFormState::new(AppType::Codex).template_labels(); assert_eq!( labels.last().copied(), - Some("DeepSeek"), - "DeepSeek should stay after all partner presets" + Some("OpenRouter"), + "migrated built-ins should stay after all partner presets in flat order" ); let fields = form.fields(); @@ -450,18 +731,8 @@ fn provider_add_form_codex_deepseek_template_matches_upstream_preset_values() { assert_eq!(provider["category"], "cn_official"); assert_eq!(provider["icon"], "deepseek"); assert_eq!(provider["iconColor"], "#1E88E5"); - assert_eq!(provider["meta"]["apiFormat"], "openai_chat"); - assert_eq!( - provider["meta"]["codexChatReasoning"], - json!({ - "supportsThinking": true, - "supportsEffort": true, - "thinkingParam": "thinking", - "effortParam": "reasoning_effort", - "effortValueMode": "deepseek", - "outputFormat": "reasoning_content", - }) - ); + assert_eq!(provider["meta"]["apiFormat"], "openai_responses"); + assert!(provider["meta"].get("codexChatReasoning").is_none()); let cfg = provider["settingsConfig"]["config"] .as_str() @@ -485,12 +756,14 @@ fn provider_add_form_codex_deepseek_template_matches_upstream_preset_values() { { "model": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash", - "contextWindow": 1000000, + "contextWindow": 1048576, + "reasoningLevels": ["low", "high", "max"], }, { "model": "deepseek-v4-pro", "displayName": "DeepSeek V4 Pro", - "contextWindow": 1000000, + "contextWindow": 1048576, + "reasoningLevels": ["low", "high", "max"], }, ], }) @@ -2067,7 +2340,9 @@ fn provider_edit_form_codex_conflict_preserves_local_routing_storage() { "contextWindow": 200000, "supportsParallelToolCalls": true, "inputModalities": ["text", "image"], - "baseInstructions": "Use native Responses." + "baseInstructions": "Use native Responses.", + "reasoningLevels": ["none", "high"], + "defaultReasoningLevel": "high" }, { "model": "gpt-y", @@ -3912,6 +4187,8 @@ fn codex_config_preview_builder_matches_save_when_enabled_catalog_is_empty() { supports_parallel_tool_calls: None, input_modalities: Vec::new(), base_instructions: String::new(), + reasoning_levels: Vec::new(), + default_reasoning_level: String::new(), }]; let preview_config = form.effective_codex_config_text(); diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index 731077f13..7775beb47 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -2843,6 +2843,8 @@ fn codex_model_catalog_renders_only_the_selected_large_collection_window() { supports_parallel_tool_calls: None, input_modalities: Vec::new(), base_instructions: String::new(), + reasoning_levels: Vec::new(), + default_reasoning_level: String::new(), }) .collect(); form.codex_model_catalog[selected].model = format!("SELECTED-CODEX-{}", "X".repeat(1_000_000)); diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 131c7503d..06a1d71db 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -45,6 +45,45 @@ impl CodexCatalogToolProfile { } } +/// Native Responses gateways used by the migrated presets that reject Codex's +/// hosted web-search tool. +const CODEX_WEB_SEARCH_REJECT_HOSTS: &[&str] = &["xiaomimimo.com", "minimaxi.com"]; +const CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES: &[&str] = &["mimo", "minimax"]; + +fn codex_top_level_model(config_text: &str) -> Option { + let doc = config_text.parse::().ok()?; + doc.get("model") + .and_then(toml::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn codex_native_gateway_rejects_web_search(config_text: &str) -> bool { + if let Some(base_url) = extract_codex_base_url(config_text) { + let base_url = base_url.to_ascii_lowercase(); + if CODEX_WEB_SEARCH_REJECT_HOSTS + .iter() + .any(|host| base_url.contains(host)) + { + return true; + } + } + + if let Some(model) = codex_top_level_model(config_text) { + let model = model.to_ascii_lowercase(); + let model = model.rsplit('/').next().unwrap_or(model.as_str()); + if CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES + .iter() + .any(|prefix| model.starts_with(prefix)) + { + return true; + } + } + + false +} + /// Reserved built-in provider IDs from OpenAI Codex's config/model-provider /// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy /// removed provider aliases. @@ -439,6 +478,88 @@ fn codex_catalog_input_modalities( modalities.iter().map(|item| (*item).to_string()).collect() } +/// Canonical reasoning effort levels Codex understands, with the same +/// descriptions the official gpt-5.5 template uses. `none` disables thinking. +const CODEX_REASONING_LEVEL_DESCRIPTIONS: &[(&str, &str)] = &[ + ("none", "Disable Thinking"), + ("minimal", "Minimal reasoning"), + ("low", "Fast responses with lighter reasoning"), + ( + "medium", + "Balances speed and reasoning depth for everyday tasks", + ), + ("high", "Greater reasoning depth for complex problems"), + ("xhigh", "Extra high reasoning depth for complex problems"), + ("max", "Maximum reasoning depth for the hardest problems"), + ("ultra", "Ultra reasoning depth"), +]; + +fn codex_reasoning_level_description(effort: &str) -> Option<&'static str> { + CODEX_REASONING_LEVEL_DESCRIPTIONS + .iter() + .find(|(candidate, _)| *candidate == effort) + .map(|(_, description)| *description) +} + +/// User-declared levels reduced to the canonical efforts Codex understands, +/// in canonical (lowest → highest) order regardless of declaration order. +/// Unknown efforts are dropped so a typo can never produce an entry Codex +/// would reject. +fn codex_canonical_efforts(levels: &[String]) -> Vec<&str> { + CODEX_REASONING_LEVEL_DESCRIPTIONS + .iter() + .filter(|(effort, _)| levels.iter().any(|candidate| candidate == effort)) + .map(|(effort, _)| *effort) + .collect() +} + +/// Build a `supported_reasoning_levels` array from user-declared effort values. +fn codex_supported_reasoning_levels(levels: &[String]) -> Value { + let entries: Vec = codex_canonical_efforts(levels) + .into_iter() + .map(|effort| { + let description = codex_reasoning_level_description(effort) + .expect("canonical effort always has a description"); + json!({ "effort": effort, "description": description }) + }) + .collect(); + json!(entries) +} + +/// Apply a per-model reasoning-level override onto a catalog entry. Returns +/// true when the override was applied (so callers can skip further work). +/// `template_default` is the base entry's `default_reasoning_level` used as the +/// fallback when the user did not declare one explicitly. +fn apply_codex_reasoning_level_override( + entry_obj: &mut serde_json::Map, + template_default: Option<&str>, + spec: &CodexCatalogModelSpec, +) -> bool { + let Some(levels) = spec.reasoning_levels.as_deref() else { + return false; + }; + let canonical = codex_canonical_efforts(levels); + if canonical.is_empty() { + return false; + } + + let supported = codex_supported_reasoning_levels(levels); + entry_obj.insert("supported_reasoning_levels".to_string(), supported); + + // Explicit value wins. Otherwise retain a still-supported template + // default, then fall back to the highest canonical supported level. + let default_level = spec + .default_reasoning_level + .as_deref() + .filter(|level| canonical.contains(level)) + .or_else(|| template_default.filter(|level| canonical.contains(level))) + .or_else(|| canonical.last().copied()); + if let Some(default_level) = default_level { + entry_obj.insert("default_reasoning_level".to_string(), json!(default_level)); + } + true +} + fn codex_catalog_model_entry( template: &Value, spec: &CodexCatalogModelSpec, @@ -507,6 +628,11 @@ fn codex_catalog_model_entry( } } + let template_default = template + .get("default_reasoning_level") + .and_then(Value::as_str); + apply_codex_reasoning_level_override(entry_obj, template_default, spec); + entry } @@ -527,6 +653,11 @@ struct CodexCatalogModelSpec { /// falls back to the template default when absent. Only consulted for /// `NativeResponses`. base_instructions: Option, + /// Per-row override for the generated catalog's reasoning capabilities. + /// Consulted for every profile. + reasoning_levels: Option>, + /// Preferred effort within `reasoning_levels`. + default_reasoning_level: Option, } fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec { @@ -594,6 +725,27 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec>() + }) + .filter(|levels| !levels.is_empty()); + let default_reasoning_level = model_config + .get("defaultReasoningLevel") + .or_else(|| model_config.get("default_reasoning_level")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|level| !level.is_empty()) + .map(str::to_string); specs.push(CodexCatalogModelSpec { model: model.to_string(), @@ -602,6 +754,8 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec true, + CodexCatalogToolProfile::NativeResponses => { + codex_native_gateway_rejects_web_search(&config_text) + } + CodexCatalogToolProfile::ProxyChat => false, + }; + let config_text = set_codex_web_search_field(&config_text, disable_web_search)?; Ok(PreparedCodexConfigText { config_text, model_catalog: Some(catalog), @@ -2024,6 +2182,50 @@ mod tests { assert!(parsed.get("web_search").is_none()); } + #[test] + fn selected_native_presets_disable_web_search_only_for_reject_gateways() { + let settings = json!({ + "modelCatalog": { "models": [{ "model": "test-model" }] } + }); + for config in [ + r#"model_provider = "custom" +model = "test-model" +[model_providers.custom] +base_url = "https://api.minimaxi.com/v1" +"#, + r#"model_provider = "custom" +model = "mimo-v2.5-pro" +[model_providers.custom] +base_url = "https://relay.example.com/v1" +"#, + ] { + let prepared = prepare_codex_config_text_with_model_catalog_payload( + &settings, + config, + CodexCatalogToolProfile::NativeResponses, + ) + .expect("prepare reject-gateway config"); + let parsed: toml::Value = toml::from_str(&prepared.config_text).unwrap(); + assert_eq!( + parsed.get("web_search").and_then(toml::Value::as_str), + Some("disabled") + ); + } + + let prepared = prepare_codex_config_text_with_model_catalog_payload( + &settings, + r#"model_provider = "custom" +model = "deepseek-v4-flash" +[model_providers.custom] +base_url = "https://api.deepseek.com" +"#, + CodexCatalogToolProfile::NativeResponses, + ) + .expect("prepare capable-gateway config"); + let parsed: toml::Value = toml::from_str(&prepared.config_text).unwrap(); + assert!(parsed.get("web_search").is_none()); + } + #[test] fn extract_base_url_prefers_active_provider_and_ignores_comments() { let config = r#"model_provider = 'current' @@ -2560,6 +2762,41 @@ experimental_bearer_token = "PROXY_MANAGED" ); } + #[test] + fn model_catalog_projects_per_model_reasoning_levels_for_every_profile() { + let settings = json!({ + "modelCatalog": { + "models": [{ + "model": "glm-5.2", + "reasoningLevels": ["none", "high"], + "defaultReasoningLevel": "none" + }] + } + }); + let specs = codex_catalog_model_specs(&settings, ""); + let template = json!({ + "default_reasoning_level": "high", + "supported_reasoning_levels": [] + }); + + for profile in [ + CodexCatalogToolProfile::ProxyChat, + CodexCatalogToolProfile::NativeResponses, + CodexCatalogToolProfile::Anthropic, + ] { + let catalog = codex_model_catalog_from_specs(&specs, &template, profile); + let entry = &catalog["models"][0]; + assert_eq!( + entry["supported_reasoning_levels"], + json!([ + { "effort": "none", "description": "Disable Thinking" }, + { "effort": "high", "description": "Greater reasoning depth for complex problems" } + ]) + ); + assert_eq!(entry["default_reasoning_level"], json!("none")); + } + } + #[test] fn native_responses_profile_suppresses_apply_patch_and_keeps_shell() { // Native (direct) /responses providers must NOT emit a freeform @@ -2642,6 +2879,8 @@ experimental_bearer_token = "PROXY_MANAGED" supports_parallel_tool_calls: None, input_modalities: None, base_instructions: None, + reasoning_levels: None, + default_reasoning_level: None, }, CodexCatalogModelSpec { model: "deepseek/deepseek-v4-pro".to_string(), @@ -2650,6 +2889,8 @@ experimental_bearer_token = "PROXY_MANAGED" supports_parallel_tool_calls: None, input_modalities: None, base_instructions: None, + reasoning_levels: None, + default_reasoning_level: None, }, CodexCatalogModelSpec { model: "glm-5.2v".to_string(), @@ -2658,6 +2899,8 @@ experimental_bearer_token = "PROXY_MANAGED" supports_parallel_tool_calls: None, input_modalities: None, base_instructions: None, + reasoning_levels: None, + default_reasoning_level: None, }, CodexCatalogModelSpec { model: "deepseek-v4-flash".to_string(), @@ -2666,6 +2909,8 @@ experimental_bearer_token = "PROXY_MANAGED" supports_parallel_tool_calls: None, input_modalities: Some(vec!["text".to_string(), "image".to_string()]), base_instructions: None, + reasoning_levels: None, + default_reasoning_level: None, }, CodexCatalogModelSpec { model: "custom-text-alias".to_string(), @@ -2674,6 +2919,8 @@ experimental_bearer_token = "PROXY_MANAGED" supports_parallel_tool_calls: None, input_modalities: Some(vec!["text".to_string()]), base_instructions: None, + reasoning_levels: None, + default_reasoning_level: None, }, ]; @@ -2744,6 +2991,8 @@ experimental_bearer_token = "PROXY_MANAGED" supports_parallel_tool_calls: None, input_modalities: None, base_instructions: None, + reasoning_levels: None, + default_reasoning_level: None, }]; // The native template lacks apply_patch_tool_type, so synthesize one to // prove ProxyChat leaves it intact (no native stripping). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 431c558c7..cbfb91bc4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,7 @@ mod prompt; mod prompt_files; mod provider; mod provider_defaults; +mod provider_preset_builtin; mod provider_preset_models; mod provider_preset_pi; mod provider_preset_sponsors; diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 6b71185a1..755ff3e67 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -470,6 +470,10 @@ pub struct CodexChatReasoningConfig { /// 保留该字段用于与上游持久化 shape 对齐。 #[serde(rename = "outputFormat", skip_serializing_if = "Option::is_none")] pub output_format: Option, + /// 运行时字段(不持久化):当前请求模型声明的合法 effort 档位。 + /// Zen 模式用它钳制或省略 `reasoning_effort`。 + #[serde(skip)] + pub effort_levels: Option>, } /// Local proxy request overrides applied after route/protocol transforms. diff --git a/src-tauri/src/provider_preset_builtin.rs b/src-tauri/src/provider_preset_builtin.rs new file mode 100644 index 000000000..c9fc5b76f --- /dev/null +++ b/src-tauri/src/provider_preset_builtin.rs @@ -0,0 +1,475 @@ +use crate::app_config::AppType; +use serde_json::{json, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BuiltinProviderPresetId { + DeepSeek, + ZhipuGlm, + ZhipuGlmEn, + ModelScope, + MiniMax, + XiaomiMimo, + XiaomiMimoTokenPlan, + OpenCodeGo, + OpenRouter, +} + +pub(crate) fn builtin_provider_preset_value( + app_type: &AppType, + preset: BuiltinProviderPresetId, +) -> Option { + match app_type { + AppType::Claude => Some(claude_provider_preset(preset)), + AppType::Codex => Some(codex_provider_preset(preset)), + _ => None, + } +} + +#[allow(clippy::too_many_arguments)] +fn claude_provider( + name: &str, + website_url: &str, + base_url: &str, + api_key_field: &str, + model: &str, + haiku_model: &str, + sonnet_model: &str, + opus_model: &str, + category: &str, + icon: &str, + icon_color: &str, + env_extra: Option, +) -> Value { + let mut env = json!({ + "ANTHROPIC_BASE_URL": base_url, + (api_key_field): "", + "ANTHROPIC_MODEL": model, + "ANTHROPIC_DEFAULT_HAIKU_MODEL": haiku_model, + "ANTHROPIC_DEFAULT_SONNET_MODEL": sonnet_model, + "ANTHROPIC_DEFAULT_OPUS_MODEL": opus_model, + }); + if let (Some(env), Some(extra)) = ( + env.as_object_mut(), + env_extra.and_then(|v| v.as_object().cloned()), + ) { + env.extend(extra); + } + + let mut provider = json!({ + "id": "", + "name": name, + "websiteUrl": website_url, + "category": category, + "icon": icon, + "iconColor": icon_color, + "settingsConfig": { "env": env }, + }); + if api_key_field == "ANTHROPIC_API_KEY" { + provider["meta"] = json!({ "apiKeyField": "ANTHROPIC_API_KEY" }); + } + provider +} + +fn claude_provider_preset(preset: BuiltinProviderPresetId) -> Value { + use BuiltinProviderPresetId::*; + + match preset { + DeepSeek => claude_provider( + "DeepSeek", + "https://platform.deepseek.com", + "https://api.deepseek.com/anthropic", + "ANTHROPIC_AUTH_TOKEN", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v4-pro", + "deepseek-v4-pro", + "cn_official", + "deepseek", + "#1E88E5", + None, + ), + ZhipuGlm => claude_provider( + "Zhipu GLM", + "https://open.bigmodel.cn", + "https://open.bigmodel.cn/api/anthropic", + "ANTHROPIC_AUTH_TOKEN", + "glm-5.1", + "glm-5.1", + "glm-5.1", + "glm-5.1", + "cn_official", + "zhipu", + "#0F62FE", + None, + ), + ZhipuGlmEn => claude_provider( + "Zhipu GLM en", + "https://z.ai", + "https://api.z.ai/api/anthropic", + "ANTHROPIC_AUTH_TOKEN", + "glm-5.1", + "glm-5.1", + "glm-5.1", + "glm-5.1", + "cn_official", + "zhipu", + "#0F62FE", + None, + ), + ModelScope => claude_provider( + "ModelScope", + "https://modelscope.cn", + "https://api-inference.modelscope.cn", + "ANTHROPIC_AUTH_TOKEN", + "ZhipuAI/GLM-5.2", + "ZhipuAI/GLM-5.2", + "ZhipuAI/GLM-5.2", + "ZhipuAI/GLM-5.2", + "aggregator", + "modelscope", + "#624AFF", + None, + ), + MiniMax => claude_provider( + "MiniMax", + "https://platform.minimaxi.com", + "https://api.minimaxi.com/anthropic", + "ANTHROPIC_AUTH_TOKEN", + "MiniMax-M2.7", + "MiniMax-M2.7", + "MiniMax-M2.7", + "MiniMax-M2.7", + "cn_official", + "minimax", + "#FF6B6B", + Some(json!({ + "API_TIMEOUT_MS": "3000000", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1, + })), + ), + XiaomiMimo => claude_provider( + "Xiaomi MiMo", + "https://platform.xiaomimimo.com", + "https://api.xiaomimimo.com/anthropic", + "ANTHROPIC_AUTH_TOKEN", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + "cn_official", + "xiaomimimo", + "#000000", + None, + ), + XiaomiMimoTokenPlan => claude_provider( + "Xiaomi MiMo Token Plan (China)", + "https://platform.xiaomimimo.com/#/token-plan", + "https://token-plan-cn.xiaomimimo.com/anthropic", + "ANTHROPIC_AUTH_TOKEN", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + "cn_official", + "xiaomimimo", + "#000000", + None, + ), + OpenCodeGo => claude_provider( + "OpenCode Go", + "https://opencode.ai/go", + "https://opencode.ai/zen/go", + "ANTHROPIC_API_KEY", + "deepseek-v4-flash", + "deepseek-v4-flash", + "deepseek-v4-flash", + "deepseek-v4-flash", + "third_party", + "opencode", + "#211E1E", + None, + ), + OpenRouter => claude_provider( + "OpenRouter", + "https://openrouter.ai", + "https://openrouter.ai/api", + "ANTHROPIC_AUTH_TOKEN", + "anthropic/claude-sonnet-5", + "anthropic/claude-haiku-4.5", + "anthropic/claude-sonnet-5", + "anthropic/claude-opus-5", + "aggregator", + "openrouter", + "#6566F1", + None, + ), + } +} + +fn codex_config(provider_name: &str, base_url: &str, model: &str) -> String { + format!( + r#"model_provider = "custom" +model = "{model}" +model_reasoning_effort = "high" +disable_response_storage = true + +[model_providers.custom] +name = "{provider_name}" +base_url = "{base_url}" +wire_api = "responses" +requires_openai_auth = true"# + ) +} + +#[allow(clippy::too_many_arguments)] +fn codex_provider( + name: &str, + website_url: &str, + provider_name: &str, + base_url: &str, + model: &str, + category: &str, + icon: &str, + icon_color: &str, + api_format: Option<&str>, + model_catalog: Vec, + reasoning: Option, +) -> Value { + let mut meta = serde_json::Map::new(); + if let Some(api_format) = api_format { + meta.insert("apiFormat".to_string(), json!(api_format)); + } + if let Some(reasoning) = reasoning { + meta.insert("codexChatReasoning".to_string(), reasoning); + } + + let mut settings = json!({ + "config": codex_config(provider_name, base_url, model), + }); + if !model_catalog.is_empty() { + settings["modelCatalog"] = json!({ "models": model_catalog }); + } + + let mut provider = json!({ + "id": "", + "name": name, + "websiteUrl": website_url, + "category": category, + "icon": icon, + "iconColor": icon_color, + "settingsConfig": settings, + }); + if !meta.is_empty() { + provider["meta"] = Value::Object(meta); + } + provider +} + +fn chat_reasoning( + thinking_param: &str, + supports_effort: bool, + effort_value_mode: Option<&str>, +) -> Value { + let mut reasoning = json!({ + "supportsThinking": true, + "supportsEffort": supports_effort, + "thinkingParam": thinking_param, + "effortParam": if supports_effort { "reasoning_effort" } else { "none" }, + "outputFormat": "reasoning_content", + }); + if let Some(mode) = effort_value_mode { + reasoning["effortValueMode"] = json!(mode); + } + reasoning +} + +fn mimo_catalog() -> Vec { + let base_instructions = "You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024."; + vec![ + json!({ + "model": "mimo-v2.5-pro", + "displayName": "MiMo V2.5 Pro", + "contextWindow": 1_048_576, + "inputModalities": ["text"], + "reasoningLevels": ["none", "high"], + "baseInstructions": base_instructions, + }), + json!({ + "model": "mimo-v2.5", + "displayName": "MiMo V2.5", + "contextWindow": 1_048_576, + "inputModalities": ["text", "image"], + "reasoningLevels": ["none", "high"], + "baseInstructions": base_instructions, + }), + ] +} + +fn codex_provider_preset(preset: BuiltinProviderPresetId) -> Value { + use BuiltinProviderPresetId::*; + + match preset { + DeepSeek => codex_provider( + "DeepSeek", + "https://platform.deepseek.com", + "deepseek", + "https://api.deepseek.com", + "deepseek-v4-flash", + "cn_official", + "deepseek", + "#1E88E5", + Some("openai_responses"), + vec![ + json!({ + "model": "deepseek-v4-flash", + "displayName": "DeepSeek V4 Flash", + "contextWindow": 1_048_576, + "reasoningLevels": ["low", "high", "max"], + }), + json!({ + "model": "deepseek-v4-pro", + "displayName": "DeepSeek V4 Pro", + "contextWindow": 1_048_576, + "reasoningLevels": ["low", "high", "max"], + }), + ], + None, + ), + ZhipuGlm | ZhipuGlmEn => { + let (name, website, provider_name, base_url) = if preset == ZhipuGlm { + ( + "Zhipu GLM", + "https://open.bigmodel.cn", + "zhipu_glm", + "https://open.bigmodel.cn/api/coding/paas/v4", + ) + } else { + ( + "Zhipu GLM en", + "https://z.ai", + "zhipu_glm_en", + "https://api.z.ai/api/coding/paas/v4", + ) + }; + codex_provider( + name, + website, + provider_name, + base_url, + "glm-5.2", + "cn_official", + "zhipu", + "#0F62FE", + Some("openai_chat"), + vec![json!({ + "model": "glm-5.2", + "displayName": "GLM-5.2", + "contextWindow": 200_000, + "reasoningLevels": ["none", "high"], + })], + Some(chat_reasoning("thinking", false, None)), + ) + } + ModelScope => codex_provider( + "ModelScope", + "https://modelscope.cn", + "modelscope", + "https://api-inference.modelscope.cn/v1", + "ZhipuAI/GLM-5.2", + "aggregator", + "modelscope", + "#624AFF", + Some("openai_chat"), + vec![json!({ + "model": "ZhipuAI/GLM-5.2", + "displayName": "ZhipuAI / GLM-5.2", + "contextWindow": 200_000, + })], + Some(chat_reasoning("enable_thinking", false, None)), + ), + MiniMax => codex_provider( + "MiniMax", + "https://platform.minimaxi.com", + "minimax", + "https://api.minimaxi.com/v1", + "MiniMax-M3", + "cn_official", + "minimax", + "#FF6B6B", + Some("openai_responses"), + vec![json!({ + "model": "MiniMax-M3", + "displayName": "MiniMax-M3", + "contextWindow": 1_000_000, + "reasoningLevels": ["none", "high"], + "supportsParallelToolCalls": true, + "inputModalities": ["text", "image"], + "baseInstructions": "You are Codex, a coding agent based on MiniMax-M3. You and the user share the same workspace and collaborate to achieve the user's goals.", + })], + None, + ), + XiaomiMimo | XiaomiMimoTokenPlan => { + let (name, website, provider_name, base_url) = if preset == XiaomiMimo { + ( + "Xiaomi MiMo", + "https://platform.xiaomimimo.com", + "xiaomi_mimo", + "https://api.xiaomimimo.com/v1", + ) + } else { + ( + "Xiaomi MiMo Token Plan (China)", + "https://platform.xiaomimimo.com/#/token-plan", + "xiaomi_mimo_token_plan", + "https://token-plan-cn.xiaomimimo.com/v1", + ) + }; + codex_provider( + name, + website, + provider_name, + base_url, + "mimo-v2.5-pro", + "cn_official", + "xiaomimimo", + "#000000", + Some("openai_responses"), + mimo_catalog(), + None, + ) + } + OpenCodeGo => codex_provider( + "OpenCode Go", + "https://opencode.ai/go", + "opencode_go", + "https://opencode.ai/zen/go/v1", + "glm-5.2", + "third_party", + "opencode", + "#211E1E", + Some("openai_chat"), + vec![ + json!({ "model": "glm-5.2", "displayName": "GLM 5.2", "contextWindow": 204_800, "reasoningLevels": ["high", "max"] }), + json!({ "model": "glm-5.1", "displayName": "GLM 5.1", "contextWindow": 204_800 }), + json!({ "model": "kimi-k2.7-code", "displayName": "Kimi K2.7 Code", "contextWindow": 262_144 }), + json!({ "model": "deepseek-v4-pro", "displayName": "DeepSeek V4 Pro", "contextWindow": 1_048_576, "reasoningLevels": ["high", "max"] }), + json!({ "model": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash", "contextWindow": 1_048_576, "reasoningLevels": ["low", "high", "max"] }), + json!({ "model": "mimo-v2.5-pro", "displayName": "MiMo V2.5 Pro", "contextWindow": 1_048_576 }), + ], + Some(chat_reasoning("none", true, Some("zen"))), + ), + OpenRouter => codex_provider( + "OpenRouter", + "https://openrouter.ai", + "openrouter", + "https://openrouter.ai/api/v1", + "gpt-5.6-sol", + "aggregator", + "openrouter", + "#6566F1", + None, + Vec::new(), + None, + ), + } +} diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index f4f52ca1a..25e21b04e 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -261,15 +261,48 @@ pub fn resolve_codex_chat_reasoning_config( provider: &Provider, body: &JsonValue, ) -> Option { - if let Some(config) = provider + let mut config = if let Some(config) = provider .meta .as_ref() .and_then(|meta| meta.codex_chat_reasoning.clone()) { - return Some(normalize_codex_chat_reasoning_config(config)); + normalize_codex_chat_reasoning_config(config) + } else { + infer_codex_chat_reasoning_config(provider, body)? + }; + + if config.effort_value_mode.as_deref() == Some("zen") { + config.effort_levels = zen_catalog_effort_levels(provider, body); } - infer_codex_chat_reasoning_config(provider, body) + Some(config) +} + +fn zen_catalog_effort_levels(provider: &Provider, body: &JsonValue) -> Option> { + let model = body.get("model")?.as_str()?.trim(); + if model.is_empty() { + return None; + } + let entries = provider + .settings_config + .get("modelCatalog")? + .get("models")? + .as_array()?; + let entry = entries.iter().find(|entry| { + entry + .get("model") + .and_then(JsonValue::as_str) + .is_some_and(|name| name.eq_ignore_ascii_case(model)) + })?; + let levels = entry + .get("reasoningLevels") + .or_else(|| entry.get("reasoning_levels"))? + .as_array()? + .iter() + .filter_map(JsonValue::as_str) + .map(str::to_string) + .collect::>(); + (!levels.is_empty()).then_some(levels) } fn normalize_codex_chat_reasoning_config( @@ -311,6 +344,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("reasoning_effort".to_string()), effort_value_mode: Some("deepseek".to_string()), output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -322,6 +356,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("reasoning_effort".to_string()), effort_value_mode: Some("low_high".to_string()), output_format: Some("reasoning".to_string()), + effort_levels: None, }); } @@ -333,6 +368,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -344,6 +380,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -355,6 +392,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -366,6 +404,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_details".to_string()), + effort_levels: None, }); } @@ -377,6 +416,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -397,6 +437,7 @@ fn infer_aggregator_platform_config( effort_param: Some("reasoning.effort".to_string()), effort_value_mode: Some("openrouter".to_string()), output_format: Some("auto".to_string()), + effort_levels: None, }); } @@ -408,6 +449,7 @@ fn infer_aggregator_platform_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -1032,6 +1074,7 @@ wire_api = "chat" effort_param: Some("reasoning.effort".to_string()), effort_value_mode: Some("openrouter".to_string()), output_format: Some("auto".to_string()), + effort_levels: None, }), ..Default::default() }); @@ -1047,6 +1090,41 @@ wire_api = "chat" assert_eq!(config.effort_value_mode.as_deref(), Some("openrouter")); } + #[test] + fn opencode_go_preset_attaches_reasoning_levels_for_the_requested_model() { + let value = crate::provider_preset_builtin::builtin_provider_preset_value( + &crate::app_config::AppType::Codex, + crate::provider_preset_builtin::BuiltinProviderPresetId::OpenCodeGo, + ) + .expect("OpenCode Go preset"); + let provider: Provider = serde_json::from_value(value).expect("valid provider preset"); + + let glm = resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "GLM-5.2" })) + .expect("GLM reasoning config"); + assert_eq!( + glm.effort_levels, + Some(vec!["high".to_string(), "max".to_string()]) + ); + + let flash = resolve_codex_chat_reasoning_config( + &provider, + &json!({ "model": "deepseek-v4-flash" }), + ) + .expect("DeepSeek reasoning config"); + assert_eq!( + flash.effort_levels, + Some(vec![ + "low".to_string(), + "high".to_string(), + "max".to_string() + ]) + ); + + let toggle = resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "glm-5.1" })) + .expect("toggle-only reasoning config"); + assert!(toggle.effort_levels.is_none()); + } + #[test] fn test_resolve_codex_chat_reasoning_openrouter_platform_overrides_model() { let provider = create_provider(json!({ diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index a3666d110..bded1fb6a 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -423,7 +423,11 @@ fn apply_reasoning_options( let Some(effort) = body.pointer("/reasoning/effort").and_then(|v| v.as_str()) else { return; }; - let Some(mapped) = map_reasoning_effort(effort, config.effort_value_mode.as_deref()) else { + let Some(mapped) = map_reasoning_effort( + effort, + config.effort_value_mode.as_deref(), + config.effort_levels.as_deref(), + ) else { return; }; @@ -454,7 +458,11 @@ fn reasoning_requested(body: &Value) -> Option { body.get("reasoning").map(|value| !value.is_null()) } -fn map_reasoning_effort(effort: &str, mode: Option<&str>) -> Option<&'static str> { +fn map_reasoning_effort<'a>( + effort: &str, + mode: Option<&str>, + effort_levels: Option<&'a [String]>, +) -> Option<&'a str> { let effort = effort.trim().to_ascii_lowercase(); if matches!(effort.as_str(), "none" | "off" | "disabled") { return None; @@ -481,6 +489,24 @@ fn map_reasoning_effort(effort: &str, mode: Option<&str>) -> Option<&'static str "minimal" => Some("minimal"), _ => None, }, + "zen" => { + let levels = effort_levels?; + let requested = zen_effort_rank(&effort)?; + levels + .iter() + .filter_map(|level| zen_effort_rank(level).map(|rank| (rank, level.as_str()))) + .filter(|(rank, _)| *rank >= requested) + .min_by_key(|(rank, _)| *rank) + .or_else(|| { + levels + .iter() + .filter_map(|level| { + zen_effort_rank(level).map(|rank| (rank, level.as_str())) + }) + .max_by_key(|(rank, _)| *rank) + }) + .map(|(_, level)| level) + } _ => match effort.as_str() { "minimal" => Some("minimal"), "low" => Some("low"), @@ -493,6 +519,19 @@ fn map_reasoning_effort(effort: &str, mode: Option<&str>) -> Option<&'static str } } +fn zen_effort_rank(effort: &str) -> Option { + match effort.trim().to_ascii_lowercase().as_str() { + "minimal" => Some(0), + "low" => Some(1), + "medium" => Some(2), + "high" => Some(3), + "xhigh" => Some(4), + "max" => Some(5), + "ultra" => Some(6), + _ => None, + } +} + /// MiniMax 严格要求 messages 中只能首条出现 `role=system`, /// 否则返回 `invalid params, chat content has invalid message role: system (2013)`。 /// 把所有 system 消息合并到首位,避免中间 system(如 Codex 的 `developer` 指令)触发该约束; @@ -2251,6 +2290,7 @@ mod tests { effort_param: Some("reasoning_effort".to_string()), effort_value_mode: Some("deepseek".to_string()), output_format: Some("reasoning_content".to_string()), + effort_levels: None, }; let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); @@ -2259,6 +2299,31 @@ mod tests { assert_eq!(result["reasoning_effort"], "max"); } + #[test] + fn responses_request_to_chat_clamps_or_omits_zen_effort_by_model() { + let mut config = CodexChatReasoningConfig { + supports_thinking: Some(true), + supports_effort: Some(true), + thinking_param: Some("none".to_string()), + effort_param: Some("reasoning_effort".to_string()), + effort_value_mode: Some("zen".to_string()), + output_format: Some("reasoning_content".to_string()), + effort_levels: Some(vec!["high".to_string(), "max".to_string()]), + }; + let input = json!({ + "model": "glm-5.2", + "input": "hello", + "reasoning": {"effort": "medium"} + }); + let result = + responses_to_chat_completions_with_reasoning(input.clone(), Some(&config)).unwrap(); + assert_eq!(result["reasoning_effort"], "high"); + + config.effort_levels = None; + let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); + assert!(result.get("reasoning_effort").is_none()); + } + #[test] fn responses_request_to_chat_maps_openrouter_to_native_reasoning_object() { // OpenRouter 平台形态:原生 reasoning:{effort} 对象 + "openrouter" 值映射 @@ -2270,6 +2335,7 @@ mod tests { effort_param: Some("reasoning.effort".to_string()), effort_value_mode: Some("openrouter".to_string()), output_format: Some("auto".to_string()), + effort_levels: None, }; // max 不在 OpenRouter 枚举内(见 openclaw#77350),必须钳成 xhigh, @@ -2311,6 +2377,7 @@ mod tests { effort_param: Some("reasoning.effort".to_string()), effort_value_mode: Some("openrouter".to_string()), output_format: Some("auto".to_string()), + effort_levels: None, }; let input = json!({ @@ -2338,6 +2405,7 @@ mod tests { effort_param: Some("reasoning_effort".to_string()), effort_value_mode: Some("deepseek".to_string()), output_format: Some("reasoning_content".to_string()), + effort_levels: None, }; let input = json!({ @@ -2367,6 +2435,7 @@ mod tests { effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }; let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); @@ -2389,6 +2458,7 @@ mod tests { effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }; let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); diff --git a/src-tauri/tests/provider_add_noninteractive.rs b/src-tauri/tests/provider_add_noninteractive.rs index 008b5e13a..5b904ac13 100644 --- a/src-tauri/tests/provider_add_noninteractive.rs +++ b/src-tauri/tests/provider_add_noninteractive.rs @@ -818,15 +818,15 @@ fn add_deepseek_template_preserves_api_format_without_override() { ) .expect("deepseek template add should succeed"); - // The template seeds openai_chat; omitting --api-format must not reset it - // to the openai_responses default. + // The current upstream template uses the Responses API; omitting + // --api-format must preserve that prefilled value. let provider = saved_provider(AppType::Codex, "deepseek"); assert_eq!( provider .meta .as_ref() .and_then(|meta| meta.api_format.as_deref()), - Some("openai_chat") + Some("openai_responses") ); }