diff --git a/.changeset/cpa-autodetect.md b/.changeset/cpa-autodetect.md new file mode 100644 index 00000000..222af09d --- /dev/null +++ b/.changeset/cpa-autodetect.md @@ -0,0 +1,8 @@ +--- +'@open-codesign/desktop': patch +'@open-codesign/i18n': patch +--- + +feat(settings): auto-detect running CLIProxyAPI and show import banner + +When the Models tab mounts, probes `http://127.0.0.1:8317/v1/models` via the existing `testEndpoint` IPC bridge. If CLIProxyAPI is running and no provider is already configured at that address, displays a `LocalCpaImportCard` banner above the provider list offering one-click import into `AddCustomProviderModal`. The banner is dismissible and the preference persists to `localStorage` via key `cpa-detection-dismissed-v1`. diff --git a/apps/desktop/src/renderer/src/components/Settings.test.ts b/apps/desktop/src/renderer/src/components/Settings.test.ts index ed7f382f..0b27fba1 100644 --- a/apps/desktop/src/renderer/src/components/Settings.test.ts +++ b/apps/desktop/src/renderer/src/components/Settings.test.ts @@ -39,6 +39,48 @@ describe('applyLocaleChange', () => { expect(result).toBe('zh-CN'); }); }); + +describe('CPA detection regex', () => { + const CPA_REGEX = /^https?:\/\/(localhost|127\.0\.0\.1):8317/; + + it('matches http://localhost:8317', () => { + expect('http://localhost:8317').toMatch(CPA_REGEX); + }); + + it('matches https://127.0.0.1:8317', () => { + expect('https://127.0.0.1:8317').toMatch(CPA_REGEX); + }); + + it('does not match other ports', () => { + expect('http://localhost:8080').not.toMatch(CPA_REGEX); + expect('https://example.com:8317').not.toMatch(CPA_REGEX); + }); +}); + +describe('CPA detection localStorage dismissal', () => { + const KEY = 'cpa-detection-dismissed-v1'; + + it('reads and writes dismissal flag', () => { + const values = new Map(); + const storage = { + getItem: vi.fn((key: string) => values.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + values.set(key, value); + }), + }; + + // Check initial read + expect(storage.getItem(KEY)).toBeNull(); + + // Simulate user dismissal + storage.setItem(KEY, '1'); + expect(storage.setItem).toHaveBeenCalledWith(KEY, '1'); + + // Verify we can read it back + expect(storage.getItem(KEY)).toBe('1'); + }); +}); + describe('computeModelOptions', () => { const suffix = '(active, not in provider list)'; diff --git a/apps/desktop/src/renderer/src/components/Settings.tsx b/apps/desktop/src/renderer/src/components/Settings.tsx index c70131ae..1e36438a 100644 --- a/apps/desktop/src/renderer/src/components/Settings.tsx +++ b/apps/desktop/src/renderer/src/components/Settings.tsx @@ -22,6 +22,7 @@ import { RotateCcw, Sliders, Trash2, + Zap, } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { AppPaths, Preferences, ProviderRow, StorageKind } from '../../../preload/index'; @@ -856,6 +857,47 @@ function WarningsList({ warnings }: { warnings: string[] }) { ); } +const CPA_DETECTION_DISMISSED_KEY = 'cpa-detection-dismissed-v1'; + +function LocalCpaImportCard({ + onImport, + onDismiss, +}: { + onImport: () => void; + onDismiss: () => void; +}) { + const t = useT(); + return ( +
+
+ ); +} + function ModelsTab() { const t = useT(); const config = useCodesignStore((s) => s.config); @@ -866,6 +908,9 @@ function ModelsTab() { const [loading, setLoading] = useState(true); const [showAddCustom, setShowAddCustom] = useState(false); const [showAddMenu, setShowAddMenu] = useState(false); + const [cpaDetection, setCpaDetection] = useState< + 'idle' | 'detecting' | 'available' | 'unavailable' + >('idle'); const [externalConfigs, setExternalConfigs] = useState<{ codex?: { count: number } | undefined; claudeCode?: @@ -994,6 +1039,44 @@ function ModelsTab() { }); }, [pushToast, t]); + useEffect(() => { + if (!window.codesign?.config?.testEndpoint) return; + // Only probe once — once we've reached a terminal state, skip. + if (cpaDetection !== 'idle') return; + // Skip if user already dismissed this banner for this install. + try { + if (window.localStorage.getItem(CPA_DETECTION_DISMISSED_KEY) === '1') return; + } catch { + // localStorage unavailable — proceed with detection + } + // Skip detection if a provider is already pointing at the CPA port. + // We wait for the rows load to settle before probing so we don't flash + // the banner and immediately hide it on the next render tick. + if (loading) return; + const alreadyConfigured = rows.some((r) => + /^https?:\/\/(localhost|127\.0\.0\.1):8317/.test(r.baseUrl ?? ''), + ); + if (alreadyConfigured) return; + + setCpaDetection('detecting'); + void window.codesign.config + .testEndpoint({ wire: 'anthropic', baseUrl: 'http://127.0.0.1:8317', apiKey: '' }) + .then((res) => { + setCpaDetection(res.ok ? 'available' : 'unavailable'); + }) + .catch((err) => { + reportableErrorToast({ + code: 'CPA_DETECTION_FAILED', + scope: 'settings', + title: t('settings.imageGen.toast.loadFailed', { + defaultValue: 'Image generation settings failed to load', + }), + description: cleanIpcError(err) || t('settings.common.unknownError'), + }); + setCpaDetection('unavailable'); + }); + }, [cpaDetection, loading, rows, pushToast, reportableErrorToast, t]); + async function reloadRows() { if (!window.codesign) return; const [nextRows, state] = await Promise.all([ @@ -1313,6 +1396,28 @@ function ModelsTab() {
+ {cpaDetection === 'available' && ( + { + setCustomProviderPreset({ + name: 'CLIProxyAPI', + baseUrl: 'http://127.0.0.1:8317', + wire: 'anthropic', + defaultModel: '', + }); + setShowAddCustom(true); + setCpaDetection('unavailable'); + }} + onDismiss={() => { + try { + window.localStorage.setItem(CPA_DETECTION_DISMISSED_KEY, '1'); + } catch { + // non-fatal + } + setCpaDetection('unavailable'); + }} + /> + )} {externalConfigs !== null && (externalConfigs.codex !== undefined || externalConfigs.claudeCode !== undefined || diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 73a8fc74..0f4ed923 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -313,6 +313,12 @@ "apiKeyOptional": "API key only required if you configured `api-keys` in CPA config.yaml", "thinkingHint": "Tip: append `(high)` / `(xhigh)` / `(8192)` to model name to control thinking budget" }, + "cpaDetection": { + "title": "CLIProxyAPI detected on your machine", + "body": "Import it as a provider to use your OAuth-authenticated Claude / Codex / Gemini accounts.", + "importAction": "Import", + "dismissAction": "Dismiss" + }, "reasoning": { "label": "Reasoning depth", "default": "Default (auto)", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index bf07677a..a0bdf354 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -313,6 +313,12 @@ "apiKeyOptional": "仅当你在 CPA config.yaml 里配置了 api-keys 才需要填", "thinkingHint": "提示:在 model 名后加 `(high)` / `(xhigh)` / `(8192)` 可控制思考力度" }, + "cpaDetection": { + "title": "检测到本机运行的 CLIProxyAPI", + "body": "一键导入即可使用你已登录的 Claude / Codex / Gemini 订阅账号。", + "importAction": "导入", + "dismissAction": "忽略" + }, "reasoning": { "label": "推理深度", "default": "默认(自动)",