From e9fa36ab8697c59845538cc02b709f417aff717a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 10:54:44 +0800 Subject: [PATCH 01/15] refactor: remove Model tab, move effort level to General, fix test button in edit mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant Model tab — model config is handled by Providers - Move Effort Level control to General tab - Fix Test Connection disabled in edit mode — use testProvider(id) when no new API key entered Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/scheduled_tasks.json | 9 +- desktop/src/pages/Settings.tsx | 181 +++++++-------------------------- 2 files changed, 39 insertions(+), 151 deletions(-) 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/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index ae307324..5ad3777d 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -5,10 +5,10 @@ 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 { PermissionMode, EffortLevel } from '../types/settings' import type { Provider, ProviderModel, UpdateProviderInput, ProviderTestResult } from '../types/provider' -type SettingsTab = 'providers' | 'model' | 'permissions' | 'general' +type SettingsTab = 'providers' | 'permissions' | 'general' export function Settings() { const [activeTab, setActiveTab] = useState('providers') @@ -31,7 +31,6 @@ export function Settings() { {/* Tab navigation */}
setActiveTab('providers')} /> - setActiveTab('model')} /> setActiveTab('permissions')} /> setActiveTab('general')} />
@@ -39,7 +38,6 @@ export function Settings() { {/* Tab content */}
{activeTab === 'providers' && } - {activeTab === 'model' && } {activeTab === 'permissions' && } {activeTab === 'general' && }
@@ -282,11 +280,18 @@ 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() || !firstModel) 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()) { + // Edit mode without new key — test saved provider directly + result = await useProviderStore.getState().testProvider(provider.id) + } else { + if (!apiKey.trim()) return + result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: firstModel.id.trim() }) + } setTestResult(result) } catch { setTestResult({ success: false, latencyMs: 0, error: 'Request failed' }) @@ -376,7 +381,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) {/* Test connection */}
- {testResult && ( @@ -390,138 +395,6 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) ) } -// ─── Model Settings ────────────────────────────────────── - -function ModelSettings() { - const { availableModels, currentModel, effortLevel, activeProviderName, setModel, setEffort, fetchAll } = useSettingsStore() - const [customModelId, setCustomModelId] = useState('') - const [showCustom, setShowCustom] = useState(false) - - useEffect(() => { fetchAll() }, [fetchAll]) - - const handleSelectModel = async (model: ModelInfo) => { - await setModel(model.id) - } - - const handleCustomModel = async () => { - const id = customModelId.trim() - if (!id) return - await setModel(id) - setShowCustom(false) - setCustomModelId('') - } - - const MODEL_ICONS: Record = { - 'opus': 'diamond', - 'sonnet': 'auto_awesome', - 'haiku': 'bolt', - } as const - - const getModelIcon = (id: string) => { - if (id.includes('opus')) return MODEL_ICONS['opus'] - if (id.includes('sonnet')) return MODEL_ICONS['sonnet'] - if (id.includes('haiku')) return MODEL_ICONS['haiku'] - return 'smart_toy' - } - - const EFFORT_LABELS: Record = { - low: 'Low', - medium: 'Medium', - high: 'High', - max: 'Max', - } - - return ( -
-

Model

-

- Choose the AI model for new conversations. - {activeProviderName && ( - - (via {activeProviderName}) - - )} -

- -
- {availableModels.map((model) => { - const isSelected = currentModel?.id === model.id - return ( - - ) - })} - - {/* Custom model */} - {showCustom ? ( -
- setCustomModelId(e.target.value)} - placeholder="Enter model ID (e.g. claude-sonnet-4-6-20250514)" - className="flex-1 text-sm bg-transparent outline-none text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)]" - onKeyDown={(e) => e.key === 'Enter' && handleCustomModel()} - /> - - -
- ) : ( - - )} -
- - {/* Effort level */} -

Effort Level

-

Controls how much computation the model uses.

-
- {(['low', 'medium', 'high', 'max'] as EffortLevel[]).map((level) => ( - - ))} -
-
- ) -} // ─── Permission Settings ────────────────────────────────────── @@ -574,12 +447,34 @@ function PermissionSettings() { // ─── General Settings ────────────────────────────────────── function GeneralSettings() { + const { effortLevel, setEffort } = useSettingsStore() + + const EFFORT_LABELS: Record = { + low: 'Low', + medium: 'Medium', + high: 'High', + max: 'Max', + } + return (
-

General

-

- API configuration is now managed through the Providers tab. -

+

Effort Level

+

Controls how much computation the model uses.

+
+ {(['low', 'medium', 'high', 'max'] as EffortLevel[]).map((level) => ( + + ))} +
) } From ec78c066f9c6627f9365333f5d129d2491de7e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 11:11:34 +0800 Subject: [PATCH 02/15] docs: add provider preset redesign spec Preset-based provider configuration inspired by cc-switch. Presets: Official, DeepSeek, ZhipuGLM, Kimi, MiniMax, Custom. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-07-provider-preset-redesign.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-07-provider-preset-redesign.md diff --git a/docs/superpowers/specs/2026-04-07-provider-preset-redesign.md b/docs/superpowers/specs/2026-04-07-provider-preset-redesign.md new file mode 100644 index 00000000..b5900c6f --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-provider-preset-redesign.md @@ -0,0 +1,184 @@ +# Provider Preset Redesign + +## Overview + +Replace the manual provider configuration UI with a preset-based system inspired by [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, Copyright 2025 Jason Young). Users select a preset provider, enter an API key, and the system writes the correct env vars to `~/.claude/settings.json`. + +## Presets + +Six presets, each with pre-configured baseUrl and model mapping: + +| ID | Name | baseUrl | Main Model | API Key Required | +|---|---|---|---|---| +| `official` | Claude Official | _(clear env)_ | _(default)_ | No | +| `deepseek` | DeepSeek | `https://api.deepseek.com/anthropic` | `DeepSeek-V3.2` | Yes | +| `zhipuglm` | Zhipu GLM | `https://open.bigmodel.cn/api/anthropic` | `glm-5` | Yes | +| `kimi` | Kimi | `https://api.moonshot.cn/anthropic` | `kimi-k2.5` | Yes | +| `minimax` | MiniMax | `https://api.minimaxi.com/anthropic` | `MiniMax-M2.7` | Yes | +| `custom` | Custom | User-provided | User-provided | Yes | + +Each preset defaults Haiku/Sonnet/Opus to the same as main model. Users can override via advanced settings. + +## Data Flow + +``` +Preset Provider List (frontend hardcoded) + | +User selects preset -> enters API Key -> optionally customizes model mapping + | +Save -> write ~/.claude/cc-haha/providers.json (index of saved providers) + -> write ~/.claude/settings.json env block (active provider only) + | +Claude Code reads settings.json on next session +``` + +## Storage + +### Index File: `~/.claude/cc-haha/providers.json` + +Stores all saved provider profiles for switching. Lightweight — no full settingsConfig, just what's needed to reconstruct the env block. + +```json +{ + "activeId": "deepseek-1", + "providers": [ + { + "id": "deepseek-1", + "presetId": "deepseek", + "name": "DeepSeek", + "apiKey": "sk-xxx", + "baseUrl": "https://api.deepseek.com/anthropic", + "models": { + "main": "DeepSeek-V3.2", + "haiku": "DeepSeek-V3.2", + "sonnet": "DeepSeek-V3.2", + "opus": "DeepSeek-V3.2" + }, + "notes": "" + } + ] +} +``` + +For `custom` preset, `baseUrl` is user-provided. For other presets, `baseUrl` comes from the preset default but can be overridden. + +### settings.json env block (written on activation) + +Third-party provider activation writes 6 keys: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", + "ANTHROPIC_AUTH_TOKEN": "sk-xxx", + "ANTHROPIC_MODEL": "DeepSeek-V3.2", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "DeepSeek-V3.2", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "DeepSeek-V3.2", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "DeepSeek-V3.2" + } +} +``` + +Official provider activation removes these 6 keys from env, preserving all other settings.json fields. + +## Frontend UI + +### ProviderSettings Component + +Replaces current provider list + modal with: + +1. **Preset chip bar** — horizontal row: `官方` | `DeepSeek` | `ZhipuGLM` | `Kimi` | `MiniMax` | `自定义` +2. **Saved provider list** — cards showing each saved provider with active badge, switch/edit/delete actions +3. **Add/Edit form** (inline or modal): + - Preset selector (chips, pre-fills form) + - API Key input (not shown for official) + - Base URL input (only shown for custom preset, or editable in advanced) + - Model mapping (collapsible advanced section): Main / Haiku / Sonnet / Opus inputs, pre-filled from preset + - Notes (optional) + - Test Connection button + - Save / Cancel buttons + +### Activation Flow + +- Click "Activate" on a saved provider card +- Backend writes env to settings.json +- UI updates active badge +- Official preset clears env keys + +### Delete Provider + +- Cannot delete the active provider (must switch first, or switch to official) +- Confirmation dialog before delete + +## Backend Changes + +### New: `src/server/config/providerPresets.ts` + +Shared preset definitions used by both API validation and settings generation. + +```typescript +export type ProviderPreset = { + id: string + name: string + baseUrl: string + models: { main: string; haiku: string; sonnet: string; opus: string } + needsApiKey: boolean + websiteUrl: string +} +``` + +### Modified: `src/server/services/providerService.ts` + +- Change storage path from `~/.claude/providers.json` to `~/.claude/cc-haha/providers.json` +- Simplify provider data structure (remove ProviderModel array, use models record) +- `syncToSettings()` writes all 6 env keys +- New `clearProviderFromSettings()` for official preset — removes the 6 env keys +- Remove `activateProvider(id, modelId)` modelId param — models stored in provider config +- Ensure `~/.claude/cc-haha/` directory is created on first write + +### Modified: `src/server/api/providers.ts` + +- Update activate endpoint: `POST /api/providers/:id/activate` (no body needed) +- Add preset list endpoint: `GET /api/providers/presets` (returns preset definitions) + +### Deleted after migration + +- Old `~/.claude/providers.json` is not migrated — users reconfigure (acceptable since this is pre-release) + +## Frontend File Changes + +| Action | File | Description | +|--------|------|-------------| +| New | `desktop/src/config/providerPresets.ts` | Frontend preset definitions | +| Rewrite | `desktop/src/pages/Settings.tsx` ProviderSettings section | Preset-based UI | +| Modify | `desktop/src/types/provider.ts` | Simplify types (remove ProviderModel, add models record) | +| Modify | `desktop/src/stores/providerStore.ts` | Align with new API shape | +| Modify | `desktop/src/api/providers.ts` | Update activate call (remove modelId), add presets endpoint | + +## Backend File Changes + +| Action | File | Description | +|--------|------|-------------| +| New | `src/server/config/providerPresets.ts` | Preset definitions | +| Modify | `src/server/services/providerService.ts` | New storage path, simplified model structure, full env write | +| Modify | `src/server/types/provider.ts` | Simplify schema | +| Modify | `src/server/api/providers.ts` | Update routes | + +## Attribution + +```typescript +// Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch) +// Original work by Jason Young, MIT License +``` + +Added as comment header in preset configuration files. + +## Testing + +1. Select DeepSeek preset, enter API key, save — verify `~/.claude/cc-haha/providers.json` and `~/.claude/settings.json` written correctly +2. Switch to Kimi — verify settings.json updated, index updated +3. Switch to Official — verify env keys removed from settings.json +4. Edit a provider's model mapping — verify changes persisted +5. Test Connection — verify connectivity check works +6. Delete non-active provider — verify removed from index +7. Custom preset — verify baseUrl input appears, full flow works From 468a2853e0286a448557b24f1ee35fa67dc6973a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 11:17:10 +0800 Subject: [PATCH 03/15] docs: add provider preset redesign implementation plan 7 tasks: presets config, backend types/service/API rewrite, frontend types/API/store/UI rewrite. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-07-provider-preset-redesign.md | 1333 +++++++++++++++++ 1 file changed, 1333 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-provider-preset-redesign.md diff --git a/docs/superpowers/plans/2026-04-07-provider-preset-redesign.md b/docs/superpowers/plans/2026-04-07-provider-preset-redesign.md new file mode 100644 index 00000000..9dd01122 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-provider-preset-redesign.md @@ -0,0 +1,1333 @@ +# Provider Preset Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace manual provider configuration with preset-based system — users pick a provider, enter API key, done. + +**Architecture:** Frontend preset definitions hardcoded in a shared config file. Backend stores saved providers in `~/.claude/cc-haha/providers.json` (lightweight index). On activation, backend writes 6 env keys to `~/.claude/settings.json`. Official preset clears those keys. + +**Tech Stack:** TypeScript, Zod, Zustand, React, Tailwind CSS + +**Spec:** `docs/superpowers/specs/2026-04-07-provider-preset-redesign.md` + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `src/server/config/providerPresets.ts` | Preset definitions (shared backend) | +| Create | `desktop/src/config/providerPresets.ts` | Preset definitions (frontend copy) | +| Rewrite | `src/server/types/provider.ts` | Simplified schemas — models as record not array | +| Rewrite | `src/server/services/providerService.ts` | New storage path, full env write, official clear | +| Modify | `src/server/api/providers.ts` | Simplified activate (no modelId), add presets endpoint | +| Rewrite | `desktop/src/types/provider.ts` | Simplified frontend types | +| Rewrite | `desktop/src/api/providers.ts` | Updated API calls | +| Rewrite | `desktop/src/stores/providerStore.ts` | Aligned with new API shape | +| Rewrite | `desktop/src/pages/Settings.tsx` (ProviderSettings section) | Preset chips + simplified form | + +--- + +### Task 1: Create Preset Definitions + +**Files:** +- Create: `src/server/config/providerPresets.ts` +- Create: `desktop/src/config/providerPresets.ts` + +- [ ] **Step 1: Create backend preset file** + +```typescript +// src/server/config/providerPresets.ts + +// 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: '', + }, +] +``` + +- [ ] **Step 2: Create frontend preset file (identical content)** + +Copy `src/server/config/providerPresets.ts` to `desktop/src/config/providerPresets.ts`. Same content — the type + array are plain data, no server dependencies. + +- [ ] **Step 3: Commit** + +```bash +git add src/server/config/providerPresets.ts desktop/src/config/providerPresets.ts +git commit -m "feat: add provider preset definitions (Official, DeepSeek, ZhipuGLM, Kimi, MiniMax, Custom)" +``` + +--- + +### Task 2: Rewrite Backend Types + +**Files:** +- Rewrite: `src/server/types/provider.ts` + +- [ ] **Step 1: Rewrite provider types with simplified model structure** + +Replace `src/server/types/provider.ts` entirely: + +```typescript +// src/server/types/provider.ts + +/** + * Provider types — preset-based provider configuration. + * + * Providers are stored in ~/.claude/cc-haha/providers.json as a lightweight index. + * The active provider's env vars are written to ~/.claude/settings.json. + */ + +import { z } from 'zod' + +export const ModelMappingSchema = z.object({ + main: z.string(), + haiku: z.string(), + sonnet: z.string(), + opus: z.string(), +}) + +export const SavedProviderSchema = z.object({ + id: z.string(), + presetId: z.string(), + name: z.string().min(1), + apiKey: z.string(), + baseUrl: z.string(), + models: ModelMappingSchema, + notes: z.string().optional(), +}) + +export const ProvidersIndexSchema = z.object({ + activeId: z.string().nullable(), + providers: z.array(SavedProviderSchema), +}) + +export const CreateProviderSchema = z.object({ + presetId: z.string().min(1), + name: z.string().min(1), + apiKey: z.string(), + baseUrl: z.string(), + models: ModelMappingSchema, + notes: z.string().optional(), +}) + +export const UpdateProviderSchema = z.object({ + name: z.string().min(1).optional(), + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + models: ModelMappingSchema.optional(), + notes: z.string().optional(), +}) + +export const TestProviderSchema = z.object({ + baseUrl: z.string().url(), + apiKey: z.string().min(1), + modelId: z.string().min(1), +}) + +// TypeScript types +export type ModelMapping = z.infer +export type SavedProvider = z.infer +export type ProvidersIndex = z.infer +export type CreateProviderInput = z.infer +export type UpdateProviderInput = z.infer +export type TestProviderInput = z.infer + +export interface ProviderTestResult { + success: boolean + latencyMs: number + error?: string + modelUsed?: string + httpStatus?: number +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/server/types/provider.ts +git commit -m "refactor: rewrite provider types — simplified model mapping, preset-based structure" +``` + +--- + +### Task 3: Rewrite Backend Provider Service + +**Files:** +- Rewrite: `src/server/services/providerService.ts` + +- [ ] **Step 1: Rewrite providerService.ts** + +Replace `src/server/services/providerService.ts` entirely: + +```typescript +// src/server/services/providerService.ts + +/** + * Provider Service — preset-based provider configuration + * + * Storage: ~/.claude/cc-haha/providers.json (lightweight index) + * Active provider env vars written to ~/.claude/settings.json + */ + +import * as fs from 'fs/promises' +import * as path from 'path' +import * as os from 'os' +import { ApiError } from '../middleware/errorHandler.js' +import type { + SavedProvider, + ProvidersIndex, + CreateProviderInput, + UpdateProviderInput, + TestProviderInput, + ProviderTestResult, +} from '../types/provider.js' + +/** The 6 env keys we manage in settings.json */ +const MANAGED_ENV_KEYS = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', +] as const + +const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] } + +export class ProviderService { + private getConfigDir(): string { + return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + } + + private getCcHahaDir(): string { + return path.join(this.getConfigDir(), 'cc-haha') + } + + private getIndexPath(): string { + return path.join(this.getCcHahaDir(), 'providers.json') + } + + private getSettingsPath(): string { + return path.join(this.getConfigDir(), 'settings.json') + } + + // --- File I/O (atomic write) --- + + private async readIndex(): Promise { + try { + const raw = await fs.readFile(this.getIndexPath(), 'utf-8') + return JSON.parse(raw) as ProvidersIndex + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { ...DEFAULT_INDEX, providers: [] } + } + throw ApiError.internal(`Failed to read providers index: ${err}`) + } + } + + private async writeIndex(index: ProvidersIndex): Promise { + const filePath = this.getIndexPath() + const dir = path.dirname(filePath) + await fs.mkdir(dir, { recursive: true }) + + const tmpFile = `${filePath}.tmp.${Date.now()}` + try { + await fs.writeFile(tmpFile, JSON.stringify(index, null, 2) + '\n', 'utf-8') + await fs.rename(tmpFile, filePath) + } catch (err) { + await fs.unlink(tmpFile).catch(() => {}) + throw ApiError.internal(`Failed to write providers index: ${err}`) + } + } + + private async readSettings(): Promise> { + try { + const raw = await fs.readFile(this.getSettingsPath(), 'utf-8') + return JSON.parse(raw) as Record + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw ApiError.internal(`Failed to read settings.json: ${err}`) + } + } + + private async writeSettings(settings: Record): Promise { + const filePath = this.getSettingsPath() + const dir = path.dirname(filePath) + await fs.mkdir(dir, { recursive: true }) + + const tmpFile = `${filePath}.tmp.${Date.now()}` + try { + await fs.writeFile(tmpFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8') + await fs.rename(tmpFile, filePath) + } catch (err) { + await fs.unlink(tmpFile).catch(() => {}) + throw ApiError.internal(`Failed to write settings.json: ${err}`) + } + } + + // --- CRUD --- + + async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> { + const index = await this.readIndex() + return { providers: index.providers, activeId: index.activeId } + } + + async getProvider(id: string): Promise { + const index = await this.readIndex() + const provider = index.providers.find((p) => p.id === id) + if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) + return provider + } + + async addProvider(input: CreateProviderInput): Promise { + const index = await this.readIndex() + + const provider: SavedProvider = { + id: crypto.randomUUID(), + presetId: input.presetId, + name: input.name, + apiKey: input.apiKey, + baseUrl: input.baseUrl, + models: input.models, + ...(input.notes !== undefined && { notes: input.notes }), + } + + index.providers.push(provider) + await this.writeIndex(index) + return provider + } + + async updateProvider(id: string, input: UpdateProviderInput): Promise { + const index = await this.readIndex() + const idx = index.providers.findIndex((p) => p.id === id) + if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`) + + const existing = index.providers[idx] + const updated: SavedProvider = { + ...existing, + ...(input.name !== undefined && { name: input.name }), + ...(input.apiKey !== undefined && { apiKey: input.apiKey }), + ...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }), + ...(input.models !== undefined && { models: input.models }), + ...(input.notes !== undefined && { notes: input.notes }), + } + + index.providers[idx] = updated + await this.writeIndex(index) + + // Re-sync if this is the active provider + if (index.activeId === id) { + await this.syncToSettings(updated) + } + + return updated + } + + async deleteProvider(id: string): Promise { + const index = await this.readIndex() + const idx = index.providers.findIndex((p) => p.id === id) + if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`) + + if (index.activeId === id) { + throw ApiError.conflict('Cannot delete the active provider. Switch to another provider first.') + } + + index.providers.splice(idx, 1) + await this.writeIndex(index) + } + + // --- Activation --- + + async activateProvider(id: string): Promise { + const index = await this.readIndex() + const provider = index.providers.find((p) => p.id === id) + if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) + + index.activeId = id + await this.writeIndex(index) + + if (provider.presetId === 'official') { + await this.clearProviderFromSettings() + } else { + await this.syncToSettings(provider) + } + } + + /** Activate official — clear all managed env keys */ + async activateOfficial(): Promise { + const index = await this.readIndex() + index.activeId = null + await this.writeIndex(index) + await this.clearProviderFromSettings() + } + + // --- Settings sync --- + + private async syncToSettings(provider: SavedProvider): Promise { + const settings = await this.readSettings() + const existingEnv = (settings.env as Record) || {} + + settings.env = { + ...existingEnv, + ANTHROPIC_BASE_URL: provider.baseUrl, + ANTHROPIC_AUTH_TOKEN: provider.apiKey, + ANTHROPIC_MODEL: provider.models.main, + ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku, + ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet, + ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus, + } + + await this.writeSettings(settings) + } + + private async clearProviderFromSettings(): Promise { + const settings = await this.readSettings() + const env = (settings.env as Record) || {} + + for (const key of MANAGED_ENV_KEYS) { + delete env[key] + } + + settings.env = env + // Clean up empty env object + if (Object.keys(env).length === 0) { + delete settings.env + } + + await this.writeSettings(settings) + } + + // --- Test --- + + async testProvider(id: string): Promise { + const provider = await this.getProvider(id) + if (!provider.baseUrl || !provider.apiKey) { + return { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' } + } + return this.testProviderConfig({ + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + modelId: provider.models.main, + }) + } + + async testProviderConfig(input: TestProviderInput): Promise { + const url = `${input.baseUrl.replace(/\/+$/, '')}/v1/messages` + const start = Date.now() + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': input.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: input.modelId, + max_tokens: 1, + messages: [{ role: 'user', content: 'Hi' }], + }), + signal: AbortSignal.timeout(15000), + }) + + const latencyMs = Date.now() - start + + if (response.ok) { + return { success: true, latencyMs, modelUsed: input.modelId, httpStatus: response.status } + } + + let errorMessage = `HTTP ${response.status}` + try { + const body = (await response.json()) as Record + if (body.error && typeof body.error === 'object') { + errorMessage = ((body.error as Record).message as string) || errorMessage + } else if (typeof body.message === 'string') { + errorMessage = body.message + } + } catch { + errorMessage = `HTTP ${response.status} ${response.statusText}` + } + + return { success: false, latencyMs, error: errorMessage, modelUsed: input.modelId, httpStatus: response.status } + } catch (err: unknown) { + const latencyMs = Date.now() - start + if (err instanceof DOMException && err.name === 'TimeoutError') { + return { success: false, latencyMs, error: 'Request timed out after 15 seconds', modelUsed: input.modelId } + } + return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: input.modelId } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/server/services/providerService.ts +git commit -m "refactor: rewrite providerService — cc-haha storage, full env sync, official clear" +``` + +--- + +### Task 4: Update Backend API Routes + +**Files:** +- Modify: `src/server/api/providers.ts` + +- [ ] **Step 1: Rewrite providers API** + +Replace `src/server/api/providers.ts` entirely: + +```typescript +// src/server/api/providers.ts + +/** + * Providers REST API + * + * GET /api/providers — list all saved providers + activeId + * GET /api/providers/presets — list available presets + * POST /api/providers — add a provider + * PUT /api/providers/:id — update a provider + * DELETE /api/providers/:id — delete a provider + * POST /api/providers/:id/activate — activate a saved provider + * POST /api/providers/official — activate official (clear env) + * POST /api/providers/:id/test — test a saved provider + * POST /api/providers/test — test unsaved config + */ + +import { z } from 'zod' +import { ProviderService } from '../services/providerService.js' +import { PROVIDER_PRESETS } from '../config/providerPresets.js' +import { + CreateProviderSchema, + UpdateProviderSchema, + TestProviderSchema, +} from '../types/provider.js' +import { ApiError, errorResponse } from '../middleware/errorHandler.js' + +const providerService = new ProviderService() + +function maskApiKey(key: string): string { + if (key.length <= 8) return '****' + return key.slice(0, 4) + '****' + key.slice(-4) +} + +function sanitizeProvider(provider: Record): Record { + if (typeof provider.apiKey === 'string') { + return { ...provider, apiKey: maskApiKey(provider.apiKey) } + } + return provider +} + +export async function handleProvidersApi( + req: Request, + _url: URL, + segments: string[], +): Promise { + try { + const id = segments[2] + const action = segments[3] + + // POST /api/providers/test + if (id === 'test' && req.method === 'POST') { + return await handleTestUnsaved(req) + } + + // GET /api/providers/presets + if (id === 'presets' && req.method === 'GET') { + return Response.json({ presets: PROVIDER_PRESETS }) + } + + // POST /api/providers/official + if (id === 'official' && req.method === 'POST') { + await providerService.activateOfficial() + return Response.json({ ok: true }) + } + + // /api/providers (no ID) + if (!id) { + if (req.method === 'GET') { + const { providers, activeId } = await providerService.listProviders() + return Response.json({ providers: providers.map(sanitizeProvider), activeId }) + } + if (req.method === 'POST') { + return await handleCreate(req) + } + throw methodNotAllowed(req.method) + } + + // /api/providers/:id/activate + if (action === 'activate') { + if (req.method !== 'POST') throw methodNotAllowed(req.method) + await providerService.activateProvider(id) + return Response.json({ ok: true }) + } + + // /api/providers/:id/test + if (action === 'test') { + if (req.method !== 'POST') throw methodNotAllowed(req.method) + const result = await providerService.testProvider(id) + return Response.json({ result }) + } + + // /api/providers/:id + if (req.method === 'GET') { + const provider = await providerService.getProvider(id) + return Response.json({ provider: sanitizeProvider(provider) }) + } + if (req.method === 'PUT') { + return await handleUpdate(req, id) + } + if (req.method === 'DELETE') { + await providerService.deleteProvider(id) + return Response.json({ ok: true }) + } + + throw methodNotAllowed(req.method) + } catch (error) { + return errorResponse(error) + } +} + +async function handleCreate(req: Request): Promise { + const body = await parseJsonBody(req) + try { + const input = CreateProviderSchema.parse(body) + const provider = await providerService.addProvider(input) + return Response.json({ provider }, { status: 201 }) + } catch (err) { + if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + throw err + } +} + +async function handleUpdate(req: Request, id: string): Promise { + const body = await parseJsonBody(req) + try { + const input = UpdateProviderSchema.parse(body) + const provider = await providerService.updateProvider(id, input) + return Response.json({ provider }) + } catch (err) { + if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + throw err + } +} + +async function handleTestUnsaved(req: Request): Promise { + const body = await parseJsonBody(req) + try { + const input = TestProviderSchema.parse(body) + const result = await providerService.testProviderConfig(input) + return Response.json({ result }) + } catch (err) { + if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + throw err + } +} + +async function parseJsonBody(req: Request): Promise> { + try { + return (await req.json()) as Record + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} + +function methodNotAllowed(method: string): ApiError { + return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED') +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/server/api/providers.ts +git commit -m "refactor: update provider API — add presets endpoint, simplify activate, add official route" +``` + +--- + +### Task 5: Rewrite Frontend Types + API + Store + +**Files:** +- Rewrite: `desktop/src/types/provider.ts` +- Rewrite: `desktop/src/api/providers.ts` +- Rewrite: `desktop/src/stores/providerStore.ts` + +- [ ] **Step 1: Rewrite frontend types** + +Replace `desktop/src/types/provider.ts`: + +```typescript +// desktop/src/types/provider.ts + +export type ModelMapping = { + main: string + haiku: string + sonnet: string + opus: string +} + +export type SavedProvider = { + id: string + presetId: string + name: string + apiKey: string // masked from server + baseUrl: string + models: ModelMapping + notes?: string +} + +export type CreateProviderInput = { + presetId: string + name: string + apiKey: string + baseUrl: string + models: ModelMapping + notes?: string +} + +export type UpdateProviderInput = { + name?: string + apiKey?: string + baseUrl?: string + models?: ModelMapping + notes?: string +} + +export type TestProviderConfigInput = { + baseUrl: string + apiKey: string + modelId: string +} + +export type ProviderTestResult = { + success: boolean + latencyMs: number + error?: string + modelUsed?: string + httpStatus?: number +} +``` + +- [ ] **Step 2: Rewrite frontend API client** + +Replace `desktop/src/api/providers.ts`: + +```typescript +// desktop/src/api/providers.ts + +import { api } from './client' +import type { + SavedProvider, + CreateProviderInput, + UpdateProviderInput, + TestProviderConfigInput, + ProviderTestResult, +} from '../types/provider' +import type { ProviderPreset } from '../config/providerPresets' + +type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null } +type ProviderResponse = { provider: SavedProvider } +type PresetsResponse = { presets: ProviderPreset[] } +type TestResultResponse = { result: ProviderTestResult } + +export const providersApi = { + list() { + return api.get('/api/providers') + }, + + presets() { + return api.get('/api/providers/presets') + }, + + create(input: CreateProviderInput) { + return api.post('/api/providers', input) + }, + + update(id: string, input: UpdateProviderInput) { + return api.put(`/api/providers/${id}`, input) + }, + + delete(id: string) { + return api.delete<{ ok: true }>(`/api/providers/${id}`) + }, + + activate(id: string) { + return api.post<{ ok: true }>(`/api/providers/${id}/activate`) + }, + + activateOfficial() { + return api.post<{ ok: true }>('/api/providers/official') + }, + + test(id: string) { + return api.post(`/api/providers/${id}/test`) + }, + + testConfig(input: TestProviderConfigInput) { + return api.post('/api/providers/test', input) + }, +} +``` + +- [ ] **Step 3: Rewrite frontend store** + +Replace `desktop/src/stores/providerStore.ts`: + +```typescript +// desktop/src/stores/providerStore.ts + +import { create } from 'zustand' +import { providersApi } from '../api/providers' +import type { + SavedProvider, + CreateProviderInput, + UpdateProviderInput, + TestProviderConfigInput, + ProviderTestResult, +} from '../types/provider' + +type ProviderStore = { + providers: SavedProvider[] + activeId: string | null + isLoading: boolean + + fetchProviders: () => Promise + createProvider: (input: CreateProviderInput) => Promise + updateProvider: (id: string, input: UpdateProviderInput) => Promise + deleteProvider: (id: string) => Promise + activateProvider: (id: string) => Promise + activateOfficial: () => Promise + testProvider: (id: string) => Promise + testConfig: (input: TestProviderConfigInput) => Promise +} + +export const useProviderStore = create((set, get) => ({ + providers: [], + activeId: null, + isLoading: false, + + fetchProviders: async () => { + set({ isLoading: true }) + try { + const { providers, activeId } = await providersApi.list() + set({ providers, activeId, isLoading: false }) + } catch { + set({ isLoading: false }) + } + }, + + createProvider: async (input) => { + const { provider } = await providersApi.create(input) + await get().fetchProviders() + return provider + }, + + updateProvider: async (id, input) => { + const { provider } = await providersApi.update(id, input) + await get().fetchProviders() + return provider + }, + + deleteProvider: async (id) => { + await providersApi.delete(id) + await get().fetchProviders() + }, + + activateProvider: async (id) => { + await providersApi.activate(id) + await get().fetchProviders() + }, + + activateOfficial: async () => { + await providersApi.activateOfficial() + await get().fetchProviders() + }, + + testProvider: async (id) => { + const { result } = await providersApi.test(id) + return result + }, + + testConfig: async (input) => { + const { result } = await providersApi.testConfig(input) + return result + }, +})) +``` + +- [ ] **Step 4: Commit** + +```bash +git add desktop/src/types/provider.ts desktop/src/api/providers.ts desktop/src/stores/providerStore.ts +git commit -m "refactor: rewrite frontend provider types, API client, and store for preset system" +``` + +--- + +### Task 6: Rewrite ProviderSettings UI in Settings.tsx + +**Files:** +- Rewrite: `desktop/src/pages/Settings.tsx` (ProviderSettings + ProviderFormModal sections only, lines 65-396) + +- [ ] **Step 1: Rewrite ProviderSettings and ProviderFormModal** + +Replace the `ProviderSettings` function and `ProviderFormModal` function in `desktop/src/pages/Settings.tsx`. Keep the imports, `Settings` component, `TabButton`, `PermissionSettings`, and `GeneralSettings` unchanged. + +Update the imports at the top of the file: + +```typescript +import { useState, useEffect } from 'react' +import { useSettingsStore } from '../stores/settingsStore' +import { useProviderStore } from '../stores/providerStore' +import { useUIStore } from '../stores/uiStore' +import { Modal } from '../components/shared/Modal' +import { Input } from '../components/shared/Input' +import { Button } from '../components/shared/Button' +import { PROVIDER_PRESETS } from '../config/providerPresets' +import type { PermissionMode, EffortLevel } from '../types/settings' +import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider' +``` + +New `ProviderSettings`: + +```typescript +function ProviderSettings() { + const { providers, activeId, isLoading, fetchProviders, deleteProvider, activateProvider, activateOfficial, testProvider } = useProviderStore() + const fetchSettings = useSettingsStore((s) => s.fetchAll) + const [editingProvider, setEditingProvider] = useState(null) + const [showCreateModal, setShowCreateModal] = useState(false) + const [testResults, setTestResults] = useState>({}) + + useEffect(() => { fetchProviders() }, [fetchProviders]) + + const handleDelete = async (provider: SavedProvider) => { + if (activeId === provider.id) return + if (!window.confirm(`Delete provider "${provider.name}"? This cannot be undone.`)) return + await deleteProvider(provider.id).catch(console.error) + } + + const handleTest = async (provider: SavedProvider) => { + setTestResults((r) => ({ ...r, [provider.id]: { loading: true } })) + try { + const result = await testProvider(provider.id) + setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } })) + } catch { + setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { success: false, latencyMs: 0, error: 'Request failed' } } })) + } + } + + const handleActivate = async (id: string) => { + await activateProvider(id) + await fetchSettings() + } + + const handleActivateOfficial = async () => { + await activateOfficial() + await fetchSettings() + } + + const isOfficialActive = activeId === null + + return ( +
+
+
+

Providers

+

Manage API providers for model access.

+
+ +
+ + {/* Official provider card */} +
!isOfficialActive && handleActivateOfficial()} + > + +
+
+ Claude Official + {isOfficialActive && ( + ACTIVE + )} +
+
Anthropic native — no API key required
+
+
+ + {/* 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 ( +
+ +
+
+ {provider.name} + {preset && preset.id !== 'custom' && ( + {preset.name} + )} + {isActive && ( + ACTIVE + )} +
+
+ {provider.baseUrl} · {provider.models.main} +
+ {test && !test.loading && test.result && ( +
+ {test.result.success ? `Connected (${test.result.latencyMs}ms)` : `Failed: ${test.result.error}`} +
+ )} +
+
+ {!isActive && ( + + )} + + + {!isActive && ( + + )} +
+
+ ) + })} +
+ )} + + {/* Create Modal */} + setShowCreateModal(false)} mode="create" /> + + {/* Edit Modal */} + {editingProvider && ( + setEditingProvider(null)} mode="edit" provider={editingProvider} /> + )} +
+ ) +} +``` + +New `ProviderFormModal`: + +```typescript +type ProviderFormProps = { + open: boolean + onClose: () => void + mode: 'create' | 'edit' + provider?: SavedProvider +} + +function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) { + const { createProvider, updateProvider, testConfig } = useProviderStore() + const fetchSettings = useSettingsStore((s) => s.fetchAll) + + // Exclude 'official' from create presets — it has its own card + 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 ?? { ...initialPreset.defaultModels }) + const [showAdvanced, setShowAdvanced] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [testResult, setTestResult] = useState(null) + const [isTesting, setIsTesting] = useState(false) + + 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() + + const handleSubmit = async () => { + if (!canSubmit) return + setIsSubmitting(true) + try { + if (mode === 'create') { + await createProvider({ + presetId: selectedPreset.id, + name: name.trim(), + apiKey: apiKey.trim(), + baseUrl: baseUrl.trim(), + models, + notes: notes.trim() || undefined, + }) + } else if (provider) { + const input: UpdateProviderInput = { + name: name.trim(), + baseUrl: baseUrl.trim(), + models, + notes: notes.trim() || undefined, + } + if (apiKey.trim()) input.apiKey = apiKey.trim() + await updateProvider(provider.id, input) + if (useProviderStore.getState().activeId === provider.id) await fetchSettings() + } + onClose() + } catch (err) { + console.error('Failed to save provider:', err) + } finally { + setIsSubmitting(false) + } + } + + const handleTest = async () => { + if (!baseUrl.trim() || !models.main.trim()) return + setIsTesting(true) + setTestResult(null) + try { + 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' }) + } finally { + setIsTesting(false) + } + } + + return ( + + + + + } + > +
+ {/* Preset chips (create mode only) */} + {mode === 'create' && ( +
+ +
+ {availablePresets.map((preset) => ( + + ))} +
+
+ )} + + setName(e.target.value)} placeholder="Provider name" /> + + {/* Base URL — always visible for custom, read-only hint for presets */} + {isCustom || mode === 'edit' ? ( + setBaseUrl(e.target.value)} placeholder="https://api.example.com/anthropic" /> + ) : ( +
+ +
+ {baseUrl} +
+
+ )} + + setApiKey(e.target.value)} + placeholder={mode === 'edit' ? '****' : 'sk-...'} + /> + + setNotes(e.target.value)} placeholder="Optional notes..." /> + + {/* Advanced: Model Mapping */} +
+ + {showAdvanced && ( +
+ 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" /> +
+ )} +
+ + {/* Test connection */} +
+ + {testResult && ( + + {testResult.success ? `Connected (${testResult.latencyMs}ms)` : `Failed: ${testResult.error}`} + + )} +
+
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/src/pages/Settings.tsx +git commit -m "feat: rewrite ProviderSettings UI — preset chips, simplified form, official card" +``` + +--- + +### Task 7: Clean Up Unused Code + +**Files:** +- Modify: `desktop/src/pages/Settings.tsx` (remove unused imports) + +- [ ] **Step 1: Verify no unused imports** + +Check that the imports at the top of Settings.tsx match what's used. The old `Provider`, `ProviderModel` imports should be removed, replaced with `SavedProvider`, `ModelMapping`. `ProviderTestResult` and `UpdateProviderInput` should remain. + +Remove `ProviderModel` from type imports if still present (it no longer exists). + +- [ ] **Step 2: Remove old `~/.claude/providers.json`** + +No code change needed — the old file is just ignored. The new storage path is `~/.claude/cc-haha/providers.json`. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/src/pages/Settings.tsx +git commit -m "chore: clean up unused imports after provider preset migration" +``` + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-04-07-provider-preset-redesign.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session, batch execution with checkpoints + +Which approach? \ No newline at end of file From 08cd15ed1bcbacf2ae55765ab1daaba04d81e806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 11:21:35 +0800 Subject: [PATCH 04/15] feat: add provider preset definitions (Official, DeepSeek, ZhipuGLM, Kimi, MiniMax, Custom) Co-Authored-By: Claude Sonnet 4.6 --- desktop/src/config/providerPresets.ts | 69 +++++++++++++++++++++++++++ src/server/config/providerPresets.ts | 69 +++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 desktop/src/config/providerPresets.ts create mode 100644 src/server/config/providerPresets.ts 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/src/server/config/providerPresets.ts b/src/server/config/providerPresets.ts new file mode 100644 index 00000000..df62f5dd --- /dev/null +++ b/src/server/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: '', + }, +] From 9806bef5ec914cb567920bd694f146fc7cce5082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 11:23:24 +0800 Subject: [PATCH 05/15] =?UTF-8?q?refactor:=20rewrite=20provider=20types=20?= =?UTF-8?q?=E2=80=94=20simplified=20model=20mapping,=20preset-based=20stru?= =?UTF-8?q?cture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/types/provider.ts | 70 ++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 40 deletions(-) diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts index b7d067d3..0a94a9a7 100644 --- a/src/server/types/provider.ts +++ b/src/server/types/provider.ts @@ -1,46 +1,48 @@ /** - * Provider 类型定义 + * Provider types — preset-based provider configuration. * - * Provider 是自定义 API 供应商的配置单元,包含 Base URL、API Key 和可用模型列表。 - * 激活 Provider 时,其配置会写入 ~/.claude/settings.json 的 env 字段。 + * Providers are stored in ~/.claude/cc-haha/providers.json as a lightweight index. + * The active provider's env vars are written to ~/.claude/settings.json. */ import { z } from 'zod' -// ─── Zod Schemas ────────────────────────────────────────────────────────────── - -export const ProviderModelSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - context: z.string().optional(), +export const ModelMappingSchema = z.object({ + main: z.string(), + haiku: z.string(), + sonnet: z.string(), + opus: z.string(), }) -export const ProviderSchema = z.object({ - id: z.string().uuid(), +export const SavedProviderSchema = z.object({ + id: z.string(), + presetId: z.string(), name: z.string().min(1), - baseUrl: z.string().url(), - apiKey: z.string().min(1), - models: z.array(ProviderModelSchema).min(1), - isActive: z.boolean(), - createdAt: z.number(), - updatedAt: z.number(), + apiKey: z.string(), + baseUrl: z.string(), + models: ModelMappingSchema, notes: z.string().optional(), }) +export const ProvidersIndexSchema = z.object({ + activeId: z.string().nullable(), + providers: z.array(SavedProviderSchema), +}) + export const CreateProviderSchema = z.object({ + presetId: z.string().min(1), name: z.string().min(1), - baseUrl: z.string().url(), - apiKey: z.string().min(1), - models: z.array(ProviderModelSchema).min(1), + apiKey: z.string(), + baseUrl: z.string(), + models: ModelMappingSchema, notes: z.string().optional(), }) export const UpdateProviderSchema = z.object({ name: z.string().min(1).optional(), - baseUrl: z.string().url().optional(), - apiKey: z.string().min(1).optional(), - models: z.array(ProviderModelSchema).min(1).optional(), + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + models: ModelMappingSchema.optional(), notes: z.string().optional(), }) @@ -50,25 +52,13 @@ export const TestProviderSchema = z.object({ modelId: z.string().min(1), }) -export const ActivateProviderSchema = z.object({ - modelId: z.string().min(1), -}) - -export const ProvidersConfigSchema = z.object({ - providers: z.array(ProviderSchema), - activeModel: z.string().optional(), - version: z.number(), -}) - -// ─── TypeScript Types ───────────────────────────────────────────────────────── - -export type ProviderModel = z.infer -export type Provider = z.infer +// TypeScript types +export type ModelMapping = z.infer +export type SavedProvider = z.infer +export type ProvidersIndex = z.infer export type CreateProviderInput = z.infer export type UpdateProviderInput = z.infer export type TestProviderInput = z.infer -export type ActivateProviderInput = z.infer -export type ProvidersConfig = z.infer export interface ProviderTestResult { success: boolean From 747b45b14fa89b16501cc9255dc8f020d1cf1430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 11:23:59 +0800 Subject: [PATCH 06/15] =?UTF-8?q?refactor:=20rewrite=20providerService=20?= =?UTF-8?q?=E2=80=94=20cc-haha=20storage,=20full=20env=20sync,=20official?= =?UTF-8?q?=20clear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/services/providerService.ts | 454 ++++++++++--------------- 1 file changed, 183 insertions(+), 271 deletions(-) diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index f0d1b313..9f1d08aa 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -1,11 +1,8 @@ /** - * Provider Service — AI provider configuration management + * Provider Service — preset-based provider configuration * - * Manages custom API providers (base URL, API key, models). - * When a provider is activated, its configuration is synced to - * ~/.claude/settings.json so that Claude Code uses it. - * - * Storage: ~/.claude/providers.json (atomic write via .tmp + rename) + * Storage: ~/.claude/cc-haha/providers.json (lightweight index) + * Active provider env vars written to ~/.claude/settings.json */ import * as fs from 'fs/promises' @@ -13,296 +10,237 @@ import * as path from 'path' import * as os from 'os' import { ApiError } from '../middleware/errorHandler.js' import type { - Provider, + SavedProvider, + ProvidersIndex, CreateProviderInput, UpdateProviderInput, - ProvidersConfig, TestProviderInput, ProviderTestResult, } from '../types/provider.js' -const DEFAULT_CONFIG: ProvidersConfig = { - providers: [], - version: 1, -} +const MANAGED_ENV_KEYS = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', +] as const + +const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] } export class ProviderService { - // --------------------------------------------------------------------------- - // Internal helpers - // --------------------------------------------------------------------------- - - /** Config directory, overridable via CLAUDE_CONFIG_DIR (for testing) */ private getConfigDir(): string { return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') } - /** Path to the providers configuration file */ - private getProvidersPath(): string { - return path.join(this.getConfigDir(), 'providers.json') + private getCcHahaDir(): string { + return path.join(this.getConfigDir(), 'cc-haha') } - /** Read providers config; returns empty defaults when the file does not exist */ - private async readProvidersConfig(): Promise { + private getIndexPath(): string { + return path.join(this.getCcHahaDir(), 'providers.json') + } + + private getSettingsPath(): string { + return path.join(this.getConfigDir(), 'settings.json') + } + + private async readIndex(): Promise { try { - const raw = await fs.readFile(this.getProvidersPath(), 'utf-8') - return JSON.parse(raw) as ProvidersConfig + const raw = await fs.readFile(this.getIndexPath(), 'utf-8') + return JSON.parse(raw) as ProvidersIndex } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return { ...DEFAULT_CONFIG, providers: [] } + return { ...DEFAULT_INDEX, providers: [] } } - throw ApiError.internal( - `Failed to read providers config: ${err}`, - ) + throw ApiError.internal(`Failed to read providers index: ${err}`) } } - /** Atomic write: write to .tmp first, then rename */ - private async writeProvidersConfig(config: ProvidersConfig): Promise { - const filePath = this.getProvidersPath() + private async writeIndex(index: ProvidersIndex): Promise { + const filePath = this.getIndexPath() const dir = path.dirname(filePath) await fs.mkdir(dir, { recursive: true }) const tmpFile = `${filePath}.tmp.${Date.now()}` try { - await fs.writeFile(tmpFile, JSON.stringify(config, null, 2) + '\n', 'utf-8') + await fs.writeFile(tmpFile, JSON.stringify(index, null, 2) + '\n', 'utf-8') await fs.rename(tmpFile, filePath) } catch (err) { await fs.unlink(tmpFile).catch(() => {}) - throw ApiError.internal(`Failed to write providers config: ${err}`) + throw ApiError.internal(`Failed to write providers index: ${err}`) } } - // --------------------------------------------------------------------------- - // CRUD - // --------------------------------------------------------------------------- - - /** List all providers */ - async listProviders(): Promise { - const config = await this.readProvidersConfig() - return config.providers - } - - /** Get a single provider by id; throws 404 if not found */ - async getProvider(id: string): Promise { - const config = await this.readProvidersConfig() - const provider = config.providers.find((p) => p.id === id) - if (!provider) { - throw ApiError.notFound(`Provider not found: ${id}`) - } - return provider - } - - /** Get the currently active provider, or null if none */ - async getActiveProvider(): Promise { - const config = await this.readProvidersConfig() - return config.providers.find((p) => p.isActive) ?? null - } - - /** Add a new provider. If it is the first one, activate it automatically. */ - async addProvider(input: CreateProviderInput): Promise { - const config = await this.readProvidersConfig() - const now = Date.now() - const isFirst = config.providers.length === 0 - - const provider: Provider = { - id: crypto.randomUUID(), - name: input.name, - baseUrl: input.baseUrl, - apiKey: input.apiKey, - models: input.models, - isActive: isFirst, - createdAt: now, - updatedAt: now, - ...(input.notes !== undefined && { notes: input.notes }), - } - - config.providers.push(provider) - - // Auto-activate the first provider with its first model - if (isFirst && provider.models.length > 0) { - config.activeModel = provider.models[0].id - } - - await this.writeProvidersConfig(config) - - // Sync to settings if this provider was auto-activated - if (isFirst && provider.models.length > 0) { - await this.syncToSettings(provider, provider.models[0].id) - } - - return provider - } - - /** Update an existing provider */ - async updateProvider(id: string, input: UpdateProviderInput): Promise { - const config = await this.readProvidersConfig() - const index = config.providers.findIndex((p) => p.id === id) - if (index === -1) { - throw ApiError.notFound(`Provider not found: ${id}`) - } - - const existing = config.providers[index] - const updated: Provider = { - ...existing, - ...(input.name !== undefined && { name: input.name }), - ...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }), - ...(input.apiKey !== undefined && { apiKey: input.apiKey }), - ...(input.models !== undefined && { models: input.models }), - ...(input.notes !== undefined && { notes: input.notes }), - updatedAt: Date.now(), - } - - config.providers[index] = updated - - // If the updated provider is active, validate activeModel still exists - if (updated.isActive && config.activeModel) { - const modelStillExists = updated.models.some((m) => m.id === config.activeModel) - if (!modelStillExists) { - config.activeModel = updated.models[0]?.id - } - } - - await this.writeProvidersConfig(config) - - // Re-sync settings if active - if (updated.isActive && config.activeModel) { - await this.syncToSettings(updated, config.activeModel) - } - - return updated - } - - /** Delete a provider; refuses to delete an active provider */ - async deleteProvider(id: string): Promise { - const config = await this.readProvidersConfig() - const index = config.providers.findIndex((p) => p.id === id) - if (index === -1) { - throw ApiError.notFound(`Provider not found: ${id}`) - } - - if (config.providers[index].isActive) { - throw ApiError.conflict( - 'Cannot delete an active provider. Deactivate it first by activating another provider.', - ) - } - - config.providers.splice(index, 1) - await this.writeProvidersConfig(config) - } - - // --------------------------------------------------------------------------- - // Activation - // --------------------------------------------------------------------------- - - /** - * Activate a provider with a specific model. - * - * 1. Validate provider exists and modelId belongs to it - * 2. Deactivate all providers - * 3. Activate the target provider - * 4. Set config.activeModel - * 5. Persist providers.json - * 6. Sync env to settings.json - */ - async activateProvider(id: string, modelId: string): Promise { - const config = await this.readProvidersConfig() - const provider = config.providers.find((p) => p.id === id) - if (!provider) { - throw ApiError.notFound(`Provider not found: ${id}`) - } - - const model = provider.models.find((m) => m.id === modelId) - if (!model) { - throw ApiError.badRequest( - `Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}`, - ) - } - - // Deactivate all, then activate target - for (const p of config.providers) { - p.isActive = false - } - provider.isActive = true - config.activeModel = modelId - - await this.writeProvidersConfig(config) - await this.syncToSettings(provider, modelId) - } - - // --------------------------------------------------------------------------- - // Settings sync - // --------------------------------------------------------------------------- - - /** - * Sync the active provider's configuration to settings.json. - * - * Preserves all existing fields; only updates `env` (merging into existing - * env vars) and `model`. - */ - private async syncToSettings(provider: Provider, modelId: string): Promise { - const settingsPath = path.join(this.getConfigDir(), 'settings.json') - - // Read existing settings - let settings: Record = {} + private async readSettings(): Promise> { try { - const raw = await fs.readFile(settingsPath, 'utf-8') - settings = JSON.parse(raw) as Record + const raw = await fs.readFile(this.getSettingsPath(), 'utf-8') + return JSON.parse(raw) as Record } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - throw ApiError.internal(`Failed to read settings.json: ${err}`) - } - // File doesn't exist yet — start with empty object + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw ApiError.internal(`Failed to read settings.json: ${err}`) } + } - // Merge env: preserve existing env vars, override only ours - const existingEnv = (settings.env as Record) || {} - settings.env = { - ...existingEnv, - ANTHROPIC_BASE_URL: provider.baseUrl, - ANTHROPIC_AUTH_TOKEN: provider.apiKey, - } - - // Set model - settings.model = modelId - - // Atomic write - const dir = path.dirname(settingsPath) + private async writeSettings(settings: Record): Promise { + const filePath = this.getSettingsPath() + const dir = path.dirname(filePath) await fs.mkdir(dir, { recursive: true }) - const tmpFile = `${settingsPath}.tmp.${Date.now()}` + const tmpFile = `${filePath}.tmp.${Date.now()}` try { await fs.writeFile(tmpFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8') - await fs.rename(tmpFile, settingsPath) + await fs.rename(tmpFile, filePath) } catch (err) { await fs.unlink(tmpFile).catch(() => {}) throw ApiError.internal(`Failed to write settings.json: ${err}`) } } - // --------------------------------------------------------------------------- - // Connectivity testing - // --------------------------------------------------------------------------- + // --- CRUD --- - /** Test connectivity of a saved provider */ - async testProvider(id: string): Promise { - const provider = await this.getProvider(id) + async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> { + const index = await this.readIndex() + return { providers: index.providers, activeId: index.activeId } + } - if (provider.models.length === 0) { - return { - success: false, - latencyMs: 0, - error: 'Provider has no models configured', - } + async getProvider(id: string): Promise { + const index = await this.readIndex() + const provider = index.providers.find((p) => p.id === id) + if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) + return provider + } + + async addProvider(input: CreateProviderInput): Promise { + const index = await this.readIndex() + + const provider: SavedProvider = { + id: crypto.randomUUID(), + presetId: input.presetId, + name: input.name, + apiKey: input.apiKey, + baseUrl: input.baseUrl, + models: input.models, + ...(input.notes !== undefined && { notes: input.notes }), } + index.providers.push(provider) + await this.writeIndex(index) + return provider + } + + async updateProvider(id: string, input: UpdateProviderInput): Promise { + const index = await this.readIndex() + const idx = index.providers.findIndex((p) => p.id === id) + if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`) + + const existing = index.providers[idx] + const updated: SavedProvider = { + ...existing, + ...(input.name !== undefined && { name: input.name }), + ...(input.apiKey !== undefined && { apiKey: input.apiKey }), + ...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }), + ...(input.models !== undefined && { models: input.models }), + ...(input.notes !== undefined && { notes: input.notes }), + } + + index.providers[idx] = updated + await this.writeIndex(index) + + if (index.activeId === id) { + await this.syncToSettings(updated) + } + + return updated + } + + async deleteProvider(id: string): Promise { + const index = await this.readIndex() + const idx = index.providers.findIndex((p) => p.id === id) + if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`) + + if (index.activeId === id) { + throw ApiError.conflict('Cannot delete the active provider. Switch to another provider first.') + } + + index.providers.splice(idx, 1) + await this.writeIndex(index) + } + + // --- Activation --- + + async activateProvider(id: string): Promise { + const index = await this.readIndex() + const provider = index.providers.find((p) => p.id === id) + if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) + + index.activeId = id + await this.writeIndex(index) + + if (provider.presetId === 'official') { + await this.clearProviderFromSettings() + } else { + await this.syncToSettings(provider) + } + } + + async activateOfficial(): Promise { + const index = await this.readIndex() + index.activeId = null + await this.writeIndex(index) + await this.clearProviderFromSettings() + } + + // --- Settings sync --- + + private async syncToSettings(provider: SavedProvider): Promise { + const settings = await this.readSettings() + const existingEnv = (settings.env as Record) || {} + + settings.env = { + ...existingEnv, + ANTHROPIC_BASE_URL: provider.baseUrl, + ANTHROPIC_AUTH_TOKEN: provider.apiKey, + ANTHROPIC_MODEL: provider.models.main, + ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku, + ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet, + ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus, + } + + await this.writeSettings(settings) + } + + private async clearProviderFromSettings(): Promise { + const settings = await this.readSettings() + const env = (settings.env as Record) || {} + + for (const key of MANAGED_ENV_KEYS) { + delete env[key] + } + + settings.env = env + if (Object.keys(env).length === 0) { + delete settings.env + } + + await this.writeSettings(settings) + } + + // --- Test --- + + async testProvider(id: string): Promise { + const provider = await this.getProvider(id) + if (!provider.baseUrl || !provider.apiKey) { + return { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' } + } return this.testProviderConfig({ baseUrl: provider.baseUrl, apiKey: provider.apiKey, - modelId: provider.models[0].id, + modelId: provider.models.main, }) } - /** Test connectivity with an arbitrary configuration */ async testProviderConfig(input: TestProviderInput): Promise { const url = `${input.baseUrl.replace(/\/+$/, '')}/v1/messages` const start = Date.now() @@ -326,54 +264,28 @@ export class ProviderService { const latencyMs = Date.now() - start if (response.ok) { - return { - success: true, - latencyMs, - modelUsed: input.modelId, - httpStatus: response.status, - } + return { success: true, latencyMs, modelUsed: input.modelId, httpStatus: response.status } } - // Non-OK response — try to extract error message let errorMessage = `HTTP ${response.status}` try { const body = (await response.json()) as Record if (body.error && typeof body.error === 'object') { - const errObj = body.error as Record - errorMessage = (errObj.message as string) || errorMessage + errorMessage = ((body.error as Record).message as string) || errorMessage } else if (typeof body.message === 'string') { errorMessage = body.message } } catch { - // Could not parse body — use status text errorMessage = `HTTP ${response.status} ${response.statusText}` } - return { - success: false, - latencyMs, - error: errorMessage, - modelUsed: input.modelId, - httpStatus: response.status, - } + return { success: false, latencyMs, error: errorMessage, modelUsed: input.modelId, httpStatus: response.status } } catch (err: unknown) { const latencyMs = Date.now() - start - if (err instanceof DOMException && err.name === 'TimeoutError') { - return { - success: false, - latencyMs, - error: 'Request timed out after 15 seconds', - modelUsed: input.modelId, - } - } - - return { - success: false, - latencyMs, - error: err instanceof Error ? err.message : String(err), - modelUsed: input.modelId, + return { success: false, latencyMs, error: 'Request timed out after 15 seconds', modelUsed: input.modelId } } + return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: input.modelId } } } } From e584df1d28edf1079ace06cbd12ed8451f1b9399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 11:24:22 +0800 Subject: [PATCH 07/15] =?UTF-8?q?refactor:=20update=20provider=20API=20?= =?UTF-8?q?=E2=80=94=20presets=20endpoint,=20simplified=20activate,=20offi?= =?UTF-8?q?cial=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/api/providers.ts | 87 +++++++++++++++---------------------- 1 file changed, 36 insertions(+), 51 deletions(-) diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts index 82247cc4..cdbc673b 100644 --- a/src/server/api/providers.ts +++ b/src/server/api/providers.ts @@ -1,30 +1,29 @@ /** * Providers REST API * - * GET /api/providers — 列出所有 provider - * GET /api/providers/:id — 获取单个 provider - * POST /api/providers — 添加 provider - * PUT /api/providers/:id — 更新 provider - * DELETE /api/providers/:id — 删除 provider - * POST /api/providers/:id/activate — 激活 provider - * POST /api/providers/:id/test — 测试已保存 provider - * POST /api/providers/test — 测试未保存的配置 + * GET /api/providers — list all saved providers + activeId + * GET /api/providers/presets — list available presets + * POST /api/providers — add a provider + * PUT /api/providers/:id — update a provider + * DELETE /api/providers/:id — delete a provider + * POST /api/providers/:id/activate — activate a saved provider + * POST /api/providers/official — activate official (clear env) + * POST /api/providers/:id/test — test a saved provider + * POST /api/providers/test — test unsaved config */ import { z } from 'zod' import { ProviderService } from '../services/providerService.js' +import { PROVIDER_PRESETS } from '../config/providerPresets.js' import { CreateProviderSchema, UpdateProviderSchema, TestProviderSchema, - ActivateProviderSchema, } from '../types/provider.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' const providerService = new ProviderService() -// ─── Sanitization ───────────────────────────────────────────────────────────── - function maskApiKey(key: string): string { if (key.length <= 8) return '****' return key.slice(0, 4) + '****' + key.slice(-4) @@ -37,27 +36,36 @@ function sanitizeProvider(provider: Record): Record { try { - const id = segments[2] // provider ID or 'test' - const action = segments[3] // 'activate' | 'test' | undefined + const id = segments[2] + const action = segments[3] - // ── POST /api/providers/test (test unsaved configuration) ────────── + // POST /api/providers/test if (id === 'test' && req.method === 'POST') { return await handleTestUnsaved(req) } - // ── /api/providers (no ID) ──────────────────────────────────────── + // GET /api/providers/presets + if (id === 'presets' && req.method === 'GET') { + return Response.json({ presets: PROVIDER_PRESETS }) + } + + // POST /api/providers/official + if (id === 'official' && req.method === 'POST') { + await providerService.activateOfficial() + return Response.json({ ok: true }) + } + + // /api/providers (no ID) if (!id) { if (req.method === 'GET') { - const providers = await providerService.listProviders() - return Response.json({ providers: providers.map(sanitizeProvider) }) + const { providers, activeId } = await providerService.listProviders() + return Response.json({ providers: providers.map(sanitizeProvider), activeId }) } if (req.method === 'POST') { return await handleCreate(req) @@ -65,20 +73,21 @@ export async function handleProvidersApi( throw methodNotAllowed(req.method) } - // ── /api/providers/:id/activate ─────────────────────────────────── + // /api/providers/:id/activate if (action === 'activate') { if (req.method !== 'POST') throw methodNotAllowed(req.method) - return await handleActivate(req, id) + await providerService.activateProvider(id) + return Response.json({ ok: true }) } - // ── /api/providers/:id/test ─────────────────────────────────────── + // /api/providers/:id/test if (action === 'test') { if (req.method !== 'POST') throw methodNotAllowed(req.method) const result = await providerService.testProvider(id) return Response.json({ result }) } - // ── /api/providers/:id (no action) ──────────────────────────────── + // /api/providers/:id if (req.method === 'GET') { const provider = await providerService.getProvider(id) return Response.json({ provider: sanitizeProvider(provider) }) @@ -97,8 +106,6 @@ export async function handleProvidersApi( } } -// ─── Handlers ───────────────────────────────────────────────────────────────── - async function handleCreate(req: Request): Promise { const body = await parseJsonBody(req) try { @@ -106,9 +113,7 @@ async function handleCreate(req: Request): Promise { const provider = await providerService.addProvider(input) return Response.json({ provider }, { status: 201 }) } catch (err) { - if (err instanceof z.ZodError) { - throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) - } + if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) throw err } } @@ -120,23 +125,7 @@ async function handleUpdate(req: Request, id: string): Promise { const provider = await providerService.updateProvider(id, input) return Response.json({ provider }) } catch (err) { - if (err instanceof z.ZodError) { - throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) - } - throw err - } -} - -async function handleActivate(req: Request, id: string): Promise { - const body = await parseJsonBody(req) - try { - const input = ActivateProviderSchema.parse(body) - await providerService.activateProvider(id, input.modelId) - return Response.json({ ok: true }) - } catch (err) { - if (err instanceof z.ZodError) { - throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) - } + if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) throw err } } @@ -148,15 +137,11 @@ async function handleTestUnsaved(req: Request): Promise { const result = await providerService.testProviderConfig(input) return Response.json({ result }) } catch (err) { - if (err instanceof z.ZodError) { - throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) - } + if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) throw err } } -// ─── Helpers ────────────────────────────────────────────────────────────────── - async function parseJsonBody(req: Request): Promise> { try { return (await req.json()) as Record From 609ddd0ff5b6c6e6328ede7329da256315d1e09c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 11:28:36 +0800 Subject: [PATCH 08/15] refactor: rewrite frontend provider types, API client, and store for preset system Co-Authored-By: Claude Sonnet 4.6 --- desktop/src/api/providers.ts | 22 +++++++++++++------- desktop/src/stores/providerStore.ts | 28 +++++++++++++++++--------- desktop/src/types/provider.ts | 31 ++++++++++++++--------------- 3 files changed, 49 insertions(+), 32 deletions(-) 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/stores/providerStore.ts b/desktop/src/stores/providerStore.ts index 2adc5fe7..0d803f80 100644 --- a/desktop/src/stores/providerStore.ts +++ b/desktop/src/stores/providerStore.ts @@ -1,7 +1,9 @@ +// desktop/src/stores/providerStore.ts + import { create } from 'zustand' import { providersApi } from '../api/providers' import type { - Provider, + SavedProvider, CreateProviderInput, UpdateProviderInput, TestProviderConfigInput, @@ -9,27 +11,30 @@ import type { } from '../types/provider' type ProviderStore = { - providers: Provider[] + providers: SavedProvider[] + activeId: string | null isLoading: boolean fetchProviders: () => Promise - createProvider: (input: CreateProviderInput) => Promise - updateProvider: (id: string, input: UpdateProviderInput) => Promise + createProvider: (input: CreateProviderInput) => Promise + updateProvider: (id: string, input: UpdateProviderInput) => Promise deleteProvider: (id: string) => Promise - activateProvider: (id: string, modelId: string) => Promise + activateProvider: (id: string) => Promise + activateOfficial: () => Promise testProvider: (id: string) => Promise testConfig: (input: TestProviderConfigInput) => Promise } export const useProviderStore = create((set, get) => ({ providers: [], + activeId: null, isLoading: false, fetchProviders: async () => { set({ isLoading: true }) try { - const { providers } = await providersApi.list() - set({ providers, isLoading: false }) + const { providers, activeId } = await providersApi.list() + set({ providers, activeId, isLoading: false }) } catch { set({ isLoading: false }) } @@ -52,8 +57,13 @@ export const useProviderStore = create((set, get) => ({ await get().fetchProviders() }, - activateProvider: async (id, modelId) => { - await providersApi.activate(id, modelId) + activateProvider: async (id) => { + await providersApi.activate(id) + await get().fetchProviders() + }, + + activateOfficial: async () => { + await providersApi.activateOfficial() await get().fetchProviders() }, diff --git a/desktop/src/types/provider.ts b/desktop/src/types/provider.ts index 9929b958..33a123a7 100644 --- a/desktop/src/types/provider.ts +++ b/desktop/src/types/provider.ts @@ -1,37 +1,36 @@ -// Source: src/server/types/provider.ts +// desktop/src/types/provider.ts -export type ProviderModel = { - id: string - name: string - description?: string - context?: string +export type ModelMapping = { + main: string + haiku: string + sonnet: string + opus: string } -export type Provider = { +export type SavedProvider = { id: string + presetId: string name: string + apiKey: string // masked from server baseUrl: string - apiKey: string // masked from server: "sk-a****xyz" - models: ProviderModel[] - isActive: boolean - createdAt: number - updatedAt: number + models: ModelMapping notes?: string } export type CreateProviderInput = { + presetId: string name: string - baseUrl: string apiKey: string - models: ProviderModel[] + baseUrl: string + models: ModelMapping notes?: string } export type UpdateProviderInput = { name?: string - baseUrl?: string apiKey?: string - models?: ProviderModel[] + baseUrl?: string + models?: ModelMapping notes?: string } From 232f768750b4b50b2f42aea133972b71afb6b1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 11:28:39 +0800 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20rewrite=20ProviderSettings=20UI?= =?UTF-8?q?=20=E2=80=94=20preset=20chips,=20official=20card,=20simplified?= =?UTF-8?q?=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- desktop/src/pages/Settings.tsx | 282 ++++++++++++++++----------------- 1 file changed, 140 insertions(+), 142 deletions(-) diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 5ad3777d..ef9150d1 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -6,7 +6,8 @@ import { Modal } from '../components/shared/Modal' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import type { PermissionMode, EffortLevel } from '../types/settings' -import type { Provider, ProviderModel, UpdateProviderInput, ProviderTestResult } from '../types/provider' +import { PROVIDER_PRESETS } from '../config/providerPresets' +import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider' type SettingsTab = 'providers' | 'permissions' | 'general' @@ -65,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) @@ -94,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 (
@@ -113,64 +115,75 @@ function ProviderSettings() {
- {isLoading && providers.length === 0 ? ( -
-
+ {/* Official provider card */} +
!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 && ( + )}
@@ -180,43 +193,11 @@ function ProviderSettings() { )} {/* Create Modal */} - setShowCreateModal(false)} - mode="create" - /> + 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} /> )}
) @@ -228,47 +209,61 @@ 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 [showAdvanced, setShowAdvanced] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [testResult, setTestResult] = useState(null) const [isTesting, setIsTesting] = useState(false) - const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.some((m) => m.id.trim() && m.name.trim()) + 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() const handleSubmit = async () => { if (!canSubmit) return setIsSubmitting(true) try { - const validModels = models.filter((m) => m.id.trim() && m.name.trim()) 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() + if (useProviderStore.getState().activeId === provider.id) await fetchSettings() } onClose() } catch (err) { @@ -279,18 +274,16 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) } const handleTest = async () => { - const firstModel = models.find((m) => m.id.trim()) - if (!baseUrl.trim() || !firstModel) return + if (!baseUrl.trim() || !models.main.trim()) return setIsTesting(true) setTestResult(null) try { let result: ProviderTestResult if (mode === 'edit' && provider && !apiKey.trim()) { - // Edit mode without new key — test saved provider directly result = await useProviderStore.getState().testProvider(provider.id) } else { if (!apiKey.trim()) return - result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: firstModel.id.trim() }) + result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim() }) } setTestResult(result) } catch { @@ -300,12 +293,6 @@ 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 (
- setName(e.target.value)} placeholder="e.g. OpenRouter, Anthropic Official" /> - setBaseUrl(e.target.value)} placeholder="https://api.anthropic.com" /> + {/* Preset chips (create mode only) */} + {mode === 'create' && ( +
+ +
+ {availablePresets.map((preset) => ( + + ))} +
+
+ )} + + setName(e.target.value)} placeholder="Provider name" /> + + {/* Base URL — always visible for custom, read-only hint for presets */} + {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 */} -
-
- - -
- {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 && ( - - )} + {/* Advanced: Model Mapping */} +
+ + {showAdvanced && ( +
+ 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" />
- ))} + )}
{/* Test connection */}
- {testResult && ( From 2e92ba31166ab5302432a8d2e4ec6df3a52c545f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 11:54:11 +0800 Subject: [PATCH 10/15] fix: official preset in chips, model mapping always visible, settings.json preview - Move Official to first preset chip instead of standalone card - Show model mapping directly without collapsible - Add settings.json preview showing the env vars that will be written - Official preset shows "no config needed" message Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/pages/Settings.tsx | 154 +++++++++++++++++---------------- 1 file changed, 78 insertions(+), 76 deletions(-) diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index ef9150d1..b1d444e3 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -115,27 +115,6 @@ function ProviderSettings() {
- {/* Official provider card */} -
!isOfficialActive && handleActivateOfficial()} - > - -
-
- Claude Official - {isOfficialActive && ( - ACTIVE - )} -
-
Anthropic native — no API key required
-
-
- {/* Saved providers */} {isLoading && providers.length === 0 ? (
@@ -216,7 +195,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) const { createProvider, updateProvider, testConfig } = useProviderStore() const fetchSettings = useSettingsStore((s) => s.fetchAll) - const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official') + const availablePresets = PROVIDER_PRESETS const initialPreset = provider ? availablePresets.find((p) => p.id === provider.presetId) || availablePresets.at(-1)! : availablePresets[0] const [selectedPreset, setSelectedPreset] = useState(initialPreset) @@ -225,7 +204,6 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) const [apiKey, setApiKey] = useState('') const [notes, setNotes] = useState(provider?.notes ?? '') const [models, setModels] = useState(provider?.models ?? { ...initialPreset.defaultModels }) - const [showAdvanced, setShowAdvanced] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [testResult, setTestResult] = useState(null) const [isTesting, setIsTesting] = useState(false) @@ -239,12 +217,19 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) } const isCustom = selectedPreset.id === 'custom' - const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim() + const isOfficial = selectedPreset.id === 'official' + const canSubmit = isOfficial || (name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim()) const handleSubmit = async () => { if (!canSubmit) return setIsSubmitting(true) try { + if (isOfficial) { + await useProviderStore.getState().activateOfficial() + await fetchSettings() + onClose() + return + } if (mode === 'create') { await createProvider({ presetId: selectedPreset.id, @@ -309,7 +294,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) } >
- {/* Preset chips (create mode only) */} + {/* Preset chips */} {mode === 'create' && (
@@ -331,63 +316,80 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
)} - setName(e.target.value)} placeholder="Provider name" /> - - {/* Base URL — always visible for custom, read-only hint for presets */} - {isCustom || mode === 'edit' ? ( - setBaseUrl(e.target.value)} placeholder="https://api.example.com/anthropic" /> - ) : ( -
- -
- {baseUrl} -
+ {/* Official — no config needed */} + {isOfficial && mode === 'create' ? ( +
+ Official provider uses Anthropic native login — no API key or configuration needed.
- )} + ) : ( + <> + setName(e.target.value)} placeholder="Provider name" /> - setApiKey(e.target.value)} - placeholder={mode === 'edit' ? '****' : 'sk-...'} - /> + {/* Base URL */} + {isCustom || mode === 'edit' ? ( + setBaseUrl(e.target.value)} placeholder="https://api.example.com/anthropic" /> + ) : ( +
+ +
+ {baseUrl} +
+
+ )} - setNotes(e.target.value)} placeholder="Optional notes..." /> + setApiKey(e.target.value)} + placeholder={mode === 'edit' ? '****' : 'sk-...'} + /> - {/* Advanced: Model Mapping */} -
- - {showAdvanced && ( -
- 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" /> + setNotes(e.target.value)} placeholder="Optional notes..." /> + + {/* Model Mapping — always visible */} +
+ +
+ 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" /> +
- )} -
- {/* Test connection */} -
- - {testResult && ( - - {testResult.success ? `Connected (${testResult.latencyMs}ms)` : `Failed: ${testResult.error}`} - - )} -
+ {/* Test connection */} +
+ + {testResult && ( + + {testResult.success ? `Connected (${testResult.latencyMs}ms)` : `Failed: ${testResult.error}`} + + )} +
+ + {/* Settings JSON Preview */} +
+ +
+{JSON.stringify({
+  env: {
+    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 || '...',
+  },
+}, null, 2)}
+              
+

Will be written to ~/.claude/settings.json on activation.

+
+ + )}
) From d109759c9c00ce2b5caebefa290053c1d907e9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 12:00:10 +0800 Subject: [PATCH 11/15] feat: editable settings.json in provider form with JSON validation - Load full ~/.claude/settings.json content, merge provider env vars - Editable textarea replaces read-only preview - Real-time JSON syntax validation, blocks submit on error - On save, writes edited JSON to settings.json then saves provider - Shows all existing settings (plugins, hooks, etc.) preserved Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/pages/Settings.tsx | 92 +++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 17 deletions(-) diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index b1d444e3..7dd9dd95 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -207,6 +207,42 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) const [isSubmitting, setIsSubmitting] = useState(false) const [testResult, setTestResult] = useState(null) const [isTesting, setIsTesting] = useState(false) + const [settingsJson, setSettingsJson] = useState('') + const [settingsJsonError, setSettingsJsonError] = useState(null) + + // Load current settings.json and merge provider env + 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(() => { + const fallback = { + env: { + 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(fallback, null, 2)) + }) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const handlePresetChange = (preset: typeof initialPreset) => { setSelectedPreset(preset) @@ -218,7 +254,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) const isCustom = selectedPreset.id === 'custom' const isOfficial = selectedPreset.id === 'official' - const canSubmit = isOfficial || (name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim()) + const canSubmit = isOfficial || (name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim() && !settingsJsonError) const handleSubmit = async () => { if (!canSubmit) return @@ -230,6 +266,17 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) onClose() return } + // Write the edited settings.json directly + 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, @@ -248,8 +295,8 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) } if (apiKey.trim()) input.apiKey = apiKey.trim() await updateProvider(provider.id, input) - if (useProviderStore.getState().activeId === provider.id) await fetchSettings() } + await fetchSettings() onClose() } catch (err) { console.error('Failed to save provider:', err) @@ -371,22 +418,33 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) )}
- {/* Settings JSON Preview */} + {/* Settings JSON — editable */}
- -
-{JSON.stringify({
-  env: {
-    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 || '...',
-  },
-}, null, 2)}
-              
-

Will be written to ~/.claude/settings.json on activation.

+ +