Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/cpa-autodetect.md
Original file line number Diff line number Diff line change
@@ -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`.
42 changes: 42 additions & 0 deletions apps/desktop/src/renderer/src/components/Settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,48 @@ describe('applyLocaleChange', () => {
expect(result).toBe('zh-CN');
});
});

describe('CPA detection regex', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] These tests validate a local regex constant, not the ModelsTab detection effect/interaction path. Please add behavior tests around render + probe + dismiss/import.

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<string, string>();
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)';

Expand Down
105 changes: 105 additions & 0 deletions apps/desktop/src/renderer/src/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<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)]">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] This new card introduces raw numeric utilities (px-3, py-2.5, gap-3, w-4, h-7, etc.). Per project constraints, UI values should come from packages/ui tokens. Please replace these with tokenized var(--space-*)/control-size vars.

<Zap className="w-4 h-4 mt-0.5 shrink-0 text-[var(--color-accent)]" aria-hidden="true" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] New UI uses raw numeric utilities (w-4, h-4, mt-0.5) instead of tokenized values. Please switch to var(--space-*) / control-size tokens.

<div className="flex-1 min-w-0">
<p className="text-[var(--text-sm)] font-medium text-[var(--color-text-primary)] leading-snug">
{t('settings.providers.cpaDetection.title')}
</p>
<p className="text-[var(--text-xs)] text-[var(--color-text-secondary)] mt-0.5 leading-[var(--leading-body)]">
{t('settings.providers.cpaDetection.body')}
</p>
</div>
<div className="flex items-center gap-[var(--space-1_5)] shrink-0">
<button
type="button"
onClick={onImport}
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"
>
{t('settings.providers.cpaDetection.importAction')}
</button>
<button
type="button"
onClick={onDismiss}
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"
>
{t('settings.providers.cpaDetection.dismissAction')}
</button>
</div>
</div>
);
}

function ModelsTab() {
const t = useT();
const config = useCodesignStore((s) => s.config);
Expand All @@ -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?:
Expand Down Expand Up @@ -994,6 +1039,44 @@ function ModelsTab() {
});
}, [pushToast, t]);

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this new detection effect adds multiple branches (dismissed, already-configured, probe success/failure) but there is no new/updated Vitest in Settings.test.ts. Please add focused tests for banner visibility, dismiss persistence behavior, and import prefill flow.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] New CPA detection flow (detect/success/failure/dismiss/import) lacks Vitest coverage in this PR. Please add at least one focused test for the branch logic and dismissal persistence.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocker] This catch silently ignores localStorage read failures. Project constraint requires surfacing errors with context instead of silent fallback.

// 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 ?? ''),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Regex is not bounded after :8317, so URLs like http://localhost:83170 also match and suppress the detection banner. Consider :8317(?:/|$) to avoid false positives.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Regex is not bounded after :8317; http://localhost:83170 will match and incorrectly suppress the banner.

);
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', {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] This error path is for CPA detection, but the toast title uses settings.imageGen.toast.loadFailed ("Image generation settings failed to load"). Please switch to a provider/connection failure key so users get accurate context.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] This CPA-detection error path uses settings.imageGen.toast.loadFailed, which shows the wrong feature context to users.

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([
Expand Down Expand Up @@ -1313,6 +1396,28 @@ function ModelsTab() {

<div className="space-y-[var(--space-3)]">
<ChatgptLoginCard onStatusChange={reloadRows} />
{cpaDetection === 'available' && (
<LocalCpaImportCard
onImport={() => {
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 ||
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "默认(自动)",
Expand Down
Loading