Skip to content

Commit 3d7b74e

Browse files
feat(settings): auto-detect running CLIProxyAPI and show import banner (#190)
## Summary Follow-up to #178 (CLIProxyAPI preset). When Settings > Models mounts, probe `http://127.0.0.1:8317/v1/models` — if CPA is running locally and not yet configured, show a one-click Import banner at the top of the provider list. Pattern borrowed from EasyCLI (official CPA GUI) and ProxyPal (1k★ SolidJS+Tauri wrapper). Both show the running CPA prominently on first load; users click once and are done. ## Flow 1. Models tab mounts → `useEffect` fires once 2. Skip if localStorage `cpa-detection-dismissed-v1` is set or any existing provider points at `localhost:8317` 3. Call `window.codesign.config.testEndpoint({ wire: 'anthropic', baseUrl: 'http://127.0.0.1:8317', apiKey: '' })` — CPA has wildcard CORS + the IPC bridges through main process anyway 4. On success, render `LocalCpaImportCard` (Zap icon + title + body + Import/Dismiss buttons) 5. "Import" reuses existing `customProviderPreset` state → `AddCustomProviderModal` pre-filled with CPA preset. Modal's auto-discovery fills defaultModel. User clicks Save. Done. ## Test plan - [ ] `pnpm typecheck` green - [ ] `pnpm lint` green - [ ] Manual: install + run CPA → open Settings → see banner - [ ] Manual: click Import → modal opens with preset → save → banner disappears and does not reappear - [ ] Manual: click Dismiss → banner disappears and does not reappear on subsequent Settings opens - [ ] Manual: no CPA running → no banner (silent failure) ## PR chain Depends on #178. Base is `feat/cpa-preset` so the diff stays focused on this change. Will rebase to `main` after #178 merges. --------- Signed-off-by: hqhq1025 <1506751656@qq.com> Co-authored-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com>
1 parent 0a0ff2e commit 3d7b74e

5 files changed

Lines changed: 167 additions & 0 deletions

File tree

.changeset/cpa-autodetect.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@open-codesign/desktop': patch
3+
'@open-codesign/i18n': patch
4+
---
5+
6+
feat(settings): auto-detect running CLIProxyAPI and show import banner
7+
8+
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`.

apps/desktop/src/renderer/src/components/Settings.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,48 @@ describe('applyLocaleChange', () => {
3939
expect(result).toBe('zh-CN');
4040
});
4141
});
42+
43+
describe('CPA detection regex', () => {
44+
const CPA_REGEX = /^https?:\/\/(localhost|127\.0\.0\.1):8317/;
45+
46+
it('matches http://localhost:8317', () => {
47+
expect('http://localhost:8317').toMatch(CPA_REGEX);
48+
});
49+
50+
it('matches https://127.0.0.1:8317', () => {
51+
expect('https://127.0.0.1:8317').toMatch(CPA_REGEX);
52+
});
53+
54+
it('does not match other ports', () => {
55+
expect('http://localhost:8080').not.toMatch(CPA_REGEX);
56+
expect('https://example.com:8317').not.toMatch(CPA_REGEX);
57+
});
58+
});
59+
60+
describe('CPA detection localStorage dismissal', () => {
61+
const KEY = 'cpa-detection-dismissed-v1';
62+
63+
it('reads and writes dismissal flag', () => {
64+
const values = new Map<string, string>();
65+
const storage = {
66+
getItem: vi.fn((key: string) => values.get(key) ?? null),
67+
setItem: vi.fn((key: string, value: string) => {
68+
values.set(key, value);
69+
}),
70+
};
71+
72+
// Check initial read
73+
expect(storage.getItem(KEY)).toBeNull();
74+
75+
// Simulate user dismissal
76+
storage.setItem(KEY, '1');
77+
expect(storage.setItem).toHaveBeenCalledWith(KEY, '1');
78+
79+
// Verify we can read it back
80+
expect(storage.getItem(KEY)).toBe('1');
81+
});
82+
});
83+
4284
describe('computeModelOptions', () => {
4385
const suffix = '(active, not in provider list)';
4486

apps/desktop/src/renderer/src/components/Settings.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
RotateCcw,
2323
Sliders,
2424
Trash2,
25+
Zap,
2526
} from 'lucide-react';
2627
import { useEffect, useMemo, useRef, useState } from 'react';
2728
import type { AppPaths, Preferences, ProviderRow, StorageKind } from '../../../preload/index';
@@ -856,6 +857,47 @@ function WarningsList({ warnings }: { warnings: string[] }) {
856857
);
857858
}
858859

860+
const CPA_DETECTION_DISMISSED_KEY = 'cpa-detection-dismissed-v1';
861+
862+
function LocalCpaImportCard({
863+
onImport,
864+
onDismiss,
865+
}: {
866+
onImport: () => void;
867+
onDismiss: () => void;
868+
}) {
869+
const t = useT();
870+
return (
871+
<div className="rounded-[var(--radius-md)] border border-[var(--color-accent)] bg-[var(--color-accent-tint)] px-[var(--space-3)] py-[var(--space-2_5)] flex items-start gap-[var(--space-3)]">
872+
<Zap className="w-4 h-4 mt-0.5 shrink-0 text-[var(--color-accent)]" aria-hidden="true" />
873+
<div className="flex-1 min-w-0">
874+
<p className="text-[var(--text-sm)] font-medium text-[var(--color-text-primary)] leading-snug">
875+
{t('settings.providers.cpaDetection.title')}
876+
</p>
877+
<p className="text-[var(--text-xs)] text-[var(--color-text-secondary)] mt-0.5 leading-[var(--leading-body)]">
878+
{t('settings.providers.cpaDetection.body')}
879+
</p>
880+
</div>
881+
<div className="flex items-center gap-[var(--space-1_5)] shrink-0">
882+
<button
883+
type="button"
884+
onClick={onImport}
885+
className="h-7 px-[var(--space-2_5)] rounded-[var(--radius-sm)] text-[var(--text-xs)] text-[var(--color-on-accent)] bg-[var(--color-accent)] hover:opacity-90 transition-opacity whitespace-nowrap"
886+
>
887+
{t('settings.providers.cpaDetection.importAction')}
888+
</button>
889+
<button
890+
type="button"
891+
onClick={onDismiss}
892+
className="h-7 px-[var(--space-2)] rounded-[var(--radius-sm)] text-[var(--text-xs)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors whitespace-nowrap"
893+
>
894+
{t('settings.providers.cpaDetection.dismissAction')}
895+
</button>
896+
</div>
897+
</div>
898+
);
899+
}
900+
859901
function ModelsTab() {
860902
const t = useT();
861903
const config = useCodesignStore((s) => s.config);
@@ -866,6 +908,9 @@ function ModelsTab() {
866908
const [loading, setLoading] = useState(true);
867909
const [showAddCustom, setShowAddCustom] = useState(false);
868910
const [showAddMenu, setShowAddMenu] = useState(false);
911+
const [cpaDetection, setCpaDetection] = useState<
912+
'idle' | 'detecting' | 'available' | 'unavailable'
913+
>('idle');
869914
const [externalConfigs, setExternalConfigs] = useState<{
870915
codex?: { count: number } | undefined;
871916
claudeCode?:
@@ -994,6 +1039,44 @@ function ModelsTab() {
9941039
});
9951040
}, [pushToast, t]);
9961041

1042+
useEffect(() => {
1043+
if (!window.codesign?.config?.testEndpoint) return;
1044+
// Only probe once — once we've reached a terminal state, skip.
1045+
if (cpaDetection !== 'idle') return;
1046+
// Skip if user already dismissed this banner for this install.
1047+
try {
1048+
if (window.localStorage.getItem(CPA_DETECTION_DISMISSED_KEY) === '1') return;
1049+
} catch {
1050+
// localStorage unavailable — proceed with detection
1051+
}
1052+
// Skip detection if a provider is already pointing at the CPA port.
1053+
// We wait for the rows load to settle before probing so we don't flash
1054+
// the banner and immediately hide it on the next render tick.
1055+
if (loading) return;
1056+
const alreadyConfigured = rows.some((r) =>
1057+
/^https?:\/\/(localhost|127\.0\.0\.1):8317/.test(r.baseUrl ?? ''),
1058+
);
1059+
if (alreadyConfigured) return;
1060+
1061+
setCpaDetection('detecting');
1062+
void window.codesign.config
1063+
.testEndpoint({ wire: 'anthropic', baseUrl: 'http://127.0.0.1:8317', apiKey: '' })
1064+
.then((res) => {
1065+
setCpaDetection(res.ok ? 'available' : 'unavailable');
1066+
})
1067+
.catch((err) => {
1068+
reportableErrorToast({
1069+
code: 'CPA_DETECTION_FAILED',
1070+
scope: 'settings',
1071+
title: t('settings.imageGen.toast.loadFailed', {
1072+
defaultValue: 'Image generation settings failed to load',
1073+
}),
1074+
description: cleanIpcError(err) || t('settings.common.unknownError'),
1075+
});
1076+
setCpaDetection('unavailable');
1077+
});
1078+
}, [cpaDetection, loading, rows, pushToast, reportableErrorToast, t]);
1079+
9971080
async function reloadRows() {
9981081
if (!window.codesign) return;
9991082
const [nextRows, state] = await Promise.all([
@@ -1313,6 +1396,28 @@ function ModelsTab() {
13131396

13141397
<div className="space-y-[var(--space-3)]">
13151398
<ChatgptLoginCard onStatusChange={reloadRows} />
1399+
{cpaDetection === 'available' && (
1400+
<LocalCpaImportCard
1401+
onImport={() => {
1402+
setCustomProviderPreset({
1403+
name: 'CLIProxyAPI',
1404+
baseUrl: 'http://127.0.0.1:8317',
1405+
wire: 'anthropic',
1406+
defaultModel: '',
1407+
});
1408+
setShowAddCustom(true);
1409+
setCpaDetection('unavailable');
1410+
}}
1411+
onDismiss={() => {
1412+
try {
1413+
window.localStorage.setItem(CPA_DETECTION_DISMISSED_KEY, '1');
1414+
} catch {
1415+
// non-fatal
1416+
}
1417+
setCpaDetection('unavailable');
1418+
}}
1419+
/>
1420+
)}
13161421
{externalConfigs !== null &&
13171422
(externalConfigs.codex !== undefined ||
13181423
externalConfigs.claudeCode !== undefined ||

packages/i18n/src/locales/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,12 @@
313313
"apiKeyOptional": "API key only required if you configured `api-keys` in CPA config.yaml",
314314
"thinkingHint": "Tip: append `(high)` / `(xhigh)` / `(8192)` to model name to control thinking budget"
315315
},
316+
"cpaDetection": {
317+
"title": "CLIProxyAPI detected on your machine",
318+
"body": "Import it as a provider to use your OAuth-authenticated Claude / Codex / Gemini accounts.",
319+
"importAction": "Import",
320+
"dismissAction": "Dismiss"
321+
},
316322
"reasoning": {
317323
"label": "Reasoning depth",
318324
"default": "Default (auto)",

packages/i18n/src/locales/zh-CN.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,12 @@
313313
"apiKeyOptional": "仅当你在 CPA config.yaml 里配置了 api-keys 才需要填",
314314
"thinkingHint": "提示:在 model 名后加 `(high)` / `(xhigh)` / `(8192)` 可控制思考力度"
315315
},
316+
"cpaDetection": {
317+
"title": "检测到本机运行的 CLIProxyAPI",
318+
"body": "一键导入即可使用你已登录的 Claude / Codex / Gemini 订阅账号。",
319+
"importAction": "导入",
320+
"dismissAction": "忽略"
321+
},
316322
"reasoning": {
317323
"label": "推理深度",
318324
"default": "默认(自动)",

0 commit comments

Comments
 (0)