diff --git a/.claude/scheduled_tasks.json b/.claude/scheduled_tasks.json index cefe97df..b501d2ed 100644 --- a/.claude/scheduled_tasks.json +++ b/.claude/scheduled_tasks.json @@ -6,14 +6,7 @@ "prompt": "test prompt", "createdAt": 1775497208158, "recurring": true, - "name": "test-name", - "description": "test description", - "folder": "/test/folder", - "model": "claude-opus-4-6", - "permissionMode": "ask", - "worktree": false, - "frequency": "daily", - "scheduledTime": "09:00" + "lastFiredAt": 1775525400260 } ] } diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index c47828c9..1cf682b1 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -1,14 +1,18 @@ +// desktop/src/api/providers.ts + import { api } from './client' import type { - Provider, + SavedProvider, CreateProviderInput, UpdateProviderInput, TestProviderConfigInput, ProviderTestResult, } from '../types/provider' +import type { ProviderPreset } from '../config/providerPresets' -type ProvidersResponse = { providers: Provider[] } -type ProviderResponse = { provider: Provider } +type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null } +type ProviderResponse = { provider: SavedProvider } +type PresetsResponse = { presets: ProviderPreset[] } type TestResultResponse = { result: ProviderTestResult } export const providersApi = { @@ -16,8 +20,8 @@ export const providersApi = { return api.get('/api/providers') }, - get(id: string) { - return api.get(`/api/providers/${id}`) + presets() { + return api.get('/api/providers/presets') }, create(input: CreateProviderInput) { @@ -32,8 +36,12 @@ export const providersApi = { return api.delete<{ ok: true }>(`/api/providers/${id}`) }, - activate(id: string, modelId: string) { - return api.post<{ ok: true }>(`/api/providers/${id}/activate`, { modelId }) + activate(id: string) { + return api.post<{ ok: true }>(`/api/providers/${id}/activate`) + }, + + activateOfficial() { + return api.post<{ ok: true }>('/api/providers/official') }, test(id: string) { diff --git a/desktop/src/config/providerPresets.ts b/desktop/src/config/providerPresets.ts new file mode 100644 index 00000000..df62f5dd --- /dev/null +++ b/desktop/src/config/providerPresets.ts @@ -0,0 +1,69 @@ +// Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch) +// Original work by Jason Young, MIT License + +export type ModelMapping = { + main: string + haiku: string + sonnet: string + opus: string +} + +export type ProviderPreset = { + id: string + name: string + baseUrl: string + defaultModels: ModelMapping + needsApiKey: boolean + websiteUrl: string +} + +export const PROVIDER_PRESETS: ProviderPreset[] = [ + { + id: 'official', + name: 'Claude Official', + baseUrl: '', + defaultModels: { main: '', haiku: '', sonnet: '', opus: '' }, + needsApiKey: false, + websiteUrl: 'https://www.anthropic.com/claude-code', + }, + { + id: 'deepseek', + name: 'DeepSeek', + baseUrl: 'https://api.deepseek.com/anthropic', + defaultModels: { main: 'DeepSeek-V3.2', haiku: 'DeepSeek-V3.2', sonnet: 'DeepSeek-V3.2', opus: 'DeepSeek-V3.2' }, + needsApiKey: true, + websiteUrl: 'https://platform.deepseek.com', + }, + { + id: 'zhipuglm', + name: 'Zhipu GLM', + baseUrl: 'https://open.bigmodel.cn/api/anthropic', + defaultModels: { main: 'glm-5', haiku: 'glm-5', sonnet: 'glm-5', opus: 'glm-5' }, + needsApiKey: true, + websiteUrl: 'https://open.bigmodel.cn', + }, + { + id: 'kimi', + name: 'Kimi', + baseUrl: 'https://api.moonshot.cn/anthropic', + defaultModels: { main: 'kimi-k2.5', haiku: 'kimi-k2.5', sonnet: 'kimi-k2.5', opus: 'kimi-k2.5' }, + needsApiKey: true, + websiteUrl: 'https://platform.moonshot.cn', + }, + { + id: 'minimax', + name: 'MiniMax', + baseUrl: 'https://api.minimaxi.com/anthropic', + defaultModels: { main: 'MiniMax-M2.7', haiku: 'MiniMax-M2.7', sonnet: 'MiniMax-M2.7', opus: 'MiniMax-M2.7' }, + needsApiKey: true, + websiteUrl: 'https://platform.minimaxi.com', + }, + { + id: 'custom', + name: 'Custom', + baseUrl: '', + defaultModels: { main: '', haiku: '', sonnet: '', opus: '' }, + needsApiKey: true, + websiteUrl: '', + }, +] diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index ae307324..48fa4abc 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -5,10 +5,11 @@ import { useUIStore } from '../stores/uiStore' import { Modal } from '../components/shared/Modal' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' -import type { PermissionMode, EffortLevel, ModelInfo } from '../types/settings' -import type { Provider, ProviderModel, UpdateProviderInput, ProviderTestResult } from '../types/provider' +import type { PermissionMode, EffortLevel } from '../types/settings' +import { PROVIDER_PRESETS } from '../config/providerPresets' +import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider' -type SettingsTab = 'providers' | 'model' | 'permissions' | 'general' +type SettingsTab = 'providers' | 'permissions' | 'general' export function Settings() { const [activeTab, setActiveTab] = useState('providers') @@ -31,7 +32,6 @@ export function Settings() { {/* Tab navigation */}
setActiveTab('providers')} /> - setActiveTab('model')} /> setActiveTab('permissions')} /> setActiveTab('general')} />
@@ -39,7 +39,6 @@ export function Settings() { {/* Tab content */}
{activeTab === 'providers' && } - {activeTab === 'model' && } {activeTab === 'permissions' && } {activeTab === 'general' && }
@@ -67,26 +66,21 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri // ─── Provider Settings ────────────────────────────────────── function ProviderSettings() { - const { providers, isLoading, fetchProviders, deleteProvider, activateProvider, testProvider } = useProviderStore() + const { providers, activeId, isLoading, fetchProviders, deleteProvider, activateProvider, activateOfficial, testProvider } = useProviderStore() const fetchSettings = useSettingsStore((s) => s.fetchAll) - const [editingProvider, setEditingProvider] = useState(null) + const [editingProvider, setEditingProvider] = useState(null) const [showCreateModal, setShowCreateModal] = useState(false) - const [activatingProvider, setActivatingProvider] = useState(null) const [testResults, setTestResults] = useState>({}) useEffect(() => { fetchProviders() }, [fetchProviders]) - const handleDelete = async (provider: Provider) => { - if (provider.isActive) return + const handleDelete = async (provider: SavedProvider) => { + if (activeId === provider.id) return if (!window.confirm(`Delete provider "${provider.name}"? This cannot be undone.`)) return - try { - await deleteProvider(provider.id) - } catch (err) { - console.error('Failed to delete provider:', err) - } + await deleteProvider(provider.id).catch(console.error) } - const handleTest = async (provider: Provider) => { + const handleTest = async (provider: SavedProvider) => { setTestResults((r) => ({ ...r, [provider.id]: { loading: true } })) try { const result = await testProvider(provider.id) @@ -96,12 +90,18 @@ function ProviderSettings() { } } - const handleActivate = async (providerId: string, modelId: string) => { - await activateProvider(providerId, modelId) + const handleActivate = async (id: string) => { + await activateProvider(id) await fetchSettings() - setActivatingProvider(null) } + const handleActivateOfficial = async () => { + await activateOfficial() + await fetchSettings() + } + + const isOfficialActive = activeId === null + return (
@@ -115,64 +115,75 @@ function ProviderSettings() {
- {isLoading && providers.length === 0 ? ( -
-
+ {/* Official provider — always visible at top */} +
!isOfficialActive && handleActivateOfficial()} + > + +
+
+ Claude Official + {isOfficialActive && ( + ACTIVE + )} +
+
Anthropic native — no API key required
- ) : providers.length === 0 ? ( -
- dns -

No providers configured

- +
+ + {/* Saved providers */} + {isLoading && providers.length === 0 ? ( +
+
) : (
{providers.map((provider) => { + const isActive = activeId === provider.id const test = testResults[provider.id] + const preset = PROVIDER_PRESETS.find((p) => p.id === provider.presetId) return (
- {/* Status dot */} - - - {/* Info */} +
{provider.name} - {provider.isActive && ( + {preset && preset.id !== 'custom' && ( + {preset.name} + )} + {isActive && ( ACTIVE )}
- {provider.baseUrl} · {provider.models.length} model{provider.models.length !== 1 ? 's' : ''} + {provider.baseUrl} · {provider.models.main}
- {/* Test result inline */} {test && !test.loading && test.result && (
{test.result.success ? `Connected (${test.result.latencyMs}ms)` : `Failed: ${test.result.error}`}
)}
- - {/* Actions */}
- {!provider.isActive && ( - + {!isActive && ( + )} - + - {!provider.isActive && ( - + {!isActive && ( + )}
@@ -181,44 +192,14 @@ function ProviderSettings() {
)} - {/* Create Modal */} - setShowCreateModal(false)} - mode="create" - /> + {/* Create Modal — conditionally rendered so state resets on close */} + {showCreateModal && ( + setShowCreateModal(false)} mode="create" /> + )} {/* Edit Modal */} {editingProvider && ( - setEditingProvider(null)} - mode="edit" - provider={editingProvider} - /> - )} - - {/* Activate — model picker */} - {activatingProvider && ( - setActivatingProvider(null)} title={`Activate ${activatingProvider.name}`} width={420}> -

Select a model to use with this provider:

-
- {activatingProvider.models.map((m) => ( - - ))} -
-
+ setEditingProvider(null)} mode="edit" provider={editingProvider} /> )}
) @@ -230,48 +211,98 @@ type ProviderFormProps = { open: boolean onClose: () => void mode: 'create' | 'edit' - provider?: Provider + provider?: SavedProvider } function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) { const { createProvider, updateProvider, testConfig } = useProviderStore() const fetchSettings = useSettingsStore((s) => s.fetchAll) - const [name, setName] = useState(provider?.name ?? '') - const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? '') + const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official') + const initialPreset = provider ? availablePresets.find((p) => p.id === provider.presetId) || availablePresets.at(-1)! : availablePresets[0] + + const [selectedPreset, setSelectedPreset] = useState(initialPreset) + const [name, setName] = useState(provider?.name ?? initialPreset.name) + const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) const [apiKey, setApiKey] = useState('') const [notes, setNotes] = useState(provider?.notes ?? '') - const [models, setModels] = useState(provider?.models ?? [{ id: '', name: '', description: '', context: '' }]) + const [models, setModels] = useState(provider?.models ?? { ...initialPreset.defaultModels }) const [isSubmitting, setIsSubmitting] = useState(false) const [testResult, setTestResult] = useState(null) const [isTesting, setIsTesting] = useState(false) + const [settingsJson, setSettingsJson] = useState('') + const [settingsJsonError, setSettingsJsonError] = useState(null) - const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.some((m) => m.id.trim() && m.name.trim()) + // Load current settings.json and merge provider env vars + useEffect(() => { + import('../api/settings').then(({ settingsApi }) => { + settingsApi.getUser().then((settings) => { + const merged = { + ...settings, + env: { + ...((settings.env as Record) || {}), + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: apiKey || '(your API key)', + ANTHROPIC_MODEL: models.main, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, + }, + } + setSettingsJson(JSON.stringify(merged, null, 2)) + }).catch(() => { + setSettingsJson(JSON.stringify({}, null, 2)) + }) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedPreset.id]) + + const handlePresetChange = (preset: typeof initialPreset) => { + setSelectedPreset(preset) + setName(preset.name) + setBaseUrl(preset.baseUrl) + setModels({ ...preset.defaultModels }) + setTestResult(null) + } + + const isCustom = selectedPreset.id === 'custom' + const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim() && !settingsJsonError const handleSubmit = async () => { if (!canSubmit) return setIsSubmitting(true) try { - const validModels = models.filter((m) => m.id.trim() && m.name.trim()) + // Write the edited settings.json first (for all presets including official) + if (settingsJson.trim()) { + try { + const parsed = JSON.parse(settingsJson) + const { settingsApi } = await import('../api/settings') + await settingsApi.updateUser(parsed) + } catch { + // JSON validation already prevents this + } + } + if (mode === 'create') { await createProvider({ + presetId: selectedPreset.id, name: name.trim(), - baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), - models: validModels, + baseUrl: baseUrl.trim(), + models, notes: notes.trim() || undefined, }) } else if (provider) { const input: UpdateProviderInput = { name: name.trim(), baseUrl: baseUrl.trim(), - models: validModels, + models, notes: notes.trim() || undefined, } if (apiKey.trim()) input.apiKey = apiKey.trim() await updateProvider(provider.id, input) - if (provider.isActive) await fetchSettings() } + await fetchSettings() onClose() } catch (err) { console.error('Failed to save provider:', err) @@ -281,12 +312,17 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) } const handleTest = async () => { - const firstModel = models.find((m) => m.id.trim()) - if (!baseUrl.trim() || !apiKey.trim() || !firstModel) return + if (!baseUrl.trim() || !models.main.trim()) return setIsTesting(true) setTestResult(null) try { - const result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: firstModel.id.trim() }) + let result: ProviderTestResult + if (mode === 'edit' && provider && !apiKey.trim()) { + result = await useProviderStore.getState().testProvider(provider.id) + } else { + if (!apiKey.trim()) return + result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim() }) + } setTestResult(result) } catch { setTestResult({ success: false, latencyMs: 0, error: 'Request failed' }) @@ -295,18 +331,12 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) } } - const addModel = () => setModels([...models, { id: '', name: '', description: '', context: '' }]) - const removeModel = (index: number) => setModels(models.filter((_, i) => i !== index)) - const updateModel = (index: number, field: keyof ProviderModel, value: string) => { - setModels(models.map((m, i) => i === index ? { ...m, [field]: value } : m)) - } - return ( @@ -317,66 +347,67 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) } >
- setName(e.target.value)} placeholder="e.g. OpenRouter, Anthropic Official" /> - setBaseUrl(e.target.value)} placeholder="https://api.anthropic.com" /> + {/* Preset chips */} + {mode === 'create' && ( +
+ +
+ {availablePresets.map((preset) => ( + + ))} +
+
+ )} + + setName(e.target.value)} placeholder="Provider name" /> + + setNotes(e.target.value)} placeholder="Optional notes..." /> + + {/* Base URL */} + {isCustom || mode === 'edit' ? ( + setBaseUrl(e.target.value)} placeholder="https://api.example.com/anthropic" /> + ) : ( +
+ +
+ {baseUrl} +
+
+ )} + setApiKey(e.target.value)} - placeholder={mode === 'edit' ? '****' : 'sk-ant-...'} + placeholder={mode === 'edit' ? '****' : 'sk-...'} /> - setNotes(e.target.value)} placeholder="Optional notes..." /> - {/* Models */} -
-
- - + {/* Model Mapping */} +
+ +
+ setModels({ ...models, main: e.target.value })} placeholder="Model ID" /> + setModels({ ...models, haiku: e.target.value })} placeholder="Same as main" /> + setModels({ ...models, sonnet: e.target.value })} placeholder="Same as main" /> + setModels({ ...models, opus: e.target.value })} placeholder="Same as main" />
- {models.map((m, i) => ( -
-
- updateModel(i, 'id', e.target.value)} - placeholder="Model ID *" - className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]" - /> - updateModel(i, 'name', e.target.value)} - placeholder="Display name *" - className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]" - /> - updateModel(i, 'description', e.target.value)} - placeholder="Description" - className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]" - /> - updateModel(i, 'context', e.target.value)} - placeholder="Context (e.g. 200k)" - className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]" - /> -
- {models.length > 1 && ( - - )} -
- ))}
{/* Test connection */}
- {testResult && ( @@ -385,143 +416,39 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) )}
+ + {/* Settings JSON — editable, shown for all presets including official */} +
+ +