Skip to content

Commit 8a73158

Browse files
hqhq1025Sun-sunshine06
authored andcommitted
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 (anthropic wire, no key). If CPA is running and no provider already points at localhost:8317, a LocalCpaImportCard banner is shown above the provider list. - Detection runs once per mount (guarded by 'idle' sentinel state). - Skipped entirely if any existing provider row has a baseUrl matching /^https?://(localhost|127.0.0.1):8317/ — avoids spam after import. - "Import" opens AddCustomProviderModal pre-filled with the CPA preset (same handler as the Add Provider menu item) and immediately hides the banner. - "Dismiss" writes cpa-detection-dismissed-v1=1 to localStorage so the banner never reappears for this install. - Detection failures are silently swallowed — best-effort enhancement. - New i18n keys under settings.providers.cpaDetection.* (en + zh-CN). - Changeset: patch bump for desktop + i18n. Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent b5dbf88 commit 8a73158

6 files changed

Lines changed: 171 additions & 6 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/AddCustomProviderModal.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,27 +110,27 @@ export function AddCustomProviderModal({
110110
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
111111
const discoverySeq = useRef(0);
112112

113-
function scheduleDiscovery(currentBaseUrl: string, currentWire: WireApi) {
113+
function scheduleDiscovery(currentBaseUrl: string, currentApiKey: string, currentWire: WireApi) {
114114
if (debounceTimer.current !== null) clearTimeout(debounceTimer.current);
115115
if (!currentBaseUrl.trim().match(/^https?:\/\//)) {
116116
discoverySeq.current += 1;
117117
setDiscovery({ kind: 'idle' });
118118
return;
119119
}
120120
debounceTimer.current = setTimeout(() => {
121-
void runDiscovery(currentBaseUrl, currentWire);
121+
void runDiscovery(currentBaseUrl, currentApiKey, currentWire);
122122
}, 500);
123123
}
124124

125-
async function runDiscovery(currentBaseUrl: string, currentWire: WireApi) {
125+
async function runDiscovery(currentBaseUrl: string, currentApiKey: string, currentWire: WireApi) {
126126
if (!window.codesign?.config) return;
127127
const seq = ++discoverySeq.current;
128128
setDiscovery({ kind: 'discovering' });
129129
try {
130130
const res = await window.codesign.config.testEndpoint({
131131
wire: currentWire,
132132
baseUrl: currentBaseUrl.trim(),
133-
apiKey: '',
133+
apiKey: currentApiKey.trim(),
134134
});
135135
if (seq !== discoverySeq.current) return;
136136
if (res.ok && res.models.length > 0) {
@@ -151,17 +151,18 @@ export function AddCustomProviderModal({
151151
setBaseUrl(v);
152152
if (wireAuto) setWire(detectWireFromBaseUrl(v));
153153
setTest({ kind: 'idle' });
154-
scheduleDiscovery(v, wireAuto ? detectWireFromBaseUrl(v) : wire);
154+
scheduleDiscovery(v, apiKey, wireAuto ? detectWireFromBaseUrl(v) : wire);
155155
}
156156

157157
function handleApiKeyChange(v: string) {
158158
setApiKey(v);
159+
scheduleDiscovery(baseUrl, v, wire);
159160
}
160161

161162
function handleWireChange(v: WireApi) {
162163
setWire(v);
163164
setWireAuto(false);
164-
scheduleDiscovery(baseUrl, v);
165+
scheduleDiscovery(baseUrl, apiKey, v);
165166
}
166167

167168
function handleModelSelect(v: string) {

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,42 @@ describe('applyLocaleChange', () => {
3434
expect(result).toBe('zh-CN');
3535
});
3636
});
37+
38+
describe('CPA detection regex', () => {
39+
const CPA_REGEX = /^https?:\/\/(localhost|127\.0\.0\.1):8317/;
40+
41+
it('matches http://localhost:8317', () => {
42+
expect('http://localhost:8317').toMatch(CPA_REGEX);
43+
});
44+
45+
it('matches https://127.0.0.1:8317', () => {
46+
expect('https://127.0.0.1:8317').toMatch(CPA_REGEX);
47+
});
48+
49+
it('does not match other ports', () => {
50+
expect('http://localhost:8080').not.toMatch(CPA_REGEX);
51+
expect('https://example.com:8317').not.toMatch(CPA_REGEX);
52+
});
53+
});
54+
55+
describe('CPA detection localStorage dismissal', () => {
56+
const KEY = 'cpa-detection-dismissed-v1';
57+
58+
it('reads and writes dismissal flag', () => {
59+
const getItemSpy = vi.spyOn(Storage.prototype, 'getItem');
60+
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
61+
62+
// Check initial read
63+
expect(window.localStorage.getItem(KEY)).toBeNull();
64+
65+
// Simulate user dismissal
66+
window.localStorage.setItem(KEY, '1');
67+
expect(setItemSpy).toHaveBeenCalledWith(KEY, '1');
68+
69+
// Verify we can read it back
70+
expect(window.localStorage.getItem(KEY)).toBe('1');
71+
72+
getItemSpy.mockRestore();
73+
setItemSpy.mockRestore();
74+
});
75+
});

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, useRef, useState } from 'react';
2728
import type { AppPaths, Preferences, ProviderRow, StorageKind } from '../../../preload/index';
@@ -848,6 +849,47 @@ function WarningsList({ warnings }: { warnings: string[] }) {
848849
);
849850
}
850851

852+
const CPA_DETECTION_DISMISSED_KEY = 'cpa-detection-dismissed-v1';
853+
854+
function LocalCpaImportCard({
855+
onImport,
856+
onDismiss,
857+
}: {
858+
onImport: () => void;
859+
onDismiss: () => void;
860+
}) {
861+
const t = useT();
862+
return (
863+
<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)]">
864+
<Zap className="w-4 h-4 mt-0.5 shrink-0 text-[var(--color-accent)]" aria-hidden="true" />
865+
<div className="flex-1 min-w-0">
866+
<p className="text-[var(--text-sm)] font-medium text-[var(--color-text-primary)] leading-snug">
867+
{t('settings.providers.cpaDetection.title')}
868+
</p>
869+
<p className="text-[var(--text-xs)] text-[var(--color-text-secondary)] mt-0.5 leading-[var(--leading-body)]">
870+
{t('settings.providers.cpaDetection.body')}
871+
</p>
872+
</div>
873+
<div className="flex items-center gap-[var(--space-1_5)] shrink-0">
874+
<button
875+
type="button"
876+
onClick={onImport}
877+
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"
878+
>
879+
{t('settings.providers.cpaDetection.importAction')}
880+
</button>
881+
<button
882+
type="button"
883+
onClick={onDismiss}
884+
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"
885+
>
886+
{t('settings.providers.cpaDetection.dismissAction')}
887+
</button>
888+
</div>
889+
</div>
890+
);
891+
}
892+
851893
function ModelsTab() {
852894
const t = useT();
853895
const config = useCodesignStore((s) => s.config);
@@ -858,6 +900,9 @@ function ModelsTab() {
858900
const [loading, setLoading] = useState(true);
859901
const [showAddCustom, setShowAddCustom] = useState(false);
860902
const [showAddMenu, setShowAddMenu] = useState(false);
903+
const [cpaDetection, setCpaDetection] = useState<
904+
'idle' | 'detecting' | 'available' | 'unavailable'
905+
>('idle');
861906
const [externalConfigs, setExternalConfigs] = useState<{
862907
codex?: { count: number } | undefined;
863908
claudeCode?:
@@ -986,6 +1031,44 @@ function ModelsTab() {
9861031
});
9871032
}, [pushToast, t]);
9881033

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

13061389
<div className="space-y-[var(--space-3)]">
13071390
<ChatgptLoginCard onStatusChange={reloadRows} />
1391+
{cpaDetection === 'available' && (
1392+
<LocalCpaImportCard
1393+
onImport={() => {
1394+
setCustomProviderPreset({
1395+
name: 'CLIProxyAPI',
1396+
baseUrl: 'http://127.0.0.1:8317',
1397+
wire: 'anthropic',
1398+
defaultModel: '',
1399+
});
1400+
setShowAddCustom(true);
1401+
setCpaDetection('unavailable');
1402+
}}
1403+
onDismiss={() => {
1404+
try {
1405+
window.localStorage.setItem(CPA_DETECTION_DISMISSED_KEY, '1');
1406+
} catch {
1407+
// non-fatal
1408+
}
1409+
setCpaDetection('unavailable');
1410+
}}
1411+
/>
1412+
)}
13081413
{externalConfigs !== null &&
13091414
(externalConfigs.codex !== undefined ||
13101415
externalConfigs.claudeCode !== undefined ||

packages/i18n/src/locales/en.json

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

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

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

0 commit comments

Comments
 (0)