diff --git a/.gitignore b/.gitignore index 525d4a7b4..72d55fd7f 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ copilot-api .history CODEBUDDY.md mainWindow.js +openspec +.*/ \ No newline at end of file diff --git a/package.json b/package.json index bae1fbe70..aad5ab06c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-yaml": "^6.1.3", "@codemirror/lint": "^6.8.5", "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", @@ -89,6 +90,7 @@ "smol-toml": "^1.4.2", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", + "yaml": "^2.9.1", "zod": "^4.1.12" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f51d0c889..605ccdc28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@codemirror/lang-markdown': specifier: ^6.5.0 version: 6.5.0 + '@codemirror/lang-yaml': + specifier: ^6.1.3 + version: 6.1.3 '@codemirror/lint': specifier: ^6.8.5 version: 6.8.5 @@ -149,6 +152,9 @@ importers: tailwind-merge: specifier: ^3.3.1 version: 3.3.1 + yaml: + specifier: ^2.9.1 + version: 2.9.1 zod: specifier: ^4.1.12 version: 4.1.12 @@ -176,7 +182,7 @@ importers: version: 18.3.7(@types/react@18.3.23) '@vitejs/plugin-react': specifier: ^4.2.0 - version: 4.7.0(vite@7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1)) + version: 4.7.0(vite@7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.9.1)) autoprefixer: specifier: ^10.4.20 version: 10.4.22(postcss@8.5.6) @@ -200,16 +206,16 @@ importers: version: 3.6.2 tailwindcss: specifier: ^3.4.17 - version: 3.4.18 + version: 3.4.18(yaml@2.9.1) tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.18) + version: 1.0.7(tailwindcss@3.4.18(yaml@2.9.1)) typescript: specifier: ^5.3.0 version: 5.9.2 vite: specifier: ^7.3.0 - version: 7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1) + version: 7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.9.1) vitest: specifier: ^2.0.5 version: 2.1.9(@types/node@20.19.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2)) @@ -369,6 +375,9 @@ packages: '@codemirror/lang-markdown@6.5.0': resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + '@codemirror/lang-yaml@6.1.3': + resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==} + '@codemirror/language@6.11.3': resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==} @@ -826,6 +835,9 @@ packages: '@lezer/markdown@1.6.0': resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==} + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -1358,56 +1370,67 @@ packages: resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.46.2': resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.46.2': resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.46.2': resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.46.2': resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.46.2': resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.46.2': resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.46.2': resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.46.2': resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.46.2': resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.46.2': resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.46.2': resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==} @@ -1473,30 +1496,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.10.1': resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.10.1': resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.10.1': resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.10.1': resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} @@ -2255,24 +2283,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -3050,6 +3082,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -3310,6 +3347,16 @@ snapshots: '@lezer/common': 1.2.3 '@lezer/markdown': 1.6.0 + '@codemirror/lang-yaml@6.1.3': + dependencies: + '@codemirror/autocomplete': 6.18.7 + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + '@lezer/yaml': 1.0.4 + '@codemirror/language@6.11.3': dependencies: '@codemirror/state': 6.5.2 @@ -3646,6 +3693,12 @@ snapshots: '@lezer/common': 1.2.3 '@lezer/highlight': 1.2.1 + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + '@marijn/find-cluster-break@1.0.2': {} '@mswjs/interceptors@0.40.0': @@ -4411,7 +4464,7 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@vitejs/plugin-react@4.7.0(vite@7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1))': + '@vitejs/plugin-react@4.7.0(vite@7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.9.1))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) @@ -4419,7 +4472,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1) + vite: 7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.9.1) transitivePeerDependencies: - supports-color @@ -5236,12 +5289,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.9.1): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 + yaml: 2.9.1 postcss-nested@6.2.0(postcss@8.5.6): dependencies: @@ -5493,11 +5547,11 @@ snapshots: tailwind-merge@3.3.1: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.18): + tailwindcss-animate@1.0.7(tailwindcss@3.4.18(yaml@2.9.1)): dependencies: - tailwindcss: 3.4.18 + tailwindcss: 3.4.18(yaml@2.9.1) - tailwindcss@3.4.18: + tailwindcss@3.4.18(yaml@2.9.1): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -5516,7 +5570,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.9.1) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -5670,7 +5724,7 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.1 - vite@7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1): + vite@7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.9.1): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -5683,6 +5737,7 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 lightningcss: 1.30.1 + yaml: 2.9.1 vitest@2.1.9(@types/node@20.19.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2)): dependencies: @@ -5775,6 +5830,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.9.1: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 8826a5c90..27f393bcb 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -27,6 +27,8 @@ pub struct McpApps { pub hermes: bool, #[serde(default)] pub pi: bool, + #[serde(default)] + pub ohmypi: bool, } impl McpApps { @@ -43,6 +45,7 @@ impl McpApps { AppType::Mcode => self.mcode, AppType::Kimi => self.kimi, AppType::Pi => self.pi, + AppType::OhMyPi => self.ohmypi, AppType::ClaudeDesktop => false, AppType::Agy => self.agy, } @@ -61,8 +64,9 @@ impl McpApps { AppType::Mcode => self.mcode = enabled, AppType::Kimi => self.kimi = enabled, AppType::Pi => self.pi = enabled, - AppType::ClaudeDesktop => {} + AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here AppType::Agy => self.agy = enabled, + AppType::OhMyPi => self.ohmypi = enabled, } } @@ -99,6 +103,9 @@ impl McpApps { if self.pi { apps.push(AppType::Pi); } + if self.ohmypi { + apps.push(AppType::OhMyPi); + } apps } @@ -114,6 +121,7 @@ impl McpApps { && !self.kimi && !self.mcode && !self.pi + && !self.ohmypi } } @@ -140,6 +148,8 @@ pub struct SkillApps { pub hermes: bool, #[serde(default)] pub pi: bool, + #[serde(default)] + pub ohmypi: bool, } impl SkillApps { @@ -155,8 +165,9 @@ impl SkillApps { AppType::Pi => self.pi, AppType::Mcode => self.mcode, AppType::Kimi => self.kimi, - AppType::OpenClaw => false, + AppType::OpenClaw => false, // OpenClaw doesn't support Skills AppType::Agy => self.agy, + AppType::OhMyPi => self.ohmypi, AppType::ClaudeDesktop => false, } } @@ -173,8 +184,9 @@ impl SkillApps { AppType::Pi => self.pi = enabled, AppType::Mcode => self.mcode = enabled, AppType::Kimi => self.kimi = enabled, - AppType::OpenClaw => {} + AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore AppType::Agy => self.agy = enabled, + AppType::OhMyPi => self.ohmypi = enabled, AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync } } @@ -212,6 +224,9 @@ impl SkillApps { if self.pi { apps.push(AppType::Pi); } + if self.ohmypi { + apps.push(AppType::OhMyPi); + } apps } @@ -227,6 +242,7 @@ impl SkillApps { && !self.kimi && !self.mcode && !self.pi + && !self.ohmypi } /// 仅启用指定应用(其他应用设为禁用) @@ -449,6 +465,7 @@ pub enum AppType { Mcode, Kimi, Agy, + OhMyPi, } impl AppType { @@ -466,6 +483,7 @@ impl AppType { AppType::Mcode => "mcode", AppType::Kimi => "kimi", AppType::Agy => "agy", + AppType::OhMyPi => "ohmypi", } } @@ -483,6 +501,7 @@ impl AppType { | AppType::Pi | AppType::Mcode | AppType::Kimi + | AppType::OhMyPi ) } @@ -515,6 +534,7 @@ impl AppType { AppType::Mcode, AppType::Kimi, AppType::Agy, + AppType::OhMyPi, ] .into_iter() } @@ -538,10 +558,11 @@ impl FromStr for AppType { "mcode" => Ok(AppType::Mcode), "kimi" | "kimi-code" => Ok(AppType::Kimi), "agy" | "antigravity" | "antigravity-cli" => Ok(AppType::Agy), + "ohmypi" | "oh-my-pi" | "oh_my_pi" | "omp" => Ok(AppType::OhMyPi), other => Err(AppError::localized( "unsupported_app", - format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi, mcode, kimi, agy。"), - format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi, mcode, kimi, agy."), + format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi, mcode, kimi, agy, ohmypi。"), + format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi, mcode, kimi, agy, ohmypi."), )), } } @@ -581,7 +602,7 @@ impl CommonConfigSnippets { AppType::OpenCode => self.opencode.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), AppType::Hermes => self.hermes.as_ref(), - AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy => None, + AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy | AppType::OhMyPi => None, } } @@ -596,7 +617,7 @@ impl CommonConfigSnippets { AppType::OpenCode => self.opencode = snippet, AppType::OpenClaw => self.openclaw = snippet, AppType::Hermes => self.hermes = snippet, - AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy => {} + AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy | AppType::OhMyPi => {} } } } @@ -923,7 +944,10 @@ impl MultiAppConfig { AppType::Hermes => &mut config.prompts.hermes.prompts, // Pi was added after prompts moved to SQLite. Keeping it out of // this legacy config avoids a second, unused prompt state. - AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy => return Ok(false), + // OhMyPi likewise stores prompts in its native agent config. + AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy | AppType::OhMyPi => { + return Ok(false) + } }; prompts.insert(id, prompt); @@ -967,7 +991,9 @@ impl MultiAppConfig { AppType::OpenCode => &self.mcp.opencode.servers, AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip - AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy => continue, // Pi didn't exist in v3.6.x, skip + AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy | AppType::OhMyPi => { + continue + } // Pi didn't exist in v3.6.x, skip }; for (id, entry) in old_servers { @@ -1100,6 +1126,16 @@ mod tests { assert_eq!(AppType::ClaudeDesktop.as_str(), "claude-desktop"); } + #[test] + fn app_type_parses_ohmypi_aliases() { + assert_eq!("ohmypi".parse::().unwrap(), AppType::OhMyPi); + assert_eq!("oh-my-pi".parse::().unwrap(), AppType::OhMyPi); + assert_eq!("oh_my_pi".parse::().unwrap(), AppType::OhMyPi); + assert_eq!("omp".parse::().unwrap(), AppType::OhMyPi); + assert_eq!("OMP".parse::().unwrap(), AppType::OhMyPi); + assert_eq!(AppType::OhMyPi.as_str(), "ohmypi"); + } + struct TempHome { #[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期 dir: TempDir, diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 1e196e2f3..bfec16e69 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -170,6 +170,18 @@ pub async fn get_config_status( path: dir.to_string_lossy().into_owned(), }) } + AppType::OhMyPi => { + let config_path = + crate::ohmypi_config::get_ohmypi_models_path().map_err(|e| e.to_string())?; + let path = crate::ohmypi_config::get_ohmypi_agent_dir() + .map_err(|e| e.to_string())? + .to_string_lossy() + .to_string(); + Ok(ConfigStatus { + exists: config_path.exists(), + path, + }) + } } } @@ -193,6 +205,9 @@ pub async fn get_config_dir(app: String) -> Result { AppType::Hermes => crate::hermes_config::get_hermes_dir(), AppType::Pi => crate::pi_config::get_pi_agent_dir().map_err(|e| e.to_string())?, AppType::Kimi => crate::kimi_config::data_dir(), + AppType::OhMyPi => { + crate::ohmypi_config::get_ohmypi_agent_dir().map_err(|e| e.to_string())? + } AppType::Mcode => crate::mcode_config::config_path() .parent() .unwrap() @@ -218,6 +233,9 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result crate::hermes_config::get_hermes_dir(), AppType::Pi => crate::pi_config::get_pi_agent_dir().map_err(|e| e.to_string())?, AppType::Kimi => crate::kimi_config::data_dir(), + AppType::OhMyPi => { + crate::ohmypi_config::get_ohmypi_agent_dir().map_err(|e| e.to_string())? + } AppType::Mcode => crate::mcode_config::config_path() .parent() .unwrap() diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index be54fcfe8..d101c8966 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -170,9 +170,9 @@ pub struct ToolVersion { update_blocked_by_node: Option, } -const VALID_TOOLS: [&str; 11] = [ +const VALID_TOOLS: [&str; 12] = [ "claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes", "pi", "mcode", "kimi", - "agy", + "agy", "omp", ]; #[derive(Debug, Clone, serde::Deserialize)] @@ -626,6 +626,7 @@ fn tool_display_name(tool: &str) -> &'static str { "openclaw" => "OpenClaw", "hermes" => "Hermes", "pi" => "Pi", + "omp" => "Oh My Pi", "mcode" => "MiniMax Code", "kimi" => "Kimi Code", "agy" => "Antigravity CLI", @@ -877,6 +878,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> { "mcode" => Some( "npm i -g @minimax-ai/code@latest --ignore-scripts=false --include=optional \"--allow-scripts=@minimax-ai/code,better-sqlite3\"", ), + "omp" => Some("bun install -g @oh-my-pi/pi-coding-agent@latest"), _ => None, } } @@ -1283,6 +1285,7 @@ async fn get_single_tool_version_impl( "mcode" => fetch_npm_latest_for_tool(&client, "@minimax-ai/code", tool, local).await, "kimi" => fetch_npm_latest_for_tool(&client, "@moonshot-ai/kimi-code", tool, local).await, "agy" => fetch_agy_latest_version(&client).await, + "omp" => fetch_npm_latest_for_tool(&client, "@oh-my-pi/pi-coding-agent", tool, local).await, _ => None, }; @@ -2506,6 +2509,16 @@ fn build_tool_search_paths(tool: &str) -> Vec { push_unique_path(&mut search_paths, path); } } + if tool == "omp" { + // omp ships only as a Bun global package (`bun install -g + // @oh-my-pi/pi-coding-agent`), and Bun's global bin is not on the PATH + // a GUI process inherits — same reason OpenCode scans it. Without this, + // version probing, install enumeration and anchored updates report the + // tool as not installed whenever the launcher is only reachable there. + if !home.as_os_str().is_empty() { + push_unique_path(&mut search_paths, home.join(".bun").join("bin")); + } + } if !home.as_os_str().is_empty() { push_unique_path(&mut search_paths, home.join(".local/bin")); push_unique_path(&mut search_paths, home.join(".npm-global/bin")); @@ -3296,6 +3309,7 @@ fn npm_package_for(tool: &str) -> Option<&'static str> { "pi" => Some("@earendil-works/pi-coding-agent"), "mcode" => Some("@minimax-ai/code"), "kimi" => Some("@moonshot-ai/kimi-code"), + "omp" => Some("@oh-my-pi/pi-coding-agent"), _ => None, } } @@ -4733,6 +4747,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option { "openclaw" => crate::settings::get_openclaw_override_dir(), "hermes" => crate::settings::get_hermes_override_dir(), "pi" => crate::settings::get_pi_override_dir(), + "omp" => crate::settings::get_ohmypi_override_dir(), _ => None, }?; @@ -7137,6 +7152,27 @@ mod tests { assert_eq!(official_update_args("pi"), None); } + #[test] + fn omp_lifecycle_metadata_names_the_tool_and_keeps_one_package() { + let requested = vec!["unsupported".to_string(), "omp".to_string()]; + assert_eq!(normalize_requested_tools(&requested), vec!["omp"]); + // Without this arm the user-facing strings read "Unknown at + // was not installed by npm..." and the batch header prints + // `========== Unknown ==========`. + assert_eq!(tool_display_name("omp"), "Oh My Pi"); + // Install and the version check must agree on one package: omp is + // distributed as @oh-my-pi/pi-coding-agent and installed with bun. + assert_eq!(npm_package_for("omp"), Some("@oh-my-pi/pi-coding-agent")); + assert_eq!( + npm_install_command_for("omp"), + Some("bun install -g @oh-my-pi/pi-coding-agent@latest") + ); + // No updater contract is assumed; upgrades stay on the package-manager + // path (an unmanaged standalone binary is reported, not silently + // reinstalled through npm). + assert_eq!(official_update_args("omp"), None); + } + #[test] fn mcode_lifecycle_metadata_skips_non_tty_self_update() { let requested = vec!["unsupported".to_string(), "mcode".to_string()]; @@ -10066,6 +10102,18 @@ mod tests { )); } + #[test] + fn omp_search_paths_include_the_bun_global_bin() { + // omp is distributed only as a Bun global package, and a GUI process does + // not inherit `~/.bun/bin`: without it the Apps page reports the tool as + // not installed and updates lose their anchor. + let home = dirs::home_dir().expect("home should resolve"); + let bun_bin = home.join(".bun").join("bin"); + assert!(build_tool_search_paths("omp").contains(&bun_bin)); + // Other Bun-installable tools keep their previous search set. + assert!(!build_tool_search_paths("gemini").contains(&bun_bin)); + } + #[cfg(target_os = "windows")] #[test] fn windows_path_lookup_ignores_same_named_file_in_current_directory() { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 03018fe86..ebd5c604a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ mod kimi_oauth; mod mcp; mod misc; mod model_fetch; +mod ohmypi; mod omo; mod openclaw; mod pi; @@ -58,6 +59,7 @@ pub use kimi_oauth::*; pub use mcp::*; pub use misc::*; pub use model_fetch::*; +pub(crate) use ohmypi::*; pub use omo::*; pub use openclaw::*; pub(crate) use pi::*; diff --git a/src-tauri/src/commands/ohmypi.rs b/src-tauri/src/commands/ohmypi.rs new file mode 100644 index 000000000..e7271c095 --- /dev/null +++ b/src-tauri/src/commands/ohmypi.rs @@ -0,0 +1,22 @@ +use crate::provider::UsageScript; +use crate::services::ohmypi_state::{OhMyPiCurrentState, OhMyPiStateService}; +use crate::services::ProviderService; +use crate::store::AppState; +use tauri::State; + +#[tauri::command] +pub(crate) fn get_ohmypi_current_state( + state: State<'_, AppState>, +) -> Result { + OhMyPiStateService::current(state.inner()).map_err(|error| error.to_string()) +} + +#[tauri::command] +pub(crate) fn update_ohmypi_provider_usage_script( + state: State<'_, AppState>, + id: String, + #[allow(non_snake_case)] usageScript: UsageScript, +) -> Result { + ProviderService::update_ohmypi_usage_script(state.inner(), &id, usageScript) + .map_err(|error| error.to_string()) +} diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index 09b859e40..24d81ccc5 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -9,7 +9,7 @@ use indexmap::IndexMap; use rusqlite::{params, OptionalExtension, Row}; const MCP_SERVER_SELECT: &str = - "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, enabled_mcode, enabled_pi, enabled_agy, enabled_kimi FROM mcp_servers"; + "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, enabled_mcode, enabled_pi, enabled_agy, enabled_kimi, enabled_ohmypi FROM mcp_servers"; fn row_to_mcp_server(row: &Row<'_>) -> rusqlite::Result<(String, McpServer)> { let id: String = row.get(0)?; @@ -46,6 +46,7 @@ fn row_to_mcp_server(row: &Row<'_>) -> rusqlite::Result<(String, McpServer)> { pi: row.get(14)?, agy: row.get(15)?, kimi: row.get(16)?, + ohmypi: row.get(17)?, }, description, homepage, @@ -98,6 +99,7 @@ impl Database { AppType::Mcode => Some("enabled_mcode"), AppType::Kimi => Some("enabled_kimi"), AppType::Pi => Some("enabled_pi"), + AppType::OhMyPi => Some("enabled_ohmypi"), AppType::ClaudeDesktop | AppType::OpenClaw => None, AppType::Agy => Some("enabled_agy"), }; @@ -128,8 +130,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO mcp_servers ( id, name, server_config, description, homepage, docs, tags, - enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, enabled_mcode, enabled_pi, enabled_agy, enabled_kimi - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, enabled_mcode, enabled_pi, enabled_agy, enabled_kimi, enabled_ohmypi + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", params![ server.id, server.name, @@ -151,6 +153,7 @@ impl Database { server.apps.pi, server.apps.agy, server.apps.kimi, + server.apps.ohmypi, ], ) .map_err(|e| AppError::Database(e.to_string()))?; diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index f33abbbd1..17a2768a2 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -23,7 +23,7 @@ impl Database { .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, - enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at, enabled_mcode, enabled_agy, enabled_kimi + enabled_opencode, enabled_hermes, enabled_mcode, enabled_agy, enabled_ohmypi, enabled_kimi, installed_at, content_hash, updated_at FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -47,13 +47,14 @@ impl Database { opencode: row.get(12)?, hermes: row.get(13)?, pi: false, - mcode: row.get(17)?, - agy: row.get(18)?, - kimi: row.get(19)?, + mcode: row.get(14)?, + agy: row.get(15)?, + ohmypi: row.get(16)?, + kimi: row.get(17)?, }, - installed_at: row.get(14)?, - content_hash: row.get(15)?, - updated_at: row.get::<_, i64>(16).unwrap_or(0), + installed_at: row.get(18)?, + content_hash: row.get(19)?, + updated_at: row.get::<_, i64>(20).unwrap_or(0), }) }) .map_err(|e| AppError::Database(e.to_string()))?; @@ -73,7 +74,7 @@ impl Database { .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, - enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at, enabled_mcode, enabled_agy, enabled_kimi + enabled_opencode, enabled_hermes, enabled_mcode, enabled_agy, enabled_ohmypi, enabled_kimi, installed_at, content_hash, updated_at FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -96,13 +97,14 @@ impl Database { opencode: row.get(12)?, hermes: row.get(13)?, pi: false, - mcode: row.get(17)?, - agy: row.get(18)?, - kimi: row.get(19)?, + mcode: row.get(14)?, + agy: row.get(15)?, + ohmypi: row.get(16)?, + kimi: row.get(17)?, }, - installed_at: row.get(14)?, - content_hash: row.get(15)?, - updated_at: row.get::<_, i64>(16).unwrap_or(0), + installed_at: row.get(18)?, + content_hash: row.get(19)?, + updated_at: row.get::<_, i64>(20).unwrap_or(0), }) }); @@ -119,9 +121,9 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO skills (id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, - installed_at, content_hash, updated_at, enabled_mcode, enabled_agy, enabled_kimi) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, enabled_mcode, enabled_agy, enabled_ohmypi, enabled_kimi, + installed_at, content_hash, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", params![ skill.id, skill.name, @@ -137,12 +139,13 @@ impl Database { skill.apps.grokbuild, skill.apps.opencode, skill.apps.hermes, + skill.apps.mcode, + skill.apps.agy, + skill.apps.ohmypi, + skill.apps.kimi, skill.installed_at, skill.content_hash, skill.updated_at, - skill.apps.mcode, - skill.apps.agy, - skill.apps.kimi, ], ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -211,8 +214,8 @@ impl Database { let conn = lock_conn!(self.conn); let affected = conn .execute( - "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6, enabled_mcode = ?8, enabled_agy = ?9, enabled_kimi = ?10 WHERE id = ?7", - params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id, apps.mcode, apps.agy, apps.kimi], + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6, enabled_mcode = ?8, enabled_agy = ?9, enabled_ohmypi = ?10, enabled_kimi = ?11 WHERE id = ?7", + params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id, apps.mcode, apps.agy, apps.ohmypi, apps.kimi], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(affected > 0) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 83533f039..1ed192c48 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -71,7 +71,8 @@ impl Database { enabled_agy BOOLEAN NOT NULL DEFAULT 0, enabled_kimi BOOLEAN NOT NULL DEFAULT 0, enabled_hermes BOOLEAN NOT NULL DEFAULT 0, - enabled_pi BOOLEAN NOT NULL DEFAULT 0 + enabled_pi BOOLEAN NOT NULL DEFAULT 0, + enabled_ohmypi BOOLEAN NOT NULL DEFAULT 0 )", [], ) @@ -104,6 +105,7 @@ impl Database { enabled_agy BOOLEAN NOT NULL DEFAULT 0, enabled_kimi BOOLEAN NOT NULL DEFAULT 0, enabled_hermes BOOLEAN NOT NULL DEFAULT 0, + enabled_ohmypi BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0, content_hash TEXT, updated_at INTEGER NOT NULL DEFAULT 0 @@ -582,6 +584,7 @@ impl Database { Self::set_user_version(conn, 20)?; } 20 => { + log::info!("迁移数据库从 v20 到 v21(添加 Antigravity 支持)"); for table in ["mcp_servers", "skills"] { if Self::table_exists(conn, table)? { Self::add_column_if_missing( @@ -595,6 +598,8 @@ impl Database { Self::set_user_version(conn, 21)?; } 21 => { + // Kimi Code support (upstream) then Oh My Pi support (local), + // both idempotent via add_column_if_missing. for table in ["mcp_servers", "skills"] { if Self::table_exists(conn, table)? { Self::add_column_if_missing( @@ -603,6 +608,12 @@ impl Database { "enabled_kimi", "BOOLEAN NOT NULL DEFAULT 0", )?; + Self::add_column_if_missing( + conn, + table, + "enabled_ohmypi", + "BOOLEAN NOT NULL DEFAULT 0", + )?; } } Self::set_user_version(conn, 22)?; diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 371e0e204..7bf70ec1e 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -1458,3 +1458,69 @@ fn incremental_vacuum_reclaims_entire_freelist() { Database::incremental_vacuum_on_conn(&conn).expect("incremental vacuum"); assert_eq!(freelist(&conn), 0, "all free pages should be reclaimed"); } + +#[test] +fn migration_v20_to_v21_adds_enabled_ohmypi_columns() { + let conn = Connection::open_in_memory().expect("open memory db"); + // v20 形态:含 Mcode/Pi 列(v18->v20 产物),无 Oh My Pi 列 + conn.execute_batch( + "CREATE TABLE mcp_servers ( + id TEXT PRIMARY KEY, + enabled_hermes BOOLEAN NOT NULL DEFAULT 0, + enabled_mcode BOOLEAN NOT NULL DEFAULT 0, + enabled_pi BOOLEAN NOT NULL DEFAULT 0 + ); + CREATE TABLE skills ( + id TEXT PRIMARY KEY, + enabled_hermes BOOLEAN NOT NULL DEFAULT 0, + enabled_mcode BOOLEAN NOT NULL DEFAULT 0 + );", + ) + .expect("seed v20 shape"); + conn.execute( + "INSERT INTO mcp_servers (id, enabled_hermes, enabled_mcode, enabled_pi) VALUES ('mcp-1', 1, 1, 1)", + [], + ) + .expect("seed mcp row"); + conn.execute( + "INSERT INTO skills (id, enabled_hermes, enabled_mcode) VALUES ('skill-1', 1, 0)", + [], + ) + .expect("seed skill row"); + Database::set_user_version(&conn, 20).expect("set user_version=20"); + + Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); + + assert_eq!( + Database::get_user_version(&conn).expect("user_version after migration"), + SCHEMA_VERSION + ); + for (table, column) in [ + ("mcp_servers", "enabled_agy"), + ("skills", "enabled_agy"), + ("mcp_servers", "enabled_ohmypi"), + ("skills", "enabled_ohmypi"), + ] { + assert!( + Database::has_column(&conn, table, column).expect("check column"), + "{table}.{column} should exist after v20 -> {SCHEMA_VERSION}" + ); + } + // 既有标志列的值必须保留 + let (mcp_hermes, mcp_mcode, mcp_pi, mcp_ohmypi): (i64, i64, i64, i64) = conn + .query_row( + "SELECT enabled_hermes, enabled_mcode, enabled_pi, enabled_ohmypi FROM mcp_servers WHERE id = 'mcp-1'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .expect("read mcp row"); + assert_eq!((mcp_hermes, mcp_mcode, mcp_pi, mcp_ohmypi), (1, 1, 1, 0)); + let (skill_hermes, skill_mcode, skill_ohmypi): (i64, i64, i64) = conn + .query_row( + "SELECT enabled_hermes, enabled_mcode, enabled_ohmypi FROM skills WHERE id = 'skill-1'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("read skill row"); + assert_eq!((skill_hermes, skill_mcode, skill_ohmypi), (1, 0, 0)); +} diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 8cd7c4eba..0bccb213e 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -163,6 +163,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { mcode: false, kimi: false, pi: false, + ohmypi: false, }; for app in apps_str.split(',') { @@ -181,6 +182,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { "mcode" => apps.mcode = true, "kimi" => apps.kimi = true, "pi" => apps.pi = true, + "ohmypi" => apps.ohmypi = true, other => { return Err(AppError::InvalidInput(format!( "Invalid app in 'apps': {other}" diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index c4d7d3730..b59ee85dd 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -88,7 +88,17 @@ fn parse_provider_deeplink( .ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))? .clone(); - // Validate app type + // Validate app type. + // + // Oh My Pi is refused with the same guidance as the import path + // (`deeplink/provider.rs`): its providers are created from the Oh My Pi + // page, where the models.yml text editor can compose the entry. Accepting + // the link here would only fail later with a generic "invalid app" error. + if matches!(app.as_str(), "ohmypi" | "omp" | "oh-my-pi" | "oh_my_pi") { + return Err(AppError::InvalidInput( + "Oh My Pi providers must be added from the Oh My Pi provider page".to_string(), + )); + } if !matches!( app.as_str(), "agy" @@ -243,7 +253,13 @@ fn parse_prompt_deeplink( .ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter for prompt".to_string()))? .clone(); - // Validate app type + // Validate app type. + // + // Oh My Pi is deliberately absent: it has no prompt panel, so accepting a + // link for it would let an import write `/AGENTS.md` through + // `PromptService`, with no surface in the app showing what landed there and + // no way to undo it. The prompt file mapping stays in place for the day a + // panel exists; until then the link is refused at parse time. if !matches!( app.as_str(), "claude" @@ -324,7 +340,9 @@ fn parse_mcp_deeplink( .ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))? .clone(); - // Validate apps format + // Validate apps format. Keep this list in step with `parse_mcp_apps` + // (`deeplink/mcp.rs`), which is what actually maps the names onto `McpApps`: + // rejecting a name here makes the importer's arm for it unreachable. for app in apps.split(',') { let trimmed = app.trim(); if !matches!( @@ -341,9 +359,10 @@ fn parse_mcp_deeplink( | "pi" | "mcode" | "kimi" + | "ohmypi" ) { return Err(AppError::InvalidInput(format!( - "Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'agy', 'grokbuild', 'opencode', 'hermes', 'pi', 'kimi', or 'mcode', got '{trimmed}'" + "Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'agy', 'grokbuild', 'opencode', 'hermes', 'pi', 'kimi', 'mcode', or 'ohmypi', got '{trimmed}'" ))); } } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index d6cc4607a..60aa49d1c 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -361,6 +361,11 @@ pub(crate) fn build_provider_from_request( } settings } + AppType::OhMyPi => { + return Err(AppError::InvalidInput( + "Oh My Pi providers must be added from the Oh My Pi provider page".to_string(), + )); + } }; // Build usage script configuration if provided diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index a1727cc15..863a7c1d5 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -219,15 +219,30 @@ fn test_parse_rejects_config_url_up_front() { assert!(err.to_string().contains("configUrl"), "{err}"); } -/// 下游 parse_mcp_apps 与 McpService 都支持 Pi / MiniMax Code,解析层不能先把它们拦掉 +/// 下游 parse_mcp_apps 与 McpService 都支持 Pi / MiniMax Code / Oh My Pi,解析层不能先把它们拦掉 #[test] -fn test_parse_mcp_deeplink_accepts_pi_and_mcode() { +fn test_parse_mcp_deeplink_accepts_pi_mcode_and_ohmypi() { let config = BASE64_URL_SAFE_NO_PAD.encode(r#"{"mcpServers":{"fetch":{"command":"uvx"}}}"#); - let url = format!("ccswitch://v1/import?resource=mcp&apps=claude,pi,mcode&config={config}"); + let url = + format!("ccswitch://v1/import?resource=mcp&apps=claude,pi,mcode,ohmypi&config={config}"); - let request = parse_deeplink_url(&url).expect("pi and mcode are valid MCP targets"); + let request = parse_deeplink_url(&url).expect("pi, mcode and ohmpi are valid MCP targets"); let apps = parse_mcp_apps(request.apps.as_deref().unwrap()).unwrap(); - assert!(apps.claude && apps.pi && apps.mcode); + assert!(apps.claude && apps.pi && apps.mcode && apps.ohmypi); +} + +/// 供应商链接对 Oh My Pi 明确拒绝,而不是落到「Invalid provider app type」。 +#[test] +fn test_parse_provider_deeplink_refuses_ohmypi_with_guidance() { + for app in ["ohmypi", "omp", "oh-my-pi", "oh_my_pi"] { + let url = + format!("ccswitch://v1/import?resource=provider&app={app}&name=Test&endpoint=https%3A%2F%2Fapi.example.com"); + let err = parse_deeplink_url(&url).unwrap_err().to_string(); + assert!( + err.contains("Oh My Pi provider page"), + "{app} should be sent to the Oh My Pi page, got: {err}" + ); + } } #[test] @@ -238,6 +253,15 @@ fn test_parse_prompt_deeplink_accepts_mcode() { assert_eq!(request.app.as_deref(), Some("mcode")); } +/// Oh My Pi has no prompt panel: the link must be refused instead of writing +/// `~/.omp/agent/AGENTS.md` with nothing in the UI showing what landed there. +#[test] +fn test_parse_prompt_deeplink_refuses_ohmypi() { + let url = "ccswitch://v1/import?resource=prompt&app=ohmypi&name=Rules&content=IyBoaQ%3D%3D"; + let err = parse_deeplink_url(url).unwrap_err().to_string(); + assert!(err.contains("Invalid app type"), "{err}"); +} + /// 读取不清除,按编号确认才清除:前端读到后、显示前被打断(effect 重建、窗口销毁), /// 下次读取仍能拿到;期间来了新链接,迟到的旧确认不能把新链接清掉 #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d631c9e5..80ab2271b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,7 @@ mod mcode_config; mod mcp; pub mod mode; mod model_capabilities; +mod ohmypi_config; mod openclaw_config; mod opencode_config; mod panic_hook; @@ -971,6 +972,13 @@ pub fn run() { Ok(_) => log::debug!("○ No Pi provider changes from native config"), Err(e) => log::warn!("✗ Failed to import Pi providers: {e}"), } + match crate::services::provider::import_ohmypi_providers_from_live(&app_state) { + Ok(count) if count > 0 => { + log::info!("✓ Synced {count} Oh My Pi provider(s) from native config"); + } + Ok(_) => log::debug!("○ No Oh My Pi provider changes from native config"), + Err(e) => log::warn!("✗ Failed to import Oh My Pi providers: {e}"), + } // 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入) { @@ -1618,6 +1626,9 @@ pub fn run() { commands::get_pi_current_state, commands::update_pi_provider_usage_script, commands::get_pi_session_discovery, + // Oh My Pi native provider and session views + commands::get_ohmypi_current_state, + commands::update_ohmypi_provider_usage_script, // Profile management (项目配置方案) commands::list_profiles, commands::create_profile, diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs index 565fccd22..95982a8d9 100644 --- a/src-tauri/src/mcp/claude.rs +++ b/src-tauri/src/mcp/claude.rs @@ -98,6 +98,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs index ff43cc973..00e84c6d2 100644 --- a/src-tauri/src/mcp/gemini.rs +++ b/src-tauri/src/mcp/gemini.rs @@ -94,6 +94,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result Result = Mutex::new(()); +const TRANSPORT_FIELDS: [&str; 7] = ["command", "args", "env", "cwd", "url", "headers", "type"]; + +#[derive(Clone, Copy)] +pub(crate) enum OhMyPiChange<'a> { + Enable(&'a Value, &'a Value), + Disable(&'a Value, &'a Value), + Remove(Option<&'a Value>), +} + +impl<'a> OhMyPiChange<'a> { + pub(crate) fn from_enabled(spec: &'a Value, enabled: bool) -> Self { + Self::from_previous(spec, spec, enabled) + } + pub(crate) fn from_previous(spec: &'a Value, previous: &'a Value, enabled: bool) -> Self { + if enabled { + Self::Enable(spec, previous) + } else { + Self::Disable(spec, previous) + } + } +} + +pub fn sync_single_server_to_ohmypi(id: &str, spec: &Value) -> Result<(), AppError> { + sync(id, OhMyPiChange::Enable(spec, spec)) +} + +pub fn remove_server_from_ohmypi(id: &str) -> Result<(), AppError> { + sync(id, OhMyPiChange::Remove(None)) +} + +fn sync(id: &str, change: OhMyPiChange<'_>) -> Result<(), AppError> { + let _guard = WRITE_LOCK + .lock() + .map_err(|e| AppError::Message(e.to_string()))?; + sync_locked(id, change) +} + +pub(crate) fn sync_and_commit( + id: &str, + change: OhMyPiChange<'_>, + commit: impl FnOnce() -> Result, +) -> Result { + let _guard = WRITE_LOCK + .lock() + .map_err(|e| AppError::Message(e.to_string()))?; + crate::mcode_config::write_and_commit( + &crate::ohmypi_config::get_ohmypi_mcp_path()?, + || sync_locked(id, change), + commit, + ) +} + +fn update_list(document: &mut Value, field: &str, id: &str, include: bool) -> Result<(), AppError> { + if document.get(field).is_none() && !include { + return Ok(()); + } + let list = document + .as_object_mut() + .unwrap() + .entry(field) + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or_else(|| AppError::Config(format!("Oh My Pi MCP '{field}' must be an array")))?; + list.retain(|value| value.as_str() != Some(id)); + if include { + list.push(json!(id)); + } + Ok(()) +} + +fn sync_locked(id: &str, change: OhMyPiChange<'_>) -> Result<(), AppError> { + crate::ohmypi_config::update_ohmypi_mcp_document(|document| { + let before = document.clone(); + let servers = document + .as_object_mut() + .unwrap() + .entry("mcpServers") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| { + AppError::Config("Oh My Pi MCP 'mcpServers' must be an object".into()) + })?; + match change { + OhMyPiChange::Enable(spec, previous) | OhMyPiChange::Disable(spec, previous) => { + let unified = unified_spec(spec); + super::validation::validate_server_spec(&unified)?; + if servers + .get(id) + .is_some_and(|native| transport_spec(native) != transport_spec(previous)) + { + return Err(AppError::InvalidInput(format!("Oh My Pi MCP '{id}' conflicts with an independently configured server; native configuration preserved"))); + } + // Native options edited by OMP win; stored options restore a removed entry. + let mut merged = servers.get(id).cloned().unwrap_or_else(|| unified.clone()); + let entry = merged + .as_object_mut() + .ok_or_else(|| AppError::Config("Invalid Oh My Pi MCP entry".into()))?; + for field in TRANSPORT_FIELDS { + entry.remove(field); + } + entry.extend( + unified + .as_object() + .unwrap() + .iter() + .filter(|(key, _)| TRANSPORT_FIELDS.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())), + ); + entry.insert( + "enabled".into(), + json!(matches!(change, OhMyPiChange::Enable(_, _))), + ); + servers.insert(id.into(), merged); + update_list( + document, + "disabledServers", + id, + matches!(change, OhMyPiChange::Disable(_, _)), + )?; + update_list(document, "enabledServers", id, false)?; + } + OhMyPiChange::Remove(expected) => { + if expected.is_some_and(|spec| { + servers + .get(id) + .is_some_and(|native| transport_spec(native) != transport_spec(spec)) + }) { + return Err(AppError::InvalidInput(format!("Oh My Pi MCP '{id}' changed outside CC Switch; native configuration preserved"))); + } + servers.remove(id); + update_list(document, "disabledServers", id, false)?; + update_list(document, "enabledServers", id, false)?; + } + } + Ok(*document != before) + }) +} + +fn read_document() -> Result { + let mut result = json!({}); + crate::ohmypi_config::update_ohmypi_mcp_document(|document| { + result = document.clone(); + Ok(false) + })?; + if result + .get("mcpServers") + .is_some_and(|servers| !servers.is_object()) + { + return Err(AppError::Config( + "Oh My Pi MCP 'mcpServers' must be an object".into(), + )); + } + Ok(result) +} + +pub(crate) fn is_disabled_managed(id: &str, spec: &Value) -> Result { + let document = read_document()?; + Ok(document["mcpServers"].get(id).is_some_and(|entry| { + entry.get("enabled") == Some(&json!(false)) && transport_spec(entry) == transport_spec(spec) + }) && document["disabledServers"] + .as_array() + .is_some_and(|items| items.contains(&json!(id)))) +} + +pub(crate) fn remove_disabled_if_managed(id: &str, spec: &Value) -> Result<(), AppError> { + let _guard = WRITE_LOCK + .lock() + .map_err(|e| AppError::Message(e.to_string()))?; + let document = read_document()?; + if document["mcpServers"].get(id).is_some_and(|entry| { + entry.get("enabled") == Some(&json!(false)) && transport_spec(entry) == transport_spec(spec) + }) { + sync_locked(id, OhMyPiChange::Remove(Some(spec)))?; + } + Ok(()) +} + +fn native_enabled(document: &Value, id: &str, spec: &Value) -> bool { + let listed = |key: &str| { + document[key] + .as_array() + .is_some_and(|items| items.iter().any(|item| item.as_str() == Some(id))) + }; + if listed("disabledServers") { + return false; + } + let disabled = spec.get("enabled").is_some_and(|value| { + value == &json!(false) + || value + .as_str() + .is_some_and(|text| matches!(text.to_lowercase().as_str(), "false" | "0")) + }); + !disabled || listed("enabledServers") +} + +pub(crate) fn import(state: &AppState) -> Result { + let document = read_document()?; + let mut existing = state.db.get_all_mcp_servers()?; + let mut count = 0; + let mut skipped = Vec::new(); + let servers = document.get("mcpServers").and_then(Value::as_object); + for (id, native) in servers.into_iter().flatten() { + let mut spec = unified_spec(native); + if super::validation::validate_server_spec(&spec).is_err() { + skipped.push(format!("'{id}': invalid transport configuration")); + continue; + } + let enabled = native_enabled(&document, id, native); + spec.as_object_mut().unwrap().remove("enabled"); + let server = if let Some(mut server) = existing.shift_remove(id) { + if transport_spec(&server.server) != transport_spec(&spec) { + skipped.push(format!("'{id}': conflicts with an existing server")); + continue; + } + server.apps.ohmypi = enabled; + server + } else { + count += 1; + McpServer { + id: id.clone(), + name: id.clone(), + server: spec, + apps: McpApps { + ohmypi: enabled, + ..Default::default() + }, + description: None, + homepage: None, + docs: None, + tags: vec![], + } + }; + state.db.save_mcp_server(&server)?; + } + if skipped.is_empty() { + Ok(count) + } else { + Err(AppError::InvalidInput(format!("Imported {count} Oh My Pi MCP servers; skipped {}. Native configurations were preserved.", skipped.join("; ")))) + } +} + +fn unified_spec(native: &Value) -> Value { + let mut spec = native.clone(); + if spec.is_object() && spec.get("type").is_none() { + if spec.get("command").is_some() { + spec["type"] = json!("stdio"); + } else if spec.get("url").is_some() { + spec["type"] = json!("http"); + } + } + spec +} + +fn transport_spec(spec: &Value) -> Value { + let mut spec = unified_spec(spec); + if let Some(object) = spec.as_object_mut() { + object.retain(|key, _| TRANSPORT_FIELDS.contains(&key.as_str())); + } + spec +} diff --git a/src-tauri/src/mcp/opencode.rs b/src-tauri/src/mcp/opencode.rs index 3814e2f27..a6f112314 100644 --- a/src-tauri/src/mcp/opencode.rs +++ b/src-tauri/src/mcp/opencode.rs @@ -266,6 +266,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result/agent` for a named profile): +//! - `models.yml` / `models.yaml` — provider entries (`providers` map), YAML. +//! - `config.yml` / `config.yaml` — settings (`modelRoles.default`), YAML. +//! - `mcp.json` — MCP servers (`mcpServers` map), JSON. +//! + +use crate::config::{atomic_write_private, get_home_dir}; +use crate::error::AppError; +use indexmap::IndexMap; +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}; + +mod validation; + +const MAX_FILE_BYTES: u64 = 1024 * 1024; +const MISSING_REVISION: &str = "missing"; +static FILE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +#[cfg(test)] +static TEST_AGENT_DIR: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +// ============================================================================ +// Path resolution +// ============================================================================ + +pub(crate) fn get_ohmypi_agent_dir() -> Result { + #[cfg(test)] + if let Some(path) = TEST_AGENT_DIR + .lock() + .expect("lock Oh My Pi test directory") + .clone() + { + return Ok(path); + } + + if let Some(dir) = crate::settings::get_ohmypi_override_dir() { + return Ok(dir); + } + + get_ohmypi_native_agent_dir() +} + +pub(crate) fn get_ohmypi_active_profile() -> Result, AppError> { + let selected = std::env::var_os("OMP_PROFILE").or_else(|| std::env::var_os("PI_PROFILE")); + normalize_profile( + selected + .as_deref() + .map(|name| name.to_string_lossy()) + .as_deref(), + ) +} + +fn normalize_profile(raw: Option<&str>) -> Result, AppError> { + let name = raw.unwrap_or_default().trim(); + if name.is_empty() || name == "default" { + return Ok(None); + } + let reserved = name + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + let reserved_device = matches!(reserved.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (reserved.len() == 4 + && (reserved.starts_with("COM") || reserved.starts_with("LPT")) + && reserved.as_bytes()[3].is_ascii_digit()); + if name.len() > 64 + || !name.as_bytes()[0].is_ascii_lowercase() && !name.as_bytes()[0].is_ascii_digit() + || !name + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"._-".contains(&c)) + || name.ends_with('.') + || reserved_device + { + return Err(AppError::Config(format!( + "Invalid Oh My Pi profile: {name}" + ))); + } + Ok(Some(name.to_string())) +} + +fn native_agent_dir( + home: &Path, + config_dir: Option<&str>, + profile: Option<&str>, + inherited_profile: Option<&str>, + agent_dir: Option<&Path>, +) -> Result { + // PI_CONFIG_DIR is a directory name relative to home, as in omp's path.join(home, name). + let config_dir = config_dir.filter(|name| !name.is_empty()).unwrap_or(".omp"); + let root = home.join(config_dir.trim_start_matches(std::path::MAIN_SEPARATOR)); + if let Some(profile) = profile { + return Ok(root.join("profiles").join(profile).join("agent")); + } + // A parent omp process may have exported its named profile directory. + // Explicit default mode must ignore that derived override. + let agent_dir = agent_dir.filter(|agent| { + inherited_profile + .is_none_or(|profile| *agent != root.join("profiles").join(profile).join("agent")) + }); + if let Some(agent) = agent_dir.filter(|path| !path.as_os_str().is_empty()) { + return if agent.is_absolute() { + Ok(agent.to_path_buf()) + } else { + std::env::current_dir() + .map(|cwd| cwd.join(agent)) + .map_err(|error| { + AppError::Config(format!("Cannot resolve Oh My Pi agent directory: {error}")) + }) + }; + } + Ok(root.join("agent")) +} + +pub(crate) fn get_ohmypi_native_agent_dir() -> Result { + let profile = get_ohmypi_active_profile()?; + let config_dir = std::env::var("PI_CONFIG_DIR").ok(); + let agent_dir = std::env::var_os("PI_CODING_AGENT_DIR").map(PathBuf::from); + let inherited_profile = normalize_profile(std::env::var("PI_PROFILE").ok().as_deref()) + .ok() + .flatten(); + native_agent_dir( + &get_home_dir(), + config_dir.as_deref(), + profile.as_deref(), + inherited_profile.as_deref(), + agent_dir.as_deref(), + ) +} + +pub(crate) fn get_ohmypi_data_dir() -> Result { + let agent = get_ohmypi_agent_dir()?; + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let profile = get_ohmypi_active_profile()?; + let config_dir = std::env::var("PI_CONFIG_DIR").ok(); + let default_agent = native_agent_dir( + &get_home_dir(), + config_dir.as_deref(), + profile.as_deref(), + None, + None, + )?; + let xdg = std::env::var_os("XDG_DATA_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + Ok(data_dir( + &agent, + &default_agent, + profile.as_deref(), + xdg.as_deref(), + )) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + Ok(agent) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn data_dir( + agent: &Path, + default_agent: &Path, + profile: Option<&str>, + xdg: Option<&Path>, +) -> PathBuf { + if agent == default_agent { + if let Some(xdg) = xdg { + let mut root = xdg.join("omp"); + if let Some(profile) = profile { + root = root.join("profiles").join(profile); + } + if root.exists() { + return root; + } + } + } + agent.to_path_buf() +} + +/// Existing `models.yml` → `models.yaml` wins; greenfield defaults to `models.yml`. +pub(crate) fn get_ohmypi_models_path() -> Result { + let agent = get_ohmypi_agent_dir()?; + for name in ["models.yml", "models.yaml"] { + let path = agent.join(name); + if path.exists() { + return Ok(path); + } + } + Ok(agent.join("models.yml")) +} + +/// Existing `config.yml` → `config.yaml` wins; greenfield defaults to `config.yml`. +pub(crate) fn get_ohmypi_settings_path() -> Result { + let agent = get_ohmypi_agent_dir()?; + for name in ["config.yml", "config.yaml"] { + let path = agent.join(name); + if path.exists() { + return Ok(path); + } + } + Ok(agent.join("config.yml")) +} + +/// User-level MCP config path (`~/.omp/agent/mcp.json`). +pub(crate) fn get_ohmypi_mcp_path() -> Result { + Ok(get_ohmypi_agent_dir()?.join("mcp.json")) +} + +// ============================================================================ +// Read/write primitives +// ============================================================================ + +fn lock_files() -> Result, AppError> { + FILE_LOCK + .lock() + .map_err(|error| AppError::Config(format!("Oh My Pi file lock is poisoned: {error}"))) +} + +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_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_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| AppError::io(path, error))?; + if bytes.len() as u64 > MAX_FILE_BYTES { + return Err(AppError::InvalidInput(format!( + "{label} file exceeds the 1 MiB limit: {}", + path.display() + ))); + } + Ok(bytes) +} + +fn revision(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn read_yaml_value(path: &Path, label: &str) -> Result { + let bytes = read_file_limited(path, label)?; + let source = String::from_utf8(bytes).map_err(|error| { + AppError::Config(format!( + "{label} file must be UTF-8 ({}): {error}", + path.display() + )) + })?; + if source.trim().is_empty() { + return Ok(Value::Object(Map::new())); + } + let yaml: serde_yaml::Value = serde_yaml::from_str(&source).map_err(|error| { + AppError::Config(format!( + "{label} file is not valid YAML ({}): {error}", + path.display() + )) + })?; + serde_json::to_value(yaml).map_err(|error| { + AppError::Config(format!( + "{label} file could not be converted from YAML ({}): {error}", + path.display() + )) + }) +} + +fn read_json_value(path: &Path, label: &str) -> Result { + let bytes = read_file_limited(path, label)?; + let source = String::from_utf8(bytes).map_err(|error| { + AppError::Config(format!( + "{label} file must be UTF-8 ({}): {error}", + path.display() + )) + })?; + if source.trim().is_empty() { + return Ok(Value::Object(Map::new())); + } + serde_json::from_str(&source).map_err(|error| { + AppError::Config(format!( + "{label} file is not valid JSON ({}): {error}", + path.display() + )) + }) +} + +fn read_document_with_revision( + path: &Path, + label: &str, + yaml: bool, +) -> Result<(Value, String), AppError> { + if !path.exists() { + return Ok((Value::Object(Map::new()), MISSING_REVISION.to_string())); + } + let bytes = read_file_limited(path, label)?; + let revision = revision(&bytes); + let document = if yaml { + read_yaml_value(path, label)? + } else { + read_json_value(path, label)? + }; + Ok((document, revision)) +} + +fn write_document( + path: &Path, + document: &Value, + expected_revision: &str, + label: &str, +) -> Result<(), AppError> { + let mut bytes = + serde_json::to_vec_pretty(document).map_err(|source| AppError::JsonSerialize { source })?; + bytes.push(b'\n'); + ensure_parent(path)?; + ensure_revision(path, expected_revision, label)?; + atomic_write_private(path, &bytes) +} + +fn ensure_revision(path: &Path, expected_revision: &str, label: &str) -> Result<(), AppError> { + let actual_revision = match fs::File::open(path) { + Ok(_) => revision(&read_file_limited(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_revision == expected_revision { + Ok(()) + } else { + Err(AppError::Conflict(format!( + "{label} changed outside CC Switch: {}", + path.display() + ))) + } +} + +fn ensure_parent(path: &Path) -> Result<(), AppError> { + let parent = path.parent().ok_or_else(|| { + AppError::Config(format!( + "Oh My Pi config path has no parent directory: {}", + path.display() + )) + })?; + if !parent.exists() { + fs::create_dir_all(parent).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!( + "Oh My 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()) +} + +// ============================================================================ +// Provider CRUD (models.yml) + +/// Root keys owned by the curated Oh My Pi form controls. They are serialized +/// from the settings object; everything else may come from the raw passthrough +/// text (with comments) the user typed in the YAML editor. +/// +/// `discovery` is deliberately absent: the form keeps the editor's own +/// `discovery:` block (comments, `timeoutMs`, `injectV1`) in the passthrough +/// text, and [`compose_provider_entry`] writes a passthrough key verbatim +/// instead of serializing it from the settings object. Listing it here would +/// drop the block from the passthrough and re-serialize it from the curated +/// value, throwing its comments away. +const CONTROLLED_PROVIDER_KEYS: [&str; 7] = [ + "name", "baseUrl", "api", "apiKey", "headers", "compat", "models", +]; + +/// Byte range of a top-level (`column 0`) YAML section, hermes-style: the +/// section spans from its key line to the next top-level key line or EOF. +fn top_level_section_range(raw: &str, section_key: &str) -> Option<(usize, usize)> { + let target = format!("{section_key}:"); + let mut start = None; + let mut offset = 0; + for line in raw.split('\n') { + let trimmed = line.trim_end_matches('\r'); + let is_key_line = !line.is_empty() + && !line.starts_with([' ', '\t', '#', '-']) + && line.find(':').is_some_and(|colon| { + line[colon + 1..].is_empty() || line[colon + 1..].starts_with([' ', '\t', '\r']) + }); + if start.is_none() { + if is_key_line && trimmed.starts_with(&target) { + let after = &trimmed[target.len()..]; + if after.is_empty() || after.starts_with([' ', '\t']) { + start = Some(offset); + } + } + } else if is_key_line { + if let Some(start) = start { + return Some((start, offset)); + } + } + offset += line.len() + 1; + } + start.map(|start| (start, raw.len())) +} + +fn line_indent(line: &str) -> usize { + line.len() - line.trim_start_matches([' ', '\t']).len() +} + +/// The YAML key a mapping key line declares, or `None` when the line is not a +/// key line (`:`, `: value`). +/// +/// Quoted keys are unwrapped, honouring YAML's doubled-quote escape: `'it''s':` +/// names `it's`. +fn key_line_name(line: &str) -> Option { + let mut chars = line.char_indices(); + let (_, first) = chars.next()?; + if first == '\'' || first == '"' { + let mut name = String::new(); + let mut rest: Option<&str> = None; + while let Some((_, ch)) = chars.next() { + if ch != first { + name.push(ch); + continue; + } + // Doubled quote inside the scalar: a literal quote, not the end. + match chars.next() { + Some((_, next)) if next == first => name.push(first), + Some((next_index, ':')) => { + rest = Some(&line[next_index + 1..]); + break; + } + _ => return None, + } + } + let rest = rest?; + return (rest.is_empty() || rest.starts_with([' ', '\t'])).then_some(name); + } + let colon = line.find(':')?; + let (key, rest) = (&line[..colon], &line[colon + 1..]); + if !(rest.is_empty() || rest.starts_with([' ', '\t'])) { + return None; + } + let key = key.trim_end(); + (!key.is_empty()).then(|| key.to_string()) +} + +/// Whether a `providers:` child line declares `provider_key`. +/// +/// Compared on the **YAML value** of the key, not as text: `serde_yaml` quotes +/// any key whose plain form would not round-trip (`'true':`, `"1e5":`), so a +/// text-prefix match misses entries the file really holds — the removal then +/// silently no-ops while the update appends a duplicate key. +fn provider_entry_matches(line: &str, provider_key: &str) -> bool { + key_line_name(line).is_some_and(|name| name == provider_key) +} + +/// Guard one text-level splice by comparing the **semantics** of the file +/// before and after it. +/// +/// A text splice can break in ways its own output does not show: an anchor +/// (`&name`) defined on one provider and referenced (`*name`, or a `<<:` merge +/// key) from another leaves a dangling alias once either side is rewritten, and +/// omp then disables *every* custom provider (`ModelsConfigFile.tryLoad` → +/// `{error, models: []}`). Tabs and flow-style sequences break indentation the +/// same way. `expected` is the providers map the edit intends to leave behind; +/// anything else — a parse failure, a survivor, a lost sibling, a duplicated +/// key, a value that no longer round-trips — is refused before the write, so +/// the file on disk is never the one we could not reason about. +fn verify_splice( + before: &str, + after: &str, + provider_key: &str, + expected: &Map, +) -> Result<(), AppError> { + let before_map = + providers_from_raw(before).map_err(|error| splice_refused(provider_key, &error))?; + let after_map = + providers_from_raw(after).map_err(|error| splice_refused(provider_key, &error))?; + // The edited key is expected to differ; every *other* key must not, or the + // splice reached past the block it was supposed to rewrite. + if let Some(untouched) = first_divergence(&before_map, &after_map, provider_key) { + return Err(splice_refused( + provider_key, + &AppError::Config(format!( + "editing it would also change '{untouched}', which is why the edit was not written" + )), + )); + } + if after_map != *expected { + return Err(splice_refused( + provider_key, + &AppError::Config(format!( + "the result does not mean what was asked for (expected {expected:?}, got {after_map:?})" + )), + )); + } + Ok(()) +} + +/// The first key other than `edited` that the splice was not supposed to touch +/// but changed. +fn first_divergence( + before: &Map, + after: &Map, + edited: &str, +) -> Option { + before + .iter() + .find(|(key, value)| key.as_str() != edited && after.get(*key) != Some(*value)) + .map(|(key, _)| key.clone()) +} + +fn splice_refused(provider_key: &str, cause: &AppError) -> AppError { + AppError::Config(format!( + "Oh My Pi provider '{provider_key}' could not be edited in models.yml: {cause}. \ + Edit that file by hand — CC Switch only writes in place when the rest of the file \ + keeps its exact meaning." + )) +} + +/// The providers map as parsed from `raw`, so a rewrite can be checked against +/// the intent instead of trusted. +/// +/// A blank file (or a file that parses as null) is a valid starting point — the +/// first insert into a `models.yml` that has no providers yet — not an error. +fn providers_from_raw(raw: &str) -> Result, AppError> { + if raw.trim().is_empty() { + return Ok(Map::new()); + } + let document: serde_yaml::Value = serde_yaml::from_str(raw) + .map_err(|error| AppError::Config(format!("the file is not valid YAML: {error}")))?; + if document.is_null() { + return Ok(Map::new()); + } + let root = document + .as_mapping() + .ok_or_else(|| AppError::Config("the file's root is not an object".to_string()))?; + match root.get(serde_yaml::Value::String("providers".to_string())) { + None | Some(serde_yaml::Value::Null) => Ok(Map::new()), + Some(providers) => serde_json::to_value(providers) + .ok() + .and_then(|value| value.as_object().cloned()) + .ok_or_else(|| AppError::Config("'providers' is not an object".to_string())), + } +} + +/// Byte range of one `providers.` entry inside the `providers:` section. +/// +/// Entries are the first indented mapping level below `providers:`; an entry +/// ends at the next line indented no deeper (a sibling entry or the end of the +/// section). Blank lines and comments belong to whatever follows them. +fn provider_entry_range(raw: &str, provider_key: &str) -> Option<(usize, usize)> { + let (section_start, section_end) = top_level_section_range(raw, "providers")?; + let mut entry_indent: Option = None; + let mut entry_start: Option = None; + let mut entry_end: Option = None; + let mut offset = section_start; + for line in raw[section_start..section_end].split('\n') { + let trimmed_end = line.trim_end_matches('\r'); + let trimmed = trimmed_end.trim_start(); + let indent = line_indent(trimmed_end); + let line_end = offset + line.len() + 1; + if trimmed.is_empty() { + offset = line_end; + continue; + } + // A comment indented deeper than the entry key can only document that + // entry, so it stays inside the block. One at the entry's own indent is + // a separator that introduces the next provider: ending the range here + // keeps it with its owner instead of moving it on save. + if trimmed.starts_with('#') { + if let Some(current_end) = entry_end { + if indent > entry_indent.unwrap_or(0) { + entry_end = Some(line_end); + } else { + return Some((entry_start?, current_end)); + } + } + offset = line_end; + continue; + } + // The `providers:` key line itself (indent 0) must not set the entry + // indent; entries are the first indented level below it. + if entry_indent.is_none() && indent == 0 { + offset = line_end; + continue; + } + match entry_indent { + None => entry_indent = Some(indent), + Some(entry_indent) => { + if let Some(start) = entry_start { + if indent <= entry_indent { + return Some((start, entry_end.unwrap_or(offset))); + } + } + } + } + if entry_start.is_none() + && indent == entry_indent.expect("set above") + && provider_entry_matches(trimmed, provider_key) + { + entry_start = Some(offset); + } + if entry_start.is_some() { + entry_end = Some(line_end); + } + offset = line_end; + } + entry_start.map(|start| (start, section_end.min(entry_end.unwrap_or(offset)))) +} + +/// The raw text of one provider entry (as written in models.yml), or `None` +/// when the entry is absent. Indentation is stripped so the result can be +/// shown in the "other fields" YAML editor. +pub(crate) fn read_ohmypi_provider_entry_raw( + provider_key: &str, +) -> Result, AppError> { + let _guard = lock_files()?; + let path = get_ohmypi_models_path()?; + if !path.exists() { + return Ok(None); + } + let raw = String::from_utf8(read_file_limited(&path, "Oh My Pi models")?).map_err(|error| { + AppError::Config(format!( + "Oh My Pi models file must be UTF-8 ({}): {error}", + path.display() + )) + })?; + Ok(entry_raw_from_source(&raw, provider_key)) +} + +/// The raw text of every requested provider entry, from a single file read. +/// Missing keys are simply absent from the result. +pub(crate) fn read_ohmypi_provider_entries_raw( + provider_keys: &[String], +) -> Result, AppError> { + let _guard = lock_files()?; + let path = get_ohmypi_models_path()?; + let mut entries = IndexMap::new(); + if !path.exists() { + return Ok(entries); + } + let raw = String::from_utf8(read_file_limited(&path, "Oh My Pi models")?).map_err(|error| { + AppError::Config(format!( + "Oh My Pi models file must be UTF-8 ({}): {error}", + path.display() + )) + })?; + for key in provider_keys { + if let Some(text) = entry_raw_from_source(&raw, key) { + entries.insert(key.clone(), text); + } + } + Ok(entries) +} + +fn entry_raw_from_source(raw: &str, provider_key: &str) -> Option { + let (start, end) = provider_entry_range(raw, provider_key)?; + let entry = &raw[start..end]; + // Skip the `:` line and dedent its indent level plus one more: the + // editor text is the entry's fields at column 0 ("other fields" YAML), + // not the keyed entry. The key line's comment never reaches the editor; + // the writer preserves it in place instead. + let key_indent = entry.lines().next().map(line_indent).unwrap_or(0); + let strip = key_indent + 2; + let dedented = entry + .lines() + .skip(1) + .map(|line| { + if line.len() >= strip && line[..strip].trim().is_empty() { + &line[strip..] + } else { + line.trim_start_matches('\r') + } + }) + .collect::>() + .join("\n"); + Some(dedented.trim_end_matches(['\n', '\r', ' ']).to_string()) +} + +/// Top-level (column 0) lines of a controlled key, plus the nested block that +/// follows them, are dropped from the passthrough text: those keys belong to +/// the curated form fields and are serialized from the settings object. +/// Returns the kept text plus each controlled key's trailing comment (the +/// text after `#` on its key line, e.g. `baseUrl: https://x #local`), which +/// the composer re-attaches to the serialized line. +fn drop_controlled_keys(passthrough: &str) -> (String, Vec<(String, String)>) { + let mut kept: Vec<&str> = Vec::new(); + let mut comments: Vec<(String, String)> = Vec::new(); + let mut skipping_indent: Option = None; + for line in passthrough.lines() { + let trimmed_end = line.trim_end_matches('\r'); + let trimmed = trimmed_end.trim_start(); + if trimmed.is_empty() || trimmed.starts_with('#') { + // Comments and blank lines always survive, even inside a skipped + // controlled-key block — they may document the field after them. + kept.push(line); + continue; + } + let indent = line_indent(trimmed_end); + if let Some(limit) = skipping_indent { + // Nested content (including sequence items at the same indent, as + // YAML allows for block sequences under a mapping key) belongs to + // the skipped controlled key. + if indent > limit || (indent == limit && trimmed.starts_with('-')) { + continue; + } + skipping_indent = None; + } + if indent == 0 { + let key = trimmed.split(':').next().unwrap_or_default().trim(); + if CONTROLLED_PROVIDER_KEYS.contains(&key) { + skipping_indent = Some(0); + if let Some((_, comment)) = trimmed_end.split_once('#') { + // Keep the text exactly as typed (minus the marker itself): + // `# secret` must not come back as `#secret`, and dropping the + // leading space would also reflow a `#noqa`-style marker. + comments.push((key.to_string(), comment.trim_end().to_string())); + } + continue; + } + } + kept.push(line); + } + (kept.join("\n"), comments) +} + +/// Compose the `models.yml` fragment for one provider entry: curated fields +/// serialized from `config`, then the raw passthrough text (comments kept) +/// indented under the entry. Keys parsed from `passthrough_raw` are excluded +/// from the serialized part so they are written exactly as typed. +fn compose_provider_entry( + provider_key: &str, + config: &Value, + passthrough_raw: Option<&str>, +) -> Result { + let Some(object) = config.as_object() else { + return Err(AppError::Config( + "Oh My Pi provider configuration must be an object".to_string(), + )); + }; + + let (passthrough_kept, key_comments) = passthrough_raw + .map(drop_controlled_keys) + .unwrap_or_default(); + let mut passthrough_text = passthrough_kept; + if passthrough_text.trim().is_empty() { + passthrough_text.clear(); + } + + // A stale editor snapshot may repeat the `:` line (comment included); + // peel it off so it doesn't leak into the passthrough YAML. + let first_line = passthrough_text + .lines() + .next() + .unwrap_or_default() + .to_string(); + if first_line + .trim_start() + .starts_with(&format!("{provider_key}:")) + { + passthrough_text = passthrough_text + .lines() + .skip(1) + .collect::>() + .join("\n"); + if passthrough_text.trim().is_empty() { + passthrough_text.clear(); + } + } + + // Which root keys does the passthrough text own? Parsed (not extracted from + // lines) so quoted/exotic keys are handled correctly. + let passthrough_keys: std::collections::HashSet = if passthrough_text.is_empty() { + Default::default() + } else { + serde_yaml::from_str::(&passthrough_text) + .ok() + .and_then(|doc| match doc { + serde_yaml::Value::Mapping(map) => Some( + map.into_iter() + .filter_map(|(key, _)| key.as_str().map(str::to_string)) + .collect(), + ), + _ => None, + }) + .unwrap_or_default() + }; + + let mut curated = serde_yaml::Mapping::new(); + for (key, value) in object { + if passthrough_keys.contains(key) { + continue; + } + let yaml_value = + serde_json::from_value::(value.clone()).map_err(|error| { + AppError::Config(format!( + "Oh My Pi provider '{provider_key}' could not be converted to YAML: {error}" + )) + })?; + curated.insert(serde_yaml::Value::String(key.clone()), yaml_value); + } + + let body = if curated.is_empty() { + format!("{provider_key}:\n") + } else { + let mut entry = serde_yaml::Mapping::new(); + entry.insert( + serde_yaml::Value::String(provider_key.to_string()), + serde_yaml::Value::Mapping(curated), + ); + serde_yaml::to_string(&serde_yaml::Value::Mapping(entry)).map_err(|error| { + AppError::Config(format!( + "Failed to serialize Oh My Pi provider '{provider_key}': {error}" + )) + })? + }; + let mut body = body.trim_end_matches('\n').to_string(); + // Re-attach the trailing comments users typed on controlled-key lines + // (e.g. `baseUrl: https://x #local`): the line itself is re-serialized + // from the form value, only the comment survives from the editor text. + for (key, comment) in &key_comments { + if comment.is_empty() { + continue; + } + // serde_yaml 0.9 serializes the entry's fields at indent 2. + let marker = format!("\n {key}:"); + if let Some(line_start) = body.find(&marker) { + // Search past the marker's own leading newline. + let line_end = body[line_start + 1..] + .find('\n') + .map(|end| line_start + 1 + end) + .unwrap_or(body.len()); + let line = body[line_start..line_end].to_string(); + if !line.contains('#') { + let annotated = format!("{line} #{comment}"); + body.replace_range(line_start..line_end, &annotated); + } + } + } + if !passthrough_text.is_empty() { + for line in passthrough_text.trim_end().lines() { + if line.trim().is_empty() { + continue; + } + body.push('\n'); + body.push_str(" "); + body.push_str(line.trim_end_matches('\r')); + } + } + // The fragment lives inside `providers:` — indent every line by one level. + let fragment = body + .lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n"); + Ok(format!("{fragment}\n")) +} + +/// Replace (or append) one provider entry in the raw models.yml text, leaving +/// every other byte — comments included — untouched. +fn replace_provider_entry_in_raw(raw: &str, provider_key: &str, entry_fragment: &str) -> String { + if let Some((start, end)) = provider_entry_range(raw, provider_key) { + // Preserve the key line's comment (e.g. `example: #local`): it lives + // outside the editor text, so only the file can carry it. Re-attach + // it to the regenerated key line unless one was typed there already. + let old_key_line = raw[start..end].lines().next().unwrap_or_default(); + let new_key_line = entry_fragment.lines().next().unwrap_or_default(); + let owned_fragment; + let old_comment = old_key_line.split_once('#').map(|(_, c)| c); + let new_comment = new_key_line.split_once('#').map(|(_, c)| c); + let entry_fragment = if let (Some(comment), None) = (old_comment, new_comment) { + let mut fragment = new_key_line.to_string(); + fragment.push_str(" #"); + fragment.push_str(comment.trim_end_matches('\r')); + fragment.push('\n'); + fragment.push_str( + &entry_fragment + .lines() + .skip(1) + .collect::>() + .join("\n"), + ); + if entry_fragment.ends_with('\n') { + fragment.push('\n'); + } + owned_fragment = fragment; + owned_fragment.as_str() + } else { + entry_fragment + }; + let mut result = String::with_capacity(raw.len()); + result.push_str(&raw[..start]); + result.push_str(entry_fragment); + let mut remainder = &raw[end..]; + while remainder.starts_with('\n') || remainder.starts_with('\r') { + remainder = &remainder[1..]; + } + result.push_str(remainder); + return result; + } + + let mut result = raw.to_string(); + if let Some((_, section_end)) = top_level_section_range(raw, "providers") { + // Insert before the next top-level section (or EOF). + let insert_at = result.len().min(section_end); + let mut prefix = result[..insert_at].to_string(); + while prefix.ends_with('\n') || prefix.ends_with('\r') || prefix.ends_with(' ') { + prefix.pop(); + } + prefix.push('\n'); + let suffix = result[insert_at..].to_string(); + result = prefix; + result.push_str(entry_fragment); + // The fragment already ends with a newline; only add separation when + // another top-level section follows. + if !suffix.is_empty() && !suffix.starts_with('\n') { + result.push('\n'); + } + result.push_str(&suffix); + } else { + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } + result.push_str("providers:\n"); + result.push_str(entry_fragment); + } + result +} + +/// Remove one provider entry from the raw models.yml text, leaving every +/// other byte — comments included — untouched. +fn remove_provider_entry_from_raw(raw: &str, provider_key: &str) -> String { + let Some((entry_start, entry_end)) = provider_entry_range(raw, provider_key) else { + return raw.to_string(); + }; + let entry_indent = line_indent(raw[entry_start..].split('\n').next().unwrap_or_default()); + let start = reclaim_leading_comments(raw, entry_start, entry_indent); + let end = reclaim_trailing_comments(raw, entry_end, entry_indent); + let mut result = String::with_capacity(raw.len()); + result.push_str(&raw[..start]); + let mut remainder = &raw[end..]; + while remainder.starts_with('\n') || remainder.starts_with('\r') { + remainder = &remainder[1..]; + } + result.push_str(remainder); + result +} + +/// Move the start of a removal back over the comment lines that belong to the +/// entry being deleted. +/// +/// `provider_entry_range` starts at the key line, but a comment directly above +/// an entry usually documents *that* entry. Leaving it behind re-attaches it to +/// whichever entry follows — silently rewriting someone else's documentation. +/// Only the contiguous run of comments indented at least as deep as the entry +/// is reclaimed; a blank line ends the run, and the `providers:` header is +/// never taken. +fn reclaim_leading_comments(raw: &str, entry_start: usize, entry_indent: usize) -> usize { + let header_end = raw + .find("providers:") + .map(|index| index + "providers:".len()) + .unwrap_or(0); + let mut start = entry_start; + while start > header_end { + let Some(previous_newline) = raw[..start].rfind('\n') else { + break; + }; + let previous_start = raw[..previous_newline] + .rfind('\n') + .map(|index| index + 1) + .unwrap_or(0); + if previous_start <= header_end { + break; + } + let line = &raw[previous_start..previous_newline]; + let trimmed = line.trim_start(); + // A blank line separates the comment from the entry; keep it. + if trimmed.is_empty() || !trimmed.starts_with('#') { + break; + } + if line.len() - trimmed.len() < entry_indent { + break; + } + start = previous_start; + } + start +} + +/// Take back the comment lines that trail *inside* the entry being removed. +/// +/// They are indented deeper than the entry key, so they can only document the +/// entry itself. A comment at the entry's own indent is left alone: it precedes +/// the next provider and belongs to it. +fn reclaim_trailing_comments(raw: &str, entry_end: usize, entry_indent: usize) -> usize { + let mut end = entry_end; + while end < raw.len() { + let line_end = raw[end..] + .find('\n') + .map(|index| end + index) + .unwrap_or(raw.len()); + let line = &raw[end..line_end]; + let trimmed = line.trim_start(); + if trimmed.is_empty() || !trimmed.starts_with('#') { + break; + } + if line.len() - trimmed.len() <= entry_indent { + break; + } + end = (line_end + 1).min(raw.len()); + } + end +} + +/// Write one provider entry to models.yml at the text level: only the +/// `providers.` block is rewritten (curated fields serialized from +/// `config`, other fields from `passthrough_raw` verbatim with comments); +/// the rest of the file — comments included — is left untouched. +/// +/// The write goes through `verify_splice`, so the file is only replaced when +/// the result still parses and every other provider keeps its exact meaning. +/// +/// Returns `Ok(false)` when nothing changed on disk. +pub(crate) fn write_ohmypi_provider_entry( + provider_key: &str, + config: &Value, + passthrough_raw: Option<&str>, +) -> Result { + validate_provider_node(provider_key, config)?; + let _guard = lock_files()?; + let path = get_ohmypi_models_path()?; + let raw = if path.exists() { + String::from_utf8(read_file_limited(&path, "Oh My Pi models")?).map_err(|error| { + AppError::Config(format!( + "Oh My Pi models file must be UTF-8 ({}): {error}", + path.display() + )) + })? + } else { + String::new() + }; + let entry_fragment = compose_provider_entry(provider_key, config, passthrough_raw)?; + let after = replace_provider_entry_in_raw(&raw, provider_key, &entry_fragment); + if after == raw { + return Ok(false); + } + let mut expected = + providers_from_raw(&raw).map_err(|error| splice_refused(provider_key, &error))?; + expected.insert(provider_key.to_string(), config.clone()); + verify_splice(&raw, &after, provider_key, &expected)?; + ensure_parent(&path)?; + atomic_write_private(&path, after.as_bytes())?; + Ok(true) +} + +pub(crate) fn read_ohmypi_native_providers() -> Result, AppError> { + let _guard = lock_files()?; + let path = get_ohmypi_models_path()?; + let document = read_document_with_revision(&path, "Oh My Pi models", true)?.0; + Ok(providers(&document, &path)? + .iter() + .map(|(key, config)| (key.clone(), config.clone())) + .collect()) +} + +pub(crate) fn read_ohmypi_native_provider(provider_key: &str) -> Result, AppError> { + let _guard = lock_files()?; + let path = get_ohmypi_models_path()?; + let document = read_document_with_revision(&path, "Oh My Pi models", true)?.0; + Ok(providers(&document, &path)?.get(provider_key).cloned()) +} + +pub(crate) fn ohmypi_provider_exists(provider_key: &str) -> Result { + let _guard = lock_files()?; + let path = get_ohmypi_models_path()?; + let document = read_document_with_revision(&path, "Oh My Pi models", true)?.0; + Ok(providers(&document, &path)?.contains_key(provider_key)) +} + +pub(crate) fn insert_ohmypi_provider(provider_key: &str, config: &Value) -> Result { + validate_provider_node(provider_key, config)?; + let _guard = lock_files()?; + let path = get_ohmypi_models_path()?; + let (document, _) = read_document_with_revision(&path, "Oh My Pi models", true)?; + match providers(&document, &path)?.get(provider_key) { + Some(current) if current == config => return Ok(false), + Some(_) => { + return Err(AppError::InvalidInput(format!( + "Oh My Pi provider key '{provider_key}' already exists in models.yml" + ))) + } + None => {} + } + + // Text-level writer: only the `providers.` block is composed; the + // rest of the file — comments included — is preserved byte for byte. + drop(_guard); + write_ohmypi_provider_entry(provider_key, config, None)?; + Ok(true) +} + +pub(crate) fn replace_ohmypi_provider( + provider_key: &str, + expected: &Value, + replacement: &Value, +) -> Result<(), AppError> { + replace_ohmypi_provider_if_present(provider_key, expected, replacement, None)?.ok_or_else( + || { + AppError::Conflict(format!( + "Oh My Pi provider '{provider_key}' is no longer present in models.yml" + )) + }, + )?; + Ok(()) +} + +/// Replace a provider entry at the text level so comments survive. Returns the +/// previous entry value. +pub(crate) fn replace_ohmypi_provider_if_present( + provider_key: &str, + expected: &Value, + replacement: &Value, + passthrough_raw: Option<&str>, +) -> Result, AppError> { + validate_provider_node(provider_key, replacement)?; + let _guard = lock_files()?; + let path = get_ohmypi_models_path()?; + let (document, _) = read_document_with_revision(&path, "Oh My Pi models", true)?; + let Some(current) = providers(&document, &path)?.get(provider_key).cloned() else { + return Ok(None); + }; + if current != *expected { + return Err(AppError::Conflict(format!( + "Oh My Pi provider '{provider_key}' changed outside CC Switch" + ))); + } + if current == *replacement && passthrough_raw.is_none() { + return Ok(Some(current)); + } + // Text-level writer takes its own file lock; release ours first. It runs + // even when the parsed config is unchanged: comment-only edits never + // change `replacement` yet must reach the file (the writer's own + // `new_raw == raw` check is the real no-op guard). + drop(_guard); + write_ohmypi_provider_entry(provider_key, replacement, passthrough_raw)?; + Ok(Some(current)) +} + +pub(crate) fn remove_ohmypi_provider(provider_key: &str) -> Result, AppError> { + remove_ohmypi_provider_inner(provider_key, None) +} + +pub(crate) fn remove_ohmypi_provider_if_matches( + provider_key: &str, + expected: &Value, +) -> Result { + remove_ohmypi_provider_inner(provider_key, Some(expected)).map(|removed| removed.is_some()) +} + +fn remove_ohmypi_provider_inner( + provider_key: &str, + expected: Option<&Value>, +) -> Result, AppError> { + let _guard = lock_files()?; + let path = get_ohmypi_models_path()?; + let (document, expected_revision) = + read_document_with_revision(&path, "Oh My Pi models", true)?; + let Some(current) = providers(&document, &path)?.get(provider_key).cloned() else { + return Ok(None); + }; + if expected.is_some_and(|expected| current != *expected) { + return Err(AppError::Conflict(format!( + "Oh My Pi provider '{provider_key}' changed outside CC Switch" + ))); + } + // Text-level splice: only the `providers.` block is removed; the + // rest of the file — comments included — is preserved byte for byte. + // + // This used to trust the splice. It cannot: an anchor defined on the entry + // being deleted leaves its aliases dangling elsewhere, which makes omp + // reject the whole file and disable *every* custom provider, and an entry + // written with a quoted key (`'true':`) is not found by a text-prefix match + // at all — the removal silently no-ops while the caller drops the DB row, + // so the provider reappears on the next list. Both are caught here by + // `verify_splice`, which re-parses the result and compares every other + // provider against the file we read. + if path.exists() { + let raw = + String::from_utf8(read_file_limited(&path, "Oh My Pi models")?).map_err(|error| { + AppError::Config(format!( + "Oh My Pi models file must be UTF-8 ({}): {error}", + path.display() + )) + })?; + let after = remove_provider_entry_from_raw(&raw, provider_key); + if after == raw { + return Err(AppError::Config(format!( + "Oh My Pi provider '{provider_key}' could not be located in models.yml for removal" + ))); + } + let mut expected = + providers_from_raw(&raw).map_err(|error| splice_refused(provider_key, &error))?; + expected.remove(provider_key); + verify_splice(&raw, &after, provider_key, &expected)?; + // Fail if the file changed between the read above and this write. + ensure_revision(&path, &expected_revision, "Oh My Pi models")?; + ensure_parent(&path)?; + atomic_write_private(&path, after.as_bytes())?; + } + Ok(Some(current)) +} + +pub(crate) fn restore_ohmypi_provider_if_missing( + provider_key: &str, + config: &Value, +) -> Result<(), AppError> { + validate_provider_node(provider_key, config)?; + let _guard = lock_files()?; + let path = get_ohmypi_models_path()?; + let (document, _) = read_document_with_revision(&path, "Oh My Pi models", true)?; + match providers(&document, &path)?.get(provider_key) { + Some(current) if current == config => Ok(()), + Some(_) => Err(AppError::Conflict(format!( + "cannot restore Oh My Pi provider '{provider_key}' because another value now owns the key" + ))), + None => { + // Text-level writer: only the `providers.` block is composed; + // the rest of the file — comments included — is preserved. + drop(_guard); + write_ohmypi_provider_entry(provider_key, config, None)?; + Ok(()) + } + } +} + +/// Validate the shape CC Switch can persist as one `models.yml.providers.` node. +/// +/// Both full providers (`models` non-empty) and override-only providers +/// (`models` absent/empty) are accepted; the node must simply be a non-empty +/// object keyed by a non-empty id. +pub(crate) fn validate_provider_node(provider_key: &str, config: &Value) -> Result<(), AppError> { + validation::validate_provider_node(provider_key, config) +} + +pub(crate) fn provider_base_url(config: &Value) -> Result { + let provider = config.as_object().ok_or_else(|| { + AppError::InvalidInput("Oh My 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("Oh My Pi provider has no request URL".to_string())) +} + +fn providers<'a>(document: &'a Value, path: &Path) -> Result<&'a Map, AppError> { + let root = document.as_object().ok_or_else(|| { + AppError::Config(format!( + "Oh My Pi models root must be an object: {}", + path.display() + )) + })?; + match root.get("providers") { + // `providers:` with all entries removed parses as null; treat it as + // empty rather than failing every later read. + None | Some(Value::Null) => Ok(empty_json_object()), + Some(Value::Object(providers)) => Ok(providers), + Some(_) => Err(AppError::Config(format!( + "Oh My Pi models 'providers' must be an object: {}", + path.display() + ))), + } +} + +fn empty_json_object() -> &'static Map { + static EMPTY: LazyLock> = LazyLock::new(Map::new); + &EMPTY +} + +// ============================================================================ +// Settings (config.yml) +// ============================================================================ + +/// Read the full `config.yml` as a JSON value. +fn read_ohmypi_settings() -> Result { + let _guard = lock_files()?; + let path = get_ohmypi_settings_path()?; + if !path.exists() { + return Ok(Value::Object(Map::new())); + } + read_yaml_value(&path, "Oh My Pi settings") +} + +/// Read `modelRoles.default` (the full `/` selector, if set). +pub(crate) fn read_ohmypi_default_model() -> Result, AppError> { + let document = read_ohmypi_settings()?; + let path = get_ohmypi_settings_path()?; + let Some(object) = document.as_object() else { + return Ok(None); + }; + let Some(model_roles) = object.get("modelRoles") else { + return Ok(None); + }; + let Some(roles) = model_roles.as_object() else { + return Ok(None); + }; + optional_string(roles, "default", &path) +} + +/// Split a `/` selector on the first `/` and return the provider id. +pub(crate) fn provider_from_selector(selector: &str) -> &str { + selector + .split_once('/') + .map(|(provider, _)| provider) + .unwrap_or(selector) +} + +// ============================================================================ +// MCP server I/O (mcp.json) +// ============================================================================ + +#[cfg(test)] +pub(crate) fn read_ohmypi_mcp_servers() -> Result, AppError> { + let _guard = lock_files()?; + let path = get_ohmypi_mcp_path()?; + let document = read_document_with_revision(&path, "Oh My Pi MCP", false)?.0; + let root = document.as_object().ok_or_else(|| { + AppError::Config(format!( + "Oh My Pi MCP root must be an object: {}", + path.display() + )) + })?; + match root.get("mcpServers") { + None => Ok(IndexMap::new()), + Some(Value::Object(servers)) => Ok(servers + .iter() + .map(|(key, config)| (key.clone(), config.clone())) + .collect()), + Some(_) => Err(AppError::Config(format!( + "Oh My Pi MCP 'mcpServers' must be an object: {}", + path.display() + ))), + } +} + +/// Mutate the native MCP document while preserving other root keys and checking external writes. +pub(crate) fn update_ohmypi_mcp_document( + update: impl FnOnce(&mut Value) -> Result, +) -> Result<(), AppError> { + let _guard = lock_files()?; + let path = get_ohmypi_mcp_path()?; + let (mut document, expected_revision) = + read_document_with_revision(&path, "Oh My Pi MCP", false)?; + if !document.is_object() { + return Err(AppError::Config(format!( + "Oh My Pi MCP root must be an object: {}", + path.display() + ))); + } + if update(&mut document)? { + write_document(&path, &document, &expected_revision, "Oh My Pi MCP")?; + } + Ok(()) +} + +#[cfg(test)] +pub(crate) fn set_ohmypi_mcp_server(id: &str, config: &Value) -> Result<(), AppError> { + let _guard = lock_files()?; + let path = get_ohmypi_mcp_path()?; + let (mut document, expected_revision) = + read_document_with_revision(&path, "Oh My Pi MCP", false)?; + let root = document.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "Oh My Pi MCP root must be an object: {}", + path.display() + )) + })?; + let servers = root + .entry("mcpServers".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let servers = servers.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "Oh My Pi MCP 'mcpServers' must be an object: {}", + path.display() + )) + })?; + servers.insert(id.to_string(), config.clone()); + write_document(&path, &document, &expected_revision, "Oh My Pi MCP") +} + +#[cfg(test)] +pub(crate) fn remove_ohmypi_mcp_server(id: &str) -> Result<(), AppError> { + let _guard = lock_files()?; + let path = get_ohmypi_mcp_path()?; + let (mut document, expected_revision) = + read_document_with_revision(&path, "Oh My Pi MCP", false)?; + let root = document.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "Oh My Pi MCP root must be an object: {}", + path.display() + )) + })?; + let Some(servers) = root.get_mut("mcpServers").and_then(Value::as_object_mut) else { + return Ok(()); + }; + servers.remove(id); + write_document(&path, &document, &expected_revision, "Oh My Pi MCP") +} + +#[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 Oh My Pi test directory"); + let agent_dir = dir.path().join("agent"); + Self::set(agent_dir, Some(dir)) + } + + #[allow(dead_code)] // used only by tests; compiled-in outside cfg(test) via shared test_support + 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 Oh My 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 Oh My Pi test directory") = self.previous.take(); + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::TestAgentDir; + use super::*; + use serde_json::json; + use serial_test::serial; + + #[test] + fn drop_controlled_keys_keeps_comments_and_unmanaged_fields() { + let passthrough = "# edited comment\nbaseUrl: https://api.duolalab.test/v1 #local\napi: openai-completions\napiKey: sk-live\n# extra unmanaged field\nauth: none\nmodels:\n- id: glm-4.7\n name: GLM 4.7\n"; + let (dropped, comments) = drop_controlled_keys(passthrough); + assert!(dropped.contains("# edited comment"), "{dropped}"); + assert!(dropped.contains("# extra unmanaged field"), "{dropped}"); + assert!(dropped.contains("auth: none"), "{dropped}"); + assert!(!dropped.contains("baseUrl:"), "{dropped}"); + assert!(!dropped.contains("apiKey:"), "{dropped}"); + assert!(!dropped.contains("models:"), "{dropped}"); + assert!(!dropped.contains("glm-4.7"), "{dropped}"); + // Trailing comments on controlled-key lines are captured for the + // composer to re-attach. + assert_eq!(comments, vec![("baseUrl".to_string(), "local".to_string())]); + } + + #[test] + fn native_profile_and_config_root_resolution() { + let home = tempfile::tempdir().expect("home"); + assert_eq!(normalize_profile(Some("").or(Some("work"))).unwrap(), None); + assert_eq!( + normalize_profile(Some("default").or(Some("work"))).unwrap(), + None + ); + assert_eq!( + normalize_profile(None.or(Some("work"))).unwrap().as_deref(), + Some("work") + ); + assert_eq!( + native_agent_dir(home.path(), Some(".custom"), None, None, None).unwrap(), + home.path().join(".custom/agent") + ); + assert_eq!( + native_agent_dir( + home.path(), + Some(".custom"), + Some("work"), + None, + Some(Path::new("ignored")) + ) + .unwrap(), + home.path().join(".custom/profiles/work/agent") + ); + let inherited = home.path().join(".custom/profiles/work/agent"); + assert_eq!( + native_agent_dir( + home.path(), + Some(".custom"), + None, + Some("work"), + Some(&inherited) + ) + .unwrap(), + home.path().join(".custom/agent") + ); + let custom = home.path().join("custom-agent"); + assert_eq!( + native_agent_dir( + home.path(), + Some(".custom"), + None, + Some("work"), + Some(&custom) + ) + .unwrap(), + custom + ); + for invalid in ["../other", "UPPER", "con", "nul.txt", "trailing."] { + assert!(normalize_profile(Some(invalid)).is_err(), "{invalid}"); + } + } + + #[test] + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn xdg_data_root_follows_existing_profile_and_agent_override() { + let home = tempfile::tempdir().expect("home"); + let xdg = tempfile::tempdir().expect("xdg"); + let default = home.path().join(".omp/agent"); + std::fs::create_dir_all(xdg.path().join("omp")).unwrap(); + assert_eq!( + data_dir(&default, &default, None, Some(xdg.path())), + xdg.path().join("omp") + ); + let custom = home.path().join("custom-agent"); + assert_eq!(data_dir(&custom, &default, None, Some(xdg.path())), custom); + let work = home.path().join(".omp/profiles/work/agent"); + assert_eq!(data_dir(&work, &work, Some("work"), Some(xdg.path())), work); + std::fs::create_dir_all(xdg.path().join("omp/profiles/work")).unwrap(); + assert_eq!( + data_dir(&work, &work, Some("work"), Some(xdg.path())), + xdg.path().join("omp/profiles/work") + ); + } + + fn provider() -> Value { + json!({ + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "apiKey": "secret", + "models": [{"id": "example-model", "name": "Example Model"}] + }) + } + + fn override_only_provider() -> Value { + json!({ + "baseUrl": "https://custom-proxy.example.com", + "api": "anthropic-messages", + "apiKey": "proxy-key" + }) + } + + fn write_agent_file(name: &str, content: &str) { + let agent = get_ohmypi_agent_dir().expect("agent dir"); + std::fs::create_dir_all(&agent).expect("create agent dir"); + std::fs::write(agent.join(name), content).expect("write file"); + } + + fn read_agent_file(name: &str) -> String { + let agent = get_ohmypi_agent_dir().expect("agent dir"); + std::fs::read_to_string(agent.join(name)).expect("read file") + } + + /// A comment at the entry's own indent introduces the provider below it; + /// saving one provider must not move it. + #[test] + #[serial] + fn saving_a_provider_leaves_the_next_providers_comment_in_place() { + let _agent = TestAgentDir::new(); + write_agent_file( + "models.yml", + "providers:\n demo:\n baseUrl: https://a.example\n api: openai-completions\n apiKey: k\n # about other\n other:\n baseUrl: https://b.example\n", + ); + let config = read_ohmypi_native_provider("demo") + .expect("read demo") + .expect("demo present"); + let mut edited = config.clone(); + edited["apiKey"] = json!("rotated"); + replace_ohmypi_provider_if_present("demo", &config, &edited, None).expect("save demo"); + + let source = read_agent_file("models.yml"); + assert!( + source.contains(" # about other\n other:"), + "the sibling's comment must stay attached to it:\n{source}" + ); + assert!( + !source.contains(" # about other"), + "the sibling's comment must not be swallowed into demo:\n{source}" + ); + } + + /// Comments that document the entry itself survive a save: leading + /// comments, an inner comment, and the spacing after `#` on a field. + #[test] + #[serial] + fn saving_a_provider_keeps_its_own_comments_and_their_spacing() { + let _agent = TestAgentDir::new(); + write_agent_file( + "models.yml", + "providers:\n demo: # key comment\n # inner note\n baseUrl: https://a.example # endpoint\n api: openai-completions\n apiKey: k # secret\n", + ); + let config = read_ohmypi_native_provider("demo") + .expect("read demo") + .expect("demo present"); + let mut edited = config.clone(); + edited["apiKey"] = json!("rotated"); + let passthrough = read_ohmypi_provider_entry_raw("demo") + .expect("read entry") + .expect("entry present"); + replace_ohmypi_provider_if_present("demo", &config, &edited, Some(&passthrough)) + .expect("save demo"); + + let source = read_agent_file("models.yml"); + for expected in [ + "demo: # key comment", + "# inner note", + "https://a.example # endpoint", + "# secret", + ] { + assert!( + source.contains(expected), + "missing {expected:?} in:\n{source}" + ); + } + // The marker keeps the space the user typed: `# secret`, not `#secret`. + assert!(!source.contains("#secret"), "{source}"); + } + + /// Removing a provider takes the comments that belong to it and leaves the + /// ones that introduce the following provider. + #[test] + #[serial] + fn removal_takes_own_comments_and_keeps_the_siblings() { + let _agent = TestAgentDir::new(); + write_agent_file( + "models.yml", + "providers:\n # demo docs\n demo:\n baseUrl: https://a.example\n # inside demo\n # about other\n other:\n baseUrl: https://b.example\n", + ); + assert!(remove_ohmypi_provider("demo").expect("remove").is_some()); + + let source = read_agent_file("models.yml"); + assert!(!source.contains("# demo docs"), "{source}"); + assert!(!source.contains("# inside demo"), "{source}"); + assert!( + source.contains(" # about other\n other:"), + "the surviving provider keeps its comment:\n{source}" + ); + } + + #[test] + #[serial] + fn provider_crud_round_trip() { + let _agent = TestAgentDir::new(); + let config = provider(); + + assert!(insert_ohmypi_provider("example", &config).expect("insert")); + assert!(!insert_ohmypi_provider("example", &config).expect("identical insert no-op")); + + let providers = read_ohmypi_native_providers().expect("read providers"); + assert_eq!(providers.len(), 1); + assert_eq!(providers.get("example"), Some(&config)); + + let mut updated = config.clone(); + updated["apiKey"] = json!("new-secret"); + replace_ohmypi_provider("example", &config, &updated).expect("replace"); + assert_eq!( + read_ohmypi_native_provider("example").expect("read provider"), + Some(updated.clone()) + ); + + let removed = remove_ohmypi_provider("example").expect("remove"); + assert_eq!(removed, Some(updated)); + assert!(!ohmypi_provider_exists("example").expect("exists")); + } + + /// A key `serde_yaml` chooses to quote (`'true':`) must still be found, or + /// the removal silently no-ops and the update appends a duplicate key. + #[test] + #[serial] + fn quoted_provider_key_is_found_for_remove_and_replace() { + let _agent = TestAgentDir::new(); + // Written by an earlier insert: serde_yaml quotes a key whose plain + // form would not round-trip. + write_agent_file( + "models.yml", + "providers:\n peer:\n baseUrl: https://peer/v1\n api: openai-completions\n apiKey: p\n", + ); + let config = provider(); + write_agent_file( + "models.yml", + &format!( + "providers:\n{}\n peer:\n baseUrl: https://peer/v1\n api: openai-completions\n apiKey: p\n", + compose_provider_entry("true", &config, None).expect("compose") + ), + ); + assert!(read_ohmypi_provider_entry_raw("true") + .expect("read") + .is_some()); + + let mut updated = config.clone(); + updated["apiKey"] = json!("rotated"); + replace_ohmypi_provider("true", &config, &updated).expect("replace quoted key"); + assert_eq!( + read_ohmypi_native_provider("true").expect("read"), + Some(updated.clone()) + ); + // The sibling must still be there: a text-prefix match used to append a + // duplicate key instead, which left the file unparsable for omp. + assert!(read_ohmypi_native_provider("peer") + .expect("read peer") + .is_some()); + + assert_eq!( + remove_ohmypi_provider("true").expect("remove quoted key"), + Some(updated) + ); + assert!(!ohmypi_provider_exists("true").expect("exists")); + assert!(ohmypi_provider_exists("peer").expect("peer survives")); + } + + /// An anchor defined on one provider and referenced by another cannot be + /// spliced: removing the definer would leave a dangling alias, and omp then + /// disables every custom provider. The edit must be refused, not written. + #[test] + #[serial] + fn anchored_provider_file_is_refused_instead_of_corrupted() { + let _agent = TestAgentDir::new(); + let raw = "providers:\n base:\n baseUrl: &b https://base/v1\n api: openai-completions\n apiKey: k\n other:\n baseUrl: *b\n api: openai-completions\n apiKey: k\n"; + write_agent_file("models.yml", raw); + let before = read_agent_file("models.yml"); + + let error = remove_ohmypi_provider("base").expect_err("alias must be refused"); + assert!( + error.to_string().contains("base"), + "error should name the provider: {error}" + ); + assert_eq!( + read_agent_file("models.yml"), + before, + "a refused removal must leave the file untouched" + ); + assert!(ohmypi_provider_exists("base").expect("base survives")); + assert!(ohmypi_provider_exists("other").expect("other survives")); + } + + #[test] + #[serial] + fn override_only_provider_is_accepted() { + let _agent = TestAgentDir::new(); + let config = override_only_provider(); + assert!(insert_ohmypi_provider("proxy", &config).expect("insert override-only")); + let providers = read_ohmypi_native_providers().expect("read providers"); + assert_eq!(providers.get("proxy"), Some(&config)); + } + + #[test] + #[serial] + fn controlled_key_trailing_comment_survives_save() { + let _agent = TestAgentDir::new(); + write_agent_file( + "models.yml", + "providers:\n llama.cpp:\n api: openai-responses\n name: llama.cpp\n baseUrl: http://127.0.0.1:8080\n auth: none #ab\n discovery: #ab\n type: llama.cpp\n", + ); + let config = json!({ + "api": "openai-responses", + "name": "llama.cpp", + "baseUrl": "http://127.0.0.1:8080", + "auth": "none", + "discovery": {"type": "llama.cpp"} + }); + // Editor text mirrors the file plus a new trailing comment on `api`. + let passthrough = "api: openai-responses #asd\nname: llama.cpp\nbaseUrl: http://127.0.0.1:8080\nauth: none #ab\ndiscovery: #ab\n type: llama.cpp"; + assert!( + write_ohmypi_provider_entry("llama.cpp", &config, Some(passthrough)).expect("write"), + "must not be a no-op" + ); + let source = read_agent_file("models.yml"); + assert!(source.contains("api: openai-responses #asd"), "{source}"); + assert!(source.contains("auth: none #ab"), "{source}"); + assert_eq!( + read_ohmypi_native_provider("llama.cpp").expect("read"), + Some(config) + ); + } + + #[test] + #[serial] + fn key_line_comment_round_trips_through_raw_editor() { + let _agent = TestAgentDir::new(); + write_agent_file( + "models.yml", + "providers:\n example: #local\n baseUrl: https://a.example\n", + ); + + // The key line never reaches the editor text. + let raw = read_ohmypi_provider_entry_raw("example") + .expect("read raw") + .expect("entry present"); + assert!(!raw.contains("example:"), "{raw}"); + assert_eq!(raw, "baseUrl: https://a.example", "{raw}"); + + // Saving edited fields keeps the key line comment from the file. + let config = json!({"baseUrl": "https://b.example"}); + let edited = format!("{raw}\n"); + assert!( + write_ohmypi_provider_entry("example", &config, Some(&edited)) + .expect("text-level write") + ); + + let source = read_agent_file("models.yml"); + assert!(source.contains("example: #local"), "{source}"); + assert!(source.contains("https://b.example"), "{source}"); + assert_eq!( + read_ohmypi_native_provider("example").expect("read"), + Some(config.clone()) + ); + + // Removing the comment in the file and saving again drops it. + write_agent_file( + "models.yml", + "providers:\n example:\n baseUrl: https://b.example\n", + ); + let changed_config = json!({"baseUrl": "https://c.example"}); + assert!( + write_ohmypi_provider_entry("example", &changed_config, Some(&edited)) + .expect("write again") + ); + assert!( + !read_agent_file("models.yml").contains("example: #"), + "{}", + read_agent_file("models.yml") + ); + } + + #[test] + #[serial] + fn comment_only_edit_reaches_file_despite_unchanged_config() { + let _agent = TestAgentDir::new(); + let config = json!({"baseUrl": "https://a.example", "auth": "none"}); + insert_ohmypi_provider("example", &config).expect("insert"); + + // User only adds a comment in the editor: parsed config is unchanged, + // but the new comment must reach the file. + let passthrough = "# my note\nauth: none\n"; + let previous = + replace_ohmypi_provider_if_present("example", &config, &config, Some(passthrough)) + .expect("replace") + .expect("present"); + assert_eq!(previous, config); + + let source = read_agent_file("models.yml"); + assert!(source.contains("# my note"), "{source}"); + assert_eq!( + read_ohmypi_native_provider("example").expect("read"), + Some(config) + ); + } + + #[test] + #[serial] + fn text_level_edit_preserves_comments_and_sibling_entries() { + let _agent = TestAgentDir::new(); + write_agent_file( + "models.yml", + "# top comment\nproviders:\n # sibling comment\n existing:\n baseUrl: https://a.example\n compat:\n supportsStore: true\n", + ); + + let mut config = provider(); + config["auth"] = json!("none"); + let passthrough = "# unmanaged key\nauth: none\n"; + assert!( + write_ohmypi_provider_entry("example", &config, Some(passthrough)) + .expect("text-level write") + ); + + let source = read_agent_file("models.yml"); + assert!(source.contains("# top comment"), "{source}"); + assert!(source.contains("# sibling comment"), "{source}"); + assert!(source.contains("# unmanaged key"), "{source}"); + assert!(source.contains("supportsStore: true"), "{source}"); + assert!(source.contains("auth: none"), "{source}"); + + // Parsed content must round-trip the full config object. + let providers = read_ohmypi_native_providers().expect("read providers"); + assert_eq!(providers.get("example"), Some(&config)); + assert!(providers.contains_key("existing")); + + // Raw entry (for the editor) keeps the comment. + let raw = read_ohmypi_provider_entry_raw("example") + .expect("read raw entry") + .expect("entry present"); + assert!(raw.contains("# unmanaged key"), "{raw}"); + + // Re-writing with the same passthrough is a no-op. + assert!( + !write_ohmypi_provider_entry("example", &config, Some(passthrough)) + .expect("idempotent write") + ); + } + + #[test] + #[serial] + fn read_default_model_and_selector_split() { + let _agent = TestAgentDir::new(); + write_agent_file("config.yml", "modelRoles:\n smol: openai/gpt-4o-mini\n"); + + assert_eq!(read_ohmypi_default_model().expect("read default"), None); + assert_eq!(provider_from_selector("example/example-model"), "example"); + } + + #[test] + #[serial] + fn mcp_server_io() { + let _agent = TestAgentDir::new(); + let spec = json!({ + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server"], + "env": {"API_KEY": "secret"} + }); + set_ohmypi_mcp_server("filesystem", &spec).expect("set server"); + let servers = read_ohmypi_mcp_servers().expect("read servers"); + assert_eq!(servers.get("filesystem"), Some(&spec)); + + remove_ohmypi_mcp_server("filesystem").expect("remove server"); + assert!(read_ohmypi_mcp_servers().expect("read servers").is_empty()); + } + + #[test] + #[serial] + fn unmanaged_keys_preserved_after_provider_edit() { + let _agent = TestAgentDir::new(); + write_agent_file( + "models.yml", + "providers:\n existing:\n baseUrl: https://a.example\n compat:\n supportsStore: true\n", + ); + + let config = provider(); + insert_ohmypi_provider("example", &config).expect("insert second provider"); + + let source = read_agent_file("models.yml"); + assert!(source.contains("supportsStore: true")); + assert!(source.contains("example")); + assert!(source.contains("existing")); + } + + #[test] + #[serial] + fn provider_base_url_falls_back_to_model_url() { + let config = json!({ + "api": "openai-completions", + "models": [{"id": "m", "baseUrl": "https://model.example"}] + }); + assert_eq!( + provider_base_url(&config).expect("base url"), + "https://model.example" + ); + } +} diff --git a/src-tauri/src/ohmypi_config/validation.rs b/src-tauri/src/ohmypi_config/validation.rs new file mode 100644 index 000000000..7edd56088 --- /dev/null +++ b/src-tauri/src/ohmypi_config/validation.rs @@ -0,0 +1,608 @@ +//! Known `models.yml` fields and business rules from omp v18.8.7. +//! Unknown fields are intentionally retained for newer native releases. + +use crate::error::AppError; +use serde_json::{Map, Value}; + +const APIS: &[&str] = &[ + "openai-completions", + "openai-responses", + "openai-codex-responses", + "azure-openai-responses", + "anthropic-messages", + "bedrock-converse-stream", + "google-generative-ai", + "google-gemini-cli", + "google-vertex", + "openrouter-decisions", + "typesafe", +]; +const RUNNERS: &[&str] = &[ + "openai-images", + "openrouter-images", + "xai-tts", + "openai-speech", + "openai-embeddings", + "openrouter-rerank", + "openrouter-video", + "openai-transcriptions", +]; +const EFFORTS: &[&str] = &["minimal", "low", "medium", "high", "xhigh", "max"]; + +fn invalid(path: &str, expected: &str) -> AppError { + AppError::InvalidInput(format!("Oh My Pi {path}: expected {expected}")) +} + +fn object<'a>(value: &'a Value, path: &str) -> Result<&'a Map, AppError> { + value.as_object().ok_or_else(|| invalid(path, "an object")) +} + +fn fields( + value: &Map, + path: &str, + strings: &[&str], + bools: &[&str], + numbers: &[&str], +) -> Result<(), AppError> { + for (keys, predicate, expected) in [ + (strings, Value::is_string as fn(&Value) -> bool, "a string"), + (bools, Value::is_boolean as fn(&Value) -> bool, "a boolean"), + (numbers, Value::is_number as fn(&Value) -> bool, "a number"), + ] { + for key in keys { + if value.get(*key).is_some_and(|entry| !predicate(entry)) { + return Err(invalid(&format!("{path}.{key}"), expected)); + } + } + } + Ok(()) +} + +fn nonempty(value: &Map, path: &str, keys: &[&str]) -> Result<(), AppError> { + fields(value, path, keys, &[], &[])?; + for key in keys { + if value.get(*key).and_then(Value::as_str) == Some("") { + return Err(invalid(&format!("{path}.{key}"), "a non-empty string")); + } + } + Ok(()) +} + +fn enumeration( + value: &Map, + path: &str, + key: &str, + allowed: &[&str], +) -> Result<(), AppError> { + if let Some(entry) = value.get(key) { + if !entry.as_str().is_some_and(|text| allowed.contains(&text)) { + return Err(invalid(&format!("{path}.{key}"), &allowed.join(" | "))); + } + } + Ok(()) +} + +fn string_map(value: &Value, path: &str) -> Result<(), AppError> { + for (key, entry) in object(value, path)? { + if !entry.is_string() { + return Err(invalid(&format!("{path}.{key}"), "a string")); + } + } + Ok(()) +} + +fn string_array(value: &Value, path: &str, allowed: &[&str]) -> Result<(), AppError> { + let entries = value.as_array().ok_or_else(|| invalid(path, "an array"))?; + for entry in entries { + if !entry + .as_str() + .is_some_and(|text| allowed.is_empty() || allowed.contains(&text)) + { + return Err(invalid(path, "an array of allowed strings")); + } + } + Ok(()) +} + +fn compat(value: &Value, path: &str, when_thinking: bool) -> Result<(), AppError> { + let value = object(value, path)?; + fields( + value, + path, + &[], + &[ + "supportsStore", + "supportsDeveloperRole", + "supportsMultipleSystemMessages", + "supportsReasoningEffort", + "supportsUsageInStreaming", + "requiresToolResultName", + "requiresMistralToolIds", + "requiresAssistantAfterToolResult", + "requiresThinkingAsText", + "requiresReasoningContentForToolCalls", + "allowsSyntheticReasoningContentForToolCalls", + "requiresAssistantContentForToolCalls", + "supportsToolChoice", + "supportsForcedToolChoice", + "disableReasoningOnForcedToolChoice", + "disableReasoningOnToolChoice", + "disableReasoningWithTools", + "qwenTemplateReasoningEffort", + "supportsStrictMode", + "supportsLongPromptCacheRetention", + "supportsReasoningParams", + "supportsReasoningSummary", + "statefulResponses", + "alwaysSendMaxTokens", + "strictResponsesPairing", + "supportsImageDetailOriginal", + "supportsConfigurationUpdate", + "supportsSteering", + "stripImageInput", + "supportsContextManagement", + "supportsEagerToolInputStreaming", + "allowAnthropicHeaderOverrides", + "requiresToolResultId", + "replayUnsignedThinking", + "bedrockMessagesApi", + ], + &[ + "streamIdleTimeoutMs", + "promptCacheMinimumTokens", + "promptCacheMaximumCheckpoints", + ], + )?; + for (key, allowed) in [ + ( + "thinkingFormat", + &["openai", "openrouter", "zai", "qwen", "qwen-chat-template"][..], + ), + ( + "maxTokensField", + &["max_completion_tokens", "max_tokens"][..], + ), + ( + "reasoningContentField", + &["reasoning_content", "reasoning", "reasoning_text"][..], + ), + ("cacheControlFormat", &["anthropic"][..]), + ("toolStrictMode", &["all_strict", "none"][..]), + ( + "streamMarkupHealingPattern", + &["kimi", "dsml", "qwen", "thinking"][..], + ), + ("promptCacheMode", &["none", "automatic", "explicit"][..]), + ] { + enumeration(value, path, key, allowed)?; + } + for key in [ + "streamIdleTimeoutMs", + "promptCacheMinimumTokens", + "promptCacheMaximumCheckpoints", + ] { + if value + .get(key) + .and_then(Value::as_f64) + .is_some_and(|number| number < 0.0) + { + return Err(invalid(&format!("{path}.{key}"), "a non-negative number")); + } + } + if let Some(map) = value.get("reasoningEffortMap") { + fields(object(map, path)?, path, EFFORTS, &[], &[])?; + } + for key in ["openRouterRouting", "vercelGatewayRouting"] { + if let Some(routing) = value.get(key) { + for (field, entry) in object(routing, path)? { + if matches!(field.as_str(), "only" | "order") { + string_array(entry, path, &[])?; + } + } + } + } + if let Some(extra) = value.get("extraBody") { + object(extra, path)?; + } + if !when_thinking { + if let Some(thinking) = value.get("whenThinking") { + compat(thinking, &format!("{path}.whenThinking"), true)?; + } + } + Ok(()) +} + +fn thinking(value: &Value, path: &str) -> Result<(), AppError> { + let value = object(value, path)?; + if !value.contains_key("mode") { + return Err(invalid(path, "thinking.mode")); + } + enumeration( + value, + path, + "mode", + &[ + "effort", + "budget", + "google-level", + "anthropic-adaptive", + "anthropic-budget-effort", + ], + )?; + fields( + value, + path, + &[], + &["supportsDisplay", "requiresEffort"], + &[], + )?; + for key in ["defaultLevel", "minLevel", "maxLevel"] { + enumeration(value, path, key, EFFORTS)?; + } + for key in ["efforts", "levels"] { + if let Some(entry) = value.get(key) { + string_array(entry, &format!("{path}.{key}"), EFFORTS)?; + } + } + if let Some(map) = value.get("effortMap") { + fields(object(map, path)?, path, EFFORTS, &[], &[])?; + } + if !(value.contains_key("efforts") + || value.contains_key("levels") + || value.contains_key("minLevel") && value.contains_key("maxLevel")) + { + return Err(invalid(path, "efforts, levels, or minLevel and maxLevel")); + } + Ok(()) +} + +fn remote_compaction(value: &Value, path: &str) -> Result<(), AppError> { + let value = object(value, path)?; + fields(value, path, &[], &["enabled", "v2StreamingEnabled"], &[])?; + nonempty( + value, + path, + &["endpoint", "model", "v2Endpoint", "streamingEndpoint"], + )?; + enumeration(value, path, "api", APIS) +} + +fn kind_matches_api( + value: &Map, + inherited_api: Option<&str>, + path: &str, +) -> Result<(), AppError> { + let Some(kind) = value.get("kind").and_then(Value::as_str) else { + return Ok(()); + }; + let Some(api) = value.get("api").and_then(Value::as_str).or(inherited_api) else { + return Ok(()); + }; + let allowed = match api { + "typesafe" | "openrouter-decisions" => &["judge"][..], + "openai-images" | "openrouter-images" => &["image"][..], + "xai-tts" | "openai-speech" => &["tts"][..], + "openai-embeddings" => &["embedding"][..], + "openrouter-rerank" => &["rerank"][..], + "openrouter-video" => &["video"][..], + "openai-transcriptions" => &["stt"][..], + "google-generative-ai" + | "google-gemini-cli" + | "openai-responses" + | "openai-codex-responses" => &["chat", "tiny", "image"][..], + _ => &["chat", "tiny"][..], + }; + if !allowed.contains(&kind) { + return Err(invalid(path, "kind matching the selected api")); + } + Ok(()) +} + +fn model(value: &Value, path: &str, definition: bool) -> Result<(), AppError> { + let value = object(value, path)?; + nonempty( + value, + path, + &["name", "contextPromotionTarget", "compactionModel"], + )?; + fields( + value, + path, + &[], + &[ + "reasoning", + "supportsTools", + "omitMaxOutputTokens", + "preferWebsockets", + ], + &[ + "premiumMultiplier", + "contextWindow", + "maxContextWindow", + "maxTokens", + ], + )?; + let model_apis: Vec<_> = APIS.iter().chain(RUNNERS).copied().collect(); + enumeration(value, path, "api", &model_apis)?; + enumeration( + value, + path, + "kind", + &[ + "chat", + "tiny", + "image", + "tts", + "stt", + "judge", + "embedding", + "rerank", + "video", + ], + )?; + enumeration(value, path, "imageInputDecoder", &["stb"])?; + enumeration( + value, + path, + "tokenizer", + &[ + "claude-v3", + "claude-v47", + "claude-v5", + "claude-v5-sonnet", + "qwen3", + "deepseek-v3", + "kimi-k2", + "glm5", + ], + )?; + if definition { + if !value.contains_key("id") { + return Err(invalid(path, "a model id")); + } + nonempty(value, path, &["id", "baseUrl"])?; + for key in ["contextWindow", "maxTokens"] { + if value + .get(key) + .and_then(Value::as_f64) + .is_some_and(|number| number <= 0.0) + { + return Err(invalid(&format!("{path}.{key}"), "a positive number")); + } + } + } + if let Some(number) = value.get("maxContextWindow").and_then(Value::as_f64) { + if number <= 0.0 + || number.fract() != 0.0 + || number > 9_007_199_254_740_991.0 + || value + .get("contextWindow") + .and_then(Value::as_f64) + .is_some_and(|context| number < context) + { + return Err(invalid( + &format!("{path}.maxContextWindow"), + "a positive safe integer no smaller than contextWindow", + )); + } + } + for (key, entry) in value { + let nested = format!("{path}.{key}"); + match key.as_str() { + "input" => string_array(entry, &nested, &["text", "image"])?, + "thinking" => thinking(entry, &nested)?, + "headers" => string_map(entry, &nested)?, + "compat" => compat(entry, &nested, false)?, + "remoteCompaction" => remote_compaction(entry, &nested)?, + "cost" => { + let cost = object(entry, &nested)?; + let keys = &["input", "output", "cacheRead", "cacheWrite"]; + fields(cost, &nested, &[], &[], keys)?; + if definition && keys.iter().any(|key| !cost.contains_key(*key)) { + return Err(invalid( + &nested, + "input, output, cacheRead and cacheWrite prices", + )); + } + } + "promptCache" => fields( + object(entry, &nested)?, + &nested, + &[], + &[], + &["short", "long"], + )?, + _ => {} + } + } + Ok(()) +} + +pub(super) fn validate_provider_node(provider_key: &str, config: &Value) -> Result<(), AppError> { + if provider_key.trim().is_empty() { + return Err(invalid("provider key", "a non-empty string")); + } + let path = format!("providers.{provider_key}"); + let provider = object(config, &path)?; + nonempty(provider, &path, &["baseUrl", "apiKey"])?; + fields( + provider, + &path, + &["guardrailIdentifier", "guardrailVersion"], + &["authHeader", "disableStrictTools"], + &[], + )?; + enumeration(provider, &path, "api", APIS)?; + enumeration(provider, &path, "auth", &["apiKey", "none", "oauth"])?; + enumeration(provider, &path, "transport", &["pi-native"])?; + enumeration( + provider, + &path, + "guardrailTrace", + &["enabled", "disabled", "enabled_full"], + )?; + for (key, entry) in provider { + let nested = format!("{path}.{key}"); + match key.as_str() { + "headers" | "requestMetadata" => string_map(entry, &nested)?, + "compat" => compat(entry, &nested, false)?, + "remoteCompaction" => remote_compaction(entry, &nested)?, + "discovery" => { + let discovery = object(entry, &nested)?; + if !discovery.contains_key("type") { + return Err(invalid(&nested, "a discovery type")); + } + enumeration( + discovery, + &nested, + "type", + &[ + "ollama", + "llama.cpp", + "lm-studio", + "openai-models-list", + "proxy", + "litellm", + "apple-foundation-models", + ], + )?; + fields(discovery, &nested, &[], &["injectV1"], &["timeoutMs"])?; + if discovery + .get("timeoutMs") + .and_then(Value::as_f64) + .is_some_and(|number| number <= 0.0) + { + return Err(invalid(&nested, "a positive timeoutMs")); + } + if discovery.contains_key("injectV1") + && discovery.get("type").and_then(Value::as_str) != Some("openai-models-list") + { + return Err(invalid( + &nested, + "injectV1 only on openai-models-list discovery", + )); + } + if !provider.contains_key("api") + && discovery.get("type").and_then(Value::as_str) != Some("proxy") + { + return Err(invalid(&path, "api when discovery is enabled")); + } + } + _ => {} + } + } + let models = match provider.get("models") { + Some(value) => value + .as_array() + .ok_or_else(|| invalid(&format!("{path}.models"), "an array"))? + .as_slice(), + None => &[], + }; + let api = provider.get("api").and_then(Value::as_str); + for (index, entry) in models.iter().enumerate() { + let nested = format!("{path}.models[{index}]"); + model(entry, &nested, true)?; + let entry = object(entry, &nested)?; + if api.is_none() && !entry.contains_key("api") { + return Err(invalid(&nested, "api at provider or model level")); + } + kind_matches_api(entry, api, &nested)?; + } + let overrides = provider + .get("modelOverrides") + .map(|entry| object(entry, &format!("{path}.modelOverrides"))) + .transpose()?; + if let Some(overrides) = overrides { + for (id, entry) in overrides { + let nested = format!("{path}.modelOverrides.{id}"); + model(entry, &nested, false)?; + let declared = models + .iter() + .find(|entry| entry.get("id").and_then(Value::as_str) == Some(id)); + let declared_api = declared + .and_then(|entry| entry.get("api")) + .and_then(Value::as_str) + .or(if declared.is_some() { api } else { None }); + kind_matches_api(object(entry, &nested)?, declared_api, &nested)?; + } + } + if !models.is_empty() { + if !provider.contains_key("baseUrl") { + return Err(invalid(&path, "baseUrl when defining custom models")); + } + if !provider.contains_key("apiKey") + && !matches!( + provider.get("auth").and_then(Value::as_str), + Some("none" | "oauth") + ) + { + return Err(invalid(&path, "apiKey unless auth is none or oauth")); + } + } else { + let has_override = [ + "baseUrl", + "headers", + "compat", + "apiKey", + "requestMetadata", + "remoteCompaction", + "discovery", + ] + .iter() + .any(|key| provider.contains_key(*key)) + || provider.get("auth").and_then(Value::as_str) == Some("none") + || provider.get("disableStrictTools").and_then(Value::as_bool) == Some(true) + || provider + .get("guardrailIdentifier") + .and_then(Value::as_str) + .is_some_and(|text| !text.is_empty()) + || overrides.is_some_and(|entries| !entries.is_empty()); + if !has_override { + return Err(invalid( + &path, + "models, discovery, or a native provider override", + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn rejects_invalid_native_fields_without_rejecting_sparse_or_future_fields() { + for config in [ + json!({"name": "Display name"}), + json!({"baseUrl": "https://test", "compat": {"thinkingFormat": "deepseek"}}), + json!({"baseUrl": "https://test", "api": "invalid"}), + json!({"baseUrl": "https://test", "headers": {"x-header": 1}}), + json!({"baseUrl": "https://test", "modelOverrides": {"m": {"thinking": "high"}}}), + json!({"baseUrl": "https://test", "modelOverrides": {"m": {"thinking": {"mode": "effort"}}}}), + json!({"api": "openai-completions", "auth": "none", "models": [{"id": "m"}]}), + json!({"baseUrl": "https://test", "api": "openai-completions", "models": [{"id": "m"}]}), + json!({"baseUrl": "https://test", "api": "openai-completions", "auth": "none", "models": [{"id": "m", "cost": {"input": 0.15, "output": 0.25}}]}), + json!({"baseUrl": "https://test", "api": "openai-completions", "auth": "none", "models": [{"id": "m", "kind": "tts"}]}), + json!({"baseUrl": "https://test", "discovery": {"type": "ollama"}}), + ] { + assert!( + validate_provider_node("test", &config).is_err(), + "accepted {config}" + ); + } + for config in [ + json!({"baseUrl": "https://proxy", "apiKey": "test"}), + json!({"compat": {"supportsStore": false, "futureCompat": {"enabled": true}}, "futureProvider": [1,2]}), + json!({"modelOverrides": {"m": {"cost": {"input": 0.15}, "futureModel": true}}}), + json!({"baseUrl": "https://test", "auth": "none", "models": [{"id": "m", "api": "openai-images", "kind": "image", "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}, "thinking": {"mode": "effort", "minLevel": "low", "maxLevel": "high"}}]}), + json!({"discovery": {"type": "proxy"}}), + ] { + assert!( + validate_provider_node("test", &config).is_ok(), + "rejected {config}" + ); + } + } +} diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index b5531895a..37b34a71f 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -19,7 +19,7 @@ pub(crate) fn validate_prompt_content(app: &AppType, content: &str) -> Result<() /// 返回指定应用所使用的提示词文件路径。 pub fn prompt_file_path(app: &AppType) -> Result { - if matches!(app, AppType::ClaudeDesktop) { + if matches!(app, AppType::ClaudeDesktop | AppType::OhMyPi) { return Err(AppError::localized( "app.prompts_unsupported", "当前应用暂不支持 Prompts", @@ -41,7 +41,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { // agy 只从各「定制根目录」读独立的 AGENTS.md;全局那一个是 ~/.gemini/config,Key 模式的 // 私有 --gemini_dir 也经软链共享它。别用 ~/.gemini/GEMINI.md:那是 Gemini CLI 的。 AppType::Agy => crate::agy_config::customizations_dir(), - AppType::ClaudeDesktop => unreachable!("handled above"), + AppType::ClaudeDesktop | AppType::OhMyPi => unreachable!("handled above"), }; let filename = match app { @@ -51,7 +51,8 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw => "AGENTS.md", AppType::Hermes => "SOUL.md", AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi => "AGENTS.md", - AppType::ClaudeDesktop => unreachable!("handled above"), + + AppType::ClaudeDesktop | AppType::OhMyPi => unreachable!("handled above"), }; Ok(base_dir.join(filename)) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 2e7243752..ab495024c 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -256,6 +256,11 @@ impl Provider { ); (base_url, api_key) } + // Oh My Pi custom providers use the models.yml + AppType::OhMyPi => ( + crate::ohmypi_config::provider_base_url(settings).unwrap_or_default(), + str_at(settings.get("apiKey")), + ), }; // Normalize like the JS-script path (extract_base_url_from_provider) so a @@ -617,6 +622,14 @@ pub struct ProviderMeta { skip_serializing_if = "Option::is_none" )] pub stack_models: Option>, + /// Oh My Pi:编辑器里“其他字段(YAML,原样保留)”的原文(含注释与顺序)。 + /// 仅存于数据库,写 models.yml 时用于文本级定点替换,不参与其他应用。 + #[serde( + rename = "ohmypiPassthroughYaml", + default, + skip_serializing_if = "Option::is_none" + )] + pub ohmypi_passthrough_yaml: Option, } /// Stack 模式下 Claude Code 供应商发布的一个模型。 diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 69b84851a..14524035f 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -215,7 +215,9 @@ impl ProviderType { } AppType::GrokBuild => ProviderType::Codex, AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex, - AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy => return None, + AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy | AppType::OhMyPi => { + return None + } }; Some(provider_type) } @@ -271,7 +273,9 @@ pub fn get_adapter(app_type: &AppType) -> Option> { AppType::Gemini => Box::new(GeminiAdapter::new()), AppType::GrokBuild => Box::new(CodexAdapter::new()), AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()), - AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy => return None, + AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy | AppType::OhMyPi => { + return None + } }) } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 95eacd213..4f655b489 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -28,23 +28,47 @@ impl McpService { /// 添加或更新 MCP 服务器 pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> { // 读取旧状态:用于处理“编辑时取消勾选某个应用”的场景(需要从对应 live 配置中移除) - let prev_apps = state - .db - .get_all_mcp_servers()? - .get(&server.id) + let previous = state.db.get_all_mcp_servers()?.get(&server.id).cloned(); + let prev_apps = previous + .as_ref() .map(|s| s.apps.clone()) .unwrap_or_default(); + // Keep our disabled OMP tombstone in step with edits while the checkbox is off. + let managed_ohmypi = server.apps.ohmypi + || prev_apps.ohmypi + || previous.as_ref().is_some_and(|saved| { + mcp::ohmypi::is_disabled_managed(&saved.id, &saved.server).unwrap_or(false) + }); + // Native MCP writes and managed state commit together. + let save_with_ohmypi = || { + if managed_ohmypi { + mcp::ohmypi::sync_and_commit( + &server.id, + mcp::ohmypi::OhMyPiChange::from_previous( + &server.server, + previous + .as_ref() + .map(|s| &s.server) + .unwrap_or(&server.server), + server.apps.ohmypi, + ), + || state.db.save_mcp_server(&server), + ) + } else { + state.db.save_mcp_server(&server) + } + }; // MCode / Pi 的文件和数据库一起提交:任一步失败都恢复原样 let save_with_kimi = || { if server.apps.kimi || prev_apps.kimi { mcp::kimi::sync_and_commit( &server.id, server.apps.kimi.then_some(&server.server), - || state.db.save_mcp_server(&server), + save_with_ohmypi, ) } else { - state.db.save_mcp_server(&server) + save_with_ohmypi() } }; let save_with_agy = || { @@ -117,15 +141,24 @@ impl McpService { state.db.delete_mcp_server(id) } }; - let delete_with_agy = || { - if server.apps.agy { - mcp::agy::sync_and_commit(id, mcp::agy::AgyChange::Remove, || { - delete_with_kimi() - }) + let delete_with_ohmypi = || { + if server.apps.ohmypi { + mcp::ohmypi::sync_and_commit( + id, + mcp::ohmypi::OhMyPiChange::Remove(Some(&server.server)), + delete_with_kimi, + ) } else { delete_with_kimi() } }; + let delete_with_agy = || { + if server.apps.agy { + mcp::agy::sync_and_commit(id, mcp::agy::AgyChange::Remove, delete_with_ohmypi) + } else { + delete_with_ohmypi() + } + }; let delete_with_pi = || { if server.apps.pi { mcp::pi::sync_and_commit(id, mcp::pi::PiChange::Remove, delete_with_agy) @@ -147,6 +180,11 @@ impl McpService { } } + if !server.apps.ohmypi { + if let Err(err) = mcp::ohmypi::remove_disabled_if_managed(id, &server.server) { + log::warn!("Cleaning disabled Oh My Pi MCP '{id}' failed: {err}"); + } + } // 从所有应用的 live 配置中移除 Self::remove_server_from_all_apps(state, id, &server)?; Ok(true) @@ -179,7 +217,7 @@ impl McpService { ) -> Result<(), AppError> { if matches!( app, - AppType::Mcode | AppType::Kimi | AppType::Pi | AppType::Agy + AppType::Mcode | AppType::Kimi | AppType::Pi | AppType::Agy | AppType::OhMyPi ) { if let Some(server) = state.db.get_all_mcp_servers()?.get(server_id) { let spec = enabled.then_some(&server.server); @@ -198,6 +236,12 @@ impl McpService { mcp::kimi::sync_and_commit(server_id, spec, commit)?; } else if app == AppType::Mcode { mcp::mcode::sync_and_commit(server_id, spec, commit)?; + } else if app == AppType::OhMyPi { + mcp::ohmypi::sync_and_commit( + server_id, + mcp::ohmypi::OhMyPiChange::from_enabled(&server.server, enabled), + commit, + )?; } else { mcp::pi::sync_and_commit( server_id, @@ -228,7 +272,7 @@ impl McpService { for app in server.apps.enabled_apps() { if matches!( app, - AppType::Mcode | AppType::Kimi | AppType::Pi | AppType::Agy + AppType::Mcode | AppType::Kimi | AppType::Pi | AppType::Agy | AppType::OhMyPi ) { continue; // Already written before saving the managed state. } @@ -288,6 +332,7 @@ impl McpService { AppType::Kimi => mcp::kimi::sync(&server.id, Some(&server.server))?, AppType::Pi => mcp::pi::sync(&server.id, Some(&server.server))?, AppType::Agy => mcp::agy::sync(&server.id, Some(&server.server))?, + AppType::OhMyPi => mcp::sync_single_server_to_ohmypi(&server.id, &server.server)?, } Ok(()) } @@ -302,7 +347,7 @@ impl McpService { for app in server.apps.enabled_apps() { if matches!( app, - AppType::Mcode | AppType::Kimi | AppType::Pi | AppType::Agy + AppType::Mcode | AppType::Kimi | AppType::Pi | AppType::Agy | AppType::OhMyPi ) { continue; // Already removed before deleting the managed record. } @@ -334,6 +379,7 @@ impl McpService { AppType::Kimi => mcp::kimi::sync(id, None)?, AppType::Pi => mcp::pi::sync(id, None)?, AppType::Agy => mcp::agy::sync(id, None)?, + AppType::OhMyPi => mcp::remove_server_from_ohmypi(id)?, } Ok(()) } @@ -441,7 +487,7 @@ impl McpService { Self::sync_server_to_app(state, server, app) } else if !matches!( app, - AppType::Mcode | AppType::Kimi | AppType::Pi | AppType::Agy + AppType::Mcode | AppType::Kimi | AppType::Pi | AppType::Agy | AppType::OhMyPi ) { Self::remove_server_from_app(state, &server.id, app) } else { @@ -736,6 +782,11 @@ impl McpService { Ok(new_count) } + /// 从 Oh My Pi 导入 MCP + pub fn import_from_ohmypi(state: &AppState) -> Result { + mcp::ohmypi::import(state) + } + /// 从所有支持 MCP 的应用导入服务器,返回新导入的数量。 /// /// Best-effort:单个应用导入失败(如坏 config.toml)不阻断其余应用; @@ -746,7 +797,7 @@ impl McpService { let mut total = 0; let mut failures: Vec = Vec::new(); - let results: [(&str, Result); 10] = [ + let results: [(&str, Result); 11] = [ ("claude", Self::import_from_claude(state)), ("codex", Self::import_from_codex(state)), ("gemini", Self::import_from_gemini(state)), @@ -757,6 +808,7 @@ impl McpService { ("kimi", mcp::kimi::import(state)), ("agy", mcp::agy::import(state)), ("pi", mcp::pi::import(state)), + ("ohmypi", Self::import_from_ohmypi(state)), ]; for (app, result) in results { match result { diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 76d7ead43..11c8bb10c 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -11,6 +11,7 @@ pub mod kimi_oauth; pub mod mcp; pub mod model_fetch; pub mod model_pricing; +pub(crate) mod ohmypi_state; pub mod omo; pub mod pi_prompt_files; pub(crate) mod pi_state; diff --git a/src-tauri/src/services/ohmypi_state.rs b/src-tauri/src/services/ohmypi_state.rs new file mode 100644 index 000000000..1fcc0dc54 --- /dev/null +++ b/src-tauri/src/services/ohmypi_state.rs @@ -0,0 +1,103 @@ +//! Read-only Oh My Pi provider membership and global default reference. + +use crate::error::AppError; +use crate::ohmypi_config::{ + provider_from_selector, read_ohmypi_default_model, read_ohmypi_native_providers, +}; +use crate::store::AppState; +use serde::Serialize; + +const OHMYPI_APP: &str = "ohmypi"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OhMyPiCurrentState { + pub enabled_provider_ids: Vec, + pub default_provider_id: Option, +} + +pub(crate) struct OhMyPiStateService; + +impl OhMyPiStateService { + pub(crate) fn current(state: &AppState) -> Result { + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(OHMYPI_APP)); + let native = read_ohmypi_native_providers()?; + let enabled_provider_ids = native.keys().cloned().collect::>(); + // `modelRoles.default` is a `/` selector. The provider + // prefix is exposed even when the provider has no explicit node in + // `models.yml` (built-in model activation). + let default_provider_id = match read_ohmypi_default_model() { + Ok(Some(selector)) => Some(provider_from_selector(&selector).to_string()), + Ok(None) => None, + Err(error) => { + log::warn!("Failed to read Oh My Pi default model: {error}"); + None + } + }; + Ok(OhMyPiCurrentState { + enabled_provider_ids, + default_provider_id, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use crate::ohmypi_config::test_support::TestAgentDir; + use serial_test::serial; + use std::fs; + use std::sync::Arc; + + #[test] + #[serial] + fn state_exposes_explicit_providers_and_default() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new( + Database::memory().expect("create in-memory database"), + )); + let models_path = crate::ohmypi_config::get_ohmypi_models_path().expect("models path"); + fs::create_dir_all(models_path.parent().expect("models directory")) + .expect("create models directory"); + fs::write( + models_path, + "providers:\n managed:\n baseUrl: https://api.example.com/v1\n api: openai-completions\n models:\n - id: model-a\n native:\n baseUrl: https://native.example\n", + ) + .expect("write models"); + let settings_path = + crate::ohmypi_config::get_ohmypi_settings_path().expect("settings path"); + fs::write(settings_path, "modelRoles:\n default: managed/model-a\n") + .expect("write settings"); + + let current = OhMyPiStateService::current(&state).expect("read state"); + assert_eq!( + current.enabled_provider_ids, + vec!["managed".to_string(), "native".to_string()] + ); + assert_eq!(current.default_provider_id.as_deref(), Some("managed")); + } + + #[test] + #[serial] + fn built_in_default_without_explicit_node_is_tolerated() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new( + Database::memory().expect("create in-memory database"), + )); + let models_path = crate::ohmypi_config::get_ohmypi_models_path().expect("models path"); + fs::create_dir_all(models_path.parent().expect("models directory")) + .expect("create models directory"); + fs::write(models_path, "providers: {}\n").expect("write empty models"); + let settings_path = + crate::ohmypi_config::get_ohmypi_settings_path().expect("settings path"); + fs::write(settings_path, "modelRoles:\n default: openai/gpt-4o\n") + .expect("write settings"); + + let current = OhMyPiStateService::current(&state).expect("read state"); + assert!(current.enabled_provider_ids.is_empty()); + // modelRoles.default references a built-in provider with no explicit node. + assert_eq!(current.default_provider_id.as_deref(), Some("openai")); + } +} diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index 1f0f84d51..d3020f69a 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -281,7 +281,7 @@ impl PromptService { pub fn sync_to_live(state: &AppState, app: AppType) -> Result<(), AppError> { // Pi derives activation from its native AGENTS.md; its persisted prompt // rows are intentionally disabled and must not drive generic projection. - if matches!(app, AppType::ClaudeDesktop | AppType::Pi) { + if matches!(app, AppType::ClaudeDesktop | AppType::Pi | AppType::OhMyPi) { return Ok(()); } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 11b0a6802..035719178 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -35,6 +35,7 @@ pub(crate) fn provider_exists_in_live_config( } AppType::Mcode => crate::mcode_config::get_providers() .map(|providers| providers.contains_key(provider_id)), + AppType::OhMyPi => crate::ohmypi_config::ohmypi_provider_exists(provider_id), _ => Ok(false), } } @@ -306,6 +307,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: | AppType::Mcode | AppType::Kimi | AppType::Agy + | AppType::OhMyPi | AppType::ClaudeDesktop => false, } } @@ -384,7 +386,8 @@ pub(crate) fn remove_common_config_from_settings( | AppType::Mcode | AppType::Kimi | AppType::ClaudeDesktop - | AppType::Agy => Ok(settings.clone()), + | AppType::Agy + | AppType::OhMyPi => Ok(settings.clone()), } } @@ -715,6 +718,11 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re "Antigravity configuration requires the provider switch flow".into(), )); } + AppType::OhMyPi => { + return Err(AppError::InvalidInput( + "Oh My Pi providers use the Oh My Pi provider service".to_string(), + )); + } } Ok(()) } @@ -845,7 +853,10 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { // Sync providers based on mode for app_type in AppType::all() { - if matches!(app_type, AppType::Pi | AppType::Mcode | AppType::Kimi) { + if matches!( + app_type, + AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::OhMyPi + ) { continue; } let result = if app_type.is_additive_mode() { @@ -1006,6 +1017,9 @@ pub fn read_live_settings(app_type: AppType) -> Result { "Pi providers are read from Pi's native models file".to_string(), )), AppType::Agy => crate::agy_config::read_live_settings(), + AppType::OhMyPi => Err(AppError::InvalidInput( + "Oh My Pi providers are read from Oh My Pi's native models file".to_string(), + )), } } @@ -1125,7 +1139,8 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { + | AppType::Kimi + | AppType::OhMyPi => { unreachable!("additive mode apps are handled by early return") } }; diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 9e10a8960..8b46023de 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod grok_direct; mod grok_editor; pub(crate) mod icon; mod live; +mod ohmypi; #[cfg(test)] mod opencode_tests; mod pi; @@ -47,6 +48,10 @@ pub fn import_pi_providers_from_live(state: &AppState) -> Result Result { + ohmypi::import_from_live(state) +} + pub use claude_editor::{EditorSave, EditorView}; // Internal re-exports (pub(crate)) @@ -5458,6 +5463,9 @@ impl ProviderService { if app_type == AppType::Pi { return pi::list(state); } + if app_type == AppType::OhMyPi { + return ohmypi::list(state); + } if matches!(app_type, AppType::Mcode | AppType::Kimi) { let native = if app_type == AppType::Kimi { crate::kimi_config::get_providers()? @@ -5617,6 +5625,10 @@ impl ProviderService { return pi::add(state, provider, add_to_live); } + if app_type == AppType::OhMyPi { + return ohmypi::add(state, provider, add_to_live); + } + let mut provider = provider; Self::prepare_new_provider(&app_type, &mut provider)?; if app_type.is_additive_mode() { @@ -6189,6 +6201,10 @@ impl ProviderService { return pi::update(state, original_id, provider); } + if app_type == AppType::OhMyPi { + return ohmypi::update(state, original_id, provider); + } + let mut provider = provider; let original_id = original_id.unwrap_or(provider.id.as_str()).to_string(); let provider_id_changed = original_id != provider.id; @@ -6466,6 +6482,14 @@ impl ProviderService { pi::update_usage_script(state, id, script) } + pub(crate) fn update_ohmypi_usage_script( + state: &AppState, + id: &str, + script: crate::provider::UsageScript, + ) -> Result { + ohmypi::update_usage_script(state, id, script) + } + /// Delete a provider /// /// 直连指针或代理路由指着的供应商不能删(见 `mode::current::is_referenced`)。 @@ -6492,6 +6516,10 @@ impl ProviderService { return Ok(()); } + if app_type == AppType::OhMyPi { + return ohmypi::delete(state, id); + } + // Additive mode apps - no current provider concept if app_type.is_additive_mode() { // Single DB read shared across all additive-mode sub-paths below. @@ -6613,6 +6641,10 @@ impl ProviderService { }); } + if app_type == AppType::OhMyPi { + return ohmypi::remove(state, id); + } + match app_type { AppType::OpenCode => { let provider_category = state @@ -6729,6 +6761,10 @@ impl ProviderService { )); } + if app_type == AppType::OhMyPi { + return ohmypi::enable(state, id); + } + // Check if provider exists let providers = state.db.get_all_providers(app_type.as_str())?; let _provider = providers @@ -7177,7 +7213,9 @@ impl ProviderService { AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config), AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets - AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy => Ok(String::new()), + AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy | AppType::OhMyPi => { + Ok(String::new()) + } } } @@ -7195,7 +7233,9 @@ impl ProviderService { AppType::OpenCode => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets - AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy => Ok(String::new()), + AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy | AppType::OhMyPi => { + Ok(String::new()) + } } } @@ -7970,6 +8010,12 @@ impl ProviderService { AppType::Pi => { crate::pi_config::validate_provider_node(&provider.id, &provider.settings_config)?; } + AppType::OhMyPi => { + crate::ohmypi_config::validate_provider_node( + &provider.id, + &provider.settings_config, + )?; + } } // Validate and clean UsageScript configuration (common for all app types) @@ -8182,7 +8228,11 @@ impl ProviderService { .unwrap_or_default() .into(), )), - AppType::OpenClaw | AppType::Hermes | AppType::Pi | AppType::Mcode => { + AppType::OpenClaw + | AppType::Hermes + | AppType::Pi + | AppType::Mcode + | AppType::OhMyPi => { // These native formats use apiKey and baseUrl directly on the object. let api_key = provider .settings_config diff --git a/src-tauri/src/services/provider/ohmypi.rs b/src-tauri/src/services/provider/ohmypi.rs new file mode 100644 index 000000000..a8c4aa8b3 --- /dev/null +++ b/src-tauri/src/services/provider/ohmypi.rs @@ -0,0 +1,329 @@ +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 OHMYPI_APP: &str = "ohmypi"; + +pub(super) fn list(state: &AppState) -> Result, AppError> { + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app(OHMYPI_APP)); + match crate::ohmypi_config::read_ohmypi_native_providers() { + Ok(native) => { + if let Err(error) = sync_native_locked(state, &native) { + log::warn!("Failed to sync Oh My Pi providers from native config: {error}"); + } + } + Err(error) => { + log::warn!("Failed to read Oh My Pi providers; showing saved catalog: {error}"); + } + } + let mut providers = state.db.get_all_providers(OHMYPI_APP)?; + // Show the raw models.yml entry (comments included) in the YAML editor. + // models.yml is the source of truth: re-read on every list so external + // edits to an entry (comments, formatting, unmanaged fields) reach the + // editor; the stored snapshot is only a fallback when the file or the + // entry cannot be read. + let ids: Vec = providers.keys().cloned().collect(); + match crate::ohmypi_config::read_ohmypi_provider_entries_raw(&ids) { + Ok(entries) => { + for (id, provider) in providers.iter_mut() { + if let Some(text) = entries.get(id) { + let meta = provider.meta.get_or_insert_with(ProviderMeta::default); + meta.ohmypi_passthrough_yaml = Some(text.clone()); + } + // Entry absent from models.yml (not yet written to live): + // keep the stored snapshot so pending editor text survives. + } + } + Err(error) => { + log::warn!("Failed to read Oh My Pi raw entries: {error}"); + } + } + Ok(providers) +} + +pub(super) fn import_from_live(state: &AppState) -> Result { + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app(OHMYPI_APP)); + let native = crate::ohmypi_config::read_ohmypi_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::OhMyPi; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + strip_unsupported_ohmypi_metadata(&mut provider); + ProviderService::validate_provider_settings(&app_type, &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!( + "Oh My Pi provider '{}' already exists", + provider.id + ))); + } + + if !add_to_live && crate::ohmypi_config::ohmypi_provider_exists(&provider.id)? { + return Err(AppError::InvalidInput(format!( + "Oh My Pi provider key '{}' already exists in models.yml", + provider.id + ))); + } + + let passthrough_yaml = provider + .meta + .as_ref() + .and_then(|meta| meta.ohmypi_passthrough_yaml.clone()); + let native_inserted = if add_to_live { + crate::ohmypi_config::write_ohmypi_provider_entry( + &provider.id, + &provider.settings_config, + passthrough_yaml.as_deref(), + )? + } else { + false + }; + + if let Err(error) = state.db.save_provider(app_type.as_str(), &provider) { + if native_inserted { + if let Err(rollback) = crate::ohmypi_config::remove_ohmypi_provider_if_matches( + &provider.id, + &provider.settings_config, + ) { + return Err(AppError::Config(format!( + "failed to save Oh My 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::OhMyPi; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + super::validate_usage_script(&script)?; + + let mut provider = state + .db + .get_provider_by_id(id, app_type.as_str())? + .ok_or_else(|| AppError::InvalidInput(format!("Oh My Pi provider '{id}' not found")))?; + provider + .meta + .get_or_insert_with(ProviderMeta::default) + .usage_script = Some(script); + strip_unsupported_ohmypi_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 update( + state: &AppState, + original_id: Option<&str>, + mut provider: Provider, +) -> Result { + let app_type = AppType::OhMyPi; + 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( + "Oh My Pi provider keys cannot be renamed".to_string(), + )); + } + + let existing = state + .db + .get_provider_by_id(&original_id, app_type.as_str())? + .ok_or_else(|| { + AppError::InvalidInput(format!("Oh My Pi provider '{original_id}' not found")) + })?; + strip_unsupported_ohmypi_metadata(&mut provider); + ProviderService::validate_provider_settings(&app_type, &provider)?; + ProviderService::normalize_usage_script_credential_overrides(&app_type, &mut provider); + + let passthrough_yaml = provider + .meta + .as_ref() + .and_then(|meta| meta.ohmypi_passthrough_yaml.clone()); + // 冲突检查的底是数据库那一行(list() 时与 models.yml 同步过),不是编辑器重组的 + // 新配置:表单控件对字段顺序/表示的归一化不应被当成外部修改。 + let previous_native = crate::ohmypi_config::replace_ohmypi_provider_if_present( + &original_id, + &existing.settings_config, + &provider.settings_config, + passthrough_yaml.as_deref(), + )?; + 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::ohmypi_config::replace_ohmypi_provider( + &original_id, + &provider.settings_config, + previous_native, + ) { + return Err(AppError::Config(format!( + "failed to save Oh My 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::OhMyPi; + 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(()); + }; + let removed = crate::ohmypi_config::remove_ohmypi_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::ohmypi_config::restore_ohmypi_provider_if_missing(id, removed) + { + return Err(AppError::Config(format!( + "failed to delete Oh My 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::OhMyPi; + 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!("Oh My Pi provider '{id}' not found")))?; + let Some(removed) = crate::ohmypi_config::remove_ohmypi_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::ohmypi_config::restore_ohmypi_provider_if_missing(id, &removed) + { + return Err(AppError::Config(format!( + "failed to preserve Oh My 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::OhMyPi; + 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!("Oh My Pi provider '{id}' not found")))?; + + if let Some(native) = crate::ohmypi_config::read_ohmypi_native_provider(id)? { + let mut synced = provider; + merge_native_config(&mut synced, native); + // models.yml 是真相源:切换后刷新 passthrough 快照,编辑器读到的是 + // 当前文件内容,不会用旧 meta 文本把外部修改盖回去。 + if let Ok(Some(text)) = crate::ohmypi_config::read_ohmypi_provider_entry_raw(id) { + synced + .meta + .get_or_insert_with(ProviderMeta::default) + .ohmypi_passthrough_yaml = Some(text); + } + state.db.save_provider(app_type.as_str(), &synced)?; + return Ok(SwitchResult::default()); + } + + ProviderService::validate_provider_settings(&app_type, &provider)?; + crate::ohmypi_config::insert_ohmypi_provider(id, &provider.settings_config)?; + // D3: enable = add only. `modelRoles.default` is left unchanged; the + // read-only state service still shows the active provider, and a separate + // "set as default" action is future frontend work. + Ok(SwitchResult::default()) +} + +fn sync_native_locked( + state: &AppState, + native: &IndexMap, +) -> Result { + let saved = state.db.get_all_providers(OHMYPI_APP)?; + let mut changed = 0; + + for (id, config) in native { + let mut provider = saved.get(id).cloned().unwrap_or_else(|| { + let mut imported = Provider::with_id(id.clone(), id.clone(), config.clone(), None); + imported.category = Some("custom".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(OHMYPI_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 strip_unsupported_ohmypi_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, + ohmypi_passthrough_yaml: meta.ohmypi_passthrough_yaml, + ..ProviderMeta::default() + }); +} diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 6c405885f..51d12f107 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -697,6 +697,9 @@ impl SkillService { .join("config") .join("skills")); } + AppType::OhMyPi => { + return Ok(crate::ohmypi_config::get_ohmypi_agent_dir()?.join("skills")); + } } // 默认路径:回退到用户主目录下的标准位置。 @@ -717,6 +720,7 @@ impl SkillService { AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), AppType::Pi => crate::pi_config::get_pi_agent_dir()?.join("skills"), AppType::Agy => home.join(".gemini").join("config").join("skills"), + AppType::OhMyPi => crate::ohmypi_config::get_ohmypi_agent_dir()?.join("skills"), }) } @@ -1067,7 +1071,7 @@ impl SkillService { let ssot_dir = Self::get_ssot_dir()?; let source = ssot_dir.join(&directory); let mut managed_destinations = Vec::new(); - for app in [AppType::Mcode, AppType::Agy, AppType::Kimi] { + for app in [AppType::Mcode, AppType::Agy, AppType::Kimi, AppType::OhMyPi] { if skill.apps.is_enabled_for(&app) { let destination = Self::get_distinct_app_skills_dir(&ssot_dir, &app)? .join(&directory); @@ -1149,7 +1153,11 @@ impl SkillService { for app in AppType::all() { if matches!( app, - AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi + AppType::Pi + | AppType::Mcode + | AppType::Agy + | AppType::Kimi + | AppType::OhMyPi ) { continue; } @@ -1616,7 +1624,13 @@ impl SkillService { let dest = ssot_dir.join(&skill.directory); let mut deployments = Vec::new(); - for app in [AppType::Pi, AppType::Mcode, AppType::Agy, AppType::Kimi] { + for app in [ + AppType::Pi, + AppType::Mcode, + AppType::Agy, + AppType::Kimi, + AppType::OhMyPi, + ] { if skill.apps.is_enabled_for(&app) { let destination = Self::get_distinct_app_skills_dir(&ssot_dir, &app)?.join(&skill.directory); @@ -1631,7 +1645,11 @@ impl SkillService { // 备份旧文件 let _ = Self::create_uninstall_backup(&skill); - if !(skill.apps.mcode || skill.apps.agy || skill.apps.kimi) { + let owned_apps: Vec<_> = [AppType::Mcode, AppType::Agy, AppType::Kimi, AppType::OhMyPi] + .into_iter() + .filter(|app| skill.apps.is_enabled_for(app)) + .collect(); + if owned_apps.is_empty() { if dest.exists() { fs::remove_dir_all(&dest)?; } @@ -1639,7 +1657,7 @@ impl SkillService { } // 计算新哈希 + 解析新元数据 - let metadata_source = if skill.apps.mcode || skill.apps.agy || skill.apps.kimi { + let metadata_source = if !owned_apps.is_empty() { &source } else { &dest @@ -1671,15 +1689,14 @@ impl SkillService { updated_at: chrono::Utc::now().timestamp(), }; - let mut updated_skill = if skill.apps.mcode || skill.apps.agy || skill.apps.kimi { + let mut updated_skill = if !owned_apps.is_empty() { Self::update_managed_skill_files( &source, &dest, &skill.directory, &deployments, - &[AppType::Mcode, AppType::Agy, AppType::Kimi] + &owned_apps .iter() - .filter(|app| skill.apps.is_enabled_for(app)) .map(|app| Self::get_app_skills_dir(app).map(|dir| dir.join(&skill.directory))) .collect::>>()?, || { @@ -1706,7 +1723,7 @@ impl SkillService { for app in updated_skill.apps.enabled_apps() { if matches!( app, - AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi + AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi | AppType::OhMyPi ) { continue; } @@ -1722,11 +1739,12 @@ impl SkillService { // Keep the old SSOT and native copies until metadata is committed. In particular, // a failed native write must not make the old copy appear to be a user edit. #[cfg(test)] - fn update_mcode_skill_files( + fn update_owned_skill_files( source: &Path, ssot: &Path, directory: &str, deployments: &[(PathBuf, PiSkillDeployment)], + native_apps: &[AppType], commit: impl FnOnce() -> Result, ) -> Result { Self::update_managed_skill_files( @@ -1734,7 +1752,10 @@ impl SkillService { ssot, directory, deployments, - &[Self::get_app_skills_dir(&AppType::Mcode)?.join(directory)], + &native_apps + .iter() + .map(|app| Self::get_app_skills_dir(app).map(|dir| dir.join(directory))) + .collect::>>()?, commit, ) } @@ -1816,7 +1837,7 @@ impl SkillService { if !rollback_errors.is_empty() { let backups: Vec<_> = staged.into_iter().map(|(_, stage)| stage.keep()).collect(); return Err(anyhow!( - "{error}; MiniMax Code Skill rollback failed: {}; backups: {backups:?}", + "{error}; Native Skill rollback failed: {}; backups: {backups:?}", rollback_errors.join("; ") )); } @@ -2294,7 +2315,7 @@ impl SkillService { let ssot_dir = Self::get_ssot_dir()?; let agents_lock = parse_agents_lock(); let mut imported = Vec::new(); - let mut skipped_mcode = Vec::new(); + let mut skipped_native = Vec::new(); let mut not_enabled = Vec::new(); // 将 lock 文件中发现的仓库保存到 skill_repos @@ -2363,47 +2384,49 @@ impl SkillService { let skill_md = dest.join("SKILL.md"); let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name); - // 其他应用保存用户选择;Pi 的 exists=active 必须直接来自原生目录。 + // Pi follows directory presence; OMP native imports are claimed after ownership checks. let mut apps = selection.apps; apps.pi = Self::skill_exists_in_app(&dir_name, &AppType::Pi); + apps.ohmypi = Self::skill_exists_in_app(&dir_name, &AppType::OhMyPi) || apps.ohmypi; - // An explicitly imported MCode link must point at the managed copy + // An explicitly imported native link must point at the managed copy // so subsequent toggles and updates can verify its ownership. - for app in [AppType::Mcode, AppType::Agy, AppType::Kimi] { - if apps.is_enabled_for(&app) { - let projection = (|| -> Result<()> { - let native = - Self::get_distinct_app_skills_dir(&ssot_dir, &app)?.join(&dir_name); - if Self::is_symlink(&native) && !Self::paths_alias(&native, &dest) { - if Self::compute_pi_deployment_hash(&native)? - != Self::compute_pi_deployment_hash(&dest)? - { - return Err(anyhow!( - "Skill 与托管副本内容不同,拒绝替换链接: {dir_name}" - )); - } - if let Some(deployment) = - Self::inspect_pi_skill_destination(&native, &native, &dir_name)? - { - Self::refresh_pi_skill_destination( - &dest, - &native, - &dir_name, - &deployment, - )?; - } - } else { - Self::preflight_install_destination(&dest, &dir_name, &app)?; + for app in [AppType::Mcode, AppType::Agy, AppType::Kimi, AppType::OhMyPi] { + if !apps.is_enabled_for(&app) { + continue; + } + let projection = (|| -> Result<()> { + let native = + Self::get_distinct_app_skills_dir(&ssot_dir, &app)?.join(&dir_name); + if Self::is_symlink(&native) && !Self::paths_alias(&native, &dest) { + if Self::compute_pi_deployment_hash(&native)? + != Self::compute_pi_deployment_hash(&dest)? + { + return Err(anyhow!( + "{app:?} Skill 与托管副本内容不同,拒绝替换链接: {dir_name}" + )); } - if !native.exists() { - Self::sync_to_app_dir(&dir_name, &app)?; + if let Some(deployment) = + Self::inspect_pi_skill_destination(&native, &native, &dir_name)? + { + Self::refresh_pi_skill_destination( + &dest, + &native, + &dir_name, + &deployment, + )?; } - Ok(()) - })(); - if let Err(error) = projection { - skipped_mcode.push(format!("{dir_name}: {error}")); - continue 'imports; + } else { + Self::preflight_install_destination(&dest, &dir_name, &app)?; } + if !native.exists() { + Self::sync_to_app_dir(&dir_name, &app)?; + } + Ok(()) + })(); + if let Err(error) = projection { + skipped_native.push(format!("{dir_name} ({app:?}): {error}")); + continue 'imports; } } // 勾选的应用里还没有这个 Skill(它是在别处发现的):导入时就投影过去。 @@ -2459,10 +2482,10 @@ impl SkillService { log::info!("成功导入 {} 个 Skills", imported.len()); let mut problems = Vec::new(); - if !skipped_mcode.is_empty() { + if !skipped_native.is_empty() { problems.push(format!( - "skipped MiniMax Code entries: {}", - skipped_mcode.join("; ") + "skipped native entries: {}", + skipped_native.join("; ") )); } if !not_enabled.is_empty() { @@ -2520,7 +2543,7 @@ impl SkillService { let app_dir = Self::get_distinct_app_skills_dir(&ssot_dir, app)?; if !matches!( app, - AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi + AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi | AppType::OhMyPi ) { return Ok(()); } @@ -2600,7 +2623,7 @@ impl SkillService { } Err(anyhow!( - "Pi 中已存在同名但内容不同的 Skill,拒绝覆盖或删除: {directory}" + "原生应用中已存在同名但内容不同的 Skill,拒绝覆盖或删除: {directory}" )) } @@ -2627,7 +2650,7 @@ impl SkillService { PiSkillDeployment::Symlink { expected_target } => { if !Self::is_symlink(destination) { return Err(anyhow!( - "Pi 中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" + "原生应用中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" )); } let target = fs::read_link(destination)?; @@ -2641,7 +2664,7 @@ impl SkillService { }; if &resolved != expected_target { return Err(anyhow!( - "Pi 中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" + "原生应用中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" )); } Self::remove_path(destination)?; @@ -2656,7 +2679,7 @@ impl SkillService { ) { return Err(anyhow!( - "Pi 中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" + "原生应用中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" )); } Self::replace_dest_with_copy(source, destination, directory)?; @@ -2697,7 +2720,7 @@ impl SkillService { if matches!( app, - AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi + AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi | AppType::OhMyPi ) && (dest.exists() || Self::is_symlink(&dest)) { Self::ensure_pi_skill_destination_matches(&source, &dest, &directory)?; @@ -2897,7 +2920,7 @@ impl SkillService { if skill_path.exists() || Self::is_symlink(&skill_path) { if matches!( app, - AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi + AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi | AppType::OhMyPi ) { let source = ssot_dir.join(&directory); Self::ensure_pi_skill_destination_matches(&source, &skill_path, &directory)?; @@ -2969,7 +2992,12 @@ impl SkillService { // Unselected MCode, Agy and Kimi directories may have been installed outside CC Switch. // Explicit disable/uninstall handles removal of managed deployments. - if app_dir.exists() && !matches!(app, AppType::Mcode | AppType::Agy | AppType::Kimi) { + if app_dir.exists() + && !matches!( + app, + AppType::Mcode | AppType::Agy | AppType::Kimi | AppType::OhMyPi + ) + { for entry in fs::read_dir(&app_dir)? { let entry = entry?; let path = entry.path(); @@ -5717,6 +5745,54 @@ mod tests { assert!(!SkillService::get_all_installed(&db).unwrap()[0].apps.pi); } + #[test] + #[serial_test::serial] + fn ohmypi_skill_state_preserves_managed_desired_flags() { + let temp = tempdir().expect("tempdir"); + let _home = TestHomeGuard::set(temp.path()); + let _omp_dir = crate::ohmypi_config::test_support::TestAgentDir::new(); + + let db = std::sync::Arc::new(Database::memory().expect("memory db")); + + // DB 标志保持权威:enabled_ohmypi=true 且无原生目录时仍应视为启用 + let mut managed = poisoned_skill("owner/repo:skill", "managed-skill"); + managed.apps.ohmypi = true; + db.save_skill(&managed).expect("save skill"); + + // Independently authored native directories do not claim the managed switch. + let native = poisoned_skill("owner/repo:native", "native-skill"); + db.save_skill(&native).expect("save skill"); + write_skill( + &SkillService::get_app_skills_dir(&AppType::OhMyPi) + .unwrap() + .join("native-skill"), + "native", + ); + + // 无 DB 记录也无原生目录的技能不得被误启用 + let absent = poisoned_skill("owner/repo:absent", "absent-skill"); + db.save_skill(&absent).expect("save skill"); + + let installed = SkillService::get_all_installed(&db).expect("read skills"); + let state = |directory: &str| { + installed + .iter() + .find(|s| s.directory == directory) + .expect("skill present") + .apps + .ohmypi + }; + assert!(state("managed-skill"), "a DB flag must stay authoritative"); + assert!( + !state("native-skill"), + "an unclaimed native directory must not activate the managed OMP switch" + ); + assert!( + !state("absent-skill"), + "no DB flag and no native presence must stay disabled" + ); + } + #[test] #[serial_test::serial] fn pi_skill_toggle_preserves_a_same_name_external_directory() { @@ -6338,11 +6414,12 @@ mod tests { .unwrap() .execute_batch("PRAGMA query_only = ON") .unwrap(); - assert!(SkillService::update_mcode_skill_files( + assert!(SkillService::update_owned_skill_files( &source, &ssot, &skill.directory, &deployments, + &[AppType::Mcode], || SkillService::persist_updated_skill_metadata(&db, &updated), ) .is_err()); @@ -6363,11 +6440,12 @@ mod tests { .unwrap() .execute_batch("PRAGMA query_only = OFF") .unwrap(); - SkillService::update_mcode_skill_files( + SkillService::update_owned_skill_files( &source, &ssot, &skill.directory, &deployments, + &[AppType::Mcode], || SkillService::persist_updated_skill_metadata(&db, &updated), ) .unwrap(); @@ -6409,11 +6487,12 @@ mod tests { let parent = native.parent().unwrap(); let original_permissions = fs::metadata(parent).unwrap().permissions(); fs::set_permissions(parent, fs::Permissions::from_mode(0o555)).unwrap(); - let result: Result<()> = SkillService::update_mcode_skill_files( + let result: Result<()> = SkillService::update_owned_skill_files( &source, &ssot, "test-skill", &deployments, + &[AppType::Mcode], || panic!("failed native staging must not commit metadata"), ); fs::set_permissions(parent, original_permissions).unwrap(); @@ -6422,9 +6501,14 @@ mod tests { .unwrap() .contains("name: old")); SkillService::ensure_pi_skill_destination_matches(&ssot, &native, "test-skill").unwrap(); - SkillService::update_mcode_skill_files(&source, &ssot, "test-skill", &deployments, || { - Ok(()) - }) + SkillService::update_owned_skill_files( + &source, + &ssot, + "test-skill", + &deployments, + &[AppType::Mcode], + || Ok(()), + ) .unwrap(); assert!(fs::read_to_string(native.join("SKILL.md")) .unwrap() @@ -6517,6 +6601,208 @@ mod tests { assert!(SkillService::get_all_installed(&db).unwrap()[0].apps.pi); } + #[test] + #[cfg(unix)] + #[serial_test::serial] + fn ohmypi_imported_external_symlink_adopts_only_matching_contents() { + let temp = tempdir().unwrap(); + let _home = TestHomeGuard::set(temp.path()); + let _omp = crate::ohmypi_config::test_support::TestAgentDir::new(); + let db = Arc::new(Database::memory().unwrap()); + let external = temp.path().join("external"); + write_skill(&external, "native"); + let native = SkillService::get_app_skills_dir(&AppType::OhMyPi) + .unwrap() + .join("native-skill"); + fs::create_dir_all(native.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&external, &native).unwrap(); + let imported = SkillService::import_from_apps( + &db, + vec![ImportSkillSelection { + directory: "native-skill".into(), + apps: SkillApps::default(), + }], + ) + .unwrap(); + let ssot = SkillService::get_ssot_dir().unwrap().join("native-skill"); + assert_eq!(fs::read_link(&native).unwrap(), ssot); + SkillService::toggle_app(&db, &imported[0].id, &AppType::OhMyPi, false).unwrap(); + assert!(!native.exists()); + assert!(external.join("SKILL.md").exists()); + let conflicting = SkillService::get_app_skills_dir(&AppType::OhMyPi) + .unwrap() + .join("different"); + write_skill(&conflicting, "authored"); + write_skill( + &SkillService::get_ssot_dir().unwrap().join("different"), + "managed", + ); + assert!(SkillService::import_from_apps( + &db, + vec![ImportSkillSelection { + directory: "different".into(), + apps: SkillApps::default(), + }] + ) + .is_err()); + assert!(!db + .get_all_installed_skills() + .unwrap() + .values() + .any(|skill| skill.directory == "different")); + assert!(fs::read_to_string(conflicting.join("SKILL.md")) + .unwrap() + .contains("name: authored")); + } + + #[test] + #[serial_test::serial] + fn ohmypi_unclaimed_skill_survives_toggle_resync_and_uninstall() { + let temp = tempdir().unwrap(); + let _home = TestHomeGuard::set(temp.path()); + let _omp = crate::ohmypi_config::test_support::TestAgentDir::new(); + let db = Arc::new(Database::memory().unwrap()); + let skill = poisoned_skill("owner/repo:skill", "same-name"); + db.save_skill(&skill).unwrap(); + let ssot = SkillService::get_ssot_dir().unwrap().join(&skill.directory); + let native = SkillService::get_app_skills_dir(&AppType::OhMyPi) + .unwrap() + .join(&skill.directory); + write_skill(&ssot, "managed"); + write_skill(&native, "authored"); + let original = fs::read(native.join("SKILL.md")).unwrap(); + assert!(!SkillService::get_all_installed(&db).unwrap()[0].apps.ohmypi); + for enabled in [true, false] { + assert!(SkillService::toggle_app(&db, &skill.id, &AppType::OhMyPi, enabled).is_err()); + assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); + } + SkillService::sync_to_app(&db, &AppType::OhMyPi).unwrap(); + assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); + SkillService::uninstall(&db, &skill.id).unwrap(); + assert!(!ssot.exists()); + assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); + } + + #[test] + #[serial_test::serial] + fn ohmypi_modified_managed_skill_rejects_resync_and_uninstall() { + let temp = tempdir().unwrap(); + let _home = TestHomeGuard::set(temp.path()); + let _omp = crate::ohmypi_config::test_support::TestAgentDir::new(); + let db = Arc::new(Database::memory().unwrap()); + let mut skill = poisoned_skill("owner/repo:skill", "same-name"); + skill.apps.ohmypi = true; + db.save_skill(&skill).unwrap(); + let ssot = SkillService::get_ssot_dir().unwrap().join(&skill.directory); + let native = SkillService::get_app_skills_dir(&AppType::OhMyPi) + .unwrap() + .join(&skill.directory); + write_skill(&ssot, "managed"); + write_skill(&native, "edited"); + let outcome = SkillService::resync_all_apps(&db) + .into_iter() + .find(|item| item.app == "ohmypi") + .unwrap(); + assert!(!outcome.ok); + assert!(SkillService::uninstall(&db, &skill.id).is_err()); + assert!(db.get_installed_skill(&skill.id).unwrap().is_some()); + assert!(ssot.exists()); + assert!(fs::read_to_string(native.join("SKILL.md")) + .unwrap() + .contains("name: edited")); + write_skill(&native, "managed"); + SkillService::toggle_app(&db, &skill.id, &AppType::OhMyPi, false).unwrap(); + assert!(!native.exists()); + assert!( + !db.get_installed_skill(&skill.id) + .unwrap() + .unwrap() + .apps + .ohmypi + ); + SkillService::toggle_app(&db, &skill.id, &AppType::OhMyPi, true).unwrap(); + assert!(native.exists()); + SkillService::uninstall(&db, &skill.id).unwrap(); + assert!(!native.exists()); + } + + #[test] + #[serial_test::serial] + fn ohmypi_copy_update_keeps_ownership_after_ssot_replacement() { + let temp = tempdir().unwrap(); + let _home = TestHomeGuard::set(temp.path()); + let _omp = crate::ohmypi_config::test_support::TestAgentDir::new(); + let ssot = SkillService::get_ssot_dir().unwrap().join("test-skill"); + let source = temp.path().join("download"); + let native = SkillService::get_app_skills_dir(&AppType::OhMyPi) + .unwrap() + .join("test-skill"); + write_skill(&ssot, "old"); + write_skill(&source, "new"); + SkillService::copy_dir_recursive(&ssot, &native).unwrap(); + let deployment = SkillService::inspect_pi_skill_destination(&ssot, &native, "test-skill") + .unwrap() + .unwrap(); + let result: Result<()> = SkillService::update_owned_skill_files( + &source, + &ssot, + "test-skill", + &[(native.clone(), deployment)], + &[AppType::OhMyPi], + || Err(anyhow!("metadata failure")), + ); + assert!(result.is_err()); + assert!(fs::read_to_string(ssot.join("SKILL.md")) + .unwrap() + .contains("name: old")); + SkillService::ensure_pi_skill_destination_matches(&ssot, &native, "test-skill").unwrap(); + let deployment = SkillService::inspect_pi_skill_destination(&ssot, &native, "test-skill") + .unwrap() + .unwrap(); + SkillService::update_owned_skill_files( + &source, + &ssot, + "test-skill", + &[(native.clone(), deployment)], + &[AppType::OhMyPi], + || Ok(()), + ) + .unwrap(); + assert!(fs::read_to_string(native.join("SKILL.md")) + .unwrap() + .contains("name: new")); + assert!(!SkillService::get_app_skills_dir(&AppType::Mcode) + .unwrap() + .join("test-skill") + .exists()); + } + + #[test] + #[serial_test::serial] + fn importing_a_native_omp_skill_returns_its_derived_active_state() { + let temp = tempdir().expect("tempdir"); + let _home = TestHomeGuard::set(temp.path()); + let _omp_dir = crate::ohmypi_config::test_support::TestAgentDir::new(); + let db = std::sync::Arc::new(Database::memory().expect("memory db")); + let omp_skill = SkillService::get_app_skills_dir(&AppType::OhMyPi) + .unwrap() + .join("native-skill"); + write_skill(&omp_skill, "native"); + + let imported = SkillService::import_from_apps( + &db, + vec![ImportSkillSelection { + directory: "native-skill".to_string(), + apps: SkillApps::default(), + }], + ) + .expect("import native OhMyPi skill"); + + assert_eq!(imported.len(), 1); + assert!(imported[0].apps.ohmypi); + assert!(SkillService::get_all_installed(&db).unwrap()[0].apps.ohmypi); + } + #[test] #[serial_test::serial] fn uninstall_preserves_an_external_pi_skill_and_removes_the_managed_record() { diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 174dd67a4..73e49a285 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -177,6 +177,7 @@ impl StreamCheckService { AppType::OpenClaw => Self::extract_openclaw_base_url(provider), AppType::Hermes => Self::extract_hermes_base_url(provider), AppType::Pi => crate::pi_config::provider_base_url(&provider.settings_config), + AppType::OhMyPi => crate::ohmypi_config::provider_base_url(&provider.settings_config), AppType::ClaudeDesktop => ClaudeAdapter::new() .extract_base_url(provider) .map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}"))), diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index e2e0707b6..e870305e6 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -14,7 +14,7 @@ use std::time::Instant; // 其余模型类型从 `session_manager::model` 引用 pub use model::SessionMessage; use providers::{ - agy, claude, codex, gemini, grokbuild, hermes, kimi, mcode, openclaw, opencode, pi, + agy, claude, codex, gemini, grokbuild, hermes, kimi, mcode, ohmypi, openclaw, opencode, pi, }; #[derive(Debug, Clone, Serialize)] @@ -58,7 +58,7 @@ pub struct DeleteSessionOutcome { } pub fn scan_sessions() -> Vec { - let (r1, r2, r3, r4, r5, r6, r7, r8) = std::thread::scope(|s| { + let (r1, r2, r3, r4, r5, r6, r7, r8, r9) = std::thread::scope(|s| { let h1 = s.spawn(codex::scan_sessions); let h2 = s.spawn(claude::scan_sessions); let h3 = s.spawn(opencode::scan_sessions); @@ -67,6 +67,7 @@ pub fn scan_sessions() -> Vec { let h6 = s.spawn(hermes::scan_sessions); let h7 = s.spawn(grokbuild::scan_sessions); let h8 = s.spawn(pi::scan_sessions); + let h9 = s.spawn(ohmypi::scan_sessions); ( h1.join().unwrap_or_default(), h2.join().unwrap_or_default(), @@ -76,6 +77,7 @@ pub fn scan_sessions() -> Vec { h6.join().unwrap_or_default(), h7.join().unwrap_or_default(), h8.join().unwrap_or_default(), + h9.join().unwrap_or_default(), ) }); @@ -88,6 +90,7 @@ pub fn scan_sessions() -> Vec { sessions.extend(r6); sessions.extend(r7); sessions.extend(r8); + sessions.extend(r9); sessions.extend(mcode::scan_sessions()); sessions.extend(kimi::scan_sessions()); sessions.extend(agy::scan_sessions()); @@ -125,6 +128,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result hermes::load_messages(path), "pi" => pi::load_messages(path), "kimi" => kimi::load_messages(path), + "ohmypi" => ohmypi::load_messages(path), _ => Err(format!("Unsupported provider: {provider_id}")), } } @@ -230,6 +234,7 @@ fn delete_session_with_roots( "grokbuild" => grokbuild::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), + "ohmypi" => ohmypi::delete_session(&validated_root, &validated_source, session_id), _ => Err(format!("Unsupported provider: {provider_id}")), } } @@ -246,6 +251,7 @@ fn provider_roots(provider_id: &str) -> Result, String> { "pi" => pi::session_roots(), "kimi" => kimi::session_roots(), "agy" => agy::session_roots(), + "ohmypi" => ohmypi::session_roots(), _ => 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 b037c928c..068e96063 100644 --- a/src-tauri/src/session_manager/providers/mod.rs +++ b/src-tauri/src/session_manager/providers/mod.rs @@ -6,6 +6,7 @@ mod codex_items; pub mod gemini; pub mod grokbuild; pub mod hermes; +pub mod ohmypi; pub mod openclaw; pub mod opencode; mod opencode_blocks; diff --git a/src-tauri/src/session_manager/providers/ohmypi.rs b/src-tauri/src/session_manager/providers/ohmypi.rs new file mode 100644 index 000000000..62fd2bdcf --- /dev/null +++ b/src-tauri/src/session_manager/providers/ohmypi.rs @@ -0,0 +1,633 @@ +//! Native OMP session buckets, active journal branches and transcript blocks. + +use super::pi::{is_valid_tree_id, MAX_SESSION_BYTES, MAX_TREE_ENTRIES}; +use super::pi_blocks::PiTranscript; +use super::utils::{ + for_each_jsonl_value, parse_timestamp_to_ms, path_basename, truncate_summary, TITLE_MAX_CHARS, +}; +use crate::session_manager::{SessionMessage, SessionMeta}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const PROVIDER_ID: &str = "ohmypi"; + +#[derive(Debug, Clone, Copy)] +enum SessionLayout { + Flat, + ProjectBuckets, +} + +fn resolve_session_root() -> Result<(PathBuf, SessionLayout), String> { + if let Some(raw) = std::env::var_os("PI_CODING_AGENT_SESSION_DIR").filter(|raw| !raw.is_empty()) + { + let root = PathBuf::from(raw); + if !root.is_absolute() { + return Err(format!( + "Relative Oh My Pi session directory requires a project cwd: {}", + root.display() + )); + } + return Ok((root, SessionLayout::Flat)); + } + crate::ohmypi_config::get_ohmypi_data_dir() + .map(|dir| (dir.join("sessions"), SessionLayout::ProjectBuckets)) + .map_err(|error| error.to_string()) +} + +pub fn session_roots() -> Vec { + match resolve_session_root() { + Ok((root, _)) => vec![root], + Err(error) => { + log::warn!("Oh My Pi session discovery unavailable: {error}"); + Vec::new() + } + } +} + +pub fn scan_sessions() -> Vec { + let (root, layout) = match resolve_session_root() { + Ok(resolution) => resolution, + Err(error) => { + log::warn!("Oh My Pi session discovery unavailable: {error}"); + return Vec::new(); + } + }; + collect_session_files(&root, layout) + .into_iter() + .filter_map(|path| { + validate_source(&root, &path, layout) + .and_then(|(_, path)| parse_session(&path)) + .map_err(|error| { + log::debug!("Skipping Oh My Pi session {}: {error}", path.display()) + }) + .ok() + }) + .collect() +} + +fn collect_session_files(root: &Path, layout: SessionLayout) -> Vec { + let Ok(entries) = fs::read_dir(root) else { + return Vec::new(); + }; + let mut files = Vec::new(); + for entry in entries.flatten() { + let Ok(kind) = entry.file_type() else { + continue; + }; + let candidates = match layout { + SessionLayout::Flat if kind.is_file() => vec![entry], + SessionLayout::ProjectBuckets if kind.is_dir() => fs::read_dir(entry.path()) + .map(|entries| entries.flatten().collect()) + .unwrap_or_default(), + _ => continue, + }; + files.extend(candidates.into_iter().filter_map(|entry| { + (entry.file_type().is_ok_and(|kind| kind.is_file()) + && entry + .path() + .extension() + .is_some_and(|extension| extension == "jsonl")) + .then(|| entry.path()) + })); + } + files +} + +fn validate_source( + root: &Path, + source: &Path, + layout: SessionLayout, +) -> Result<(PathBuf, PathBuf), String> { + let root = root + .canonicalize() + .map_err(|error| format!("Failed to resolve Oh My Pi session root: {error}"))?; + let source = source + .canonicalize() + .map_err(|error| format!("Failed to resolve Oh My Pi session: {error}"))?; + let relative = source + .strip_prefix(&root) + .map_err(|_| "Oh My Pi session source is outside the session root".to_string())?; + let expected_depth = match layout { + SessionLayout::Flat => 1, + SessionLayout::ProjectBuckets => 2, + }; + if relative.components().count() != expected_depth + || source + .extension() + .is_none_or(|extension| extension != "jsonl") + { + return Err( + "Oh My Pi session source does not match the active directory layout".to_string(), + ); + } + let metadata = fs::metadata(&source) + .map_err(|error| format!("Failed to inspect Oh My Pi session: {error}"))?; + if !metadata.is_file() || metadata.len() > MAX_SESSION_BYTES { + return Err("Invalid or oversized Oh My Pi session file".to_string()); + } + Ok((root, source)) +} + +struct SessionTree { + header: Value, + title: Option, + active_indexes: HashSet, + last_active_at: Option, +} + +fn read_tree(path: &Path) -> Result { + let mut header = None; + let mut title_slot = None; + let mut parents = HashMap::, usize)>::new(); + let mut latest_id = None; + let mut legacy_previous_id = None; + let mut last_active_at = None; + let mut index = 0usize; + for_each_jsonl_value(path, |_, value| { + index += 1; + if index > MAX_TREE_ENTRIES { + return Err("Oh My Pi session exceeds the entry limit".to_string()); + } + if header.is_none() { + if index == 1 && value.get("type").and_then(Value::as_str) == Some("title") { + title_slot = value + .get("title") + .and_then(Value::as_str) + .map(str::to_string); + return Ok(()); + } + if value.get("type").and_then(Value::as_str) != Some("session") + || !value + .get("id") + .and_then(Value::as_str) + .is_some_and(is_valid_tree_id) + { + return Err("Oh My Pi session has no valid header".to_string()); + } + header = Some(value); + return Ok(()); + } + let version = header + .as_ref() + .and_then(|header| header.get("version")) + .and_then(Value::as_u64) + .unwrap_or(1); + let identity = if version < 2 { + Some((format!("legacy-{index}"), legacy_previous_id.clone())) + } else { + value + .get("id") + .and_then(Value::as_str) + .filter(|id| is_valid_tree_id(id)) + .and_then(|id| { + let parent = 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.to_string(), parent)) + }) + }; + if let Some((id, parent)) = identity { + parents.insert(id.clone(), (parent, index)); + latest_id = Some(id.clone()); + legacy_previous_id = Some(id); + } + if let Some(timestamp) = value.get("timestamp").and_then(parse_timestamp_to_ms) { + last_active_at = + Some(last_active_at.map_or(timestamp, |current: i64| current.max(timestamp))); + } + Ok(()) + })?; + let header = header.ok_or_else(|| "Oh My Pi session has no valid header".to_string())?; + let title = title_slot.or_else(|| { + header + .get("title") + .and_then(Value::as_str) + .map(str::to_string) + }); + let mut active_indexes = HashSet::new(); + let mut visited = HashSet::new(); + while let Some(id) = latest_id { + if !visited.insert(id.clone()) { + break; + } + let Some((parent, index)) = parents.get(&id) else { + break; + }; + active_indexes.insert(*index); + latest_id = parent.clone(); + } + Ok(SessionTree { + header, + title, + active_indexes, + last_active_at, + }) +} + +fn read_active_messages(path: &Path, tree: &SessionTree) -> Result, String> { + let mut transcript = PiTranscript::new(); + let mut index = 0usize; + for_each_jsonl_value(path, |span, mut value| { + index += 1; + if !tree.active_indexes.contains(&index) { + return Ok(()); + } + // OMP stores a complete model selector; Pi's block mapper uses two fields. + if value.get("type").and_then(Value::as_str) == Some("model_change") { + if let Some(model) = value + .get("model") + .and_then(Value::as_str) + .map(str::to_string) + { + if let Some((provider, model_id)) = model.split_once('/') { + value["provider"] = Value::String(provider.to_string()); + value["modelId"] = Value::String(model_id.to_string()); + } else { + value["modelId"] = Value::String(model); + } + } + } + let id = value.get("id").and_then(Value::as_str).map(str::to_string); + transcript.push_entry(&value, span, id); + Ok(()) + })?; + Ok(transcript.finish()) +} + +pub fn load_messages(path: &Path) -> Result, String> { + let (root, layout) = resolve_session_root()?; + let (_, source) = validate_source(&root, path, layout)?; + let tree = read_tree(&source)?; + read_active_messages(&source, &tree) +} + +fn resume_command(source: &Path) -> Result { + use crate::session_manager::terminal::shell_escape; + let agent = crate::ohmypi_config::get_ohmypi_agent_dir().map_err(|error| error.to_string())?; + let native_agent = + crate::ohmypi_config::get_ohmypi_native_agent_dir().map_err(|error| error.to_string())?; + let profile = if agent == native_agent { + crate::ohmypi_config::get_ohmypi_active_profile().map_err(|error| error.to_string())? + } else { + None + }; + let profile = profile.as_deref().unwrap_or("default"); + let mut assignments = vec![ + format!("OMP_PROFILE={}", shell_escape(profile)), + format!("PI_PROFILE={}", shell_escape(profile)), + format!( + "PI_CODING_AGENT_DIR={}", + shell_escape(&agent.to_string_lossy()) + ), + ]; + // Terminal launch is POSIX/macOS. Clear GUI-unset directory variables too: + // inheriting the terminal's profile/XDG paths would select different native state. + for variable in [ + "PI_CONFIG_DIR", + "PI_CODING_AGENT_SESSION_DIR", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "XDG_CACHE_HOME", + ] { + let value = std::env::var(variable).unwrap_or_default(); + assignments.push(format!("{variable}={}", shell_escape(&value))); + } + Ok(format!( + "{} omp --resume {}", + assignments.join(" "), + shell_escape(&source.to_string_lossy()) + )) +} + +fn parse_session(path: &Path) -> Result { + let tree = read_tree(path)?; + let messages = read_active_messages(path, &tree)?; + let cwd = tree + .header + .get("cwd") + .and_then(Value::as_str) + .filter(|cwd| !cwd.is_empty()) + .map(str::to_string); + let title = tree + .title + .filter(|title| !title.trim().is_empty()) + .or_else(|| { + messages + .iter() + .find(|message| message.role == "user" && !message.content.trim().is_empty()) + .map(|message| truncate_summary(&message.content, TITLE_MAX_CHARS)) + .or_else(|| cwd.as_deref().and_then(path_basename)) + }); + let summary = messages + .iter() + .rev() + .find(|message| { + matches!(message.role.as_str(), "user" | "assistant") + && !message.content.trim().is_empty() + }) + .map(|message| truncate_summary(&message.content, 160)); + let created_at = tree.header.get("timestamp").and_then(parse_timestamp_to_ms); + Ok(SessionMeta { + provider_id: PROVIDER_ID.to_string(), + session_id: tree.header["id"] + .as_str() + .expect("validated session id") + .to_string(), + title, + summary, + project_dir: cwd, + created_at, + last_active_at: tree.last_active_at.or(created_at), + source_path: Some(path.to_string_lossy().to_string()), + resume_command: Some(resume_command(path)?), + }) +} + +pub fn delete_session(root: &Path, path: &Path, session_id: &str) -> Result { + let (configured_root, layout) = resolve_session_root()?; + let (validated_root, source) = validate_source(&configured_root, path, layout)?; + if root.canonicalize().map_err(|error| error.to_string())? != validated_root { + return Err("Oh My Pi session root changed before deletion".to_string()); + } + let tree = read_tree(&source)?; + if tree.header["id"].as_str() != Some(session_id) { + return Err("Oh My Pi session ID mismatch".to_string()); + } + let artifacts = source.with_extension(""); + let artifacts_type = match fs::symlink_metadata(&artifacts) { + Ok(metadata) => Some(metadata.file_type()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(format!("Failed to inspect Oh My Pi artifacts: {error}")), + }; + fs::remove_file(&source) + .map_err(|error| format!("Failed to delete Oh My Pi session: {error}"))?; + if let Some(kind) = artifacts_type { + let result = if kind.is_dir() { + fs::remove_dir_all(&artifacts) + } else { + fs::remove_file(&artifacts) + }; + result.map_err(|error| { + format!("Oh My Pi session deleted but artifact cleanup failed: {error}") + })?; + } + let prefix = format!( + "{}.", + source + .file_name() + .expect("validated session file") + .to_string_lossy() + ); + if let Ok(entries) = fs::read_dir(source.parent().expect("validated session parent")) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(&prefix) + && name.ends_with(".bak") + && entry.file_type().is_ok_and(|kind| !kind.is_dir()) + { + if let Err(error) = fs::remove_file(entry.path()) { + log::warn!( + "Failed to remove Oh My Pi session backup {}: {error}", + entry.path().display() + ); + } + } + } + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ohmypi_config::test_support::TestAgentDir; + use crate::session_manager::model::{ContentRef, SessionBlock}; + use serde_json::json; + use serial_test::serial; + + struct SessionDirEnv(Option); + impl SessionDirEnv { + fn set(path: Option<&Path>) -> Self { + let previous = std::env::var_os("PI_CODING_AGENT_SESSION_DIR"); + if let Some(path) = path { + std::env::set_var("PI_CODING_AGENT_SESSION_DIR", path); + } else { + std::env::remove_var("PI_CODING_AGENT_SESSION_DIR"); + } + Self(previous) + } + } + impl Drop for SessionDirEnv { + fn drop(&mut self) { + if let Some(previous) = &self.0 { + std::env::set_var("PI_CODING_AGENT_SESSION_DIR", previous); + } else { + std::env::remove_var("PI_CODING_AGENT_SESSION_DIR"); + } + } + } + fn write(path: &Path, records: &[Value]) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + path, + records + .iter() + .map(|record| format!("{record}\n")) + .collect::(), + ) + .unwrap(); + } + fn header(id: &str) -> Value { + json!({"type":"session","version":3,"id":id,"timestamp":"2026-10-10T18:00:00Z","cwd":"/repo","title":"Legacy title"}) + } + fn message(id: &str, parent: Option<&str>, role: &str, text: &str) -> Value { + json!({"type":"message","id":id,"parentId":parent,"timestamp":"2026-10-10T18:00:01Z","message":{"role":role,"content":[{"type":"text","text":text}]}}) + } + + #[test] + #[serial] + fn renders_active_branch_and_structured_tools_with_native_titles() { + let _agent = TestAgentDir::new(); + let _env = SessionDirEnv::set(None); + let root = session_roots().remove(0); + let file = root.join("project").join("2026-10-10_session-id.jsonl"); + write( + &file, + &[ + json!({"type":"title","v":1,"title":"Current renamed title","updatedAt":"2026-10-10T18:02:00Z","pad":""}), + header("session-id"), + message("u1", None, "user", "Root request"), + message("old-answer", Some("u1"), "assistant", "Abandoned answer"), + message("u2", Some("u1"), "user", "New alternative"), + json!({"type":"model_change","id":"model","parentId":"u2","model":"openai/gpt-5"}), + json!({"type":"message","id":"a2","parentId":"model","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Check the repository ".repeat(30)},{"type":"toolCall","id":"call1","name":"bash","arguments":{"command":"pwd"}}]}}), + json!({"type":"message","id":"result","parentId":"a2","message":{"role":"toolResult","toolCallId":"call1","toolName":"bash","content":[{"type":"text","text":"/repo"}],"isError":false}}), + ], + ); + let messages = load_messages(&file).unwrap(); + assert!(!messages + .iter() + .any(|message| message.id.as_deref() == Some("old-answer"))); + assert_eq!(messages[0].content, "Root request"); + assert_eq!(messages[1].content, "New alternative"); + assert!(messages + .iter() + .any(|message| message.content == "openai/gpt-5")); + assert!(messages + .iter() + .flat_map(|message| &message.blocks) + .any(|block| matches!(block,SessionBlock::ToolCall { id,.. } if id == "call1"))); + assert!(messages.iter().flat_map(|message| &message.blocks).any( + |block| matches!(block,SessionBlock::ToolResult { preview,.. } if preview == "/repo") + )); + assert!(messages.iter().flat_map(|message| &message.blocks).any(|block| matches!(block,SessionBlock::Thinking { full:Some(ContentRef::Jsonl {pointer,..}),.. } if pointer == "/message/content/0/thinking"))); + let sessions = scan_sessions(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].title.as_deref(), Some("Current renamed title")); + fs::write( + &file, + format!( + "{}\n{}\n", + header("session-id"), + message("u1", None, "user", "Prompt title") + ), + ) + .unwrap(); + assert_eq!(scan_sessions()[0].title.as_deref(), Some("Legacy title")); + } + + #[test] + #[serial] + fn excludes_child_artifacts_and_honors_flat_custom_session_roots() { + let _agent = TestAgentDir::new(); + let _env = SessionDirEnv::set(None); + let root = session_roots().remove(0); + let file = root.join("project/parent.jsonl"); + write( + &file, + &[ + header("parent"), + message("u1", None, "user", "Interactive parent"), + ], + ); + let child = root.join("project/parent/worker.jsonl"); + write( + &child, + &[ + header("child-id"), + message("u1", None, "user", "Delegated worker"), + ], + ); + assert_eq!( + scan_sessions() + .iter() + .map(|session| &session.session_id) + .collect::>(), + vec!["parent"] + ); + assert!(load_messages(&child).is_err()); + let custom = tempfile::tempdir().unwrap(); + let _custom_env = SessionDirEnv::set(Some(custom.path())); + write(&custom.path().join("custom.jsonl"), &[header("custom")]); + write( + &custom.path().join("nested/hidden.jsonl"), + &[header("hidden")], + ); + assert_eq!(session_roots(), vec![custom.path().to_path_buf()]); + assert_eq!(scan_sessions()[0].session_id, "custom"); + assert_eq!(scan_sessions().len(), 1); + assert!(load_messages(&file).is_err()); + } + + #[test] + #[serial] + fn relative_custom_root_never_falls_back_to_default_history() { + let _agent = TestAgentDir::new(); + let _env = SessionDirEnv::set(Some(Path::new(".omp/project-sessions"))); + assert!(resolve_session_root() + .unwrap_err() + .contains("requires a project cwd")); + assert!(session_roots().is_empty()); + assert!(scan_sessions().is_empty()); + } + + #[test] + #[serial] + fn deletion_uses_header_identity_and_removes_artifacts_and_only_own_backups() { + let _agent = TestAgentDir::new(); + let _env = SessionDirEnv::set(None); + let root = session_roots().remove(0); + let file = root.join("project/custom-name.jsonl"); + write(&file, &[header("real-header-id")]); + let artifacts = file.with_extension(""); + fs::create_dir_all(&artifacts).unwrap(); + fs::write(artifacts.join("draft.txt"), "Private draft").unwrap(); + write(&artifacts.join("worker.jsonl"), &[header("worker-id")]); + let backup = file.with_file_name("custom-name.jsonl.123.bak"); + let unrelated = file.with_file_name("other.jsonl.123.bak"); + fs::write(&backup, "own backup").unwrap(); + fs::write(&unrelated, "other backup").unwrap(); + assert!(delete_session(&root, &file, "wrong-id").is_err()); + assert!(file.exists() && artifacts.exists()); + assert!(delete_session(&root, &file, "real-header-id").unwrap()); + assert!(!file.exists() && !artifacts.exists() && !backup.exists()); + assert!(unrelated.exists()); + assert!(scan_sessions().is_empty()); + } + + #[cfg(unix)] + #[test] + #[serial] + fn artifact_symlink_cleanup_never_removes_its_external_target() { + let _agent = TestAgentDir::new(); + let _env = SessionDirEnv::set(None); + let root = session_roots().remove(0); + let file = root.join("project/safe.jsonl"); + write(&file, &[header("safe")]); + let external = tempfile::tempdir().unwrap(); + fs::write(external.path().join("keep.txt"), "keep").unwrap(); + std::os::unix::fs::symlink(external.path(), file.with_extension("")).unwrap(); + assert!(delete_session(&root, &file, "safe").unwrap()); + assert!(external.path().join("keep.txt").exists()); + assert!(fs::symlink_metadata(file.with_extension("")).is_err()); + } + + #[cfg(unix)] + #[test] + #[serial] + fn resume_preserves_custom_agent_directory_and_quotes_absolute_source() { + let _agent = TestAgentDir::new(); + let _env = SessionDirEnv::set(None); + let directory = crate::ohmypi_config::get_ohmypi_agent_dir().unwrap(); + let source = directory.join("sessions/project/quote'$(false) name.jsonl"); + let command = resume_command(&source).unwrap(); + let shell=format!("omp() {{ printf '%s\\n' \"$OMP_PROFILE\" \"$PI_CODING_AGENT_DIR\" \"$@\"; }}; {command}"); + let result = std::process::Command::new("sh") + .arg("-c") + .arg(shell) + .env("OMP_PROFILE", "wrong-shell-profile") + .env("PI_PROFILE", "wrong-shell-profile") + .output() + .unwrap(); + assert!(result.status.success()); + assert_eq!( + String::from_utf8(result.stdout) + .unwrap() + .lines() + .collect::>(), + vec![ + "default", + directory.to_str().unwrap(), + "--resume", + source.to_str().unwrap() + ] + ); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 04de4f2e4..f7c2ade8a 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -51,6 +51,8 @@ pub struct VisibleApps { pub hermes: bool, #[serde(default = "default_true")] pub pi: bool, + #[serde(default)] + pub ohmypi: bool, #[serde(default = "default_true")] pub mcode: bool, #[serde(default = "default_true")] @@ -74,6 +76,7 @@ impl Default for VisibleApps { mcode: true, kimi: true, agy: true, + ohmypi: false, // 默认不显示,需用户手动启用 } } } @@ -94,6 +97,7 @@ impl VisibleApps { AppType::Mcode => self.mcode, AppType::Kimi => self.kimi, AppType::Agy => self.agy, + AppType::OhMyPi => self.ohmypi, } } } @@ -538,6 +542,8 @@ pub struct AppSettings { pub hermes_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 ohmypi_config_dir: Option, // ===== 当前供应商 ID(设备级)===== /// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current) @@ -673,6 +679,7 @@ impl Default for AppSettings { openclaw_config_dir: None, hermes_config_dir: None, pi_config_dir: None, + ohmypi_config_dir: None, current_provider_claude: None, current_provider_claude_desktop: None, current_provider_codex: None, @@ -768,6 +775,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.ohmypi_config_dir = self + .ohmypi_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -1258,6 +1272,13 @@ pub fn get_pi_override_dir() -> Option { .as_ref() .map(|path| resolve_override_path(path)) } +pub fn get_ohmypi_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .ohmypi_config_dir + .as_ref() + .map(|path| resolve_override_path(path)) +} pub fn preserve_codex_official_auth_on_switch() -> bool { settings_store() @@ -1341,7 +1362,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::OpenClaw => settings.current_provider_openclaw.clone(), AppType::Hermes => settings.current_provider_hermes.clone(), AppType::Agy => settings.current_provider_agy.clone(), - AppType::Pi | AppType::Mcode | AppType::Kimi => None, + AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::OhMyPi => None, } } @@ -1361,7 +1382,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(), AppType::Hermes => settings.current_provider_hermes = id_owned.clone(), AppType::Agy => settings.current_provider_agy = id_owned.clone(), - AppType::Pi | AppType::Mcode | AppType::Kimi => {} + AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::OhMyPi => {} }) } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index bbc1c371d..dddf98718 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -57,6 +57,7 @@ fn app_display_name(app: &AppType) -> &'static str { AppType::Mcode => "MiniMax Code", AppType::Kimi => "Kimi Code", AppType::Agy => "Antigravity", + AppType::OhMyPi => "Oh My Pi", } } diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 037c1ca5d..a280f96cb 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -427,6 +427,7 @@ command = "echo" mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -561,6 +562,7 @@ fn import_from_claude_merges_into_config() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 21cfb84a8..89f8fd375 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -762,6 +762,7 @@ command = "echo" mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -911,6 +912,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -981,6 +983,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1031,6 +1034,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1070,6 +1074,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1255,6 +1260,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1315,6 +1321,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1375,6 +1382,7 @@ fn explicit_default_claude_dir_keeps_default_split_mcp_path() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1436,6 +1444,7 @@ fn custom_claude_dir_writes_mcp_inside_config_dir() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1520,6 +1529,7 @@ fn custom_claude_dir_sync_does_not_copy_default_profile() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1664,6 +1674,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1691,6 +1702,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1737,7 +1749,8 @@ fn resync_targets_default_to_managed_apps_and_reject_unsupported() { "pi", "mcode", "kimi", - "agy" + "agy", + "ohmypi" ] ); assert_eq!(McpService::resync_targets(Some(&[])).unwrap(), all); @@ -1799,6 +1812,122 @@ fn resync_app_reports_each_app_and_leaves_broken_config_untouched() { assert_eq!(serialized, json!({"app": "claude", "ok": true})); } +fn omp_test_state() -> (cc_switch_lib::AppState, std::path::PathBuf) { + reset_test_fs(); + let path = ensure_test_home().join(".omp/agent/mcp.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "{}").unwrap(); + update_settings(AppSettings { + ohmypi_config_dir: Some(path.parent().unwrap().to_string_lossy().into_owned()), + ..Default::default() + }) + .unwrap(); + (create_test_state().unwrap(), path) +} + +#[test] +fn ohmypi_mcp_disable_reenable_preserves_options_and_suppresses_discovered_sources() { + let _guard = test_mutex().lock().unwrap(); + let (state, path) = omp_test_state(); + let native = json!({"mcpServers":{"demo":{"command":"node","args":["mcp.js"],"enabled":false,"timeout":0,"requestIdFormat":"string","instructions":false}},"disabledServers":["demo","other"],"enabledServers":["other"]}); + fs::write(&path, native.to_string()).unwrap(); + assert_eq!(McpService::import_from_ohmypi(&state).unwrap(), 1); + assert!(!state.db.get_all_mcp_servers().unwrap()["demo"].apps.ohmypi); + McpService::toggle_app(&state, "demo", AppType::OhMyPi, true).unwrap(); + let read = + || serde_json::from_str::(&fs::read_to_string(&path).unwrap()).unwrap(); + let enabled = read(); + assert_eq!(enabled["mcpServers"]["demo"]["enabled"], true); + assert_eq!(enabled["disabledServers"], json!(["other"])); + for field in ["timeout", "requestIdFormat", "instructions"] { + assert_eq!( + enabled["mcpServers"]["demo"][field], + native["mcpServers"]["demo"][field] + ); + } + McpService::toggle_app(&state, "demo", AppType::OhMyPi, false).unwrap(); + let disabled = read(); + assert_eq!(disabled["mcpServers"]["demo"]["enabled"], false); + assert!( + disabled["disabledServers"] + .as_array() + .unwrap() + .contains(&json!("demo")), + "the denylist must also suppress project/foreign discovery" + ); + McpService::toggle_app(&state, "demo", AppType::OhMyPi, true).unwrap(); + let mut edited = state.db.get_all_mcp_servers().unwrap()["demo"].clone(); + edited.apps.ohmypi = false; + McpService::upsert_server(&state, edited).unwrap(); + assert_eq!(read()["mcpServers"]["demo"]["enabled"], false); + let mut disabled_edit = state.db.get_all_mcp_servers().unwrap()["demo"].clone(); + disabled_edit.server["args"] = json!(["updated.js"]); + McpService::upsert_server(&state, disabled_edit).unwrap(); + McpService::toggle_app(&state, "demo", AppType::OhMyPi, true).unwrap(); + assert_eq!(read()["mcpServers"]["demo"]["args"], json!(["updated.js"])); + McpService::toggle_app(&state, "demo", AppType::OhMyPi, false).unwrap(); + assert!(McpService::delete_server(&state, "demo").unwrap()); + assert!(read()["mcpServers"].get("demo").is_none()); + assert_eq!(read()["disabledServers"], json!(["other"])); +} + +#[test] +fn ohmypi_mcp_resync_and_conflicting_enable_preserve_native_configuration() { + let _guard = test_mutex().lock().unwrap(); + let (state, path) = omp_test_state(); + let native = + json!({"mcpServers":{"context7":{"command":"authored","env":{"SECRET":"native"}}}}); + fs::write(&path, native.to_string()).unwrap(); + let server: McpServer = serde_json::from_value(json!({"id":"context7","name":"Context7","server":{"command":"managed"},"apps":{"claude":true}})).unwrap(); + state.db.save_mcp_server(&server).unwrap(); + McpService::sync_enabled_for_app(&state, &AppType::OhMyPi).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), native.to_string()); + assert!(McpService::toggle_app(&state, &server.id, AppType::OhMyPi, true).is_err()); + assert!( + !state.db.get_all_mcp_servers().unwrap()[&server.id] + .apps + .ohmypi + ); + assert!(McpService::import_from_ohmypi(&state) + .unwrap_err() + .to_string() + .contains("context7")); + assert_eq!(fs::read_to_string(&path).unwrap(), native.to_string()); +} + +#[test] +fn ohmypi_mcp_delete_removes_enabled_native_entry_and_write_failure_keeps_db() { + let _guard = test_mutex().lock().unwrap(); + let (state, path) = omp_test_state(); + let server: McpServer = serde_json::from_value( + json!({"id":"demo","name":"Demo","server":{"command":"node"},"apps":{"ohmypi":true}}), + ) + .unwrap(); + McpService::upsert_server(&state, server.clone()).unwrap(); + fs::write(&path, "invalid json").unwrap(); + assert!(McpService::delete_server(&state, &server.id).is_err()); + assert!(state + .db + .get_all_mcp_servers() + .unwrap() + .contains_key(&server.id)); + assert!(McpService::toggle_app(&state, &server.id, AppType::OhMyPi, false).is_err()); + assert!( + state.db.get_all_mcp_servers().unwrap()[&server.id] + .apps + .ohmypi + ); + fs::write( + &path, + json!({"mcpServers":{"demo":{"command":"node","enabled":true}}}).to_string(), + ) + .unwrap(); + assert!(McpService::delete_server(&state, &server.id).unwrap()); + let written: serde_json::Value = + serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + assert!(written["mcpServers"].get("demo").is_none()); +} + #[test] fn concurrent_toggles_keep_every_server_in_live_config() { let _guard = test_mutex().lock().unwrap(); diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index b8e6dfc91..abecc5244 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -546,6 +546,7 @@ command = "say" mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 0e636afe0..bce0c0ecc 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -171,6 +171,7 @@ command = "say" mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1583,6 +1584,7 @@ fn switch_codex_ignores_a_broken_claude_json() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -1648,6 +1650,7 @@ fn sync_all_enabled_reports_broken_app_but_projects_the_rest() { mcode: false, kimi: false, pi: false, + ohmypi: false, }, description: None, homepage: None, @@ -3697,3 +3700,77 @@ fn codex_editor_official_auth_shows_disk_login_and_keeps_row_auth() { ); } } + +#[test] +fn ohmypi_edit_save_preserves_comments_without_false_conflict() { + use cc_switch_lib::update_settings; + use std::fs; + + let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner()); + reset_test_fs(); + let home = ensure_test_home(); + let agent_dir = home.join(".omp/agent"); + fs::create_dir_all(&agent_dir).unwrap(); + let models_path = agent_dir.join("models.yml"); + fs::write( + &models_path, + "# managed by me\nproviders:\n duolalab:\n # proxy endpoint\n baseUrl: https://api.duolalab.test/v1\n api: openai-completions\n apiKey: sk-live\n # extra unmanaged field\n auth: none\n models:\n - id: glm-4.7\n name: GLM 4.7\n", + ) + .unwrap(); + update_settings(cc_switch_lib::AppSettings { + ohmypi_config_dir: Some(agent_dir.to_string_lossy().into_owned()), + ..Default::default() + }) + .unwrap(); + let state = create_test_state().expect("create test state"); + + // list() syncs the DB row from models.yml and captures the raw entry. + let providers = ProviderService::list(&state, AppType::OhMyPi).expect("list"); + let original = providers.get("duolalab").expect("provider").clone(); + assert!(original + .meta + .as_ref() + .and_then(|meta| meta.ohmypi_passthrough_yaml.as_deref()) + .expect("raw entry captured") + .contains("# proxy endpoint")); + + // Edit: the form rebuilds settingsConfig (same semantics) and keeps the + // edited passthrough text; user only tweaked a comment. + let raw = original + .meta + .as_ref() + .and_then(|meta| meta.ohmypi_passthrough_yaml.clone()) + .unwrap(); + let edited_raw = raw.replace("# proxy endpoint", "# edited comment"); + let mut edited = original.clone(); + edited.settings_config["name"] = json!("Duolalab"); + edited + .meta + .get_or_insert_with(ProviderMeta::default) + .ohmypi_passthrough_yaml = Some(edited_raw); + + ProviderService::update(&state, AppType::OhMyPi, Some("duolalab"), edited) + .expect("update without false conflict"); + + let after = fs::read_to_string(&models_path).unwrap(); + assert!(after.contains("# edited comment"), "{after}"); + assert!(after.contains("# extra unmanaged field"), "{after}"); + assert!(after.contains("# managed by me"), "{after}"); + assert!(after.contains("name: Duolalab"), "{after}"); + + // External edit after the DB sync still conflicts. + let synced = ProviderService::list(&state, AppType::OhMyPi) + .expect("list again") + .get("duolalab") + .cloned() + .expect("provider"); + fs::write(&models_path, after.replace("sk-live", "sk-external")).unwrap(); + let mut stale = synced.clone(); + stale.settings_config["name"] = json!("Stale"); + let err = ProviderService::update(&state, AppType::OhMyPi, Some("duolalab"), stale) + .expect_err("external edit must conflict"); + assert!( + err.to_string().contains("changed outside CC Switch"), + "{err}" + ); +} diff --git a/src/App.tsx b/src/App.tsx index d8dfdbcdb..221ea8b96 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,6 +52,7 @@ import { extractErrorMessage, translateKimiProviderMutationError, translatePiProviderMutationError, + translateOhMyPiProviderMutationError, } from "@/utils/errorUtils"; import { isTextEditableTarget } from "@/utils/domUtils"; import { deepClone } from "@/utils/deepClone"; @@ -125,6 +126,8 @@ import { useDisableCurrentOmoSlim, } from "@/lib/query/omo"; import { invalidatePiProviderCaches, usePiCurrentState } from "@/lib/query/pi"; +import { invalidateOhMyPiProviderCaches, ohmypiKeys } from "@/lib/query/ohmypi"; +import { ohmypiApi } from "@/lib/api"; import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel"; import EnvPanel from "@/components/openclaw/EnvPanel"; import ToolsPanel from "@/components/openclaw/ToolsPanel"; @@ -386,6 +389,39 @@ function App() { ); }; + const handleEnableOhMyPiProvider = async (provider: Provider) => { + try { + await providersApi.switch(provider.id, "ohmypi"); + await invalidateOhMyPiProviderCaches(queryClient); + await providersApi.updateTrayMenu().catch((error) => { + console.error( + "Failed to update tray menu after enabling Oh My Pi provider", + error, + ); + }); + toast.success( + t("ohmypi.provider.enabled", { + defaultValue: "已在 Oh My Pi 中启用", + }), + { closeButton: true }, + ); + } catch (error) { + const detail = extractErrorMessage(error); + toast.error( + t("ohmypi.provider.enableFailed", { + defaultValue: "无法在 Oh My Pi 中启用此供应商", + }), + { + description: + translateOhMyPiProviderMutationError(detail, t) || + detail || + undefined, + closeButton: true, + }, + ); + } + }; + const disableOmoMutation = useDisableCurrentOmo(); const handleDisableOmo = () => { disableOmoMutation.mutate(undefined, { @@ -824,6 +860,8 @@ function App() { const refreshAdditiveLiveState = async (app: AppId) => { if (app === "pi") { await invalidatePiProviderCaches(queryClient); + } else if (app === "ohmypi") { + await invalidateOhMyPiProviderCaches(queryClient); } else if (app === "opencode") { await Promise.all([ queryClient.invalidateQueries({ @@ -865,7 +903,9 @@ function App() { ? translatePiProviderMutationError(detail, t) : activeApp === "kimi" ? translateKimiProviderMutationError(detail, t) - : "") || detail; + : activeApp === "ohmypi" + ? translateOhMyPiProviderMutationError(detail, t) + : "") || detail; // 默认模型可能在客户端里改过、页面还是旧的:失败了也重读,让「默认」和移除按钮跟上 void refreshAdditiveLiveState(activeApp).catch(() => undefined); toast.error(t("notifications.removeFromConfigFailed"), { @@ -880,9 +920,13 @@ function App() { ? t("pi.provider.removed", { defaultValue: "已从 Pi 移除", }) - : t("notifications.removeFromConfigSuccess", { - defaultValue: "已从配置移除", - }), + : activeApp === "ohmypi" + ? t("ohmypi.provider.removed", { + defaultValue: "已从 Oh My Pi 移除", + }) + : t("notifications.removeFromConfigSuccess", { + defaultValue: "已从配置移除", + }), { closeButton: true }, ); } else { @@ -962,7 +1006,8 @@ function App() { activeApp === "opencode" || activeApp === "openclaw" || activeApp === "hermes" || - activeApp === "pi" + activeApp === "pi" || + activeApp === "ohmypi" ) { let liveProviderIds: string[] = []; try { @@ -982,12 +1027,19 @@ function App() { queryKey: hermesKeys.liveProviderIds, queryFn: () => providersApi.getHermesLiveProviderIds(), }) - : ( - await queryClient.ensureQueryData({ - queryKey: ["pi", "currentState"], - queryFn: () => piApi.getCurrentState(), - }) - ).enabledProviderIds; + : activeApp === "ohmypi" + ? ( + await queryClient.ensureQueryData({ + queryKey: ohmypiKeys.currentState, + queryFn: () => ohmypiApi.getCurrentState(), + }) + ).enabledProviderIds + : ( + await queryClient.ensureQueryData({ + queryKey: ["pi", "currentState"], + queryFn: () => piApi.getCurrentState(), + }) + ).enabledProviderIds; } catch (error) { console.error( "[App] Failed to load live provider IDs for duplication", @@ -1371,7 +1423,9 @@ function App() { ? handleEnablePiProvider(provider) : activeApp === "kimi" ? handleAddKimiProvider(provider) - : switchProvider(provider)) + : activeApp === "ohmypi" + ? handleEnableOhMyPiProvider(provider) + : switchProvider(provider)) } onRemoveFromConfig={ activeApp === "opencode" || @@ -1379,7 +1433,8 @@ function App() { activeApp === "hermes" || activeApp === "pi" || activeApp === "mcode" || - activeApp === "kimi" + activeApp === "kimi" || + activeApp === "ohmypi" ? (provider) => setConfirmAction({ provider, action: "remove" }) : undefined } diff --git a/src/components/JsonEditor.tsx b/src/components/JsonEditor.tsx index 9381f7fcc..04bb68262 100644 --- a/src/components/JsonEditor.tsx +++ b/src/components/JsonEditor.tsx @@ -2,6 +2,7 @@ import React, { useRef, useEffect, useMemo } from "react"; import { EditorView, basicSetup } from "codemirror"; import { json } from "@codemirror/lang-json"; import { javascript } from "@codemirror/lang-javascript"; +import { yaml } from "@codemirror/lang-yaml"; import { oneDark } from "@codemirror/theme-one-dark"; import { EditorSelection, EditorState } from "@codemirror/state"; import { placeholder } from "@codemirror/view"; @@ -20,7 +21,7 @@ interface JsonEditorProps { darkMode?: boolean; rows?: number; showValidation?: boolean; - language?: "json" | "javascript"; + language?: "json" | "javascript" | "yaml"; height?: string | number; showMinimap?: boolean; // 添加此属性以防未来使用 readOnly?: boolean; @@ -198,7 +199,11 @@ const JsonEditor: React.FC = ({ const extensions = [ basicSetup, - language === "javascript" ? javascript() : json(), + language === "javascript" + ? javascript() + : language === "yaml" + ? yaml() + : json(), placeholder(placeholderText || ""), baseTheme, sizingTheme, diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 743f9535f..4eb9b6b39 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -48,6 +48,28 @@ const VOLCENGINE_KEY_CONSOLE_URL = // 智谱团队套餐用量页(组织 ID / 项目 ID 在此页 URL 或管理后台可见) const ZHIPU_TEAM_USAGE_URL = "https://bigmodel.cn/coding-plan/team/usage-stats"; +/** + * 从 settingsConfig 里读字符串字段。供应商配置来自磁盘上的 JSON/YAML(外部输入), + * 这里只按需取字段,不做整份 schema 校验:`Record` 断言只用于 + * 「本来是 object,编译器丢了形状」这一步,值本身仍按 string 复核。 + */ +const configStringField = ( + source: unknown, + key: string, +): string | undefined => { + if (typeof source !== "object" || source === null) return undefined; + const value = (source as Record)[key]; + return typeof value === "string" ? value : undefined; +}; + +/** 第一个模型的 baseUrl(模型可覆盖供应商级 baseUrl)。 */ +const firstModelBaseUrl = (source: unknown): string | undefined => { + if (typeof source !== "object" || source === null) return undefined; + const models = (source as Record).models; + if (!Array.isArray(models) || models.length === 0) return undefined; + return configStringField(models[0], "baseUrl"); +}; + interface UsageScriptModalProps { provider: Provider; appId: AppId; @@ -325,15 +347,14 @@ const UsageScriptModal: React.FC = ({ apiKey: (config as any).api_key, baseUrl: (config as any).base_url, }; - } else if (appId === "pi") { - // Pi: provider values are camelCase; a model may override baseUrl. - const root = config as any; - const firstModel = Array.isArray(root.models) - ? root.models[0] - : undefined; + } else if (appId === "pi" || appId === "ohmypi") { + // Pi / Oh My Pi: provider values are camelCase; a model may override + // baseUrl. Mirrors the backend resolver's `Pi | OhMyPi` handling, and + // is what unblocks the balance/Coding-Plan tests on an omp card. return { - apiKey: root.apiKey, - baseUrl: firstModel?.baseUrl || root.baseUrl, + apiKey: configStringField(config, "apiKey"), + baseUrl: + firstModelBaseUrl(config) ?? configStringField(config, "baseUrl"), }; } else if (appId === "openclaw") { // OpenClaw: settingsConfig 顶层扁平(camelCase,对应 openclaw.json) diff --git a/src/components/apps/AppsPage.tsx b/src/components/apps/AppsPage.tsx index 5d6329704..b44e1dd2f 100644 --- a/src/components/apps/AppsPage.tsx +++ b/src/components/apps/AppsPage.tsx @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { AlertTriangle, ArrowUpCircle, + BookOpen, ChevronDown, Copy, Download, @@ -14,6 +15,7 @@ import { Trash2, } from "lucide-react"; import type { AppId } from "@/lib/api"; +import { settingsApi } from "@/lib/api"; import { providersApi } from "@/lib/api/providers"; import type { ToolInstallationReport } from "@/lib/api/settings"; import type { VisibleApps } from "@/types"; @@ -731,12 +733,28 @@ function ToolRow({ ) : null; const footer = - wslControls || errorLine || nodeLine || conflictList ? ( + wslControls || errorLine || nodeLine || conflictList || tool === "omp" ? (
{errorLine} {nodeLine} {wslControls} {conflictList} + {tool === "omp" && ( + + )}
) : undefined; @@ -746,6 +764,11 @@ function ToolRow({ name={ <> {toolDisplayName(tool)} + {tool === "omp" && ( + + {t("appsPage.ompDiscoveryHelp")} + + )} {sourceLabel && {sourceLabel}} {envBadge && {envBadge}} diff --git a/src/components/apps/toolUninstallCommands.ts b/src/components/apps/toolUninstallCommands.ts index a3db94412..803f09539 100644 --- a/src/components/apps/toolUninstallCommands.ts +++ b/src/components/apps/toolUninstallCommands.ts @@ -44,6 +44,7 @@ const COMMANDS: Record = { environments: ["macos", "linux"], }, { tool: "openclaw", command: "bun remove -g openclaw" }, + { tool: "omp", command: "bun remove -g @oh-my-pi/pi-coding-agent" }, ], "brew-formula": [ { diff --git a/src/components/apps/useToolManagement.ts b/src/components/apps/useToolManagement.ts index 71d49de66..05d53517e 100644 --- a/src/components/apps/useToolManagement.ts +++ b/src/components/apps/useToolManagement.ts @@ -50,6 +50,7 @@ export const TOOL_NAMES = [ "mcode", "kimi", "agy", + "omp", ] as const; export type ToolName = (typeof TOOL_NAMES)[number]; export type ToolLifecycleAction = "install" | "update" | "uninstall"; @@ -162,7 +163,9 @@ ${posixScriptInstallCommand("https://filecdn.minimax.chat/public/install.sh")} | # Kimi Code ${posixScriptInstallCommand("https://code.kimi.com/kimi-code/install.sh")} # Antigravity CLI -${posixScriptInstallCommand("https://antigravity.google/cli/install.sh")}`; +${posixScriptInstallCommand("https://antigravity.google/cli/install.sh")} +# Oh My Pi +bun install -g @oh-my-pi/pi-coding-agent@latest`; const WINDOWS_ONE_CLICK_INSTALL_COMMANDS = `# Claude Code ${CLAUDE_NPM_INSTALL_COMMAND} @@ -185,7 +188,9 @@ ${MCODE_WINDOWS_INSTALL_COMMAND} # Kimi Code ${KIMI_WINDOWS_INSTALL_COMMAND} # Antigravity CLI -${AGY_WINDOWS_INSTALL_COMMAND}`; +${AGY_WINDOWS_INSTALL_COMMAND} +# Oh My Pi +bun install -g @oh-my-pi/pi-coding-agent@latest`; export const ONE_CLICK_INSTALL_COMMANDS = isWindows() ? WINDOWS_ONE_CLICK_INSTALL_COMMANDS @@ -203,6 +208,7 @@ export const TOOL_DISPLAY_NAMES: Record = { mcode: "MiniMax Code", kimi: "Kimi Code", agy: "Antigravity CLI", + omp: "Oh My Pi", }; // 后端返回的 tool 是 string;这里收敛唯一的 ToolName 断言与兜底,供升级确认 @@ -223,6 +229,7 @@ export const TOOL_APP_IDS: Record = { mcode: "mcode", kimi: "kimi", agy: "agy", + omp: "ohmypi", }; // 工具版本探测代价高:每个工具一次 `--version` 子进程 + 一次 npm/github/pypi 网络请求。 diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 552b902cd..30076f65d 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -90,14 +90,19 @@ const IMPORT_SOURCE_FILES: Record< agy: { file: "~/.gemini/config/mcp_config.json" }, kimi: { file: "~/.kimi-code/mcp.json" }, mcode: { file: "~/.minimax/mcp.json" }, + ohmypi: { file: "~/.omp/agent/mcp.json" }, }; -/** 先写配置文件、成功后才入库的应用:写失败时开关没变,重试要逐行重写 */ +/** + * 先写配置文件、成功后才入库的应用:写失败时开关没变,重试要逐行重写。 + * Oh My Pi 走 `sync_and_commit`(`mcp/ohmypi.rs`),与 MiniMax Code / Pi 同一份契约。 + */ const WRITE_THEN_SAVE_APPS: ReadonlySet = new Set([ "mcode", "kimi", "pi", "agy", + "ohmypi", ]); /** 写入失败:记下想要的状态,「重试」按这个值再写一次 */ @@ -335,7 +340,7 @@ const UnifiedMcpPanel: React.FC = ({ const entries = rowFailsFor(app); let ok = true; if (WRITE_THEN_SAVE_APPS.has(app) && entries.length > 0) { - // MiniMax Code / Pi 写失败时开关没入库:按每行想要的值再写一次 + // 写失败时开关没入库:按每行想要的值再写一次 for (const [key, failure] of entries) { const id = key.split("\u0000")[0]; const { failed } = await writeMany([{ id, app }], failure.desired); diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 8ecc9d33c..13d33ee03 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -10,6 +10,7 @@ import { type PresetStepState, } from "@/components/providers/forms/presetStep"; import type { Provider, CustomEndpoint, UniversalProvider } from "@/types"; +import { UNIVERSAL_PROVIDER_APPS } from "@/types"; import type { AppId } from "@/lib/api"; import { providersApi, universalProvidersApi } from "@/lib/api"; import type { @@ -18,7 +19,7 @@ import type { } from "@/lib/api/providers"; import { useLiveEditConflict } from "@/components/providers/LiveEditConflictDialog"; import { toastEditorViewFailed } from "@/components/providers/forms/hooks/useDraftEditorProjection"; -import { usesEditorView } from "@/config/appConfig"; +import { SUBMIT_READY_APPS, usesEditorView } from "@/config/appConfig"; import { ProviderForm, type ProviderFormValues, @@ -64,15 +65,9 @@ export function AddProviderDialog({ }: AddProviderDialogProps) { const { t } = useTranslation(); // OpenCode and OpenClaw don't support universal providers - const showUniversalTab = - appId !== "opencode" && - appId !== "openclaw" && - appId !== "hermes" && - appId !== "pi" && - appId !== "mcode" && - appId !== "kimi" && - appId !== "grokbuild" && - appId !== "claude-desktop"; + const showUniversalTab = ( + UNIVERSAL_PROVIDER_APPS as ReadonlyArray + ).includes(appId); // 两步:先选预设,再填写(每次打开都从第 1 步开始) const [step, setStep] = useState<"pick" | "form">("pick"); const [pickerHost, setPickerHost] = useState(null); @@ -183,12 +178,12 @@ export function AddProviderDialog({ currentFormReadyToken.current = formReadyToken; const [formReadyState, setFormReadyState] = useState({ token: formReadyToken, - ready: appId !== "pi", + ready: !SUBMIT_READY_APPS[appId], }); const isFormReady = formReadyState.token === formReadyToken ? formReadyState.ready - : appId !== "pi"; + : !SUBMIT_READY_APPS[appId]; const handleSubmitReadyChange = useCallback( (ready: boolean) => { if (currentFormReadyToken.current === formReadyToken) { @@ -293,7 +288,8 @@ export function AddProviderDialog({ appId === "hermes" || appId === "pi" || appId === "mcode" || - appId === "kimi") && + appId === "kimi" || + appId === "ohmypi") && values.providerKey ) { providerData.providerKey = values.providerKey; @@ -559,7 +555,7 @@ export function AddProviderDialog({ contentClassName={ step === "pick" ? "flex h-full flex-col space-y-0 p-0" - : appId === "pi" + : appId === "pi" || appId === "ohmypi" ? "pb-0 pt-4" : "pt-4" } diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 76f6f7516..780cd1f67 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -26,7 +26,7 @@ import type { } from "@/lib/api/providers"; import { useLiveEditConflict } from "@/components/providers/LiveEditConflictDialog"; import { toastEditorViewFailed } from "@/components/providers/forms/hooks/useDraftEditorProjection"; -import { usesEditorView } from "@/config/appConfig"; +import { SUBMIT_READY_APPS, usesEditorView } from "@/config/appConfig"; interface EditProviderDialogProps { open: boolean; @@ -93,12 +93,12 @@ export function EditProviderDialog({ currentFormReadyToken.current = formReadyToken; const [formReadyState, setFormReadyState] = useState({ token: formReadyToken, - ready: appId !== "pi", + ready: !SUBMIT_READY_APPS[appId], }); const isFormReady = formReadyState.token === formReadyToken ? formReadyState.ready - : appId !== "pi"; + : !SUBMIT_READY_APPS[appId]; const handleSubmitReadyChange = useCallback( (ready: boolean) => { if (currentFormReadyToken.current === formReadyToken) { @@ -189,14 +189,15 @@ export function EditProviderDialog({ return; } - // OpenCode uses additive mode, while Pi's shared models.json is owned by - // the catalog coordinator. Neither has a per-provider generic live + // Additive mode apps (OpenCode, Oh My Pi) and Pi's shared models.json + // (owned by the catalog coordinator) have no per-provider generic live // snapshot that may replace the DB aggregate in this form. if ( appId === "opencode" || appId === "pi" || appId === "mcode" || - appId === "kimi" + appId === "kimi" || + appId === "ohmypi" ) { if (!cancelled) { setLiveSettings(null); diff --git a/src/components/providers/ProviderEmptyState.tsx b/src/components/providers/ProviderEmptyState.tsx index 5e1c815f5..3470800ec 100644 --- a/src/components/providers/ProviderEmptyState.tsx +++ b/src/components/providers/ProviderEmptyState.tsx @@ -20,11 +20,16 @@ export function ProviderEmptyState({ onImport, }: ProviderEmptyStateProps) { const { t } = useTranslation(); - // Pi / MiniMax Code 的"当前供应商"可以是原生应用自管的内置账号(不在可管理的 + // Pi / MiniMax Code / Oh My Pi 的"当前供应商"可以是原生应用自管的内置账号(不在可管理的 // live 节点里),没有可导入的内容,列表也不提供导入按钮,因此不能沿用 // "请点击导入当前配置"的通用文案。 const emptyCopyNs = - appId === "pi" || appId === "mcode" || appId === "kimi" ? appId : null; + appId === "pi" || + appId === "mcode" || + appId === "kimi" || + appId === "ohmypi" + ? appId + : null; // Antigravity 没有切换,这句「切换只替换关键字段」对它不成立 const showKeyFieldsHint = appId === "claude" || diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index cd336db00..ed3c95c00 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -48,6 +48,7 @@ import { cn } from "@/lib/utils"; import { isTextEditableTarget } from "@/utils/domUtils"; import { extractProviderBaseUrl } from "@/utils/providerConfigUtils"; import { usePiCurrentState } from "@/lib/query/pi"; +import { useOhMyPiCurrentState } from "@/lib/query/ohmypi"; import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets"; import { buildAdditiveSections, @@ -155,6 +156,14 @@ export function ProviderList({ error: piCurrentStateError, } = usePiCurrentState(appId === "pi"); const isPiStateReady = appId !== "pi" || isPiCurrentStateSuccess; + const { + data: ohmypiCurrentState, + isSuccess: isOhMyPiCurrentStateSuccess, + isPending: isOhMyPiCurrentStatePending, + isError: isOhMyPiCurrentStateError, + error: ohmypiCurrentStateError, + } = useOhMyPiCurrentState(appId === "ohmypi"); + const isOhMyPiStateReady = appId !== "ohmypi" || isOhMyPiCurrentStateSuccess; // 累加式应用要等 live 里有哪些供应商读回来(成功或失败)才知道卡片该进哪个分区; // 在那之前按加载中画骨架,不然卡片先全落进「可添加」,读回来再整体搬到「已添加」。 // 一张卡都没有时不用等,直接出空状态 @@ -162,7 +171,8 @@ export function ProviderList({ (appId === "opencode" && isOpencodeLiveIdsPending) || (appId === "openclaw" && isOpenclawLiveIdsPending) || (appId === "hermes" && isHermesLiveIdsPending) || - (appId === "pi" && isPiCurrentStatePending); + (appId === "pi" && isPiCurrentStatePending) || + (appId === "ohmypi" && isOhMyPiCurrentStatePending); const isInConfig = useCallback( (provider: Provider): boolean => { @@ -181,6 +191,11 @@ export function ProviderList({ ? (piCurrentState?.enabledProviderIds.includes(provider.id) ?? false) : false; + case "ohmypi": + return isOhMyPiStateReady + ? (ohmypiCurrentState?.enabledProviderIds.includes(provider.id) ?? + false) + : false; default: return true; } @@ -192,6 +207,8 @@ export function ProviderList({ hermesLiveIds, isPiStateReady, piCurrentState, + isOhMyPiStateReady, + ohmypiCurrentState, ], ); @@ -373,7 +390,9 @@ export function ProviderList({ hermesCurrentId: hermesModelConfig?.provider ?? null, isHermesManaged: (provider) => isHermesReadOnlyProvider(provider.settingsConfig), - piStateUnavailable: appId === "pi" && !isPiStateReady, + nativeStateUnavailable: + (appId === "pi" && !isPiStateReady) || + (appId === "ohmypi" && !isOhMyPiStateReady), kimiCurrentId: currentProviderId, actions: { add: onSwitch, @@ -403,6 +422,7 @@ export function ProviderList({ currentOmoSlimId, hermesModelConfig?.provider, isPiStateReady, + isOhMyPiStateReady, currentProviderId, onRemoveFromConfig, onDelete, @@ -428,6 +448,22 @@ export function ProviderList({ ) : null; + const ohmypiStateError = + appId === "ohmypi" && isOhMyPiCurrentStateError + ? extractErrorMessage(ohmypiCurrentStateError) + : ""; + const ohmypiStateErrorNotice = + appId === "ohmypi" && isOhMyPiCurrentStateError ? ( + + {t("ohmypi.current.stateUnavailableHint")} + {ohmypiStateError ? ` ${ohmypiStateError}` : ""} + + ) : null; if (isLoading || (isLiveMembershipPending && sortedProviders.length > 0)) { return (
@@ -445,11 +481,15 @@ export function ProviderList({ return (
{piStateErrorNotice} + {ohmypiStateErrorNotice} importMutation.mutate() } @@ -461,7 +501,7 @@ export function ProviderList({ // 「当前」只用来决定额度显示和自动刷新:Pi 没有当前项,OMO / Hermes 各看自己的当前项; // Antigravity 没有「启用」,每张卡都随时能打开,额度一律照常显示 const isCurrentFor = (provider: Provider) => { - if (appId === "pi") return false; + if (appId === "pi" || appId === "ohmypi") return false; if (appId === "agy") return true; if (provider.category === "omo") return provider.id === currentOmoId; if (provider.category === "omo-slim") @@ -573,6 +613,7 @@ export function ProviderList({ return (
{piStateErrorNotice} + {ohmypiStateErrorNotice} {searchSlot && createPortal(searchField, searchSlot)} {showListHeader && (
= { mcode: 8, kimi: 8, agy: 8, + ohmypi: 8, }; interface TestResult { diff --git a/src/components/providers/forms/OhMyPiProviderForm.tsx b/src/components/providers/forms/OhMyPiProviderForm.tsx new file mode 100644 index 000000000..e0f944b4d --- /dev/null +++ b/src/components/providers/forms/OhMyPiProviderForm.tsx @@ -0,0 +1,1724 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { ChevronRight, Download, Loader2, Plus, Trash2 } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import JsonEditor from "@/components/JsonEditor"; +import { useDarkMode } from "@/hooks/useDarkMode"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ProviderFormProps, ProviderFormValues } from "./ProviderForm"; +import { BasicFormFields } from "./BasicFormFields"; +import { ProviderPresetSelector } from "./ProviderPresetSelector"; +import { RequestHeadersEditor } from "./RequestHeadersEditor"; +import { StructuredOptionsEditor } from "./StructuredOptionsEditor"; +import { ApiKeySection, EndpointField, ModelDropdown } from "./shared"; +import { + findRequestHeaderValue, + normalizeRequestHeaders, +} from "./helpers/requestHeaders"; +import { + fetchModelsForConfig, + showFetchModelsError, + type FetchedModel, +} from "@/lib/api/model-fetch"; +import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; +import { ohmypiProviderPresets } from "@/config/ohmypiProviderPresets"; + +import type { ProviderCategory } from "@/types"; + +// Allowed `api` values per ohmypi models.yml (see oh-my-pi docs/models.md). +const API_FORMATS = [ + { value: "openai-completions", label: "OpenAI Chat Completions" }, + { value: "openai-responses", label: "OpenAI Responses" }, + { value: "openai-codex-responses", label: "OpenAI Codex Responses" }, + { value: "azure-openai-responses", label: "Azure OpenAI Responses" }, + { value: "anthropic-messages", label: "Anthropic Messages" }, + { value: "bedrock-converse-stream", label: "Amazon Bedrock" }, + { value: "google-generative-ai", label: "Google Generative AI" }, + { value: "google-gemini-cli", label: "Google Gemini CLI" }, + { value: "google-vertex", label: "Google Vertex" }, +] as const satisfies ReadonlyArray<{ value: string; label: string }>; + +// `discovery.type` values per ohmypi models.yml (see oh-my-pi docs/models.md). +// `proxy` auto-detects each model's wire, so it needs no provider-level api; +// every other type requires one (models-config loader enforces this). +const DISCOVERY_TYPES = [ + { value: "openai-models-list", label: "OpenAI Models List" }, + { value: "proxy", label: "OpenAI + Anthropic Proxy" }, + { value: "ollama", label: "Ollama" }, + { value: "llama.cpp", label: "llama.cpp" }, + { value: "lm-studio", label: "LM Studio" }, + { value: "litellm", label: "LiteLLM" }, +] as const satisfies ReadonlyArray<{ value: string; label: string }>; + +// Root-level keys owned by the curated form controls. Everything else in the +// provider's settingsConfig is treated as an unmanaged passthrough and +// preserved verbatim through the YAML escape hatch. +const ROOT_CONTROLLED_KEYS: Record = { + name: true, + baseUrl: true, + api: true, + apiKey: true, + headers: true, + compat: true, + models: true, + discovery: true, +}; + +// Per-model keys exposed by the curated model rows (omp model overrides). +const MODEL_CONTROLLED_KEYS: Record = { + id: true, + name: true, + reasoning: true, + input: true, + contextWindow: true, + maxTokens: true, +}; + +interface OhMyPiModelDraft { + key: string; + id: string; + name: string; + hasName: boolean; + reasoning: boolean; + hasReasoning: boolean; + input: string[]; + hasInput: boolean; + contextWindow: string; + hasContextWindow: boolean; + maxTokens: string; + hasMaxTokens: boolean; + passthrough: Record; +} + +class OhMyPiFormValidationError extends Error { + constructor( + message: string, + readonly fieldSelector?: string, + readonly modelKey?: string, + ) { + super(message); + this.name = "OhMyPiFormValidationError"; + } +} + +function validateOhMyPiField( + operation: () => T, + fieldSelector: string, + modelKey?: string, +): T { + try { + return operation(); + } catch (error) { + throw new OhMyPiFormValidationError( + error instanceof Error ? error.message : String(error), + fieldSelector, + modelKey, + ); + } +} + +function objectWithout( + value: Record, + denied: Record, +): Record { + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + if (!denied[key]) result[key] = val; + } + return result; +} + +function asObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +/** + * `discovery` 里除 `type` 之外的子键(`timeoutMs`、`injectV1`,以及未来版本 + * 新增的键)。`type` 由下拉框拥有,其余由用户手写或导入,必须原样带回去: + */ +function discoveryExtras(value: unknown): Record { + return objectWithout(asObject(value), { type: true }); +} + +function parseYamlObject(value: string): Record | null { + try { + const parsed: unknown = parseYaml(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +/** + * 编辑器文本首行可能是 `:` 行(含注释,由后端原样带出,仅作展示)。 + * 解析其 YAML 语义时先剥离这一行,否则会得到 `: null` 污染配置。 + */ +function parseYamlObjectWithoutKeyLine( + value: string, + providerKey: string, +): Record | null { + const trimmed = value.trim(); + if ( + providerKey && + trimmed.split("\n")[0]?.trimStart().startsWith(`${providerKey}:`) + ) { + const rest = value.split("\n").slice(1).join("\n"); + return parseYamlObject(rest); + } + return parseYamlObject(value); +} + +function optionalText(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function validateAbsoluteHttpUrl(value: string, errorMessage: string): void { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(errorMessage); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(errorMessage); + } +} + +function positiveNumber( + value: string, + errorMessage: string, + fieldSelector: string, +): number { + const parsed = Number(value); + if (value.trim() === "" || !Number.isFinite(parsed) || parsed <= 0) { + throw new OhMyPiFormValidationError(errorMessage, fieldSelector); + } + return parsed; +} + +function supportsImageInput(value: unknown): boolean { + return Array.isArray(value) && value.includes("image"); +} + +function withImageInput(value: unknown, enabled: boolean): string[] { + const additionalInputTypes = Array.isArray(value) + ? value.filter( + (item): item is string => + typeof item === "string" && item !== "text" && item !== "image", + ) + : []; + return [ + "text", + ...(enabled ? ["image"] : []), + ...new Set(additionalInputTypes), + ]; +} + +function normalizeProviderKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9-]/g, ""); +} + +function modelDraft( + value: unknown, + options: { + key?: string; + } = {}, +): OhMyPiModelDraft { + const model = asObject(value); + return { + key: options.key ?? crypto.randomUUID(), + id: optionalText(model.id), + name: optionalText(model.name), + hasName: hasOwn(model, "name"), + reasoning: model.reasoning === true, + hasReasoning: hasOwn(model, "reasoning"), + input: Array.isArray(model.input) ? [...model.input] : ["text"], + hasInput: hasOwn(model, "input"), + contextWindow: + typeof model.contextWindow === "number" && + Number.isFinite(model.contextWindow) + ? String(model.contextWindow) + : "", + hasContextWindow: hasOwn(model, "contextWindow"), + maxTokens: + typeof model.maxTokens === "number" && Number.isFinite(model.maxTokens) + ? String(model.maxTokens) + : "", + hasMaxTokens: hasOwn(model, "maxTokens"), + passthrough: objectWithout(model, MODEL_CONTROLLED_KEYS), + }; +} + +function newModel(): OhMyPiModelDraft { + return { + key: crypto.randomUUID(), + id: "", + name: "", + hasName: true, + reasoning: false, + hasReasoning: true, + input: ["text"], + hasInput: true, + contextWindow: "", + hasContextWindow: true, + maxTokens: "", + hasMaxTokens: true, + passthrough: {}, + }; +} + +function modelPreview(model: OhMyPiModelDraft): Record { + const displayName = model.name.trim(); + const previewNumber = (value: string): number | string | undefined => { + if (!value.trim()) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : value; + }; + const contextWindow = previewNumber(model.contextWindow); + const maxTokens = previewNumber(model.maxTokens); + + return { + ...model.passthrough, + id: model.id, + ...(model.hasName && displayName ? { name: displayName } : {}), + ...(model.hasReasoning ? { reasoning: model.reasoning } : {}), + ...(model.hasInput + ? { input: withImageInput(model.input, supportsImageInput(model.input)) } + : {}), + ...(model.hasContextWindow && contextWindow !== undefined + ? { contextWindow } + : {}), + ...(model.hasMaxTokens && maxTokens !== undefined ? { maxTokens } : {}), + }; +} + +function buildOhMyPiSettingsConfig({ + passthrough, + name, + baseUrl, + api, + apiKey, + headers, + compat, + includeCompat, + discoveryType, + discoveryExtra, + models, + includeModels, +}: { + passthrough: Record; + name?: string; + baseUrl: string; + api: string; + apiKey: string; + headers: Record; + compat: Record; + includeCompat: boolean; + discoveryType: string; + discoveryExtra: Record; + models: Record[]; + includeModels: boolean; +}): Record { + return { + ...passthrough, + ...(name !== undefined && name.trim() ? { name: name.trim() } : {}), + ...(baseUrl.trim() ? { baseUrl: baseUrl.trim() } : {}), + ...(api.trim() ? { api: api.trim() } : {}), + ...(apiKey ? { apiKey } : {}), + ...(Object.keys(headers).length > 0 ? { headers } : {}), + ...(includeCompat ? { compat } : {}), + ...(discoveryType + ? { discovery: { type: discoveryType, ...discoveryExtra } } + : {}), + ...(includeModels ? { models } : {}), + }; +} + +/** + * 受管键(ROOT_CONTROLLED_KEYS)部分的 YAML 文本,由表单控件值生成。 + * 这是"配置 YAML"编辑器显示文本的受管块,与控件双向实时同步。 + */ +function controlledBlockYaml( + name: string, + baseUrl: string, + api: string, + apiKey: string, + headers: Record, + compat: Record, + includeCompat: boolean, + discoveryType: string, + discoveryExtra: Record, + models: OhMyPiModelDraft[], + includeModels: boolean, +): string { + const controlled: Record = { + ...(name.trim() ? { name: name.trim() } : {}), + ...(baseUrl.trim() ? { baseUrl: baseUrl.trim() } : {}), + ...(api.trim() ? { api: api.trim() } : {}), + ...(apiKey ? { apiKey } : {}), + ...(Object.keys(headers).length > 0 ? { headers } : {}), + ...(discoveryType + ? { discovery: { type: discoveryType, ...discoveryExtra } } + : {}), + ...(includeCompat && Object.keys(compat).length > 0 ? { compat } : {}), + ...(includeModels + ? { + models: models + .filter((model) => model.id.trim()) + .map((model) => modelPreview({ ...model, id: model.id.trim() })), + } + : {}), + }; + if (Object.keys(controlled).length === 0) return ""; + return stringifyYaml(controlled, { indent: 2 }).trimEnd(); +} + +/** + * 从编辑器文本中剥离受管键行(含其嵌套块),返回剩余的非受管文本。 + * 与后端 drop_controlled_keys 的剥离规则一致。 + */ +function stripControlledKeys(text: string): string { + const kept: string[] = []; + let skippingIndent: number | null = null; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) { + kept.push(line); + continue; + } + const indent = line.length - line.trimStart().length; + if (skippingIndent !== null) { + if ( + indent > skippingIndent || + (indent === skippingIndent && trimmed.startsWith("-")) + ) { + continue; + } + skippingIndent = null; + } + if (indent === 0) { + const key = trimmed.split(":")[0]?.trim() ?? ""; + if (key in ROOT_CONTROLLED_KEYS) { + skippingIndent = 0; + continue; + } + } + kept.push(line); + } + return kept.join("\n"); +} + +export function OhMyPiProviderForm({ + providerId, + submitLabel, + onSubmit, + onCancel, + onSubmittingChange, + onSubmitReadyChange, + initialData, + showButtons = true, +}: ProviderFormProps) { + const { t } = useTranslation(); + const initialConfig = useMemo( + () => asObject(initialData?.settingsConfig), + [initialData?.settingsConfig], + ); + const isEdit = Boolean(initialData); + const initialDisplayName = + initialData?.name ?? optionalText(initialConfig.name); + + const [selectedPresetId, setSelectedPresetId] = useState( + isEdit ? null : "custom", + ); + const [category, setCategory] = useState( + initialData?.category ?? "custom", + ); + const [providerKey, setProviderKey] = useState(providerId ?? ""); + const [baseUrl, setBaseUrl] = useState(optionalText(initialConfig.baseUrl)); + const [api, setApi] = useState( + () => + optionalText(initialConfig.api) || (isEdit ? "" : "openai-completions"), + ); + const [apiKey, setApiKey] = useState(optionalText(initialConfig.apiKey)); + const [providerHeaders, setProviderHeaders] = useState< + Record + >(() => { + const initialHeaders: Record = {}; + for (const [key, val] of Object.entries(asObject(initialConfig.headers))) { + if (typeof val === "string") initialHeaders[key] = val; + } + return initialHeaders; + }); + const [providerCompat, setProviderCompat] = useState>( + () => asObject(initialConfig.compat), + ); + const includeCompatRef = useRef(hasOwn(initialConfig, "compat")); + const [providerPassthrough, setProviderPassthrough] = useState< + Record + >(() => objectWithout(initialConfig, ROOT_CONTROLLED_KEYS)); + // 编辑器里非受管部分的文本(注释、自定义字段)。受管部分由控件派生, + // 两者在 displayYaml 里组合,实现控件 ⇄ YAML 双向实时同步。 + const [unmanagedText, setUnmanagedText] = useState(() => { + // Prefer the raw text (comments, order preserved) captured from models.yml. + const raw = initialData?.meta?.ohmypiPassthroughYaml; + const source = + raw !== undefined && raw.trim() !== "" + ? raw + : Object.keys(providerPassthrough).length > 0 + ? stringifyYaml(providerPassthrough, { indent: 2 }) + : ""; + return stripControlledKeys(source).trim(); + }); + const [includeModels, setIncludeModels] = useState( + () => !isEdit || hasOwn(initialConfig, "models"), + ); + // `discovery.type`(空串 = 未启用自动发现)。其余子键(`timeoutMs`、 + // `injectV1`,或未来版本的新键)不归控件管,单独存住并原样写回。 + const [discoveryType, setDiscoveryType] = useState(() => { + const discovery = asObject(initialConfig.discovery); + return typeof discovery.type === "string" ? discovery.type : ""; + }); + const [discoveryExtra, setDiscoveryExtra] = useState>( + () => discoveryExtras(initialConfig.discovery), + ); + const initialModels = useMemo(() => { + if (!Array.isArray(initialConfig.models)) return []; + return initialConfig.models.map((model) => modelDraft(model)); + }, [initialConfig.models]); + const [models, setModels] = useState(initialModels); + const [expandedModelKeys, setExpandedModelKeys] = useState>( + () => new Set(), + ); + const [fetchedModels, setFetchedModels] = useState( + null, + ); + const [isFetchingModels, setIsFetchingModels] = useState(false); + const modelFetchGenerationRef = useRef(0); + const isDarkMode = useDarkMode(); + const [formError, setFormError] = useState(null); + + const form = useForm({ + resolver: zodResolver(providerSchema), + mode: "onChange", + defaultValues: { + name: initialData?.name ?? optionalText(initialConfig.name), + websiteUrl: initialData?.websiteUrl ?? "", + notes: initialData?.notes ?? "", + settingsConfig: JSON.stringify(initialConfig, null, 2), + icon: initialData?.icon ?? "", + iconColor: initialData?.iconColor ?? "", + }, + }); + + const hasConfigurationSelection = isEdit || selectedPresetId !== null; + + // 编辑器显示文本 = 受管块(控件派生)+ 非受管文本。控件变化实时反映到 YAML。 + const displayYaml = useMemo(() => { + const controlled = controlledBlockYaml( + form.watch("name"), + baseUrl, + api, + apiKey, + providerHeaders, + providerCompat, + includeCompatRef.current, + discoveryType, + discoveryExtra, + models, + includeModels, + ); + const unmanaged = unmanagedText.trim(); + if (controlled && unmanaged) return `${controlled}\n${unmanaged}`; + return controlled || unmanaged; + }, [ + baseUrl, + api, + apiKey, + providerHeaders, + providerCompat, + models, + discoveryType, + discoveryExtra, + includeModels, + unmanagedText, + form, + ]); + + const isSettingsConfigValid = + unmanagedText.trim() === "" || parseYamlObject(unmanagedText) !== null; + const isSubmitReady = + form.watch("name").trim().length > 0 && isSettingsConfigValid; + + useEffect(() => { + onSubmitReadyChange?.(isSubmitReady); + }, [isSubmitReady, onSubmitReadyChange]); + + const buildSettingsConfig = useCallback( + (displayName: string): Record => { + const parsedPassthrough = parseYamlObject(unmanagedText); + const passthrough = + unmanagedText.trim() === "" + ? {} + : (parsedPassthrough ?? providerPassthrough); + return buildOhMyPiSettingsConfig({ + passthrough, + name: displayName, + baseUrl, + api, + apiKey, + headers: normalizeRequestHeaders(providerHeaders), + compat: providerCompat, + includeCompat: includeCompatRef.current, + discoveryType, + discoveryExtra, + models: models + .filter((model) => model.id.trim()) + .map((model) => modelPreview({ ...model, id: model.id.trim() })), + includeModels, + }); + }, + [ + api, + apiKey, + baseUrl, + discoveryType, + discoveryExtra, + includeModels, + models, + unmanagedText, + providerCompat, + providerHeaders, + providerPassthrough, + ], + ); + + const syncSettingsConfig = useCallback(() => { + const displayName = form.getValues("name"); + const config = buildSettingsConfig( + typeof displayName === "string" ? displayName : "", + ); + form.setValue("settingsConfig", JSON.stringify(config, null, 2), { + shouldDirty: true, + shouldValidate: true, + }); + }, [buildSettingsConfig, form]); + + // Keep the hidden settingsConfig field in sync with the curated controls. + useEffect(() => { + syncSettingsConfig(); + }, [ + baseUrl, + api, + apiKey, + providerHeaders, + providerCompat, + discoveryType, + discoveryExtra, + unmanagedText, + includeModels, + models, + syncSettingsConfig, + ]); + + const invalidateFetchedModels = useCallback(() => { + modelFetchGenerationRef.current += 1; + setFetchedModels(null); + setIsFetchingModels(false); + }, []); + + useEffect( + () => () => { + modelFetchGenerationRef.current += 1; + }, + [], + ); + + const presetEntries = useMemo( + () => + ohmypiProviderPresets.map((preset, index) => ({ + id: `ohmypi-${index}`, + preset, + })), + [], + ); + + const selectPreset = (id: string) => { + setFormError(null); + setSelectedPresetId(id); + setCategory("custom"); + setProviderKey(""); + setBaseUrl(""); + setApi("openai-completions"); + setApiKey(""); + setProviderHeaders({}); + setProviderCompat({}); + includeCompatRef.current = false; + setProviderPassthrough({}); + setDiscoveryType(""); + setDiscoveryExtra({}); + setUnmanagedText(""); + setIncludeModels(true); + setModels([]); + setExpandedModelKeys(new Set()); + invalidateFetchedModels(); + form.reset({ + name: initialDisplayName, + websiteUrl: "", + notes: "", + settingsConfig: JSON.stringify( + buildOhMyPiSettingsConfig({ + passthrough: {}, + name: initialDisplayName, + baseUrl: "", + api: "openai-completions", + apiKey: "", + headers: {}, + compat: {}, + discoveryType: "", + discoveryExtra: {}, + includeCompat: false, + models: [], + includeModels: true, + }), + null, + 2, + ), + icon: initialData?.icon ?? "", + iconColor: initialData?.iconColor ?? "", + }); + if (id === "custom") return; + const entry = presetEntries.find((candidate) => candidate.id === id); + if (!entry) return; + const preset = entry.preset; + const presetConfig = asObject(preset.settingsConfig); + const headers: Record = {}; + for (const [key, val] of Object.entries(asObject(presetConfig.headers))) { + if (typeof val === "string") headers[key] = val; + } + setCategory(preset.category ?? "custom"); + setProviderKey(preset.providerKey); + setBaseUrl(preset.settingsConfig.baseUrl); + setApi(preset.settingsConfig.api); + setIncludeModels(true); + setProviderHeaders(headers); + setProviderCompat(asObject(presetConfig.compat)); + includeCompatRef.current = hasOwn(presetConfig, "compat"); + const passthrough = objectWithout(presetConfig, ROOT_CONTROLLED_KEYS); + setProviderPassthrough(passthrough); + setUnmanagedText( + Object.keys(passthrough).length > 0 + ? stringifyYaml(passthrough).trim() + : "", + ); + setModels(preset.settingsConfig.models.map((model) => modelDraft(model))); + setExpandedModelKeys(new Set()); + form.reset({ + name: preset.settingsConfig.name, + websiteUrl: preset.websiteUrl, + notes: "", + settingsConfig: JSON.stringify(presetConfig, null, 2), + icon: preset.icon ?? "", + iconColor: preset.iconColor ?? "", + }); + }; + + const handleApiChange = useCallback( + (value: string) => { + setApi(value); + invalidateFetchedModels(); + }, + [invalidateFetchedModels], + ); + + const handleApiKeyChange = useCallback( + (value: string) => { + setApiKey(value); + invalidateFetchedModels(); + }, + [invalidateFetchedModels], + ); + + const handleBaseUrlChange = useCallback( + (value: string) => { + setBaseUrl(value); + invalidateFetchedModels(); + }, + [invalidateFetchedModels], + ); + + const handleProviderHeadersChange = useCallback( + (value: Record) => { + setProviderHeaders(value); + invalidateFetchedModels(); + }, + [invalidateFetchedModels], + ); + + const handleProviderCompatChange = useCallback( + (value: Record) => { + includeCompatRef.current = Object.keys(value).length > 0; + setProviderCompat(value); + }, + [], + ); + + const handleProviderKeyChange = useCallback((value: string) => { + setProviderKey(normalizeProviderKey(value)); + }, []); + + const handleDisplayYamlChange = (value: string) => { + if (value === displayYaml) return; + // YAML → 控件:受管键行同步回表单控件(displayYaml 的受管块由控件 + // 派生,改动会立即反映回来);非受管部分原样保留。 + const parsed = parseYamlObjectWithoutKeyLine(value, providerKey); + if (parsed) { + setProviderPassthrough(parsed); + if (typeof parsed.name === "string") { + form.setValue("name", parsed.name, { shouldDirty: true }); + } + setBaseUrl(optionalText(parsed.baseUrl)); + if (typeof parsed.api === "string" && parsed.api.trim() !== "") { + setApi(parsed.api); + } + setApiKey(optionalText(parsed.apiKey)); + const parsedHeaders: Record = {}; + for (const [key, val] of Object.entries(asObject(parsed.headers))) { + if (typeof val === "string") parsedHeaders[key] = val; + } + setProviderHeaders(parsedHeaders); + setProviderCompat(asObject(parsed.compat)); + includeCompatRef.current = hasOwn(parsed, "compat"); + setDiscoveryType( + typeof asObject(parsed.discovery).type === "string" + ? (asObject(parsed.discovery).type as string) + : "", + ); + setDiscoveryExtra(discoveryExtras(parsed.discovery)); + setIncludeModels(hasOwn(parsed, "models")); + if (Array.isArray(parsed.models)) { + // 尽量沿用现有模型行的 key(先按 id 匹配,数量一致时按位置兜底), + // 只在真正增删/换行时生成新 key,避免编辑 YAML 收起已展开的模型详情。 + const previousDrafts = models; + const configModels = parsed.models; + const reservedPreviousIndexes = new Set(); + const matchedPreviousIndexes = configModels.map((model) => { + const id = optionalText(asObject(model).id); + const previousIndex = previousDrafts.findIndex( + (candidate, candidateIndex) => + !reservedPreviousIndexes.has(candidateIndex) && + candidate.id === id, + ); + if (previousIndex >= 0) reservedPreviousIndexes.add(previousIndex); + return previousIndex; + }); + const usedPreviousIndexes = new Set(reservedPreviousIndexes); + const canUsePositionalFallback = + configModels.length === previousDrafts.length; + const nextModels = configModels.map((model, index) => { + let previousIndex = matchedPreviousIndexes[index]; + if ( + previousIndex < 0 && + canUsePositionalFallback && + !usedPreviousIndexes.has(index) + ) { + previousIndex = index; + usedPreviousIndexes.add(index); + } + const previousDraft = + previousIndex >= 0 ? previousDrafts[previousIndex] : undefined; + return modelDraft(model, { key: previousDraft?.key }); + }); + setModels(nextModels); + const nextKeys = new Set(nextModels.map((model) => model.key)); + setExpandedModelKeys( + (current) => new Set([...current].filter((key) => nextKeys.has(key))), + ); + } else { + setModels([]); + } + invalidateFetchedModels(); + } + setUnmanagedText(stripControlledKeys(value)); + }; + + const changeModelId = (key: string, id: string) => { + setModels((current) => + current.map((model) => + model.key === key + ? { + ...model, + id, + name: + model.hasName && + (model.name.length === 0 || model.name === model.id) + ? id + : model.name, + } + : model, + ), + ); + }; + + const updateModelOverride = ( + key: string, + update: Partial>, + ) => { + setModels((current) => + current.map((model) => + model.key === key ? { ...model, ...update } : model, + ), + ); + }; + + const addModel = () => { + const model = newModel(); + setIncludeModels(true); + setModels((current) => [...current, model]); + }; + + const removeModel = (key: string) => { + setModels((current) => current.filter((model) => model.key !== key)); + setExpandedModelKeys((current) => { + const next = new Set(current); + next.delete(key); + return next; + }); + }; + + const toggleModelDetails = (key: string) => { + setExpandedModelKeys((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const handleFetchModels = useCallback(() => { + const endpoint = baseUrl.trim(); + const requestHeaders = normalizeRequestHeaders(providerHeaders); + const hasCredentials = + Boolean(apiKey) || Object.keys(requestHeaders).length > 0; + if (!endpoint || !hasCredentials) { + showFetchModelsError(null, t, { + hasApiKey: hasCredentials, + hasBaseUrl: Boolean(endpoint), + }); + return; + } + + const customUserAgent = findRequestHeaderValue( + requestHeaders, + "user-agent", + ); + + const requestGeneration = ++modelFetchGenerationRef.current; + setFetchedModels(null); + setIsFetchingModels(true); + fetchModelsForConfig( + endpoint, + apiKey, + undefined, + undefined, + customUserAgent, + { + apiFormat: api, + requestHeaders, + }, + ) + .then((result) => { + if (modelFetchGenerationRef.current !== requestGeneration) return; + setFetchedModels(result); + if (result.length === 0) { + toast.info(t("providerForm.fetchModelsEmpty")); + } else { + toast.success( + t("providerForm.fetchModelsSuccess", { count: result.length }), + ); + } + }) + .catch((error) => { + if (modelFetchGenerationRef.current !== requestGeneration) return; + console.warn("[ModelFetch] Failed:", error); + showFetchModelsError(error, t); + }) + .finally(() => { + if (modelFetchGenerationRef.current === requestGeneration) { + setIsFetchingModels(false); + } + }); + }, [api, apiKey, baseUrl, providerHeaders, t]); + + const submit = async (identity: ProviderFormData) => { + onSubmittingChange?.(true); + setFormError(null); + try { + const trimmedName = identity.name.trim(); + const trimmedKey = normalizeProviderKey( + isEdit ? (providerId ?? "") : providerKey || trimmedName, + ); + if (!trimmedName) { + throw new OhMyPiFormValidationError( + t("ohmypi.form.nameRequired"), + 'input[name="name"]', + ); + } + if (!isEdit && !trimmedKey) { + throw new OhMyPiFormValidationError( + t("ohmypi.form.providerKeyRequired"), + "#ohmypi-provider-key", + ); + } + const seen = new Set(); + for (const model of includeModels ? models : []) { + const id = model.id.trim(); + if (id.length === 0) { + throw new OhMyPiFormValidationError( + t("ohmypi.form.modelIdRequired", { + index: models.indexOf(model) + 1, + }), + `#ohmypi-model-id-${model.key}`, + model.key, + ); + } + if (seen.has(id)) { + throw new OhMyPiFormValidationError( + t("ohmypi.form.duplicateModel", { id }), + `#ohmypi-model-id-${model.key}`, + model.key, + ); + } + seen.add(id); + // models.yml 拒绝非正的 contextWindow/maxTokens(docs/models.md + // "Model value checks")。CC Switch 创建的模型行始终写入这两个键, + // 因此保存前必须填好;导入的行若本来就没带(override 场景)则跳过。 + const includeContextWindow = !isEdit || model.hasContextWindow; + const includeMaxTokens = !isEdit || model.hasMaxTokens; + if (includeContextWindow) { + validateOhMyPiField( + () => + positiveNumber( + model.contextWindow, + t("ohmypi.form.positiveNumberRequired", { + label: t("ohmypi.form.contextWindow"), + }), + `#ohmypi-model-context-window-${model.key}`, + ), + `#ohmypi-model-context-window-${model.key}`, + model.key, + ); + } + if (includeMaxTokens) { + validateOhMyPiField( + () => + positiveNumber( + model.maxTokens, + t("ohmypi.form.positiveNumberRequired", { + label: t("ohmypi.form.maxTokens"), + }), + `#ohmypi-model-max-tokens-${model.key}`, + ), + `#ohmypi-model-max-tokens-${model.key}`, + model.key, + ); + } + } + + // models.yml 校验:discovery 需要供应商级 api,proxy 除外(每模型 + // 自动识别接口,见 docs/models.md "Discovery")。 + if (discoveryType && discoveryType !== "proxy" && !api.trim()) { + throw new OhMyPiFormValidationError( + t("ohmypi.form.discoveryApiRequired"), + "#ohmypi-provider-api-select", + ); + } + + if (baseUrl.trim()) { + validateOhMyPiField( + () => + validateAbsoluteHttpUrl( + baseUrl.trim(), + t("ohmypi.form.absoluteHttpUrlRequired", { + label: t("opencode.baseUrl", { defaultValue: "Base URL" }), + }), + ), + "#ohmypi-provider-base-url", + ); + } + + const settingsConfig = buildSettingsConfig(trimmedName); + const values: ProviderFormValues = { + name: trimmedName, + websiteUrl: identity.websiteUrl?.trim() ?? "", + notes: identity.notes?.trim() ?? "", + settingsConfig: JSON.stringify(settingsConfig), + icon: identity.icon || "", + iconColor: identity.iconColor || "", + providerKey: isEdit ? providerId : trimmedKey, + presetId: selectedPresetId ?? undefined, + presetCategory: category, + meta: { + ...initialData?.meta, + // The edited raw text (comments included) is persisted and reused + // verbatim when writing this entry into models.yml. + ohmypiPassthroughYaml: displayYaml.trim() ? displayYaml : undefined, + }, + }; + await onSubmit(values); + } catch (error) { + const rawMessage = error instanceof Error ? error.message : String(error); + setFormError(rawMessage); + if (error instanceof OhMyPiFormValidationError) { + const modelKey = + error.modelKey ?? + error.fieldSelector?.match(/#ohmypi-model-([0-9a-f-]{36})$/)?.[1]; + if (modelKey) { + setExpandedModelKeys((current) => { + const next = new Set(current); + next.add(modelKey); + return next; + }); + } + if (error.fieldSelector) { + requestAnimationFrame(() => { + document.querySelector(error.fieldSelector!)?.focus(); + }); + } + toast.error(rawMessage); + } + } finally { + onSubmittingChange?.(false); + } + }; + + const presetCategoryLabels = useMemo>( + () => ({ + official: t("providerForm.categoryOfficial"), + cn_official: t("providerForm.categoryCnOfficial"), + aggregator: t("providerForm.categoryAggregation"), + third_party: t("providerForm.categoryThirdParty"), + custom: t("providerPreset.custom"), + }), + [t], + ); + const isKnownApiFormat = API_FORMATS.some((format) => format.value === api); + + return ( +
+ { + if (formError) setFormError(null); + }} + className="space-y-6 glass rounded-xl p-6 border border-white/10" + > + {!isEdit && ( + + )} + + {formError && ( +
+ {formError} +
+ )} + + {hasConfigurationSelection && !isSettingsConfigValid && ( +

+ {t("ohmypi.form.fixYamlFirst")} +

+ )} + + {hasConfigurationSelection && ( +
+ + + + handleProviderKeyChange(event.target.value) + } + disabled={isEdit} + placeholder="my-provider" + autoComplete="off" + /> +

+ {isEdit + ? t("opencode.providerKeyLockedHint", { + defaultValue: + "该供应商已添加到应用配置中,供应商标识不可修改", + }) + : t("opencode.providerKeyHint", { + defaultValue: + "配置文件中的唯一标识符,只能使用小写字母、数字和连字符", + })} +

+
+ ) : undefined + } + /> + + + + {isEdit ? ( +

+ {t("ohmypi.form.apiInheritHint", { + defaultValue: + "选择「不指定」时不写 api 键:标识与内置供应商相同时继承其模型的接口格式,proxy 自动发现时每个模型单独自动判断接口格式。", + })} +

+ ) : ( +

+ {t("opencode.npmPackageHint", { + defaultValue: "选择 AI 服务的 API 接口格式", + })} +

+ )} +
+ + + +
+ +

+ {t("opencode.baseUrlHint", { + defaultValue: "自定义 API 端点地址", + })} +

+
+ + + + + + + + + + {discoveryType && ( + + +

+ {t("ohmypi.form.discoveryHint", { + defaultValue: + "由 Oh My Pi 定期从端点拉取模型列表;端点返回的上下文长度等元数据会自动套用。手动模型行和下方 YAML 的 modelOverrides 仍然生效。", + })} +

+
+ )} + +
+
+ + {t("opencode.models", { defaultValue: "模型配置" })} + +
+ + +
+
+ + {discoveryType ? ( +

+ {t("ohmypi.form.modelsDiscoveryHint", { + defaultValue: + "模型列表由 Oh My Pi 运行时自动发现,此处无需手动配置;仍可通过下方 YAML 添加 models 或 modelOverrides 微调。", + })} +

+ ) : !includeModels ? ( +

+ {t("ohmypi.form.modelsHiddenHint")} +

+ ) : models.length === 0 ? ( +

+ {t("ohmypi.form.noModels", { + defaultValue: "暂无模型配置", + })} +

+ ) : ( +
+
+ + + {t("ohmypi.form.modelId")} + + + {t("ohmypi.form.modelName")} + +
+ {models.map((model) => { + const isExpanded = expandedModelKeys.has(model.key); + return ( +
+
+ +
+ + changeModelId(model.key, event.target.value) + } + placeholder="model-id" + aria-label={t("ohmypi.form.modelId")} + required + className="min-w-0 flex-1" + /> + {fetchedModels && fetchedModels.length > 0 && ( + changeModelId(model.key, id)} + /> + )} +
+ + updateModelOverride(model.key, { + name: event.target.value, + hasName: true, + }) + } + placeholder={t("ohmypi.form.modelNamePlaceholder")} + aria-label={t("ohmypi.form.modelName")} + className="min-w-0 flex-1" + /> + +
+ + {isExpanded && ( +
+
+
+ + + updateModelOverride(model.key, { + reasoning: checked, + hasReasoning: true, + }) + } + /> +
+
+ + + updateModelOverride(model.key, { + input: withImageInput( + model.input, + checked, + ), + hasInput: true, + }) + } + /> +
+
+ + {t("ohmypi.form.contextWindow")} + + + } + htmlFor={`ohmypi-model-context-window-${model.key}`} + > + + updateModelOverride(model.key, { + contextWindow: event.target.value, + hasContextWindow: true, + }) + } + placeholder="128000" + /> + + + {t("ohmypi.form.maxTokens")} + + + } + htmlFor={`ohmypi-model-max-tokens-${model.key}`} + > + + updateModelOverride(model.key, { + maxTokens: event.target.value, + hasMaxTokens: true, + }) + } + placeholder="16384" + /> + +
+ )} +
+ ); + })} +
+ )} + +

+ {t("opencode.modelsHint", { + defaultValue: "配置可用的模型及其显示名称。", + })} +

+
+ + )} + + {hasConfigurationSelection && ( + ( + + + + + + )} + /> + )} + + {showButtons && ( +
+ + +
+ )} + + + ); +} + +function Field({ + label, + htmlFor, + children, +}: { + label: React.ReactNode; + htmlFor: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 9b7d34ba3..918fd8cc9 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -102,6 +102,7 @@ import { AgyAccountSelect } from "./AgyOAuthSection"; import { isMac } from "@/lib/platform"; import { McodeProviderForm } from "./McodeProviderForm"; import { PiProviderForm } from "./PiProviderForm"; +import { OhMyPiProviderForm } from "./OhMyPiProviderForm"; import { OmoFormFields } from "./OmoFormFields"; import { parseOmoOtherFieldsObject } from "@/types/omo"; import type { AppMode } from "@/types/proxy"; @@ -333,6 +334,9 @@ export function ProviderForm(props: ProviderFormProps) { if (props.appId === "pi") { return ; } + if (props.appId === "ohmypi") { + return ; + } if (props.appId === "claude-desktop") { return ; } diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index e67fb4e9f..5f0fec46d 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -17,6 +17,7 @@ import type { HermesProviderPreset } from "@/config/hermesProviderPresets"; import type { McodeProviderPreset } from "@/config/mcodeProviderPresets"; import type { KimiProviderPreset } from "@/config/kimiProviderPresets"; import type { PiProviderPreset } from "@/config/piProviderPresets"; +import type { OhMyPiProviderPreset } from "@/config/ohmypiProviderPresets"; import type { ProviderCategory } from "@/types"; import type { AppId } from "@/lib/api"; import { @@ -73,7 +74,8 @@ export type AnyPreset = | HermesProviderPreset | PiProviderPreset | McodeProviderPreset - | KimiProviderPreset; + | KimiProviderPreset + | OhMyPiProviderPreset; export type PresetEntry = { id: string; diff --git a/src/components/providers/presentation.ts b/src/components/providers/presentation.ts index 5e9ea3a06..2e3027b94 100644 --- a/src/components/providers/presentation.ts +++ b/src/components/providers/presentation.ts @@ -695,8 +695,8 @@ export interface AdditiveInput { /** Hermes:model.provider 指向的那家 */ hermesCurrentId?: string | null; isHermesManaged?: (provider: Provider) => boolean; - /** Pi:读不到当前配置时一律不能改 */ - piStateUnavailable?: boolean; + /** Pi / Oh My Pi:读不到当前配置时一律不能改 */ + nativeStateUnavailable?: boolean; /** Kimi:原生 default_model 别名所属的供应商。 */ kimiCurrentId?: string | null; actions: { @@ -711,9 +711,9 @@ export function buildAdditiveSections(input: AdditiveInput): ProviderSection[] { const { app, t, providers, actions } = input; const added: ProviderSection["items"] = []; const available: ProviderSection["items"] = []; - const piBlocked = - app === "pi" && input.piStateUnavailable - ? t("pi.current.stateUnavailableHint") + const stateBlocked = + (app === "pi" || app === "ohmypi") && input.nativeStateUnavailable + ? t(`${app}.current.stateUnavailableHint`) : undefined; for (const p of providers) { @@ -841,16 +841,16 @@ export function buildAdditiveSections(input: AdditiveInput): ProviderSection[] { chips, dim: managed, editDisabledReason: readOnlyReason, - deleteDisabledReason: readOnlyReason ?? piBlocked, + deleteDisabledReason: readOnlyReason ?? stateBlocked, buttons: [ { key: "add", label: - app === "pi" + app === "pi" || app === "ohmypi" ? t("providerCard.action.enable") : t("providerCard.action.add"), onClick: () => actions.add(p), - disabledReason: piBlocked, + disabledReason: stateBlocked, }, ], }, @@ -863,7 +863,7 @@ export function buildAdditiveSections(input: AdditiveInput): ProviderSection[] { const menuItems: CardMenuItem[] = []; let status: CardPresentation["status"]; let isDefault = false; - let removeReason: string | undefined = piBlocked; + let removeReason: string | undefined = stateBlocked; // OpenCode 的顶层 model 一直允许移走所指的那家,这里不改;OpenClaw 不能 if (app === "openclaw" || app === "opencode") { @@ -939,7 +939,7 @@ export function buildAdditiveSections(input: AdditiveInput): ProviderSection[] { chips, dim: managed, editDisabledReason: readOnlyReason, - deleteDisabledReason: readOnlyReason ?? piBlocked, + deleteDisabledReason: readOnlyReason ?? stateBlocked, buttons, menuItems: menuItems.length ? menuItems : undefined, }, diff --git a/src/components/sessions/utils.ts b/src/components/sessions/utils.ts index 455a38930..199fc0503 100644 --- a/src/components/sessions/utils.ts +++ b/src/components/sessions/utils.ts @@ -5,7 +5,7 @@ import type { SessionMeta } from "@/types"; export const UNKNOWN_PROJECT_DIR_KEY = "__unknown_project_dir__"; -/** 会话来源:9 个应用(Claude Desktop 没有自己的会话记录,用 Claude Code 的)。 */ +/** 会话来源:10 个应用(Claude Desktop 没有自己的会话记录,用 Claude Code 的)。 */ export const SESSION_APP_IDS = [ "claude", "codex", @@ -13,6 +13,7 @@ export const SESSION_APP_IDS = [ "hermes", "gemini", "pi", + "ohmypi", "grokbuild", "openclaw", "mcode", @@ -35,6 +36,7 @@ export const SESSION_SOURCE_PATHS: Record = { openclaw: ["~/.openclaw/agents//sessions"], hermes: ["~/.hermes/state.db", "~/.hermes/sessions"], pi: ["~/.pi/agent/sessions"], + ohmypi: ["~/.omp/agent/sessions"], mcode: ["~/.minimax"], kimi: ["~/.kimi-code/sessions"], agy: [ diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx index 1ba5cea9f..5d190b9c2 100644 --- a/src/components/settings/DirectorySettings.tsx +++ b/src/components/settings/DirectorySettings.tsx @@ -27,6 +27,7 @@ interface DirectorySettingsProps { openclawDir?: string; hermesDir?: string; piDir?: string; + ohmypiDir?: string; onDirectoryChange: (app: DirectoryAppId, value?: string) => void; onBrowseDirectory: (app: DirectoryAppId) => Promise; onResetDirectory: (app: DirectoryAppId) => Promise; @@ -46,6 +47,7 @@ export function DirectorySettings({ openclawDir, hermesDir, piDir, + ohmypiDir, onDirectoryChange, onBrowseDirectory, onResetDirectory, @@ -193,6 +195,17 @@ export function DirectorySettings({ onBrowse={() => onBrowseDirectory("pi")} onReset={() => onResetDirectory("pi")} /> + + onDirectoryChange("ohmypi", val)} + onBrowse={() => onBrowseDirectory("ohmypi")} + onReset={() => onResetDirectory("ohmypi")} + />
); diff --git a/src/components/settings/sections/AppConfigSection.tsx b/src/components/settings/sections/AppConfigSection.tsx index db5b1574a..ce36b8d43 100644 --- a/src/components/settings/sections/AppConfigSection.tsx +++ b/src/components/settings/sections/AppConfigSection.tsx @@ -28,6 +28,7 @@ const DIRECTORY_FIELD: Record = { openclaw: "openclawConfigDir", hermes: "hermesConfigDir", pi: "piConfigDir", + ohmypi: "ohmypiConfigDir", }; const DIRECTORY_PLACEHOLDER: Record = { @@ -39,6 +40,7 @@ const DIRECTORY_PLACEHOLDER: Record = { openclaw: "settings.browsePlaceholderOpenclaw", hermes: "settings.browsePlaceholderHermes", pi: "settings.browsePlaceholderPi", + ohmypi: "settings.browsePlaceholderOhMyPi", }; const DIRECTORY_APPS: DirectoryAppId[] = [ @@ -50,6 +52,7 @@ const DIRECTORY_APPS: DirectoryAppId[] = [ "openclaw", "hermes", "pi", + "ohmypi", ]; const normalizeDir = (value: unknown) => diff --git a/src/components/shell/AppGlyph.tsx b/src/components/shell/AppGlyph.tsx index 0218fe31c..39fccfbaa 100644 --- a/src/components/shell/AppGlyph.tsx +++ b/src/components/shell/AppGlyph.tsx @@ -17,6 +17,7 @@ export const APP_DISPLAY_NAME: Record = { mcode: "MiniMax Code", kimi: "Kimi Code", agy: "Antigravity", + ohmypi: "Oh My Pi", }; const APP_ICON_NAME: Record = { @@ -32,6 +33,7 @@ const APP_ICON_NAME: Record = { mcode: "minimax", kimi: "kimi", agy: "antigravity", + ohmypi: "ohmypi", }; // Claude Code 和 Claude Desktop 用同一个图标,靠右下角的小角标区分终端与桌面 diff --git a/src/components/skills/SkillImportDialog.tsx b/src/components/skills/SkillImportDialog.tsx index 143d4f331..7e6a02399 100644 --- a/src/components/skills/SkillImportDialog.tsx +++ b/src/components/skills/SkillImportDialog.tsx @@ -46,7 +46,8 @@ export function SkillImportDialog({ const set = new Set(); for (const skill of skills) { for (const found of skill.foundIn) { - if (isAppId(found) && found !== "pi") set.add(found); + if (isAppId(found) && found !== "pi" && found !== "ohmypi") + set.add(found); } } return set; @@ -102,6 +103,7 @@ export function SkillImportDialog({ mcode: chosen.has("mcode"), kimi: chosen.has("kimi"), agy: chosen.has("agy"), + ohmypi: chosen.has("ohmypi"), }, }; }), diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 1acad67da..d9537f44e 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -123,6 +123,7 @@ const APP_SHORT_NAME: Record = { mcode: "MiniMax", kimi: "Kimi", agy: "Antigravity", + ohmypi: "omp", }; export function appShortName(appType: string): string { diff --git a/src/config/appConfig.tsx b/src/config/appConfig.tsx index a3aa6681a..091913680 100644 --- a/src/config/appConfig.tsx +++ b/src/config/appConfig.tsx @@ -29,6 +29,7 @@ export const APP_IDS: AppId[] = [ "mcode", "kimi", "agy", + "ohmypi", ]; export const DEFAULT_VISIBLE_APPS: VisibleApps = { @@ -44,6 +45,7 @@ export const DEFAULT_VISIBLE_APPS: VisibleApps = { mcode: true, kimi: true, agy: true, + ohmypi: false, }; /** App IDs shown in Skills panels. */ @@ -58,6 +60,7 @@ export const SKILLS_APP_IDS: AppId[] = [ "mcode", "kimi", "agy", + "ohmypi", ]; export type ProxyAppId = Extract< @@ -107,7 +110,7 @@ export function isStackAppId(appId: string): appId is StackAppId { export type AdditiveAppId = Extract< AppId, - "opencode" | "openclaw" | "hermes" | "pi" | "mcode" | "kimi" + "opencode" | "openclaw" | "hermes" | "pi" | "mcode" | "kimi" | "ohmypi" >; export const ADDITIVE_APP_IDS: AdditiveAppId[] = [ @@ -117,6 +120,7 @@ export const ADDITIVE_APP_IDS: AdditiveAppId[] = [ "openclaw", "hermes", "pi", + "ohmypi", ]; export function isAdditiveAppId(appId: string): appId is AdditiveAppId { @@ -138,6 +142,19 @@ export function usesEditorView(appId: AppId): boolean { return EDITOR_VIEW_APP_IDS.includes(appId); } +/** + * 表单会通过 `onSubmitReadyChange` 报告「现在能不能提交」的应用。 + * + * 这些表单的必填项不在对话框能看见的字段里(Pi 的激活模型、Oh My Pi 的标识与 + * YAML),所以「保存」按钮必须先等表单说可以。不在此表里的应用保持按钮可用, + * 由表单自己在提交时报错。 + * + */ +export const SUBMIT_READY_APPS: Readonly>> = { + pi: true, + ohmypi: true, +}; + /** Claude Desktop、OpenClaw 不由 CC Switch 管理 MCP;Pi 1.0 起内置 MCP(`~/.pi/agent/mcp.json`) */ export type McpAppId = Exclude; export const MCP_APP_IDS: McpAppId[] = [ @@ -151,6 +168,7 @@ export const MCP_APP_IDS: McpAppId[] = [ "mcode", "kimi", "agy", + "ohmypi", ]; export function isMcpAppId(appId: string): appId is McpAppId { @@ -284,6 +302,21 @@ export const APP_ICON_MAP: Record = { badgeClass: "bg-sky-500/10 text-sky-700 dark:text-sky-300 hover:bg-sky-500/20 border-0 gap-1.5", }, + ohmypi: { + label: "Oh My Pi", + icon: ( + + ), + activeClass: + "bg-cyan-500/10 ring-1 ring-cyan-500/20 hover:bg-cyan-500/20 text-cyan-600 dark:text-cyan-400", + badgeClass: + "bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 hover:bg-cyan-500/20 border-0 gap-1.5", + }, }; export function getAppLabel(appId: string): string { diff --git a/src/config/codingPlanProviders.test.ts b/src/config/codingPlanProviders.test.ts index 3638feddd..bd2859028 100644 --- a/src/config/codingPlanProviders.test.ts +++ b/src/config/codingPlanProviders.test.ts @@ -84,6 +84,13 @@ describe("extractBaseUrlForUsageDetection", () => { baseUrl: "https://opencode.ai/zen/go/v1", }), ).toBe("https://opencode.ai/zen/go/v1"); + // Oh My Pi reads the same camelCase field shape as Pi; without this case + // the dialog cannot auto-pick a template from an omp provider's base URL. + expect( + extractBaseUrlForUsageDetection("ohmypi", { + baseUrl: "https://opencode.ai/zen/go/v1", + }), + ).toBe("https://opencode.ai/zen/go/v1"); }); it("reads the remaining apps' native base URL fields", () => { diff --git a/src/config/codingPlanProviders.ts b/src/config/codingPlanProviders.ts index ab992f95b..bc2ee282f 100644 --- a/src/config/codingPlanProviders.ts +++ b/src/config/codingPlanProviders.ts @@ -148,6 +148,7 @@ export function extractBaseUrlForUsageDetection( raw = settingsConfig.options?.baseURL; break; case "pi": + case "ohmypi": case "openclaw": raw = settingsConfig.baseUrl; break; diff --git a/src/config/ohmypiProviderPresets.ts b/src/config/ohmypiProviderPresets.ts new file mode 100644 index 000000000..3160fe452 --- /dev/null +++ b/src/config/ohmypiProviderPresets.ts @@ -0,0 +1,248 @@ +import type { PresetTheme } from "./claudeProviderPresets"; +import type { PresetFamilyFields } from "./presetFamilies"; +import type { PiProviderPreset } from "./piProviderPresets"; +import { piProviderPresets } from "./piProviderPresets"; +import { PI_MODEL_CATALOG_REFERENCE } from "./piModelCatalog"; +import type { ProviderCategory } from "@/types"; + +/** + * Oh My Pi provider catalog, derived from the Pi catalog. + * + * `models.yml` is validated by Oh My Pi's `ModelsConfigSchema`, and only + * known fields must follow its native contract. Pi presets therefore translate + * their thinking dialect and strip Pi-only model controls: + * + * - `thinkingLevelMap` (Pi-only model field) and the embedded model-catalog + * reference symbol; + * - the `compat` keys `forceAdaptiveThinking`, + * `requiresReasoningContentOnAssistantMessages` and `deferredToolsMode` + * (Pi harness compat flags absent from omp's schema) — writing them into + * OMP's config would not apply those flags. Reasoning replay is translated + * to omp's tool-call replay controls instead. + * + * Oh My Pi presets that have no Pi counterpart are appended below so the omp + * catalog keeps its own entries. + * + * Each preset carries its own registry `icon` + `iconColor`, so a provider + * created from a preset renders its own logo in the provider list (not the + * agent mark) — matching the other agent pages. + */ + +export type OhMyPiApiFormat = + | "openai-completions" + | "openai-responses" + | "anthropic-messages" + | "google-generative-ai" + | "bedrock-converse-stream"; + +export interface OhMyPiPresetModel { + id: string; + name: string; + reasoning: boolean; + input: ("text" | "image")[]; + contextWindow: number; + maxTokens: number; + compat?: Record; + headers?: Record; +} + +export interface OhMyPiProviderPreset extends PresetFamilyFields { + name: string; + nameKey?: string; + providerKey: string; + websiteUrl: string; + apiKeyUrl?: string; + settingsConfig: { + name: string; + baseUrl: string; + api: OhMyPiApiFormat; + apiKey: string; + headers?: Record; + compat?: Record; + models: OhMyPiPresetModel[]; + }; + category?: ProviderCategory; + isPartner?: boolean; + primePartner?: boolean; + partnerPromotionKey?: string; + theme?: PresetTheme; + icon?: string; + iconColor?: string; +} + +/** Pi `compat` keys that omp's `ModelsConfigSchema` does not define. */ +const OMP_INCOMPATIBLE_COMPAT_KEYS: Record = { + forceAdaptiveThinking: true, + requiresReasoningContentOnAssistantMessages: true, + deferredToolsMode: true, +}; + +interface OhMyPiModelSeed { + id: string; + name: string; + reasoning: boolean; + input: ("text" | "image")[]; + contextWindow: number; + maxTokens: number; + compat?: Record; + headers?: Record; +} + +interface OhMyPiPresetSeed extends PresetFamilyFields { + name: string; + nameKey?: string; + providerKey: string; + websiteUrl: string; + apiKeyUrl?: string; + settingsConfig: { + name: string; + baseUrl: string; + api: OhMyPiApiFormat; + apiKey: string; + headers?: Record; + compat?: Record; + models: OhMyPiModelSeed[]; + }; + category?: ProviderCategory; + isPartner?: boolean; + primePartner?: boolean; + partnerPromotionKey?: string; + icon?: string; + iconColor?: string; +} + +// Pi's DeepSeek dialect and omp's zai dialect both send thinking.type. +// Omp uses explicit reasoning replay controls instead of Pi's assistant flag. +function toOhMyPiCompat( + compat: Record, +): Record { + const result = Object.fromEntries( + Object.entries(compat).filter( + ([key]) => !(key in OMP_INCOMPATIBLE_COMPAT_KEYS), + ), + ); + if (result.thinkingFormat === "deepseek") result.thinkingFormat = "zai"; + if (compat.requiresReasoningContentOnAssistantMessages === true) { + result.reasoningContentField = "reasoning_content"; + result.requiresReasoningContentForToolCalls = true; + result.requiresReasoningContentForAllAssistantTurns = true; + result.allowsSyntheticReasoningContentForToolCalls = false; + } + return result; +} + +function toOhMyPiModel(model: Record): OhMyPiModelSeed { + const seed: OhMyPiModelSeed = { + id: String(model.id), + name: String(model.name), + reasoning: model.reasoning === true, + input: (Array.isArray(model.input) ? model.input : ["text"]) as ( + | "text" + | "image" + )[], + contextWindow: Number(model.contextWindow), + maxTokens: Number(model.maxTokens), + }; + if (model.headers !== undefined) { + seed.headers = model.headers as Record; + } + const compat = model.compat as Record | undefined; + const stripped = toOhMyPiCompat(compat ?? {}); + if (Object.keys(stripped).length > 0) { + seed.compat = stripped; + } + return seed; +} + +function toOhMyPiPreset(preset: PiProviderPreset): OhMyPiPresetSeed { + const config = preset.settingsConfig; + return { + ...preset, + settingsConfig: { + name: config.name, + baseUrl: config.baseUrl, + api: config.api, + apiKey: config.apiKey, + ...(config.headers !== undefined ? { headers: config.headers } : {}), + ...(config.compat !== undefined + ? { + compat: toOhMyPiCompat(config.compat), + } + : {}), + models: config.models + .filter((model) => model.id !== undefined) + .map((model) => + toOhMyPiModel( + model as unknown as Record & { + [PI_MODEL_CATALOG_REFERENCE]?: unknown; + }, + ), + ), + }, + }; +} + +/** Oh My Pi presets with no Pi counterpart, kept omp-native. */ +const ohmypiOnlyPresetSeeds: OhMyPiPresetSeed[] = [ + { + name: "ETok.ai", + providerKey: "cc-switch-etok-ai", + websiteUrl: "https://etok.ai", + apiKeyUrl: "https://etok.ai", + settingsConfig: { + name: "ETok", + baseUrl: "https://api.etok.ai", + api: "anthropic-messages", + apiKey: "", + models: [ + { + id: "claude-opus-5", + name: "Claude Opus 5", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 128000, + }, + { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 128000, + }, + ], + }, + category: "third_party", + }, + { + name: "Bailian", + providerKey: "cc-switch-bailian", + websiteUrl: "https://bailian.console.aliyun.com", + apiKeyUrl: "https://bailian.console.aliyun.com/#/api-key", + settingsConfig: { + name: "Bailian", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + api: "openai-completions", + apiKey: "", + models: [ + { + id: "qwen3-coder-plus", + name: "Qwen3 Coder Plus", + reasoning: false, + input: ["text"], + contextWindow: 1000000, + maxTokens: 65536, + }, + ], + }, + category: "cn_official", + icon: "bailian", + iconColor: "#624AFF", + }, +]; + +export const ohmypiProviderPresets: OhMyPiProviderPreset[] = [ + ...piProviderPresets.map(toOhMyPiPreset), + ...ohmypiOnlyPresetSeeds, +]; diff --git a/src/hooks/useDirectorySettings.ts b/src/hooks/useDirectorySettings.ts index be9cadbd2..0c71e46ae 100644 --- a/src/hooks/useDirectorySettings.ts +++ b/src/hooks/useDirectorySettings.ts @@ -17,7 +17,8 @@ type AppDirectoryKey = | "opencode" | "openclaw" | "hermes" - | "pi"; + | "pi" + | "ohmypi"; type DirectoryKey = "appConfig" | AppDirectoryKey; export interface ResolvedDirectories { @@ -30,6 +31,7 @@ export interface ResolvedDirectories { openclaw: string; hermes: string; pi: string; + ohmypi: string; } // Single source of truth for per-app directory metadata. @@ -45,6 +47,7 @@ const APP_DIRECTORY_META: Record< openclaw: { key: "openclaw", defaultFolder: ".openclaw" }, hermes: { key: "hermes", defaultFolder: ".hermes" }, pi: { key: "pi", defaultFolder: ".pi/agent" }, + ohmypi: { key: "ohmypi", defaultFolder: ".omp/agent" }, }; const DIRECTORY_KEY_TO_SETTINGS_FIELD: Record< @@ -59,6 +62,7 @@ const DIRECTORY_KEY_TO_SETTINGS_FIELD: Record< openclaw: "openclawConfigDir", hermes: "hermesConfigDir", pi: "piConfigDir", + ohmypi: "ohmypiConfigDir", }; const sanitizeDir = (value?: string | null): string | undefined => { @@ -148,6 +152,7 @@ export function useDirectorySettings({ openclaw: "", hermes: "", pi: "", + ohmypi: "", }); const [isLoading, setIsLoading] = useState(true); @@ -161,6 +166,7 @@ export function useDirectorySettings({ openclaw: "", hermes: "", pi: "", + ohmypi: "", }); // 已保存的数据目录(保存按钮和「需要重启」都和它比)。ref 给回调用,state 让比较它的 // 组件在保存后重渲染:保存成功要把它更新成刚存的值,否则保存按钮一直在、改回去存不了 @@ -190,6 +196,7 @@ export function useDirectorySettings({ openclawDir, hermesDir, piDir, + ohmypiDir, defaultAppConfig, defaultClaudeDir, defaultCodexDir, @@ -199,6 +206,7 @@ export function useDirectorySettings({ defaultOpenclawDir, defaultHermesDir, defaultPiDir, + defaultOhmypiDir, ] = await Promise.all([ settingsApi.getAppConfigDirOverride(), settingsApi.getConfigDir("claude"), @@ -209,6 +217,7 @@ export function useDirectorySettings({ settingsApi.getConfigDir("openclaw"), settingsApi.getConfigDir("hermes"), settingsApi.getConfigDir("pi"), + settingsApi.getConfigDir("ohmypi"), computeDefaultAppConfigDir(), computeDefaultConfigDir("claude"), computeDefaultConfigDir("codex"), @@ -218,6 +227,7 @@ export function useDirectorySettings({ computeDefaultConfigDir("openclaw"), computeDefaultConfigDir("hermes"), computeDefaultConfigDir("pi"), + computeDefaultConfigDir("ohmypi"), ]); if (!active) return; @@ -234,6 +244,7 @@ export function useDirectorySettings({ openclaw: defaultOpenclawDir ?? "", hermes: defaultHermesDir ?? "", pi: defaultPiDir ?? "", + ohmypi: defaultOhmypiDir ?? "", }; setAppConfigDir(normalizedOverride); @@ -249,6 +260,7 @@ export function useDirectorySettings({ openclaw: openclawDir || defaultsRef.current.openclaw, hermes: hermesDir || defaultsRef.current.hermes, pi: piDir || defaultsRef.current.pi, + ohmypi: ohmypiDir || defaultsRef.current.ohmypi, }); } catch (error) { console.error( @@ -461,6 +473,7 @@ export function useDirectorySettings({ openclaw: overrides?.openclaw ?? defaultsRef.current.openclaw, hermes: overrides?.hermes ?? defaultsRef.current.hermes, pi: overrides?.pi ?? defaultsRef.current.pi, + ohmypi: overrides?.ohmypi ?? defaultsRef.current.ohmypi, }); }, [], diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 5850a05dd..4da5b6178 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "@/lib/toast"; import { useTranslation } from "react-i18next"; import { + ohmypiApi, piApi, providersApi, settingsApi, @@ -309,6 +310,10 @@ export function useProviderActions( if (activeApp === "pi") { await piApi.updateProviderUsageScript(provider.id, script); + } else if (activeApp === "ohmypi") { + // The dedicated command only touches the DB row: it must keep + // working for providers already removed from models.yml. + await ohmypiApi.updateProviderUsageScript(provider.id, script); } else { await providersApi.update(updatedProvider, activeApp); } diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 9f6f5b83d..0a2d01f4c 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -6,6 +6,7 @@ import { providersApi, settingsApi } from "@/lib/api"; import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync"; import { invalidatePiDirectoryCaches, + invalidateOhMyPiDirectoryCaches, useSettingsQuery, useSaveSettingsMutation, } from "@/lib/query"; @@ -61,6 +62,7 @@ const DIRECTORY_KEYS = [ "openclawConfigDir", "hermesConfigDir", "piConfigDir", + "ohmypiConfigDir", ] as const; const sanitizeDir = (value?: string | null): string | undefined => { @@ -133,6 +135,7 @@ export function useSettings(): UseSettingsResult { openclaw: sanitizeDir(data?.openclawConfigDir), hermes: sanitizeDir(data?.hermesConfigDir), pi: sanitizeDir(data?.piConfigDir), + ohmypi: sanitizeDir(data?.ohmypiConfigDir), }); setRequiresRestart(false); }, [ @@ -220,6 +223,7 @@ export function useSettings(): UseSettingsResult { mergedSettings.openclawConfigDir, ); const sanitizedPiDir = sanitizeDir(mergedSettings.piConfigDir); + const sanitizedOhmypiDir = sanitizeDir(mergedSettings.ohmypiConfigDir); const { webdavSync: _ignoredWebdavSync, s3Sync: _ignoredS3Sync, @@ -235,6 +239,7 @@ export function useSettings(): UseSettingsResult { opencodeConfigDir: sanitizedOpencodeDir, openclawConfigDir: sanitizedOpenclawDir, piConfigDir: sanitizedPiDir, + ohmypiConfigDir: sanitizedOhmypiDir, language: mergedSettings.language, }; @@ -355,6 +360,7 @@ export function useSettings(): UseSettingsResult { mergedSettings.openclawConfigDir, ); const sanitizedPiDir = sanitizeDir(mergedSettings.piConfigDir); + const sanitizedOhmypiDir = sanitizeDir(mergedSettings.ohmypiConfigDir); const previousAppDir = initialAppConfigDir; const previousClaudeDir = sanitizeDir(data?.claudeConfigDir); const previousCodexDir = sanitizeDir(data?.codexConfigDir); @@ -363,6 +369,7 @@ export function useSettings(): UseSettingsResult { const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir); const previousOpenclawDir = sanitizeDir(data?.openclawConfigDir); const previousPiDir = sanitizeDir(data?.piConfigDir); + const previousOhmypiDir = sanitizeDir(data?.ohmypiConfigDir); const { webdavSync: _ignoredWebdavSync, s3Sync: _ignoredS3Sync, @@ -378,6 +385,7 @@ export function useSettings(): UseSettingsResult { opencodeConfigDir: sanitizedOpencodeDir, openclawConfigDir: sanitizedOpenclawDir, piConfigDir: sanitizedPiDir, + ohmypiConfigDir: sanitizedOhmypiDir, language: mergedSettings.language, }; @@ -468,6 +476,7 @@ export function useSettings(): UseSettingsResult { const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir; const openclawDirChanged = sanitizedOpenclawDir !== previousOpenclawDir; const piDirChanged = sanitizedPiDir !== previousPiDir; + const ohmypiDirChanged = sanitizedOhmypiDir !== previousOhmypiDir; if ( !pluginSynced && (claudeDirChanged || @@ -488,6 +497,9 @@ export function useSettings(): UseSettingsResult { if (piDirChanged) { await invalidatePiDirectoryCaches(queryClient); } + if (ohmypiDirChanged) { + await invalidateOhMyPiDirectoryCaches(queryClient); + } const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined); setRequiresRestart(appDirChanged); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index aaa1f9ba4..5700d229a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -932,6 +932,8 @@ "hermesConfigDirDescription": "Override Hermes configuration directory (config.yaml).", "piConfigDir": "Pi Configuration Directory", "piConfigDirDescription": "Override the Pi configuration directory (models.json, instruction files, prompts, skills, and sessions).", + "ohmypiConfigDir": "Oh My Pi Configuration Directory", + "ohmypiConfigDirDescription": "Override the Oh My Pi configuration directory (models.yml, config.yml, prompts, skills, and sessions).", "browsePlaceholderClaude": "e.g., /home//.claude", "browsePlaceholderCodex": "e.g., /home//.codex", "browsePlaceholderGemini": "e.g., /home//.gemini", @@ -940,6 +942,7 @@ "browsePlaceholderOpenclaw": "e.g., /home//.openclaw", "browsePlaceholderHermes": "e.g., /home//.hermes", "browsePlaceholderPi": "e.g., /home//.pi/agent", + "browsePlaceholderOhMyPi": "e.g., /home//.omp/agent", "browseDirectory": "Browse Directory", "resetDefault": "Reset to default directory (takes effect after saving)", "checkForUpdates": "Check for Updates", @@ -974,7 +977,7 @@ "copyToolUninstallCommand": "Copy the uninstall command for {{tool}}", "uninstallCommandCopied": "Uninstall command copied", "uninstallCommandCopyFailed": "Copy failed. Please select and copy the command manually.", - "oneClickInstallHint": "Install or upgrade Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code", + "oneClickInstallHint": "Install or upgrade Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code / Oh My Pi", "localEnvCheck": "Local environment check", "updateAllTools": "Update All ({{count}})", "currentVersion": "Current Version", @@ -1098,7 +1101,8 @@ "hermes": "Hermes", "pi": "Pi", "mcode": "MiniMax Code", - "agy": "Antigravity" + "agy": "Antigravity", + "ohmypi": "Oh My Pi" }, "mcode": { "empty": { @@ -4201,8 +4205,12 @@ "hermes": "Installs with the official script", "pi": "Installs with npm", "mcode": "Installs with the official script, falls back to npm", - "agy": "Installs with the official script" - } + "agy": "Installs with the official script", + "omp": "Installs with Bun (requires Bun 1.3.14+)" + }, + "ompDiscoveryHelpTitle": "What Oh My Pi actually loads", + "ompDiscoveryHelp": "Beyond its own ~/.omp/agent config, Oh My Pi discovers MCP servers, skills, commands, hooks, rules, and settings from other agents' directories (e.g. Claude's ~/.claude, Codex's ~/.codex). To keep what Oh My Pi actually loads in sync with the CC Switch UI, disable unneeded discovery sources via disabledProviders in ~/.omp/agent/config.yml (ids match the docs, e.g. claude, codex, gemini, opencode, github, agents).", + "ompDiscoveryDocsLink": "Docs: context-files (disabledProviders)" }, "appPage": { "moreActions": "More actions for {{name}}", @@ -4967,5 +4975,63 @@ "defaultModelInUse": "Kimi Code is using a model from this provider. Switch to another model with /model in Kimi Code first.", "secondaryModelInUse": "Kimi Code's subagent model uses a model from this provider. Change it with /secondary-model in Kimi Code first." } + }, + "ohmypi": { + "empty": { + "title": "No custom providers added yet", + "description": "Oh My Pi manages its built-in providers. Click + in the top right to add a provider from a preset or custom configuration." + }, + "form": { + "modelsMode": "Model source", + "modelsModeManual": "Configure model list manually", + "modelsModeDiscovery": "Discover models automatically", + "modelsModeNone": "No models", + "modelsDiscoveryHint": "Oh My Pi discovers the model list at runtime; no manual configuration needed here. You can still fine-tune via models or modelOverrides in the YAML below.", + "discoveryType": "Discovery method", + "discoveryHint": "Oh My Pi periodically fetches the model list from the endpoint; metadata such as context window returned by the endpoint is applied automatically. Manual model rows and modelOverrides in the YAML below still take effect.", + "discoveryApiRequired": "An API format is required for automatic discovery (except the proxy type)", + "apiInherit": "Unspecified (inherit)", + "apiInheritHint": "Choosing \"Unspecified\" omits the api key: when the provider key matches a built-in provider, its models keep their built-in API formats; with proxy discovery, each model's API format is auto-detected individually.", + "modelsHiddenHint": "No model list configured: saving omits the \"models\" key. The provider only takes effect when its key matches an Oh My Pi built-in provider (its fields act as that provider's override).", + "configYaml": "Configuration YAML", + "fixYamlFirst": "Fix the configuration YAML below before editing the structured fields.", + "providerKeyDuplicate": "This key is already in use", + "absoluteHttpUrlRequired": "{{label}} must be an absolute HTTP or HTTPS URL", + "addCompatibilityOption": "Add", + "addModel": "Add model", + "compatibility": "Compatibility", + "compatibilityHint": "Adjust request behavior for compatible or local endpoints.", + "contextWindow": "Context window", + "credential": "API Key", + "duplicateModel": "Model ID {{id}} is duplicated", + "imageInput": "Supports image input", + "maxTokens": "Max output tokens", + "modelId": "Model ID", + "modelIdRequired": "Model {{index}} needs an ID", + "modelName": "Display name", + "modelNamePlaceholder": "Display name", + "nameRequired": "Display name is required", + "noCompatibilityOptions": "No compatibility options configured", + "noModels": "No models configured", + "optionKey": "Option", + "optionValue": "Value", + "positiveNumberRequired": "{{label}} must be a number greater than 0", + "providerKey": "Provider key", + "providerKeyRequired": "Provider key is required", + "reasoning": "Supports extended thinking", + "removeCompatibilityOption": "Remove compatibility option", + "removeModel": "Remove model", + "toggleModelDetails": "Toggle model details" + }, + "provider": { + "enabled": "Enabled in Oh My Pi", + "enableFailed": "Could not enable this provider in Oh My Pi", + "removed": "Removed from Oh My Pi", + "writeConflict": "Another process is updating Oh My Pi's configuration. Try again shortly." + }, + "current": { + "readFailed": "Could not read Oh My Pi's provider configuration", + "stateUnavailableHint": "Provider changes are disabled until CC Switch can read Oh My Pi's provider configuration." + } } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 028787a2c..539b186ff 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -932,6 +932,8 @@ "hermesConfigDirDescription": "Hermes の設定ディレクトリ(config.yaml)を上書きします。", "piConfigDir": "Pi 設定ディレクトリ", "piConfigDirDescription": "Pi の設定ディレクトリ(models.json、指示ファイル、プロンプト、Skills、セッション)を上書きします。", + "ohmypiConfigDir": "Oh My Pi 設定ディレクトリ", + "ohmypiConfigDirDescription": "Oh My Pi の設定ディレクトリ(models.yml、config.yml、プロンプト、Skills、セッション)を上書きします。", "browsePlaceholderClaude": "例: /home//.claude", "browsePlaceholderCodex": "例: /home//.codex", "browsePlaceholderGemini": "例: /home//.gemini", @@ -940,6 +942,7 @@ "browsePlaceholderOpenclaw": "例: /home//.openclaw", "browsePlaceholderHermes": "例: /home//.hermes", "browsePlaceholderPi": "例: /home//.pi/agent", + "browsePlaceholderOhMyPi": "例: /home//.omp/agent", "browseDirectory": "ディレクトリを選択", "resetDefault": "デフォルトに戻す(保存後に反映)", "checkForUpdates": "アップデートを確認", @@ -974,7 +977,7 @@ "copyToolUninstallCommand": "{{tool}} のアンインストールコマンドをコピー", "uninstallCommandCopied": "アンインストールコマンドをコピーしました", "uninstallCommandCopyFailed": "コピーに失敗しました。コマンドを選択して手動でコピーしてください。", - "oneClickInstallHint": "Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code をインストールまたは更新", + "oneClickInstallHint": "Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code / Oh My Pi をインストールまたは更新", "localEnvCheck": "ローカル環境チェック", "updateAllTools": "すべて更新({{count}})", "currentVersion": "現在のバージョン", @@ -1098,7 +1101,8 @@ "hermes": "Hermes", "pi": "Pi", "mcode": "MiniMax Code", - "agy": "Antigravity" + "agy": "Antigravity", + "ohmypi": "Oh My Pi" }, "mcode": { "empty": { @@ -4201,8 +4205,12 @@ "hermes": "公式インストールスクリプトでインストール", "pi": "npm でインストール", "mcode": "公式インストールスクリプトで入れ、失敗したら npm を使います", - "agy": "公式インストールスクリプトでインストール" - } + "agy": "公式インストールスクリプトでインストール", + "omp": "Bun でインストール(Bun 1.3.14+ が必要)" + }, + "ompDiscoveryHelpTitle": "Oh My Pi が実際に読み込む範囲", + "ompDiscoveryHelp": "Oh My Pi は ~/.omp/agent 自身の設定に加え、他の Agent のディレクトリ(Claude の ~/.claude、Codex の ~/.codex など)からも MCP サーバー、スキル、コマンド、フック、ルール、設定を発見します。Oh My Pi の実際の読み込み内容を CC Switch の表示と一致させるには、~/.omp/agent/config.yml の disabledProviders で不要な発行元を無効化してください(id はドキュメント準拠:claude、codex、gemini、opencode、github、agents など)。", + "ompDiscoveryDocsLink": "ドキュメント:context-files(disabledProviders)" }, "appPage": { "moreActions": "{{name}} のその他の操作", @@ -4967,5 +4975,63 @@ "defaultModelInUse": "Kimi Code がこのプロバイダーのモデルを使用中です。先に Kimi Code の /model でほかのモデルに切り替えてください。", "secondaryModelInUse": "Kimi Code のサブエージェントモデルがこのプロバイダーのモデルを使用中です。先に Kimi Code の /secondary-model で変更してください。" } + }, + "ohmypi": { + "empty": { + "title": "カスタムプロバイダーはまだ追加されていません", + "description": "Oh My Pi の組み込みプロバイダーは Oh My Pi が管理します。右上の + をクリックして、プリセットまたはカスタム設定からプロバイダーを追加してください。" + }, + "form": { + "modelsMode": "モデルのソース", + "modelsModeManual": "モデルリストを手動で設定", + "modelsModeDiscovery": "モデルを自動検出", + "modelsModeNone": "モデルを設定しない", + "modelsDiscoveryHint": "モデルリストは Oh My Pi が実行時に自動検出するため、ここでの手動設定は不要です。下の YAML の models や modelOverrides で微調整することもできます。", + "discoveryType": "検出方式", + "discoveryHint": "Oh My Pi がエンドポイントからモデルリストを定期的に取得します。エンドポイントが返すコンテキスト長などのメタデータは自動的に適用されます。手動のモデル行と下の YAML の modelOverrides も引き続き有効です。", + "discoveryApiRequired": "自動検出を有効にするには API 形式の選択が必要です(proxy タイプを除く)", + "apiInherit": "指定しない(継承)", + "apiInheritHint": "「指定しない」を選ぶと api キーを書き込みません。識別子が内蔵プロバイダーと一致する場合はそのモデルの API 形式を継承し、proxy 自動検出では各モデルの API 形式が個別に自動判定されます。", + "modelsHiddenHint": "モデルリスト未設定:保存時は models キーを書き込みません。このプロバイダーは、識別子が Oh My Pi の内蔵プロバイダーと一致する場合のみ有効になります(そのフィールドは内蔵プロバイダーのオーバーライドとして機能します)。", + "configYaml": "設定YAML", + "fixYamlFirst": "下の設定 YAML を修正してから、構造化フィールドを編集してください。", + "providerKeyDuplicate": "このキーは既に使用されています", + "absoluteHttpUrlRequired": "{{label}} は絶対 HTTP または HTTPS URL で指定してください", + "addCompatibilityOption": "追加", + "addModel": "モデルを追加", + "compatibility": "互換性", + "compatibilityHint": "互換エンドポイントやローカル環境向けにリクエスト動作を調整します。", + "contextWindow": "コンテキストウィンドウ", + "credential": "API キー", + "duplicateModel": "モデル ID {{id}} が重複しています", + "imageInput": "画像入力に対応", + "maxTokens": "最大出力トークン数", + "modelId": "モデル ID", + "modelIdRequired": "モデル {{index}} に ID が必要です", + "modelName": "表示名", + "modelNamePlaceholder": "表示名", + "nameRequired": "表示名を入力してください", + "noCompatibilityOptions": "互換性オプションはありません", + "noModels": "モデルはまだ設定されていません", + "optionKey": "オプション", + "optionValue": "値", + "positiveNumberRequired": "{{label}} は 0 より大きい数値で指定してください", + "providerKey": "プロバイダーキー", + "providerKeyRequired": "プロバイダーキーを入力してください", + "reasoning": "拡張思考に対応", + "removeCompatibilityOption": "互換性オプションを削除", + "removeModel": "モデルを削除", + "toggleModelDetails": "モデル詳細を開閉" + }, + "provider": { + "enabled": "Oh My Pi で有効化しました", + "enableFailed": "Oh My Pi でこのプロバイダーを有効化できません", + "removed": "Oh My Pi から削除しました", + "writeConflict": "別のプロセスが Oh My Pi の設定を更新しています。しばらくしてから再試行してください。" + }, + "current": { + "readFailed": "Oh My Pi のプロバイダー設定を読み取れません", + "stateUnavailableHint": "CC Switch が Oh My Pi のプロバイダー設定を読み取れるまで、プロバイダーの変更はできません。" + } } } diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 18160345b..cbcaccef1 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -898,6 +898,8 @@ "hermesConfigDirDescription": "Hermes yapılandırma dizinini (config.yaml) geçersiz kılın.", "piConfigDir": "Pi Yapılandırma Dizini", "piConfigDirDescription": "Pi yapılandırma dizinini (models.json, talimat dosyaları, istemler, beceriler ve oturumlar) geçersiz kılın.", + "ohmypiConfigDir": "Oh My Pi Yapılandırma Dizini", + "ohmypiConfigDirDescription": "Oh My Pi yapılandırma dizinini (models.yml, config.yml, istemler, beceriler ve oturumlar) geçersiz kılın.", "browsePlaceholderClaude": "ör. /home//.claude", "browsePlaceholderCodex": "ör. /home//.codex", "browsePlaceholderGemini": "ör., /home//.gemini", @@ -906,6 +908,7 @@ "browsePlaceholderOpenclaw": "ör. /home//.openclaw", "browsePlaceholderHermes": "ör. /home//.hermes", "browsePlaceholderPi": "ör. /home//.pi/agent", + "browsePlaceholderOhMyPi": "ör. /home//.omp/agent", "browseDirectory": "Dizine Göz Atın", "resetDefault": "Varsayılan dizine sıfırla (kaydettikten sonra etkili olur)", "checkForUpdates": "Güncellemeleri Kontrol Et", @@ -1062,7 +1065,8 @@ "openclaw": "OpenClaw", "hermes": "Hermes", "pi": "Pi", - "mcode": "MiniMax Code" + "mcode": "MiniMax Code", + "ohmypi": "Oh My Pi" }, "mcode": { "empty": { @@ -4109,8 +4113,12 @@ "openclaw": "Npm ile yüklenir", "hermes": "Resmi komut dosyasıyla yüklenir", "pi": "Npm ile yüklenir", - "mcode": "Resmi komut dosyasıyla yüklenir, npm'ye geri döner" - } + "mcode": "Resmi komut dosyasıyla yüklenir, npm'ye geri döner", + "omp": "Bun ile yüklenir (Bun 1.3.14+ gerekir)" + }, + "ompDiscoveryHelpTitle": "Oh My Pi'nin gerçekte yükledikleri", + "ompDiscoveryHelp": "Oh My Pi, kendi ~/.omp/agent yapılandırmasının yanı sıra diğer ajanların dizinlerinden (ör. Claude'un ~/.claude, Codex'in ~/.codex) MCP sunucularını, becerileri, komutları, kancaları, kuralları ve ayarları da keşfeder. Oh My Pi'nin gerçekte yüklediklerini CC Switch arayüzüyle uyumlu tutmak için ~/.omp/agent/config.yml içinde disabledProviders ile gereksiz keşif kaynaklarını kapatın (kimlikler belgelerle aynıdır, ör. claude, codex, gemini, opencode, github, agents).", + "ompDiscoveryDocsLink": "Belgeler: context-files (disabledProviders)" }, "appPage": { "moreActions": "{{name}} için diğer işlemler", @@ -4818,5 +4826,63 @@ "tool_use": "Araç kullanımı", "dynamically_loaded_tools": "Araçları gerektiğinde yükle" } + }, + "ohmypi": { + "current": { + "readFailed": "Oh My Pi sağlayıcı yapılandırması okunamadı", + "stateUnavailableHint": "Oh My Pi yapılandırması okunana kadar sağlayıcı değişiklikleri devre dışıdır." + }, + "empty": { + "description": "Ön ayar veya özel sağlayıcı eklemek için sağ üstteki + düğmesini kullanın.", + "title": "Henüz API anahtarı sağlayıcısı yok" + }, + "form": { + "modelsMode": "Model kaynağı", + "modelsModeManual": "Model listesini elle yapılandır", + "modelsModeDiscovery": "Modelleri otomatik keşfet", + "modelsModeNone": "Model yok", + "modelsDiscoveryHint": "Model listesi Oh My Pi tarafından çalışma anında keşfedilir; burada elle yapılandırma gerekmez. Aşağıdaki YAML içinde models veya modelOverrides ile yine de ince ayar yapabilirsiniz.", + "discoveryType": "Keşif yöntemi", + "discoveryHint": "Oh My Pi model listesini uç noktadan düzenli olarak çeker; uç noktanın döndürdüğü bağlam penceresi gibi üst veriler otomatik uygulanır. Elle eklenen model satırları ve aşağıdaki YAML içindeki modelOverrides yine geçerlidir.", + "discoveryApiRequired": "Otomatik keşif için bir API biçimi gerekir (proxy türü hariç)", + "apiInherit": "Belirtilmemiş (devral)", + "apiInheritHint": "\"Belirtilmemiş\" seçildiğinde api anahtarı yazılmaz: sağlayıcı kimliği yerleşik bir sağlayıcıyla eşleşiyorsa modelleri yerleşik API biçimlerini korur; proxy keşfinde her modelin API biçimi ayrı ayrı algılanır.", + "modelsHiddenHint": "Model listesi yapılandırılmadı: kaydederken models anahtarı yazılmaz. Sağlayıcı yalnızca kimliği bir Oh My Pi yerleşik sağlayıcıyla eşleştiğinde etkili olur (alanları o sağlayıcının geçersiz kılması olarak çalışır).", + "configYaml": "Yapılandırma YAML", + "fixYamlFirst": "Yapılandırılmış alanları düzenlemeden önce aşağıdaki yapılandırma YAML'ını düzeltin.", + "providerKeyDuplicate": "Bu kimlik zaten kullanılıyor", + "absoluteHttpUrlRequired": "{{label}} mutlak bir HTTP veya HTTPS adresi olmalıdır", + "addCompatibilityOption": "Ekle", + "addModel": "Model ekle", + "compatibility": "Uyumluluk", + "compatibilityHint": "Uyumlu veya yerel uç noktalar için istek davranışını ayarlayın.", + "contextWindow": "Bağlam penceresi", + "credential": "API Anahtarı", + "duplicateModel": "{{id}} model kimliği yineleniyor", + "imageInput": "Görsel girişini destekler", + "maxTokens": "En fazla çıktı token sayısı", + "modelId": "Model kimliği", + "modelIdRequired": "{{index}}. modelin bir kimliği olmalı", + "modelName": "Görünen ad", + "modelNamePlaceholder": "Görünen ad", + "nameRequired": "Görünen ad gerekli", + "noCompatibilityOptions": "Yapılandırılmış uyumluluk seçeneği yok", + "noModels": "Yapılandırılmış model yok", + "optionKey": "Seçenek", + "optionValue": "Değer", + "positiveNumberRequired": "{{label}} 0'dan büyük bir sayı olmalıdır", + "providerKey": "Sağlayıcı kimliği", + "providerKeyRequired": "Sağlayıcı kimliği gerekli", + "reasoning": "Genişletilmiş düşünmeyi destekler", + "removeCompatibilityOption": "Uyumluluk seçeneğini kaldır", + "removeModel": "Modeli kaldır", + "toggleModelDetails": "Model ayrıntılarını aç/kapat" + }, + "provider": { + "enableFailed": "Sağlayıcı Oh My Pi içinde etkinleştirilemedi", + "enabled": "Oh My Pi içinde etkinleştirildi", + "removed": "Oh My Pi içinden kaldırıldı", + "writeConflict": "Başka bir süreç Oh My Pi yapılandırmasını güncelliyor. Kısa süre sonra tekrar deneyin." + } } } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index f42d6906e..f6c0c3449 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -932,6 +932,8 @@ "hermesConfigDirDescription": "覆寫 Hermes 設定目錄 (config.yaml)。", "piConfigDir": "Pi 設定目錄", "piConfigDirDescription": "覆寫 Pi 設定目錄(models.json、指示檔案、提示、Skills 與工作階段)。", + "ohmypiConfigDir": "Oh My Pi 設定目錄", + "ohmypiConfigDirDescription": "覆寫 Oh My Pi 設定目錄(models.yml、config.yml、提示、Skills 與工作階段)。", "browsePlaceholderClaude": "例如:/home/<您的帳號>/.claude", "browsePlaceholderCodex": "例如:/home/<您的帳號>/.codex", "browsePlaceholderGemini": "例如:/home/<您的帳號>/.gemini", @@ -940,6 +942,7 @@ "browsePlaceholderOpenclaw": "例如:/home/<您的帳號>/.openclaw", "browsePlaceholderHermes": "例如:/home/<您的帳號>/.hermes", "browsePlaceholderPi": "例如:/home/<您的帳號>/.pi/agent", + "browsePlaceholderOhMyPi": "例如:/home/<您的帳號>/.omp/agent", "browseDirectory": "瀏覽目錄", "resetDefault": "還原預設目錄(需儲存後生效)", "checkForUpdates": "檢查更新", @@ -975,7 +978,7 @@ "uninstallCommandCopied": "解除安裝指令已複製", "uninstallCommandCopyFailed": "複製失敗,請手動選取並複製指令。", "oneClickInstall": "一鍵安裝", - "oneClickInstallHint": "安裝或更新 Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code", + "oneClickInstallHint": "安裝或更新 Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code / Oh My Pi", "localEnvCheck": "本地環境檢查", "updateAllTools": "全部更新({{count}})", "currentVersion": "目前版本", @@ -1099,7 +1102,8 @@ "hermes": "Hermes", "pi": "Pi", "mcode": "MiniMax Code", - "agy": "Antigravity" + "agy": "Antigravity", + "ohmypi": "Oh My Pi" }, "mcode": { "empty": { @@ -4202,8 +4206,12 @@ "hermes": "安裝時執行官方安裝腳本", "pi": "透過 npm 安裝", "mcode": "安裝時執行官方安裝腳本,失敗時改用 npm", - "agy": "安裝時執行官方安裝腳本" - } + "agy": "安裝時執行官方安裝腳本", + "omp": "透過 Bun 安裝(需 Bun 1.3.14+)" + }, + "ompDiscoveryHelpTitle": "Oh My Pi 的實際載入範圍", + "ompDiscoveryHelp": "Oh My Pi 除了 ~/.omp/agent 自己的設定,還會從其他 Agent 的目錄發現 MCP 伺服器、技能、命令、鉤子、規則和設定(如 Claude 的 ~/.claude、Codex 的 ~/.codex 等)。要讓 Oh My Pi 的實際生效內容與 CC Switch 介面一致,請在 ~/.omp/agent/config.yml 用 disabledProviders 停用不需要的發現來源(id 與文件一致,如 claude、codex、gemini、opencode、github、agents)。", + "ompDiscoveryDocsLink": "參考文件:context-files(disabledProviders)" }, "appPage": { "moreActions": "{{name}} 的更多操作", @@ -4968,5 +4976,63 @@ "defaultModelInUse": "Kimi Code 正在使用此供應商的模型,請先在 Kimi Code 中用 /model 切換到其他模型", "secondaryModelInUse": "Kimi Code 的子代理模型正在使用此供應商的模型,請先在 Kimi Code 中用 /secondary-model 更換" } + }, + "ohmypi": { + "empty": { + "title": "尚未新增自訂供應商", + "description": "Oh My Pi 內建供應商由 Oh My Pi 自行管理。點擊右上角 +,從預設或自訂設定新增供應商。" + }, + "form": { + "modelsMode": "模型來源", + "modelsModeManual": "手動設定模型列表", + "modelsModeDiscovery": "自動探索模型列表", + "modelsModeNone": "不設定模型", + "modelsDiscoveryHint": "模型列表由 Oh My Pi 執行時自動探索,此處無需手動設定;仍可透過下方 YAML 新增 models 或 modelOverrides 微調。", + "discoveryType": "探索方式", + "discoveryHint": "由 Oh My Pi 定期從端點擷取模型列表;端點回傳的上下文長度等中繼資料會自動套用。手動模型列和下方 YAML 的 modelOverrides 仍然生效。", + "discoveryApiRequired": "啟用自動探索時必須選擇介面格式(proxy 類型除外)", + "apiInherit": "不指定(繼承預設)", + "apiInheritHint": "選擇「不指定」時不寫 api 鍵:標識與內建供應商相同時繼承其模型的介面格式,proxy 自動探索時每個模型單獨自動判斷介面格式。", + "modelsHiddenHint": "未設定模型列表:儲存後不寫 models 鍵。此供應商僅在標識與 Oh My Pi 內建供應商相同時生效(其欄位作為該內建供應商的覆寫)。", + "configYaml": "配置 YAML", + "fixYamlFirst": "請先修正下方的設定 YAML,再編輯結構化欄位。", + "providerKeyDuplicate": "此標識已被使用,請更換", + "absoluteHttpUrlRequired": "{{label}} 必須是絕對 HTTP 或 HTTPS URL", + "addCompatibilityOption": "新增", + "addModel": "新增模型", + "compatibility": "介面相容性", + "compatibilityHint": "調整相容端點或本機服務的請求行為。", + "contextWindow": "上下文長度", + "credential": "API Key", + "duplicateModel": "模型 ID {{id}} 重複", + "imageInput": "支援圖片輸入", + "maxTokens": "最大輸出 Token 數", + "modelId": "模型 ID", + "modelIdRequired": "模型 {{index}} 需要 ID", + "modelName": "顯示名稱", + "modelNamePlaceholder": "顯示名稱", + "nameRequired": "顯示名稱為必填", + "noCompatibilityOptions": "暫無相容性選項", + "noModels": "暫無模型設定", + "optionKey": "選項", + "optionValue": "值", + "positiveNumberRequired": "{{label}} 必須是大於 0 的數字", + "providerKey": "供應商識別碼", + "providerKeyRequired": "供應商識別碼為必填", + "reasoning": "支援擴展思考", + "removeCompatibilityOption": "移除相容性選項", + "removeModel": "移除模型", + "toggleModelDetails": "展開或收合模型詳細資料" + }, + "provider": { + "enabled": "已在 Oh My Pi 中啟用", + "enableFailed": "無法在 Oh My Pi 中啟用此供應商", + "removed": "已從 Oh My Pi 移除", + "writeConflict": "Oh My Pi 設定正由其他程序修改,請稍後再試。" + }, + "current": { + "readFailed": "無法讀取 Oh My Pi 的供應商設定", + "stateUnavailableHint": "在 CC Switch 能讀取 Oh My Pi 的供應商設定之前,供應商變更暫不可用。" + } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index acafdc35e..c04d78b48 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -932,6 +932,8 @@ "hermesConfigDirDescription": "覆盖 Hermes 配置目录 (config.yaml)。", "piConfigDir": "Pi 配置目录", "piConfigDirDescription": "覆盖 Pi 配置目录(models.json、指令文件、提示词、Skills 与会话)。", + "ohmypiConfigDir": "Oh My Pi 配置目录", + "ohmypiConfigDirDescription": "覆盖 Oh My Pi 配置目录(models.yml、config.yml、提示词、Skills 与会话)。", "browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude", "browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex", "browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini", @@ -940,6 +942,7 @@ "browsePlaceholderOpenclaw": "例如:/home/<你的用户名>/.openclaw", "browsePlaceholderHermes": "例如:/home/<你的用户名>/.hermes", "browsePlaceholderPi": "例如:/home/<你的用户名>/.pi/agent", + "browsePlaceholderOhMyPi": "例如:/home/<你的用户名>/.omp/agent", "browseDirectory": "浏览目录", "resetDefault": "恢复默认目录(需保存后生效)", "checkForUpdates": "检查更新", @@ -974,7 +977,7 @@ "copyToolUninstallCommand": "复制 {{tool}} 的卸载命令", "uninstallCommandCopied": "卸载命令已复制", "uninstallCommandCopyFailed": "复制失败,请手动选择并复制命令。", - "oneClickInstallHint": "安装或升级 Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code", + "oneClickInstallHint": "安装或升级 Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code / Oh My Pi", "localEnvCheck": "本地环境检查", "updateAllTools": "全部升级({{count}})", "currentVersion": "当前版本", @@ -1098,7 +1101,8 @@ "hermes": "Hermes", "pi": "Pi", "mcode": "MiniMax Code", - "agy": "Antigravity" + "agy": "Antigravity", + "ohmypi": "Oh My Pi" }, "mcode": { "empty": { @@ -1673,7 +1677,7 @@ "hint": "仍要保存吗?保存后切换此供应商时可能失败,可以之后再补全。", "saveAnyway": "仍要保存" }, - "configJsonError": "配置JSON格式错误,请检查语法", + "configJsonError": "配置 JSON 格式错误,请检查语法", "authJsonRequired": "auth.json 必须是 JSON 对象", "authJsonError": "auth.json 格式错误,请检查JSON语法", "fillAuthJson": "请填写 auth.json 配置", @@ -4201,8 +4205,12 @@ "hermes": "安装时运行官方安装脚本", "pi": "通过 npm 安装", "mcode": "安装时运行官方安装脚本,失败时改用 npm", - "agy": "安装时运行官方安装脚本" - } + "agy": "安装时运行官方安装脚本", + "omp": "通过 Bun 安装(需 Bun 1.3.14+)" + }, + "ompDiscoveryHelpTitle": "Oh My Pi 的实际加载范围", + "ompDiscoveryHelp": "Oh My Pi 除了 ~/.omp/agent 自己的配置,还会从其他 Agent 的目录发现 MCP 服务器、技能、命令、钩子、规则和设置(如 Claude 的 ~/.claude、Codex 的 ~/.codex 等)。要让 Oh My Pi 的实际生效内容与 CC Switch 界面一致,请在 ~/.omp/agent/config.yml 用 disabledProviders 关闭不需要的发现来源(id 与文档一致,如 claude、codex、gemini、opencode、github、agents)。", + "ompDiscoveryDocsLink": "参考文档:context-files(disabledProviders)" }, "appPage": { "moreActions": "{{name}} 的更多操作", @@ -4967,5 +4975,63 @@ "defaultModelInUse": "Kimi Code 正在使用该供应商的模型,请先在 Kimi Code 中用 /model 切换到其他模型", "secondaryModelInUse": "Kimi Code 的子代理模型正在使用该供应商的模型,请先在 Kimi Code 中用 /secondary-model 更换" } + }, + "ohmypi": { + "empty": { + "title": "还没有添加自定义供应商", + "description": "Oh My Pi 内置供应商由 Oh My Pi 自行管理。点击右上角 +,从预设或自定义配置添加供应商。" + }, + "form": { + "modelsMode": "模型来源", + "modelsModeManual": "手动配置模型列表", + "modelsModeDiscovery": "自动发现模型列表", + "modelsModeNone": "不配置模型", + "modelsDiscoveryHint": "模型列表由 Oh My Pi 运行时自动发现,此处无需手动配置;仍可通过下方 YAML 添加 models 或 modelOverrides 微调。", + "discoveryType": "发现方式", + "discoveryHint": "由 Oh My Pi 定期从端点拉取模型列表;端点返回的上下文长度等元数据会自动套用。手动模型行和下方 YAML 的 modelOverrides 仍然生效。", + "discoveryApiRequired": "启用自动发现时必须选择接口格式(proxy 类型除外)", + "apiInherit": "不指定(继承默认)", + "apiInheritHint": "选择「不指定」时不写 api 键:标识与内置供应商相同时继承其模型的接口格式,proxy 自动发现时每个模型单独自动判断接口格式。", + "modelsHiddenHint": "未配置模型列表:保存后不写 models 键。此供应商仅在标识与 Oh My Pi 内置供应商相同时生效(其字段作为该内置供应商的覆盖)。", + "configYaml": "配置 YAML", + "fixYamlFirst": "请先修复下方配置 YAML,再编辑结构化字段。", + "providerKeyDuplicate": "此标识已被使用,请更换", + "absoluteHttpUrlRequired": "{{label}} 必须是绝对 HTTP 或 HTTPS 地址", + "addCompatibilityOption": "添加", + "addModel": "添加模型", + "compatibility": "接口兼容性", + "compatibilityHint": "调整兼容端点或本地服务的请求行为。", + "contextWindow": "上下文长度", + "credential": "API Key", + "duplicateModel": "模型 ID {{id}} 重复", + "imageInput": "支持图片输入", + "maxTokens": "最大输出 Token 数", + "modelId": "模型 ID", + "modelIdRequired": "第 {{index}} 个模型缺少 ID", + "modelName": "显示名称", + "modelNamePlaceholder": "显示名称", + "nameRequired": "显示名称不能为空", + "noCompatibilityOptions": "暂无兼容性选项", + "noModels": "暂无模型配置", + "optionKey": "选项", + "optionValue": "值", + "positiveNumberRequired": "{{label}} 必须是大于 0 的数字", + "providerKey": "供应商标识", + "providerKeyRequired": "供应商标识不能为空", + "reasoning": "支持扩展思考", + "removeCompatibilityOption": "移除兼容性选项", + "removeModel": "移除模型", + "toggleModelDetails": "展开或收起模型详情" + }, + "provider": { + "enabled": "已在 Oh My Pi 中启用", + "enableFailed": "无法在 Oh My Pi 中启用此供应商", + "removed": "已从 Oh My Pi 移除", + "writeConflict": "Oh My Pi 配置正在被其他进程修改,请稍后再试。" + }, + "current": { + "readFailed": "无法读取 Oh My Pi 的供应商配置", + "stateUnavailableHint": "在 CC Switch 能读取 Oh My Pi 的供应商配置之前,供应商改动暂不可用。" + } } } diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index f7545be3e..6db5fa650 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -94,6 +94,7 @@ export const icons: Record = { mistral: `Mistral`, newapi: `NewAPI`, notion: `Notion`, + ohmypi: `Oh My Pi`, ollama: `Ollama`, openai: `OpenAI`, openclaw: `OpenClaw`, diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index d8e7c070d..feb62105a 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -605,6 +605,14 @@ export const iconMetadata: Record = { keywords: [], defaultColor: "currentColor", }, + ohmypi: { + name: "ohmypi", + displayName: "Oh My Pi", + category: "ai-provider", + shape: "tile", + keywords: ["ohmypi", "oh my pi", "omp", "pi"], + defaultColor: "#9b4dff", + }, ollama: { name: "ollama", displayName: "ollama", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 2f45efae1..991d818d3 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,5 +1,6 @@ export type { AppId } from "./types"; export { piApi } from "./pi"; +export { ohmypiApi } from "./ohmypi"; export { providersApi, universalProvidersApi } from "./providers"; export { settingsApi } from "./settings"; export { backupsApi } from "./settings"; diff --git a/src/lib/api/ohmypi.ts b/src/lib/api/ohmypi.ts new file mode 100644 index 000000000..ca51895d0 --- /dev/null +++ b/src/lib/api/ohmypi.ts @@ -0,0 +1,23 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { UsageScript } from "@/types"; + +export interface OhMyPiCurrentState { + enabledProviderIds: string[]; + defaultProviderId: string | null; +} + +export const ohmypiApi = { + async getCurrentState(): Promise { + return await invoke("get_ohmypi_current_state"); + }, + + async updateProviderUsageScript( + id: string, + usageScript: UsageScript, + ): Promise { + return await invoke("update_ohmypi_provider_usage_script", { + id, + usageScript, + }); + }, +}; diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index dd34f709b..04c97ec78 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -14,7 +14,8 @@ export type AppType = | "pi" | "mcode" | "kimi" - | "agy"; + | "agy" + | "ohmypi"; /** Skill 应用启用状态 */ export interface SkillApps { @@ -30,6 +31,7 @@ export interface SkillApps { mcode?: boolean; kimi?: boolean; agy?: boolean; + ohmypi: boolean; } /** 已安装的 Skill(v3.10.0+ 统一结构) */ diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 016a1a667..e16fdeab5 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -11,4 +11,5 @@ export type AppId = | "pi" | "kimi" | "mcode" - | "agy"; + | "agy" + | "ohmypi"; diff --git a/src/lib/query/index.ts b/src/lib/query/index.ts index 50018439f..665db75ca 100644 --- a/src/lib/query/index.ts +++ b/src/lib/query/index.ts @@ -4,3 +4,4 @@ export * from "./mutations"; export * from "./proxy"; export * from "./subscription"; export * from "./pi"; +export * from "./ohmypi"; diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 3ee8fdcb7..1db9e33c7 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -18,6 +18,7 @@ import { proxyKeys } from "@/lib/query/proxy"; import { usageKeys } from "@/lib/query/usage"; import { sessionKeys } from "@/lib/query/sessions"; import { invalidatePiProviderCaches } from "@/lib/query/pi"; +import { invalidateOhMyPiProviderCaches } from "@/lib/query/ohmypi"; import { GROKBUILD_OFFICIAL_PROVIDER_ID } from "@/utils/providerCapabilities"; export const useAddProviderMutation = (appId: AppId) => { @@ -71,7 +72,8 @@ export const useAddProviderMutation = (appId: AppId) => { appId === "hermes" || appId === "pi" || appId === "mcode" || - appId === "kimi" + appId === "kimi" || + appId === "ohmypi" ) { if ( providerInput.category === "omo" || @@ -167,6 +169,9 @@ export const useAddProviderMutation = (appId: AppId) => { if (appId === "pi") { await invalidatePiProviderCaches(queryClient); } + if (appId === "ohmypi") { + await invalidateOhMyPiProviderCaches(queryClient); + } }, }); }; @@ -242,6 +247,9 @@ export const useUpdateProviderMutation = (appId: AppId) => { queryKey: ["providers", "kimi"], }); } + if (appId === "ohmypi") { + await invalidateOhMyPiProviderCaches(queryClient); + } }, }); }; @@ -325,6 +333,9 @@ export const useDeleteProviderMutation = (appId: AppId) => { queryKey: ["providers", "kimi"], }); } + if (appId === "ohmypi") { + await invalidateOhMyPiProviderCaches(queryClient); + } }, }); }; @@ -408,6 +419,9 @@ export const useSwitchProviderMutation = (appId: AppId) => { if (appId === "pi") { await invalidatePiProviderCaches(queryClient); } + if (appId === "ohmypi") { + await invalidateOhMyPiProviderCaches(queryClient); + } }, }); }; diff --git a/src/lib/query/ohmypi.ts b/src/lib/query/ohmypi.ts new file mode 100644 index 000000000..a2a99cb6f --- /dev/null +++ b/src/lib/query/ohmypi.ts @@ -0,0 +1,35 @@ +import { type QueryClient, useQuery } from "@tanstack/react-query"; +import { ohmypiApi } from "@/lib/api/ohmypi"; + +export const ohmypiKeys = { + all: ["ohmypi"] as const, + currentState: ["ohmypi", "currentState"] as const, +}; + +export const invalidateOhMyPiProviderCaches = async ( + queryClient: QueryClient, +) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ohmypiKeys.currentState }), + queryClient.invalidateQueries({ queryKey: ["providers", "ohmypi"] }), + ]); +}; + +export const invalidateOhMyPiDirectoryCaches = async ( + queryClient: QueryClient, +) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ohmypiKeys.all }), + queryClient.invalidateQueries({ queryKey: ["providers", "ohmypi"] }), + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }), + queryClient.invalidateQueries({ queryKey: ["sessions"] }), + ]); +}; + +export function useOhMyPiCurrentState(enabled = true) { + return useQuery({ + queryKey: ohmypiKeys.currentState, + queryFn: () => ohmypiApi.getCurrentState(), + enabled, + }); +} diff --git a/src/types.ts b/src/types.ts index 66e9a430f..66cf1be30 100644 --- a/src/types.ts +++ b/src/types.ts @@ -240,6 +240,8 @@ export interface ProviderMeta { githubAccountId?: string; // Stack 模式下这家 Claude Code 供应商发布的模型;没有时按模型映射发布,空列表什么都不发布 stackModels?: ClaudeStackModel[]; + // Oh My Pi:编辑器“其他字段(YAML,原样保留)”的原文(含注释),写 models.yml 时原样保留 + ohmypiPassthroughYaml?: string; } // Stack 模式下 Claude Code 供应商发布的一个模型 @@ -317,6 +319,7 @@ export interface VisibleApps { mcode: boolean; kimi: boolean; agy: boolean; + ohmypi: boolean; } // WebDAV 同步状态 @@ -480,6 +483,8 @@ export interface Settings { hermesConfigDir?: string; // 覆盖 Pi agent 配置目录(可选) piConfigDir?: string; + // 覆盖 Oh My Pi agent 配置目录(可选) + ohmypiConfigDir?: string; // ===== 当前供应商 ID(设备级)===== // 当前 Claude 供应商 ID(优先于数据库 is_current) @@ -776,6 +781,7 @@ export interface McpApps { openclaw: boolean; hermes: boolean; pi?: boolean; + ohmypi?: boolean; } // MCP 服务器条目(v3.7.0 统一结构) @@ -821,6 +827,14 @@ export interface UniversalProviderApps { gemini: boolean; } +/** + * 统一供应商能投影到的应用(镜像后端 `sync_universal_to_apps`)。 + * 其余应用(含累加模式应用)没有投影路径,不应在预设选择器里展示统一供应商。 + */ +export const UNIVERSAL_PROVIDER_APPS: ReadonlyArray< + keyof UniversalProviderApps +> = ["claude", "codex", "gemini"]; + // Claude 模型配置 export interface ClaudeModelConfig { model?: string; diff --git a/src/utils/errorUtils.ts b/src/utils/errorUtils.ts index f17e4c314..099ae652d 100644 --- a/src/utils/errorUtils.ts +++ b/src/utils/errorUtils.ts @@ -59,6 +59,30 @@ export const translatePiProviderMutationError = ( return ""; }; +export const translateOhMyPiProviderMutationError = ( + message: string, + t: (key: string, options?: Record) => string, +): string => { + if (!message) return ""; + + if ( + message.includes("changed outside CC Switch") || + message.includes("no longer present in models.yml") || + message.includes("another value now owns the key") + ) { + return t("ohmypi.provider.writeConflict"); + } + + if ( + message.includes("Oh My Pi provider") && + message.includes("already exists") + ) { + return t("ohmypi.form.providerKeyDuplicate"); + } + + return ""; +}; + /** * Kimi 的默认模型可能在 Kimi Code 里被改过、页面上还是旧的, * 后端按 config.toml 现读拒绝后,把这两条英文原因换成说明。 diff --git a/tests/components/AddProviderDialog.test.tsx b/tests/components/AddProviderDialog.test.tsx index bbf8250da..a61fffe11 100644 --- a/tests/components/AddProviderDialog.test.tsx +++ b/tests/components/AddProviderDialog.test.tsx @@ -44,6 +44,11 @@ let mockProjectedBase: Record | null = null; // 投影成那份底的草稿(预设或模板)。 let mockProjectedDraft: Record | undefined; let submitReadyCallbacks: Array<(isReady: boolean) => void> = []; +// 对话框是否把「统一供应商」入口交给表单:只有 `showUniversalTab` 为真时才传。 +let universalProps: { + onUniversalPresetSelect?: unknown; + onManageUniversalProviders?: unknown; +} = {}; vi.mock("@/components/providers/forms/ProviderForm", () => ({ ProviderForm: ({ @@ -51,6 +56,8 @@ vi.mock("@/components/providers/forms/ProviderForm", () => ({ onSubmitReadyChange, onManageAuthAccounts, onEditorBaseChange, + onUniversalPresetSelect, + onManageUniversalProviders, }: { onSubmit: (values: ProviderFormValues) => void; onSubmitReadyChange?: (isReady: boolean) => void; @@ -59,9 +66,12 @@ vi.mock("@/components/providers/forms/ProviderForm", () => ({ base: Record | null, draft?: Record, ) => void; + onUniversalPresetSelect?: unknown; + onManageUniversalProviders?: unknown; }) => { const presetStep = usePresetStep(); const registerSelector = presetStep?.registerSelector; + universalProps = { onUniversalPresetSelect, onManageUniversalProviders }; useEffect(() => { if (mockHasPresetSelector) return registerSelector?.(); }, [registerSelector]); @@ -532,4 +542,52 @@ context_window = 500000 act(() => staleCallback?.(true)); expect(reopenedButton).toBeDisabled(); }); + + // Oh My Pi 的标识、模型与 YAML 都在表单里,必填项对话框看不见:按钮必须等表单报告就绪。 + it("Oh My Pi 添加表单报告未就绪时禁用添加按钮", async () => { + mockFormReady = false; + render( + , + ); + + const addButton = await screen.findByRole("button", { name: "common.add" }); + await waitFor(() => expect(addButton).toBeDisabled()); + + act(() => submitReadyCallbacks.at(-1)?.(true)); + await waitFor(() => expect(addButton).toBeEnabled()); + }); + + // 统一供应商只能投影到 Claude Code / Codex / Gemini(后端 `UniversalProviderApps`), + // 所以对话框对 omp 不能把入口交给表单;Claude Code 作为对照必须照旧传。 + it("只有能接收统一供应商的应用才拿到统一预设入口", async () => { + const { rerender } = render( + , + ); + await screen.findByRole("button", { name: "common.add" }); + expect(universalProps.onUniversalPresetSelect).toBeUndefined(); + expect(universalProps.onManageUniversalProviders).toBeUndefined(); + + rerender( + , + ); + await waitFor(() => + expect(universalProps.onUniversalPresetSelect).toBeTypeOf("function"), + ); + expect(universalProps.onManageUniversalProviders).toBeTypeOf("function"); + }); }); diff --git a/tests/components/AppsPage.test.tsx b/tests/components/AppsPage.test.tsx index 63a8ed44d..274902ff5 100644 --- a/tests/components/AppsPage.test.tsx +++ b/tests/components/AppsPage.test.tsx @@ -432,8 +432,11 @@ describe("AppsPage concurrent CLI upgrades", () => { expect(dialog.getByText("pnpm remove -g openclaw")).toBeInTheDocument(); expect(dialog.queryByText("Codex")).not.toBeInTheDocument(); await chooseMethod("bun"); - expect(dialog.getAllByRole("listitem")).toHaveLength(1); + expect(dialog.getAllByRole("listitem")).toHaveLength(2); expect(dialog.getByText("bun remove -g openclaw")).toBeInTheDocument(); + expect( + dialog.getByText("bun remove -g @oh-my-pi/pi-coding-agent"), + ).toBeInTheDocument(); expect( dialog.queryByText("bun remove -g opencode-ai"), ).not.toBeInTheDocument(); diff --git a/tests/components/EditProviderDialog.test.tsx b/tests/components/EditProviderDialog.test.tsx index dd437bc3a..e5d74fa48 100644 --- a/tests/components/EditProviderDialog.test.tsx +++ b/tests/components/EditProviderDialog.test.tsx @@ -616,4 +616,33 @@ describe("EditProviderDialog", () => { act(() => staleCallback?.(true)); expect(reopenedButton).toBeDisabled(); }); + + it("Oh My Pi 编辑表单报告未就绪时禁用保存按钮", async () => { + const provider: Provider = { + id: "omp-provider", + name: "Oh My Pi Provider", + settingsConfig: { + baseUrl: "https://api.example.com/v1", + api: "openai-completions", + }, + }; + mockFormReady = false; + render( + , + ); + + const saveButton = await screen.findByRole("button", { + name: "common.save", + }); + await waitFor(() => expect(saveButton).toBeDisabled()); + + act(() => submitReadyCallbacks.at(-1)?.(true)); + await waitFor(() => expect(saveButton).toBeEnabled()); + }); }); diff --git a/tests/components/OhMyPiProviderForm.test.tsx b/tests/components/OhMyPiProviderForm.test.tsx new file mode 100644 index 000000000..a981da549 --- /dev/null +++ b/tests/components/OhMyPiProviderForm.test.tsx @@ -0,0 +1,373 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRef } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { OhMyPiProviderForm } from "@/components/providers/forms/OhMyPiProviderForm"; +import { renderWithQueryClient as render } from "../utils/testQueryClient"; +vi.mock("@/components/JsonEditor", () => ({ + default: ({ + id, + value, + onChange, + }: { + id?: string; + value: string; + onChange: (v: string) => void; + }) => { + // Mimic the real CodeMirror editor: when the controlled `value` prop + // changes from outside (form controls → displayYaml), it writes the new + // document and fires an update listener that calls onChange with it. + const lastValueRef = useRef(value); + if (lastValueRef.current !== value) { + lastValueRef.current = value; + queueMicrotask(() => onChange(value)); + } + return ( +