mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
7 tasks: presets config, backend types/service/API rewrite, frontend types/API/store/UI rewrite. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1333 lines
44 KiB
Markdown
1333 lines
44 KiB
Markdown
# 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<typeof ModelMappingSchema>
|
|
export type SavedProvider = z.infer<typeof SavedProviderSchema>
|
|
export type ProvidersIndex = z.infer<typeof ProvidersIndexSchema>
|
|
export type CreateProviderInput = z.infer<typeof CreateProviderSchema>
|
|
export type UpdateProviderInput = z.infer<typeof UpdateProviderSchema>
|
|
export type TestProviderInput = z.infer<typeof TestProviderSchema>
|
|
|
|
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<ProvidersIndex> {
|
|
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<void> {
|
|
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<Record<string, unknown>> {
|
|
try {
|
|
const raw = await fs.readFile(this.getSettingsPath(), 'utf-8')
|
|
return JSON.parse(raw) as Record<string, unknown>
|
|
} 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<string, unknown>): Promise<void> {
|
|
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<SavedProvider> {
|
|
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<SavedProvider> {
|
|
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<SavedProvider> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const index = await this.readIndex()
|
|
index.activeId = null
|
|
await this.writeIndex(index)
|
|
await this.clearProviderFromSettings()
|
|
}
|
|
|
|
// --- Settings sync ---
|
|
|
|
private async syncToSettings(provider: SavedProvider): Promise<void> {
|
|
const settings = await this.readSettings()
|
|
const existingEnv = (settings.env as Record<string, string>) || {}
|
|
|
|
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<void> {
|
|
const settings = await this.readSettings()
|
|
const env = (settings.env as Record<string, string>) || {}
|
|
|
|
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<ProviderTestResult> {
|
|
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<ProviderTestResult> {
|
|
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<string, unknown>
|
|
if (body.error && typeof body.error === 'object') {
|
|
errorMessage = ((body.error as Record<string, unknown>).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<string, unknown>): Record<string, unknown> {
|
|
if (typeof provider.apiKey === 'string') {
|
|
return { ...provider, apiKey: maskApiKey(provider.apiKey) }
|
|
}
|
|
return provider
|
|
}
|
|
|
|
export async function handleProvidersApi(
|
|
req: Request,
|
|
_url: URL,
|
|
segments: string[],
|
|
): Promise<Response> {
|
|
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<Response> {
|
|
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<Response> {
|
|
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<Response> {
|
|
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<Record<string, unknown>> {
|
|
try {
|
|
return (await req.json()) as Record<string, unknown>
|
|
} 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<ProvidersResponse>('/api/providers')
|
|
},
|
|
|
|
presets() {
|
|
return api.get<PresetsResponse>('/api/providers/presets')
|
|
},
|
|
|
|
create(input: CreateProviderInput) {
|
|
return api.post<ProviderResponse>('/api/providers', input)
|
|
},
|
|
|
|
update(id: string, input: UpdateProviderInput) {
|
|
return api.put<ProviderResponse>(`/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<TestResultResponse>(`/api/providers/${id}/test`)
|
|
},
|
|
|
|
testConfig(input: TestProviderConfigInput) {
|
|
return api.post<TestResultResponse>('/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<void>
|
|
createProvider: (input: CreateProviderInput) => Promise<SavedProvider>
|
|
updateProvider: (id: string, input: UpdateProviderInput) => Promise<SavedProvider>
|
|
deleteProvider: (id: string) => Promise<void>
|
|
activateProvider: (id: string) => Promise<void>
|
|
activateOfficial: () => Promise<void>
|
|
testProvider: (id: string) => Promise<ProviderTestResult>
|
|
testConfig: (input: TestProviderConfigInput) => Promise<ProviderTestResult>
|
|
}
|
|
|
|
export const useProviderStore = create<ProviderStore>((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<SavedProvider | null>(null)
|
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
|
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
|
|
|
|
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 (
|
|
<div className="max-w-2xl">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">Providers</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">Manage API providers for model access.</p>
|
|
</div>
|
|
<Button size="sm" onClick={() => setShowCreateModal(true)}>
|
|
<span className="material-symbols-outlined text-[16px]">add</span>
|
|
Add Provider
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Official provider card */}
|
|
<div
|
|
className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all mb-2 ${
|
|
isOfficialActive
|
|
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]'
|
|
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
|
|
}`}
|
|
onClick={() => !isOfficialActive && handleActivateOfficial()}
|
|
>
|
|
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-[var(--color-text-primary)]">Claude Official</span>
|
|
{isOfficialActive && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">ACTIVE</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">Anthropic native — no API key required</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Saved providers */}
|
|
{isLoading && providers.length === 0 ? (
|
|
<div className="flex justify-center py-8">
|
|
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{providers.map((provider) => {
|
|
const isActive = activeId === provider.id
|
|
const test = testResults[provider.id]
|
|
const preset = PROVIDER_PRESETS.find((p) => p.id === provider.presetId)
|
|
return (
|
|
<div
|
|
key={provider.id}
|
|
className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${
|
|
isActive
|
|
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]'
|
|
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
|
|
}`}
|
|
>
|
|
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{provider.name}</span>
|
|
{preset && preset.id !== 'custom' && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none">{preset.name}</span>
|
|
)}
|
|
{isActive && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">ACTIVE</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
|
|
{provider.baseUrl} · {provider.models.main}
|
|
</div>
|
|
{test && !test.loading && test.result && (
|
|
<div className={`text-xs mt-1 ${test.result.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
|
{test.result.success ? `Connected (${test.result.latencyMs}ms)` : `Failed: ${test.result.error}`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
|
{!isActive && (
|
|
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>Activate</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>Test</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>Edit</Button>
|
|
{!isActive && (
|
|
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">Delete</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create Modal */}
|
|
<ProviderFormModal open={showCreateModal} onClose={() => setShowCreateModal(false)} mode="create" />
|
|
|
|
{/* Edit Modal */}
|
|
{editingProvider && (
|
|
<ProviderFormModal key={editingProvider.id} open={true} onClose={() => setEditingProvider(null)} mode="edit" provider={editingProvider} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
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<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
|
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [testResult, setTestResult] = useState<ProviderTestResult | null>(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 (
|
|
<Modal
|
|
open={open}
|
|
onClose={onClose}
|
|
title={mode === 'create' ? 'Add Provider' : 'Edit Provider'}
|
|
width={600}
|
|
footer={
|
|
<>
|
|
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
|
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
|
|
{mode === 'create' ? 'Add' : 'Save'}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
{/* Preset chips (create mode only) */}
|
|
{mode === 'create' && (
|
|
<div>
|
|
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">Preset</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{availablePresets.map((preset) => (
|
|
<button
|
|
key={preset.id}
|
|
onClick={() => handlePresetChange(preset)}
|
|
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all ${
|
|
selectedPreset.id === preset.id
|
|
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)]'
|
|
}`}
|
|
>
|
|
{preset.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} placeholder="Provider name" />
|
|
|
|
{/* Base URL — always visible for custom, read-only hint for presets */}
|
|
{isCustom || mode === 'edit' ? (
|
|
<Input label="Base URL" required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/anthropic" />
|
|
) : (
|
|
<div>
|
|
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">Base URL</label>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)]">
|
|
{baseUrl}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Input
|
|
label={mode === 'edit' ? 'API Key (leave blank to keep current)' : 'API Key'}
|
|
required={mode === 'create'}
|
|
type="password"
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
placeholder={mode === 'edit' ? '****' : 'sk-...'}
|
|
/>
|
|
|
|
<Input label="Notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes..." />
|
|
|
|
{/* Advanced: Model Mapping */}
|
|
<div>
|
|
<button
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
className="flex items-center gap-1 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]" style={{ transform: showAdvanced ? 'rotate(90deg)' : 'rotate(0)', transition: 'transform 150ms' }}>
|
|
chevron_right
|
|
</span>
|
|
Model Mapping
|
|
</button>
|
|
{showAdvanced && (
|
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
<Input label="Main Model" required value={models.main} onChange={(e) => setModels({ ...models, main: e.target.value })} placeholder="Model ID" />
|
|
<Input label="Haiku Model" value={models.haiku} onChange={(e) => setModels({ ...models, haiku: e.target.value })} placeholder="Same as main" />
|
|
<Input label="Sonnet Model" value={models.sonnet} onChange={(e) => setModels({ ...models, sonnet: e.target.value })} placeholder="Same as main" />
|
|
<Input label="Opus Model" value={models.opus} onChange={(e) => setModels({ ...models, opus: e.target.value })} placeholder="Same as main" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Test connection */}
|
|
<div className="flex items-center gap-3">
|
|
<Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !models.main.trim()}>
|
|
Test Connection
|
|
</Button>
|
|
{testResult && (
|
|
<span className={`text-xs ${testResult.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
|
{testResult.success ? `Connected (${testResult.latencyMs}ms)` : `Failed: ${testResult.error}`}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **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? |