mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
Add a protocol-translating reverse proxy that allows using OpenAI-compatible API providers (DeepSeek, OpenRouter, Groq, etc.) with Claude Code. The proxy intercepts Anthropic Messages API requests from the CLI, transforms them to OpenAI Chat Completions or Responses API format, forwards to the upstream provider, and transforms streaming/non-streaming responses back. Key features: - Request transform: Anthropic Messages → OpenAI Chat/Responses - Response transform: OpenAI → Anthropic (streaming SSE + non-streaming) - Provider-agnostic reasoning support (reasoning_content, thinking_blocks, reasoning fields from DeepSeek, OpenAI o-series, GLM-5, Groq, etc.) - Event queue pattern for correct Anthropic SSE event ordering - Two-step test: ① connectivity check ② full proxy pipeline validation - Desktop UI: API format selector, two-step test results display - License attribution for cc-switch (MIT, Jason Young) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1181 lines
53 KiB
TypeScript
1181 lines
53 KiB
TypeScript
import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react'
|
|
import { useSettingsStore } from '../stores/settingsStore'
|
|
import { useProviderStore } from '../stores/providerStore'
|
|
import { useTranslation } from '../i18n'
|
|
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 { Locale } from '../i18n'
|
|
import { PROVIDER_PRESETS } from '../config/providerPresets'
|
|
import type { ProviderPreset } from '../config/providerPresets'
|
|
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider'
|
|
import { AdapterSettings } from './AdapterSettings'
|
|
import { useAgentStore } from '../stores/agentStore'
|
|
import { useSessionStore } from '../stores/sessionStore'
|
|
import type { AgentDefinition, AgentSource } from '../api/agents'
|
|
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
|
|
import { useSkillStore } from '../stores/skillStore'
|
|
import { SkillList } from '../components/skills/SkillList'
|
|
import { SkillDetail } from '../components/skills/SkillDetail'
|
|
|
|
type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'agents' | 'skills'
|
|
|
|
export function Settings() {
|
|
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
|
const t = useTranslation()
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Tab navigation */}
|
|
<div className="w-[180px] border-r border-[var(--color-border)] py-3 flex-shrink-0">
|
|
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
|
|
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
|
|
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
|
|
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
|
|
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
|
|
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div className="flex-1 overflow-y-auto px-8 py-6">
|
|
{activeTab === 'providers' && <ProviderSettings />}
|
|
{activeTab === 'permissions' && <PermissionSettings />}
|
|
{activeTab === 'general' && <GeneralSettings />}
|
|
{activeTab === 'adapters' && <AdapterSettings />}
|
|
{activeTab === 'agents' && <AgentsSettings />}
|
|
{activeTab === 'skills' && <SkillSettings />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TabButton({ icon, label, active, onClick }: { icon: string; label: string; active: boolean; onClick: () => void }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-left transition-colors ${
|
|
active
|
|
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
|
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">{icon}</span>
|
|
{label}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// ─── Provider Settings ──────────────────────────────────────
|
|
|
|
function ProviderSettings() {
|
|
const { providers, activeId, isLoading, fetchProviders, deleteProvider, activateProvider, activateOfficial, testProvider } = useProviderStore()
|
|
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
|
const t = useTranslation()
|
|
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(t('settings.providers.confirmDelete', { name: provider.name }))) 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: { connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } } } }))
|
|
}
|
|
}
|
|
|
|
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)]">{t('settings.providers.title')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.description')}</p>
|
|
</div>
|
|
<Button size="sm" onClick={() => setShowCreateModal(true)}>
|
|
<span className="material-symbols-outlined text-[16px]">add</span>
|
|
{t('settings.providers.addProvider')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Official provider — always visible at top */}
|
|
<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)]">{t('settings.providers.officialName')}</span>
|
|
{isOfficialActive && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">{t('common.active')}</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.officialDesc')}</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>
|
|
)}
|
|
{provider.apiFormat && provider.apiFormat !== 'anthropic' && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-warning)] leading-none">
|
|
{provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
|
|
</span>
|
|
)}
|
|
{isActive && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">{t('common.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 flex flex-col gap-0.5">
|
|
<span className={test.result.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
|
|
{test.result.connectivity.success
|
|
? t('settings.providers.connectivityOk', { latency: String(test.result.connectivity.latencyMs) })
|
|
: t('settings.providers.connectivityFailed', { error: test.result.connectivity.error || '' })}
|
|
</span>
|
|
{test.result.proxy && (
|
|
<span className={test.result.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
|
|
{test.result.proxy.success
|
|
? t('settings.providers.proxyOk', { latency: String(test.result.proxy.latencyMs) })
|
|
: t('settings.providers.proxyFailed', { error: test.result.proxy.error || '' })}
|
|
</span>
|
|
)}
|
|
</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)}>{t('settings.providers.activate')}</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>{t('settings.providers.test')}</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>{t('settings.providers.edit')}</Button>
|
|
{!isActive && (
|
|
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">{t('common.delete')}</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create Modal — conditionally rendered so state resets on close */}
|
|
{showCreateModal && (
|
|
<ProviderFormModal open={true} onClose={() => setShowCreateModal(false)} mode="create" />
|
|
)}
|
|
|
|
{/* Edit Modal */}
|
|
{editingProvider && (
|
|
<ProviderFormModal key={editingProvider.id} open={true} onClose={() => setEditingProvider(null)} mode="edit" provider={editingProvider} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Provider Form Modal ──────────────────────────────────────
|
|
|
|
type ProviderFormProps = {
|
|
open: boolean
|
|
onClose: () => void
|
|
mode: 'create' | 'edit'
|
|
provider?: SavedProvider
|
|
}
|
|
|
|
function requirePreset(preset: ProviderPreset | undefined): ProviderPreset {
|
|
if (!preset) {
|
|
throw new Error('Provider presets are not configured')
|
|
}
|
|
return preset
|
|
}
|
|
|
|
function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) {
|
|
const { createProvider, updateProvider, testConfig } = useProviderStore()
|
|
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
|
const t = useTranslation()
|
|
|
|
const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official')
|
|
const fallbackPreset = requirePreset(
|
|
availablePresets[availablePresets.length - 1] ?? PROVIDER_PRESETS[0],
|
|
)
|
|
const initialPreset = requirePreset(
|
|
provider
|
|
? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
|
|
: availablePresets[0] ?? fallbackPreset,
|
|
)
|
|
|
|
const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
|
|
const [name, setName] = useState(provider?.name ?? initialPreset.name)
|
|
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
|
|
const [apiFormat, setApiFormat] = useState<ApiFormat>(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic')
|
|
const [apiKey, setApiKey] = useState('')
|
|
const [notes, setNotes] = useState(provider?.notes ?? '')
|
|
const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [testResult, setTestResult] = useState<ProviderTestResult | null>(null)
|
|
const [isTesting, setIsTesting] = useState(false)
|
|
const [settingsJson, setSettingsJson] = useState('')
|
|
const [settingsJsonError, setSettingsJsonError] = useState<string | null>(null)
|
|
const jsonPastedRef = useRef(false)
|
|
|
|
// Load current settings.json and merge provider env vars
|
|
useEffect(() => {
|
|
// Skip if JSON was just populated by user paste
|
|
if (jsonPastedRef.current) {
|
|
jsonPastedRef.current = false
|
|
return
|
|
}
|
|
import('../api/settings').then(({ settingsApi }) => {
|
|
settingsApi.getUser().then((settings) => {
|
|
const needsProxy = apiFormat !== 'anthropic'
|
|
const merged = {
|
|
...settings,
|
|
env: {
|
|
...((settings.env as Record<string, string>) || {}),
|
|
ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl,
|
|
ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : (apiKey || '(your API key)'),
|
|
ANTHROPIC_MODEL: models.main,
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
|
},
|
|
}
|
|
setSettingsJson(JSON.stringify(merged, null, 2))
|
|
}).catch(() => {
|
|
setSettingsJson(JSON.stringify({}, null, 2))
|
|
})
|
|
})
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedPreset.id])
|
|
|
|
const handlePresetChange = (preset: ProviderPreset) => {
|
|
setSelectedPreset(preset)
|
|
setName(preset.name)
|
|
setBaseUrl(preset.baseUrl)
|
|
setApiFormat(preset.apiFormat ?? 'anthropic')
|
|
setModels({ ...preset.defaultModels })
|
|
setTestResult(null)
|
|
}
|
|
|
|
const isCustom = selectedPreset.id === 'custom'
|
|
const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim() && !settingsJsonError
|
|
|
|
const handleSubmit = async () => {
|
|
if (!canSubmit) return
|
|
setIsSubmitting(true)
|
|
try {
|
|
// Write the edited settings.json first (for all presets including official)
|
|
if (settingsJson.trim()) {
|
|
try {
|
|
const parsed = JSON.parse(settingsJson)
|
|
const { settingsApi } = await import('../api/settings')
|
|
await settingsApi.updateUser(parsed)
|
|
} catch {
|
|
// JSON validation already prevents this
|
|
}
|
|
}
|
|
|
|
if (mode === 'create') {
|
|
await createProvider({
|
|
presetId: selectedPreset.id,
|
|
name: name.trim(),
|
|
apiKey: apiKey.trim(),
|
|
baseUrl: baseUrl.trim(),
|
|
apiFormat,
|
|
models,
|
|
notes: notes.trim() || undefined,
|
|
})
|
|
} else if (provider) {
|
|
const input: UpdateProviderInput = {
|
|
name: name.trim(),
|
|
baseUrl: baseUrl.trim(),
|
|
apiFormat,
|
|
models,
|
|
notes: notes.trim() || undefined,
|
|
}
|
|
if (apiKey.trim()) input.apiKey = apiKey.trim()
|
|
await updateProvider(provider.id, input)
|
|
}
|
|
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, {
|
|
baseUrl: baseUrl.trim(),
|
|
modelId: models.main.trim(),
|
|
apiFormat,
|
|
})
|
|
} else {
|
|
if (!apiKey.trim()) return
|
|
result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim(), apiFormat })
|
|
}
|
|
setTestResult(result)
|
|
} catch {
|
|
setTestResult({ connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } })
|
|
} finally {
|
|
setIsTesting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onClose={onClose}
|
|
title={mode === 'create' ? t('settings.providers.addTitle') : t('settings.providers.editTitle')}
|
|
width={720}
|
|
footer={
|
|
<>
|
|
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
|
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
|
|
{mode === 'create' ? t('common.add') : t('common.save')}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
{/* Preset chips */}
|
|
{mode === 'create' && (
|
|
<div>
|
|
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.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={t('settings.providers.name')} required value={name} onChange={(e) => setName(e.target.value)} placeholder={t('settings.providers.namePlaceholder')} />
|
|
|
|
<Input label={t('settings.providers.notes')} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} />
|
|
|
|
{/* Base URL */}
|
|
{isCustom || mode === 'edit' ? (
|
|
<Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} />
|
|
) : (
|
|
<div>
|
|
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.baseUrl')}</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>
|
|
)}
|
|
|
|
{/* API Format */}
|
|
{(isCustom || mode === 'edit') ? (
|
|
<div>
|
|
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
|
|
<select
|
|
value={apiFormat}
|
|
onChange={(e) => setApiFormat(e.target.value as ApiFormat)}
|
|
className="w-full text-sm px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
|
|
>
|
|
<option value="anthropic">{t('settings.providers.apiFormatAnthropic')}</option>
|
|
<option value="openai_chat">{t('settings.providers.apiFormatOpenaiChat')}</option>
|
|
<option value="openai_responses">{t('settings.providers.apiFormatOpenaiResponses')}</option>
|
|
</select>
|
|
{apiFormat !== 'anthropic' && (
|
|
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.proxyHint')}</p>
|
|
)}
|
|
</div>
|
|
) : apiFormat !== 'anthropic' ? (
|
|
<div>
|
|
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</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)]">
|
|
{apiFormat === 'openai_chat' ? t('settings.providers.apiFormatOpenaiChat') : t('settings.providers.apiFormatOpenaiResponses')}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<Input
|
|
label={mode === 'edit' ? t('settings.providers.apiKeyKeep') : t('settings.providers.apiKey')}
|
|
required={mode === 'create'}
|
|
type="password"
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
placeholder={mode === 'edit' ? '****' : 'sk-...'}
|
|
/>
|
|
|
|
{/* Model Mapping */}
|
|
<div>
|
|
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelMapping')}</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Input label={t('settings.providers.mainModel')} required value={models.main} onChange={(e) => setModels({ ...models, main: e.target.value })} placeholder="Model ID" />
|
|
<Input label={t('settings.providers.haikuModel')} value={models.haiku} onChange={(e) => setModels({ ...models, haiku: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
|
|
<Input label={t('settings.providers.sonnetModel')} value={models.sonnet} onChange={(e) => setModels({ ...models, sonnet: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
|
|
<Input label={t('settings.providers.opusModel')} value={models.opus} onChange={(e) => setModels({ ...models, opus: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
|
|
</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()}>
|
|
{t('settings.providers.testConnection')}
|
|
</Button>
|
|
{testResult && (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className={`text-xs ${testResult.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
|
{testResult.connectivity.success
|
|
? t('settings.providers.connectivityOk', { latency: String(testResult.connectivity.latencyMs) })
|
|
: t('settings.providers.connectivityFailed', { error: testResult.connectivity.error || '' })}
|
|
</span>
|
|
{testResult.proxy && (
|
|
<span className={`text-xs ${testResult.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
|
{testResult.proxy.success
|
|
? t('settings.providers.proxyOk', { latency: String(testResult.proxy.latencyMs) })
|
|
: t('settings.providers.proxyFailed', { error: testResult.proxy.error || '' })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Settings JSON — editable, shown for all presets including official */}
|
|
<div>
|
|
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.settingsJson')}</label>
|
|
<textarea
|
|
value={settingsJson}
|
|
onChange={(e) => {
|
|
const raw = e.target.value
|
|
setSettingsJson(raw)
|
|
try {
|
|
const parsed = JSON.parse(raw)
|
|
setSettingsJsonError(null)
|
|
// Auto-fill form fields from parsed JSON env
|
|
const env = parsed.env as Record<string, string> | undefined
|
|
if (env) {
|
|
if (env.ANTHROPIC_BASE_URL) {
|
|
setBaseUrl(env.ANTHROPIC_BASE_URL)
|
|
// Auto-switch to matching preset or Custom
|
|
if (mode === 'create') {
|
|
const matchedPreset = availablePresets.find((p) => p.id !== 'custom' && p.baseUrl === env.ANTHROPIC_BASE_URL)
|
|
const targetPreset = requirePreset(
|
|
matchedPreset ?? availablePresets.find((p) => p.id === 'custom'),
|
|
)
|
|
if (targetPreset.id !== selectedPreset.id) {
|
|
jsonPastedRef.current = true
|
|
setSelectedPreset(targetPreset)
|
|
}
|
|
}
|
|
}
|
|
if (env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_AUTH_TOKEN !== '(your API key)') setApiKey(env.ANTHROPIC_AUTH_TOKEN)
|
|
const newModels: Partial<ModelMapping> = {}
|
|
if (env.ANTHROPIC_MODEL) newModels.main = env.ANTHROPIC_MODEL
|
|
if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) newModels.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
|
if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) newModels.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
|
if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) newModels.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
|
if (Object.keys(newModels).length > 0) {
|
|
setModels((prev) => ({ ...prev, ...newModels }))
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setSettingsJsonError(err instanceof Error ? err.message : 'Invalid JSON')
|
|
}
|
|
}}
|
|
rows={16}
|
|
spellCheck={false}
|
|
className={`w-full text-xs px-3 py-3 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border font-mono leading-relaxed resize-y text-[var(--color-text-secondary)] outline-none ${
|
|
settingsJsonError
|
|
? 'border-[var(--color-error)] focus:border-[var(--color-error)]'
|
|
: 'border-[var(--color-border)] focus:border-[var(--color-border-focus)]'
|
|
}`}
|
|
/>
|
|
{settingsJsonError && (
|
|
<p className="text-[11px] text-[var(--color-error)] mt-1">{t('settings.providers.jsonError', { error: settingsJsonError })}</p>
|
|
)}
|
|
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.settingsJsonDesc')}</p>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
|
|
// ─── Permission Settings ──────────────────────────────────────
|
|
|
|
function PermissionSettings() {
|
|
const { permissionMode, setPermissionMode } = useSettingsStore()
|
|
const t = useTranslation()
|
|
|
|
const MODES: Array<{ mode: PermissionMode; icon: string; label: string; desc: string }> = [
|
|
{ mode: 'default', icon: 'verified_user', label: t('settings.permissions.default'), desc: t('settings.permissions.defaultDesc') },
|
|
{ mode: 'acceptEdits', icon: 'edit_note', label: t('settings.permissions.acceptEdits'), desc: t('settings.permissions.acceptEditsDesc') },
|
|
{ mode: 'plan', icon: 'architecture', label: t('settings.permissions.plan'), desc: t('settings.permissions.planDesc') },
|
|
{ mode: 'bypassPermissions', icon: 'bolt', label: t('settings.permissions.bypass'), desc: t('settings.permissions.bypassDesc') },
|
|
]
|
|
|
|
return (
|
|
<div className="max-w-xl">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.permissions.title')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">{t('settings.permissions.description')}</p>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
{MODES.map(({ mode, icon, label, desc }) => {
|
|
const isSelected = permissionMode === mode
|
|
return (
|
|
<button
|
|
key={mode}
|
|
onClick={() => setPermissionMode(mode)}
|
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${
|
|
isSelected
|
|
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]'
|
|
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-secondary)]">{icon}</span>
|
|
<div className="flex-1">
|
|
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{label}</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)]">{desc}</div>
|
|
</div>
|
|
{isSelected && (
|
|
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
|
check_circle
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── General Settings ──────────────────────────────────────
|
|
|
|
function GeneralSettings() {
|
|
const { effortLevel, setEffort, locale, setLocale } = useSettingsStore()
|
|
const t = useTranslation()
|
|
|
|
const EFFORT_LABELS: Record<EffortLevel, string> = {
|
|
low: t('settings.general.effort.low'),
|
|
medium: t('settings.general.effort.medium'),
|
|
high: t('settings.general.effort.high'),
|
|
max: t('settings.general.effort.max'),
|
|
}
|
|
|
|
const LANGUAGES: Array<{ value: Locale; label: string }> = [
|
|
{ value: 'en', label: 'English' },
|
|
{ value: 'zh', label: '中文' },
|
|
]
|
|
|
|
return (
|
|
<div className="max-w-xl">
|
|
{/* Language selector */}
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.languageTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.languageDescription')}</p>
|
|
<div className="flex gap-2 mb-8">
|
|
{LANGUAGES.map(({ value, label }) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => setLocale(value)}
|
|
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
|
|
locale === value
|
|
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Effort Level */}
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.effortTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.effortDescription')}</p>
|
|
<div className="flex gap-2">
|
|
{(['low', 'medium', 'high', 'max'] as EffortLevel[]).map((level) => (
|
|
<button
|
|
key={level}
|
|
onClick={() => setEffort(level)}
|
|
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
|
|
effortLevel === level
|
|
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
{EFFORT_LABELS[level]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Agents Settings ──────────────────────────────────────
|
|
|
|
const AGENT_COLORS: Record<string, string> = {
|
|
red: '#ef4444',
|
|
orange: '#f97316',
|
|
yellow: '#eab308',
|
|
green: '#22c55e',
|
|
blue: '#3b82f6',
|
|
purple: '#a855f7',
|
|
pink: '#ec4899',
|
|
cyan: '#06b6d4',
|
|
}
|
|
|
|
const AGENT_SOURCE_ORDER: AgentSource[] = [
|
|
'userSettings',
|
|
'projectSettings',
|
|
'localSettings',
|
|
'policySettings',
|
|
'plugin',
|
|
'flagSettings',
|
|
'built-in',
|
|
]
|
|
|
|
function AgentsSettings() {
|
|
const {
|
|
activeAgents,
|
|
allAgents,
|
|
isLoading,
|
|
error,
|
|
selectedAgent,
|
|
fetchAgents,
|
|
selectAgent,
|
|
} = useAgentStore()
|
|
const sessions = useSessionStore((s) => s.sessions)
|
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
|
const t = useTranslation()
|
|
|
|
const activeSession = sessions.find((s) => s.id === activeSessionId)
|
|
const currentWorkDir = activeSession?.workDir || undefined
|
|
|
|
useEffect(() => {
|
|
void fetchAgents(currentWorkDir)
|
|
}, [fetchAgents, currentWorkDir])
|
|
|
|
const groupedAgents = useMemo(() => {
|
|
const groups: Partial<Record<AgentSource, AgentDefinition[]>> = {}
|
|
for (const agent of allAgents) {
|
|
;(groups[agent.source] ??= []).push(agent)
|
|
}
|
|
return groups
|
|
}, [allAgents])
|
|
|
|
const sourceCount = AGENT_SOURCE_ORDER.filter((source) => (groupedAgents[source] ?? []).length > 0).length
|
|
|
|
if (selectedAgent) {
|
|
return (
|
|
<div className="w-full min-w-0">
|
|
<AgentDetailView agent={selectedAgent} onBack={() => selectAgent(null)} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="w-full min-w-0">
|
|
{isLoading && allAgents.length === 0 ? (
|
|
<div className="flex justify-center py-12">
|
|
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-center py-12 px-4">
|
|
<span className="material-symbols-outlined text-[40px] text-[var(--color-error)] mb-3 block">error_outline</span>
|
|
<p className="text-sm text-[var(--color-error)] mb-2">{error}</p>
|
|
<button
|
|
onClick={() => void fetchAgents(currentWorkDir)}
|
|
className="text-xs text-[var(--color-text-accent)] hover:underline"
|
|
>
|
|
{t('common.retry')}
|
|
</button>
|
|
</div>
|
|
) : allAgents.length === 0 ? (
|
|
<div className="text-center py-12 px-4 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
|
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-3 block">smart_toy</span>
|
|
<p className="text-sm text-[var(--color-text-secondary)] mb-1">{t('settings.agents.empty')}</p>
|
|
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.agents.emptyHint')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-6 min-w-0">
|
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
|
|
<div className="grid gap-4 px-5 py-5 md:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)] md:items-end">
|
|
<div className="min-w-0">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
|
{t('settings.agents.browserEyebrow')}
|
|
</div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
|
|
smart_toy
|
|
</span>
|
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
|
{t('settings.agents.browserTitle')}
|
|
</h3>
|
|
</div>
|
|
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
|
|
{t('settings.agents.description')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
|
<SummaryCard
|
|
label={t('settings.agents.summary.totalAgents')}
|
|
value={String(allAgents.length)}
|
|
icon="smart_toy"
|
|
/>
|
|
<SummaryCard
|
|
label={t('settings.agents.summary.activeAgents')}
|
|
value={String(activeAgents.length)}
|
|
icon="bolt"
|
|
/>
|
|
<SummaryCard
|
|
label={t('settings.agents.summary.sources')}
|
|
value={String(sourceCount)}
|
|
icon="layers"
|
|
className="col-span-2 md:col-span-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className={`grid gap-4 ${sourceCount >= 2 ? 'xl:grid-cols-2' : ''}`}>
|
|
{AGENT_SOURCE_ORDER.map((source) => {
|
|
const group = groupedAgents[source]
|
|
if (!group?.length) return null
|
|
|
|
const sourceLabel = t(`settings.agents.source.${source}`)
|
|
return (
|
|
<section
|
|
key={source}
|
|
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden min-w-0"
|
|
>
|
|
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full ${getAgentSourceAccentClass(source)}`}>
|
|
<span className="material-symbols-outlined text-[16px]">
|
|
{getAgentSourceIcon(source)}
|
|
</span>
|
|
</span>
|
|
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
|
{sourceLabel}
|
|
</h4>
|
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
|
{group.length}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
|
|
{t('settings.agents.groupHint', {
|
|
source: sourceLabel,
|
|
count: String(group.length),
|
|
})}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col p-2">
|
|
{group.map((agent) => (
|
|
<button
|
|
key={`${agent.source}-${agent.agentType}`}
|
|
onClick={() => selectAgent(agent)}
|
|
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<span
|
|
className="mt-0.5 flex-shrink-0 inline-flex items-center justify-center"
|
|
style={{ color: getAgentDotColor(agent.color) }}
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">smart_toy</span>
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-bold text-[var(--color-text-primary)] break-all">
|
|
{agent.agentType}
|
|
</span>
|
|
{agent.modelDisplay && (
|
|
<MetaPill>{agent.modelDisplay}</MetaPill>
|
|
)}
|
|
<MetaPill>{sourceLabel}</MetaPill>
|
|
<MetaPill>
|
|
{agent.isActive
|
|
? t('settings.agents.status.active')
|
|
: t('settings.agents.status.available')}
|
|
</MetaPill>
|
|
{agent.overriddenBy && (
|
|
<MetaPill>
|
|
{t('settings.agents.overriddenBy', {
|
|
source: t(`settings.agents.source.${agent.overriddenBy}`),
|
|
})}
|
|
</MetaPill>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] break-words [&_.prose]:text-xs [&_.prose]:leading-5 [&_.prose]:text-[var(--color-text-secondary)]">
|
|
<MarkdownRenderer
|
|
content={agent.description || t('settings.agents.noDescription')}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
|
|
<span>
|
|
{agent.tools?.length
|
|
? t('settings.agents.toolCount', { count: String(agent.tools.length) })
|
|
: t('settings.agents.noTools')}
|
|
</span>
|
|
{agent.baseDir && (
|
|
<span className="break-all">{agent.baseDir}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:opacity-100">
|
|
chevron_right
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AgentDetailView({ agent, onBack }: { agent: AgentDefinition; onBack: () => void }) {
|
|
const t = useTranslation()
|
|
const sourceLabel = t(`settings.agents.source.${agent.source}`)
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col gap-4 min-w-0">
|
|
<div>
|
|
<button
|
|
onClick={onBack}
|
|
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
|
|
{t('settings.agents.backToList')}
|
|
</button>
|
|
</div>
|
|
|
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
|
|
<div className="grid gap-4 px-5 py-5 lg:grid-cols-[minmax(0,1.5fr)_minmax(280px,0.9fr)] lg:items-start">
|
|
<div className="min-w-0">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
|
{t('settings.agents.entryEyebrow')}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
|
<span
|
|
className="h-3 w-3 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: getAgentDotColor(agent.color) }}
|
|
/>
|
|
<h3 className="text-[22px] font-semibold leading-tight text-[var(--color-text-primary)] break-all">
|
|
{agent.agentType}
|
|
</h3>
|
|
<MetaPill>{sourceLabel}</MetaPill>
|
|
{agent.modelDisplay && <MetaPill>{agent.modelDisplay}</MetaPill>}
|
|
<MetaPill>
|
|
{agent.isActive
|
|
? t('settings.agents.status.active')
|
|
: t('settings.agents.status.available')}
|
|
</MetaPill>
|
|
{agent.overriddenBy && (
|
|
<MetaPill>
|
|
{t('settings.agents.overriddenByShort', {
|
|
source: t(`settings.agents.source.${agent.overriddenBy}`),
|
|
})}
|
|
</MetaPill>
|
|
)}
|
|
</div>
|
|
<div className="max-w-4xl text-sm leading-6 text-[var(--color-text-secondary)]">
|
|
<MarkdownRenderer
|
|
content={agent.description || t('settings.agents.noDescription')}
|
|
/>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-xs text-[var(--color-text-tertiary)]">
|
|
<span>
|
|
{agent.tools?.length
|
|
? t('settings.agents.toolCount', { count: String(agent.tools.length) })
|
|
: t('settings.agents.noTools')}
|
|
</span>
|
|
{agent.baseDir && <span className="break-all">{agent.baseDir}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-2">
|
|
<DetailStat
|
|
label={t('settings.agents.summary.source')}
|
|
value={sourceLabel}
|
|
icon="layers"
|
|
/>
|
|
<DetailStat
|
|
label={t('settings.agents.summary.model')}
|
|
value={agent.modelDisplay || '—'}
|
|
icon="psychology"
|
|
/>
|
|
<DetailStat
|
|
label={t('settings.agents.summary.tools')}
|
|
value={String(agent.tools?.length ?? 0)}
|
|
icon="build"
|
|
/>
|
|
<DetailStat
|
|
label={t('settings.agents.summary.status')}
|
|
value={agent.isActive ? t('settings.agents.status.active') : t('settings.agents.status.available')}
|
|
icon="bolt"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{agent.tools && agent.tools.length > 0 && (
|
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
|
|
build
|
|
</span>
|
|
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
|
{t('settings.agents.tools')}
|
|
</h4>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{agent.tools.map((tool) => (
|
|
<MetaPill key={tool}>{tool}</MetaPill>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<section className="flex flex-1 min-h-0 min-w-0 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
|
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-xs font-mono text-[var(--color-text-secondary)] break-all">
|
|
{agent.baseDir || sourceLabel}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
|
|
{t('settings.agents.promptHint')}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="rounded-full bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
|
|
{t('settings.agents.systemPrompt')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface-container-lowest)]">
|
|
{agent.systemPrompt ? (
|
|
<div className="px-6 py-5 lg:px-8">
|
|
<MarkdownRenderer
|
|
content={agent.systemPrompt}
|
|
variant="document"
|
|
className="mx-auto max-w-[72ch]"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="px-6 py-10 text-center">
|
|
<span className="material-symbols-outlined text-[32px] text-[var(--color-text-tertiary)] mb-2 block">
|
|
article
|
|
</span>
|
|
<p className="text-sm text-[var(--color-text-tertiary)]">
|
|
{t('settings.agents.noSystemPrompt')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function getAgentDotColor(color?: string) {
|
|
return color && AGENT_COLORS[color] ? AGENT_COLORS[color] : 'var(--color-text-tertiary)'
|
|
}
|
|
|
|
function getAgentSourceIcon(source: AgentSource) {
|
|
switch (source) {
|
|
case 'userSettings':
|
|
return 'person'
|
|
case 'projectSettings':
|
|
return 'folder'
|
|
case 'localSettings':
|
|
return 'folder_lock'
|
|
case 'policySettings':
|
|
return 'shield'
|
|
case 'plugin':
|
|
return 'extension'
|
|
case 'flagSettings':
|
|
return 'terminal'
|
|
case 'built-in':
|
|
return 'inventory_2'
|
|
}
|
|
}
|
|
|
|
function getAgentSourceAccentClass(source: AgentSource) {
|
|
switch (source) {
|
|
case 'userSettings':
|
|
return 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]'
|
|
case 'projectSettings':
|
|
return 'bg-[var(--color-success-container)] text-[var(--color-success)]'
|
|
case 'localSettings':
|
|
return 'bg-[var(--color-info-container)] text-[var(--color-info)]'
|
|
case 'policySettings':
|
|
return 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
|
|
case 'plugin':
|
|
return 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
|
|
case 'flagSettings':
|
|
return 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
|
|
case 'built-in':
|
|
return 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
|
|
}
|
|
}
|
|
|
|
function MetaPill({ children }: { children: ReactNode }) {
|
|
return (
|
|
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
|
|
{children}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function SummaryCard({
|
|
label,
|
|
value,
|
|
icon,
|
|
className = '',
|
|
}: {
|
|
label: string
|
|
value: string
|
|
icon: string
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 ${className}`}>
|
|
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
|
<span className="material-symbols-outlined text-[14px]">{icon}</span>
|
|
<span>{label}</span>
|
|
</div>
|
|
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)]">
|
|
{value}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DetailStat({
|
|
label,
|
|
value,
|
|
icon,
|
|
}: {
|
|
label: string
|
|
value: string
|
|
icon: string
|
|
}) {
|
|
return (
|
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
|
|
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
|
<span className="material-symbols-outlined text-[14px]">{icon}</span>
|
|
<span>{label}</span>
|
|
</div>
|
|
<div className="mt-2 text-base font-semibold text-[var(--color-text-primary)] break-all">
|
|
{value}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
// ─── Skill Settings ──────────────────────────────────────
|
|
|
|
function SkillSettings() {
|
|
const selectedSkill = useSkillStore((s) => s.selectedSkill)
|
|
const t = useTranslation()
|
|
|
|
if (selectedSkill) {
|
|
return (
|
|
<div className="w-full min-w-0">
|
|
<SkillDetail />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="w-full min-w-0">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
|
|
{t('settings.skills.title')}
|
|
</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
|
|
{t('settings.skills.description')}
|
|
</p>
|
|
<SkillList />
|
|
</div>
|
|
)
|
|
}
|