mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Update downloads never used the configured proxy because electron-updater runs all traffic on its own session partition, while the proxy was applied to app/defaultSession only. Point proxy config at autoUpdater.netSession, and make the system fallback an explicit `mode: 'system'` since an empty setProxy config means fixed_servers with no rules (= direct), which also silently downgraded the default session away from the OS proxy. Disable differential download: blockmap-driven ranged requests against the GitHub CDN are RTT-bound and run far below line speed on both macOS and Windows; full downloads restore expected bandwidth. Guard captureWindowState and the resize forwarder against destroyed windows: quitAndInstall tears the window down while late move/resize/close events still fire, crashing the main process with "Object has been destroyed". Widen the About page container from max-w-lg to max-w-2xl so release notes markdown is no longer cramped.
4783 lines
204 KiB
TypeScript
4783 lines
204 KiB
TypeScript
import { useState, useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from 'react'
|
|
import QRCode from 'qrcode'
|
|
import {
|
|
closestCenter,
|
|
DndContext,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from '@dnd-kit/core'
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import { Copy, Eye, EyeOff, GripVertical, PowerOff, QrCode, RotateCw } from 'lucide-react'
|
|
import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
|
|
import { useProviderStore } from '../stores/providerStore'
|
|
import { useTranslation, type TranslationKey } from '../i18n'
|
|
import { Modal } from '../components/shared/Modal'
|
|
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
|
|
import { Input } from '../components/shared/Input'
|
|
import { Button } from '../components/shared/Button'
|
|
import { Dropdown } from '../components/shared/Dropdown'
|
|
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
|
|
import type { ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior, OutputStyleSource } from '../types/settings'
|
|
import type { Locale } from '../i18n'
|
|
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, Model1mSupport, ApiFormat, ProviderAuthStrategy } from '../types/provider'
|
|
import type { ProviderPreset } from '../types/providerPreset'
|
|
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'
|
|
import { usePluginStore } from '../stores/pluginStore'
|
|
import { PluginList } from '../components/plugins/PluginList'
|
|
import { PluginDetail } from '../components/plugins/PluginDetail'
|
|
import { ComputerUseSettings } from './ComputerUseSettings'
|
|
import { McpSettings } from './McpSettings'
|
|
import { TerminalSettings } from './TerminalSettings'
|
|
import { DiagnosticsSettings } from './DiagnosticsSettings'
|
|
import { TraceList } from './TraceList'
|
|
import { ActivitySettings } from './ActivitySettings'
|
|
import { MemorySettings } from './MemorySettings'
|
|
import { useUIStore } from '../stores/uiStore'
|
|
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
|
import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin'
|
|
import {
|
|
BUILT_IN_PROVIDER_IDS,
|
|
CLAUDE_OFFICIAL_PROVIDER_ID,
|
|
OPENAI_OFFICIAL_PROVIDER_ID,
|
|
} from '../constants/openaiOfficialProvider'
|
|
import { useUpdateStore } from '../stores/updateStore'
|
|
import { getBaseUrl } from '../api/client'
|
|
import { formatBytes } from '../lib/formatBytes'
|
|
import { isDesktopRuntime } from '../lib/desktopRuntime'
|
|
import { getDesktopHost } from '../lib/desktopHost'
|
|
import { publicAssetPath } from '../lib/publicAsset'
|
|
import {
|
|
getDesktopNotificationPermission,
|
|
notifyDesktop,
|
|
getDesktopNotificationPlatform,
|
|
openDesktopNotificationSettings,
|
|
requestDesktopNotificationPermission,
|
|
type DesktopNotificationPermission,
|
|
} from '../lib/desktopNotifications'
|
|
import {
|
|
API_KEY_JSON_PLACEHOLDER,
|
|
maskSettingsJsonSecrets,
|
|
restoreSettingsJsonSecrets,
|
|
stripProviderSettingsJsonEnv,
|
|
} from '../lib/providerSettingsJson'
|
|
import { copyTextToClipboard } from '../components/chat/clipboard'
|
|
|
|
const NETWORK_TIMEOUT_MIN_SECONDS = 30
|
|
const NETWORK_TIMEOUT_MAX_SECONDS = 1800
|
|
const NETWORK_TIMEOUT_STEP_SECONDS = 30
|
|
const SETTINGS_CHECKBOX_INPUT_CLASS = 'settings-checkbox-input peer'
|
|
const BUILT_IN_OUTPUT_STYLE_TRANSLATION_KEYS = {
|
|
default: {
|
|
label: 'settings.general.outputStyleBuiltin.default.label',
|
|
description: 'settings.general.outputStyleBuiltin.default.description',
|
|
},
|
|
Explanatory: {
|
|
label: 'settings.general.outputStyleBuiltin.explanatory.label',
|
|
description: 'settings.general.outputStyleBuiltin.explanatory.description',
|
|
},
|
|
Learning: {
|
|
label: 'settings.general.outputStyleBuiltin.learning.label',
|
|
description: 'settings.general.outputStyleBuiltin.learning.description',
|
|
},
|
|
} satisfies Record<string, { label: TranslationKey; description: TranslationKey }>
|
|
|
|
function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string | null {
|
|
if (!baseUrl) return null
|
|
|
|
try {
|
|
const url = new URL(baseUrl)
|
|
if (token) {
|
|
url.searchParams.set('serverUrl', baseUrl)
|
|
url.searchParams.set('h5Token', token)
|
|
}
|
|
return url.toString().replace(/\/$/, '')
|
|
} catch {
|
|
return token
|
|
? `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}serverUrl=${encodeURIComponent(baseUrl)}&h5Token=${encodeURIComponent(token)}`
|
|
: baseUrl
|
|
}
|
|
}
|
|
|
|
function isLanH5BaseUrl(url: URL): boolean {
|
|
return url.protocol === 'http:' &&
|
|
!!url.port &&
|
|
(
|
|
url.hostname === 'localhost' ||
|
|
url.hostname === '127.0.0.1' ||
|
|
url.hostname.startsWith('10.') ||
|
|
url.hostname.startsWith('192.168.') ||
|
|
/^172\.(1[6-9]|2\d|3[0-1])\./.test(url.hostname) ||
|
|
url.hostname.startsWith('169.254.')
|
|
)
|
|
}
|
|
|
|
function extractH5AccessAddressDraft(baseUrl: string | null): string {
|
|
if (!baseUrl) return ''
|
|
|
|
try {
|
|
const url = new URL(baseUrl)
|
|
return isLanH5BaseUrl(url) ? url.hostname : baseUrl
|
|
} catch {
|
|
return baseUrl
|
|
}
|
|
}
|
|
|
|
function extractHostnameFromUrl(value: string | null): string | null {
|
|
if (!value) return null
|
|
try {
|
|
return new URL(value).hostname || null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function extractH5AccessPort(baseUrl: string | null): string | null {
|
|
if (!baseUrl) return null
|
|
|
|
try {
|
|
const url = new URL(baseUrl)
|
|
return url.port || null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Mirrors the server-side fixedPort range (h5AccessService MIN/MAX_FIXED_PORT).
|
|
function parseH5FixedPortDraft(draft: string): number | null | 'invalid' {
|
|
const trimmed = draft.trim()
|
|
if (!trimmed) return null
|
|
if (!/^\d{1,5}$/.test(trimmed)) return 'invalid'
|
|
const port = Number(trimmed)
|
|
return port >= 1024 && port <= 65535 ? port : 'invalid'
|
|
}
|
|
|
|
// Mirrors the server-side disconnect grace range (h5AccessService
|
|
// MIN/MAX_DISCONNECT_GRACE_SECONDS). Empty = use the built-in 30s default.
|
|
function parseH5GraceDraft(draft: string): number | null | 'invalid' {
|
|
const trimmed = draft.trim()
|
|
if (!trimmed) return null
|
|
if (!/^\d{1,5}$/.test(trimmed)) return 'invalid'
|
|
const seconds = Number(trimmed)
|
|
return seconds >= 5 && seconds <= 86400 ? seconds : 'invalid'
|
|
}
|
|
|
|
function buildH5PublicBaseUrlFromHostDraft(draft: string, currentBaseUrl: string | null): string | null {
|
|
const trimmed = draft.trim()
|
|
if (!trimmed) return null
|
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed
|
|
|
|
try {
|
|
const current = currentBaseUrl ? new URL(currentBaseUrl) : null
|
|
if (!current) return trimmed
|
|
|
|
const port = current.port ? `:${current.port}` : ''
|
|
const path = current.pathname === '/' ? '' : current.pathname.replace(/\/+$/, '')
|
|
return `${current.protocol}//${trimmed}${port}${path}`
|
|
} catch {
|
|
return trimmed
|
|
}
|
|
}
|
|
|
|
export function Settings() {
|
|
const activeTab = useUIStore((s) => s.activeSettingsTab)
|
|
const setActiveTab = useUIStore((s) => s.setActiveSettingsTab)
|
|
const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab)
|
|
const t = useTranslation()
|
|
|
|
useEffect(() => {
|
|
if (!pendingSettingsTab) return
|
|
setActiveTab(pendingSettingsTab)
|
|
useUIStore.getState().setPendingSettingsTab(null)
|
|
}, [pendingSettingsTab, setActiveTab])
|
|
|
|
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 flex flex-col">
|
|
<div className="flex-1">
|
|
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
|
|
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
|
|
<TabButton icon="qr_code_2" label={t('settings.tab.h5Access')} active={activeTab === 'h5Access'} onClick={() => setActiveTab('h5Access')} />
|
|
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
|
|
<TabButton icon="terminal" label={t('settings.tab.terminal')} active={activeTab === 'terminal'} onClick={() => setActiveTab('terminal')} />
|
|
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
|
|
<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')} />
|
|
<TabButton icon="history_edu" label={t('settings.tab.memory')} active={activeTab === 'memory'} onClick={() => setActiveTab('memory')} />
|
|
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
|
|
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
|
|
<TabButton icon="monitoring" label={t('settings.tab.activity')} active={activeTab === 'activity'} onClick={() => setActiveTab('activity')} />
|
|
<TabButton icon="account_tree" label={t('settings.tab.trace')} active={activeTab === 'trace'} onClick={() => setActiveTab('trace')} />
|
|
<TabButton icon="monitor_heart" label={t('settings.tab.diagnostics')} active={activeTab === 'diagnostics'} onClick={() => setActiveTab('diagnostics')} />
|
|
</div>
|
|
<div className="border-t border-[var(--color-border)]/40 pt-1">
|
|
<TabButton icon="info" label={t('settings.tab.about')} active={activeTab === 'about'} onClick={() => setActiveTab('about')} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab content; trace embeds a full-bleed page that manages its own scroll */}
|
|
<div className={activeTab === 'trace' ? 'flex-1 flex min-h-0 flex-col overflow-hidden' : 'flex-1 overflow-y-auto px-8 py-6'}>
|
|
{activeTab === 'providers' && <ProviderSettings />}
|
|
{activeTab === 'activity' && <ActivitySettings />}
|
|
{activeTab === 'general' && <GeneralSettings />}
|
|
{activeTab === 'h5Access' && <H5AccessSettings />}
|
|
{activeTab === 'adapters' && <AdapterSettings />}
|
|
{activeTab === 'terminal' && <TerminalSettings showPreferences />}
|
|
{activeTab === 'mcp' && <McpSettings />}
|
|
{activeTab === 'agents' && <AgentsSettings />}
|
|
{activeTab === 'skills' && <SkillSettings />}
|
|
{activeTab === 'memory' && <MemorySettings />}
|
|
{activeTab === 'plugins' && <PluginSettings />}
|
|
{activeTab === 'computerUse' && <ComputerUseSettings />}
|
|
{activeTab === 'trace' && <TraceList />}
|
|
{activeTab === 'diagnostics' && <DiagnosticsSettings />}
|
|
{activeTab === 'about' && <AboutSettings />}
|
|
</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 ──────────────────────────────────────
|
|
|
|
type ProviderListItem =
|
|
| { id: typeof CLAUDE_OFFICIAL_PROVIDER_ID; kind: 'claude-official' }
|
|
| { id: typeof OPENAI_OFFICIAL_PROVIDER_ID; kind: 'openai-official' }
|
|
| { id: string; kind: 'saved'; provider: SavedProvider }
|
|
|
|
function defaultProviderOrder(providers: SavedProvider[]): string[] {
|
|
return [
|
|
...providers.map((provider) => provider.id),
|
|
...BUILT_IN_PROVIDER_IDS,
|
|
]
|
|
}
|
|
|
|
function normalizeProviderOrder(providerOrder: string[] | undefined, providers: SavedProvider[]): string[] {
|
|
const knownIds = new Set<string>(defaultProviderOrder(providers))
|
|
const seen = new Set<string>()
|
|
const order: string[] = []
|
|
|
|
const source = providerOrder && providerOrder.length > 0
|
|
? providerOrder
|
|
: defaultProviderOrder(providers)
|
|
|
|
for (const id of source) {
|
|
if (!knownIds.has(id) || seen.has(id)) continue
|
|
seen.add(id)
|
|
order.push(id)
|
|
}
|
|
|
|
for (const id of defaultProviderOrder(providers)) {
|
|
if (seen.has(id)) continue
|
|
seen.add(id)
|
|
order.push(id)
|
|
}
|
|
|
|
return order
|
|
}
|
|
|
|
function buildProviderListItems(
|
|
providers: SavedProvider[],
|
|
providerOrder: string[] | undefined,
|
|
): ProviderListItem[] {
|
|
const savedItems = new Map(
|
|
providers.map((provider) => [
|
|
provider.id,
|
|
{ id: provider.id, kind: 'saved', provider } satisfies ProviderListItem,
|
|
]),
|
|
)
|
|
const items = new Map<string, ProviderListItem>([
|
|
[CLAUDE_OFFICIAL_PROVIDER_ID, { id: CLAUDE_OFFICIAL_PROVIDER_ID, kind: 'claude-official' }],
|
|
[OPENAI_OFFICIAL_PROVIDER_ID, { id: OPENAI_OFFICIAL_PROVIDER_ID, kind: 'openai-official' }],
|
|
...savedItems,
|
|
])
|
|
|
|
return normalizeProviderOrder(providerOrder, providers)
|
|
.map((id) => items.get(id))
|
|
.filter((item): item is ProviderListItem => item !== undefined)
|
|
}
|
|
|
|
function providerItemTestId(item: ProviderListItem): string {
|
|
switch (item.kind) {
|
|
case 'claude-official':
|
|
return 'claude-official-provider'
|
|
case 'openai-official':
|
|
return 'openai-official-provider'
|
|
case 'saved':
|
|
return `provider-${item.provider.id}`
|
|
}
|
|
}
|
|
|
|
function ProviderSettings() {
|
|
const {
|
|
providers,
|
|
providerOrder,
|
|
activeId,
|
|
hasLoadedProviders,
|
|
presets,
|
|
isLoading,
|
|
isPresetsLoading,
|
|
fetchProviders,
|
|
fetchPresets,
|
|
deleteProvider,
|
|
reorderProviders,
|
|
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 [pendingDeleteProvider, setPendingDeleteProvider] = useState<SavedProvider | null>(null)
|
|
const [isDeletingProvider, setIsDeletingProvider] = useState(false)
|
|
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 6 },
|
|
}),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
}),
|
|
)
|
|
|
|
useEffect(() => {
|
|
void fetchProviders()
|
|
void fetchPresets()
|
|
}, [fetchPresets, fetchProviders])
|
|
|
|
const presetMap = useMemo(
|
|
() => new Map(presets.map((preset) => [preset.id, preset])),
|
|
[presets],
|
|
)
|
|
|
|
const handleDelete = async (provider: SavedProvider) => {
|
|
if (activeId === provider.id) return
|
|
setPendingDeleteProvider(provider)
|
|
}
|
|
|
|
const confirmDelete = async () => {
|
|
if (!pendingDeleteProvider) return
|
|
setIsDeletingProvider(true)
|
|
try {
|
|
await deleteProvider(pendingDeleteProvider.id)
|
|
setPendingDeleteProvider(null)
|
|
} catch (error) {
|
|
console.error(error)
|
|
} finally {
|
|
setIsDeletingProvider(false)
|
|
}
|
|
}
|
|
|
|
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 providerItems = useMemo(
|
|
() => buildProviderListItems(providers, providerOrder),
|
|
[providerOrder, providers],
|
|
)
|
|
|
|
const handleProviderDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event
|
|
if (!over || active.id === over.id) return
|
|
|
|
const ids = providerItems.map((item) => item.id)
|
|
const oldIndex = ids.indexOf(String(active.id))
|
|
const newIndex = ids.indexOf(String(over.id))
|
|
if (oldIndex === -1 || newIndex === -1) return
|
|
|
|
void reorderProviders(arrayMove(ids, oldIndex, newIndex))
|
|
}
|
|
|
|
const isClaudeOfficialActive = hasLoadedProviders && activeId === null
|
|
const isOpenAIOfficialActive = hasLoadedProviders && activeId === OPENAI_OFFICIAL_PROVIDER_ID
|
|
|
|
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)} disabled={isPresetsLoading || presets.length === 0}>
|
|
<span className="material-symbols-outlined text-[16px]">add</span>
|
|
{t('settings.providers.addProvider')}
|
|
</Button>
|
|
</div>
|
|
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleProviderDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={providerItems.map((item) => item.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
{providerItems.map((item) => {
|
|
if (item.kind === 'claude-official') {
|
|
return (
|
|
<SortableProviderCard
|
|
key={item.id}
|
|
item={item}
|
|
isActive={isClaudeOfficialActive}
|
|
dragLabel={t('settings.providers.dragToReorder')}
|
|
onActivate={!isClaudeOfficialActive ? handleActivateOfficial : undefined}
|
|
title={t('settings.providers.officialName')}
|
|
subtitle={t('settings.providers.officialDesc')}
|
|
badges={isClaudeOfficialActive ? (
|
|
<span className="rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/12 px-1.5 py-0.5 text-[10px] font-bold leading-none text-[var(--color-brand)]">{t('settings.providers.default')}</span>
|
|
) : null}
|
|
details={isClaudeOfficialActive ? (
|
|
<div className="border-t border-[var(--color-border-separator)] px-4 pb-4 pt-3">
|
|
<ClaudeOfficialLogin />
|
|
</div>
|
|
) : null}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (item.kind === 'openai-official') {
|
|
return (
|
|
<SortableProviderCard
|
|
key={item.id}
|
|
item={item}
|
|
isActive={isOpenAIOfficialActive}
|
|
dragLabel={t('settings.providers.dragToReorder')}
|
|
onActivate={!isOpenAIOfficialActive ? () => handleActivate(OPENAI_OFFICIAL_PROVIDER_ID) : undefined}
|
|
title={t('settings.providers.openaiOfficialName')}
|
|
subtitle={t('settings.providers.openaiOfficialDesc')}
|
|
badges={isOpenAIOfficialActive ? (
|
|
<span className="rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/12 px-1.5 py-0.5 text-[10px] font-bold leading-none text-[var(--color-brand)]">{t('settings.providers.default')}</span>
|
|
) : null}
|
|
details={isOpenAIOfficialActive ? (
|
|
<div className="border-t border-[var(--color-border-separator)] px-4 pb-4 pt-3">
|
|
<ChatGPTOfficialLogin />
|
|
</div>
|
|
) : null}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const provider = item.provider
|
|
const isActive = activeId === provider.id
|
|
const test = testResults[provider.id]
|
|
const preset = presetMap.get(provider.presetId)
|
|
|
|
return (
|
|
<SortableProviderCard
|
|
key={item.id}
|
|
item={item}
|
|
isActive={isActive}
|
|
dragLabel={t('settings.providers.dragToReorder')}
|
|
onActivate={!isActive ? () => handleActivate(provider.id) : undefined}
|
|
title={provider.name}
|
|
subtitle={`${provider.baseUrl} · ${provider.models.main}`}
|
|
badges={(
|
|
<>
|
|
{preset && preset.id !== 'custom' && (
|
|
<span className="rounded bg-[var(--color-surface-container-high)] px-1.5 py-0.5 text-[10px] font-medium leading-none text-[var(--color-text-tertiary)]">{preset.name}</span>
|
|
)}
|
|
{provider.apiFormat && provider.apiFormat !== 'anthropic' && (
|
|
<span className="rounded bg-[var(--color-surface-container-high)] px-1.5 py-0.5 text-[10px] font-medium leading-none text-[var(--color-warning)]">
|
|
{provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
|
|
</span>
|
|
)}
|
|
{isActive && (
|
|
<span className="rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/12 px-1.5 py-0.5 text-[10px] font-bold leading-none text-[var(--color-brand)]">{t('settings.providers.default')}</span>
|
|
)}
|
|
</>
|
|
)}
|
|
result={test && !test.loading && test.result ? (
|
|
<div className="mt-1 flex flex-col gap-0.5 text-xs">
|
|
<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>
|
|
) : null}
|
|
actions={(
|
|
<>
|
|
{!isActive && (
|
|
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>{t('settings.providers.setDefault')}</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>
|
|
</SortableContext>
|
|
</DndContext>
|
|
|
|
{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>
|
|
) : null}
|
|
|
|
{/* Create Modal — conditionally rendered so state resets on close */}
|
|
{showCreateModal && (
|
|
<ProviderFormModal open={true} onClose={() => setShowCreateModal(false)} mode="create" presets={presets} />
|
|
)}
|
|
|
|
{/* Edit Modal */}
|
|
{editingProvider && (
|
|
<ProviderFormModal key={editingProvider.id} open={true} onClose={() => setEditingProvider(null)} mode="edit" provider={editingProvider} presets={presets} />
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={pendingDeleteProvider !== null}
|
|
onClose={() => {
|
|
if (isDeletingProvider) return
|
|
setPendingDeleteProvider(null)
|
|
}}
|
|
onConfirm={confirmDelete}
|
|
title={t('common.delete')}
|
|
body={pendingDeleteProvider ? t('settings.providers.confirmDelete', { name: pendingDeleteProvider.name }) : ''}
|
|
confirmLabel={t('common.delete')}
|
|
cancelLabel={t('common.cancel')}
|
|
confirmVariant="danger"
|
|
loading={isDeletingProvider}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type SortableProviderCardProps = {
|
|
item: ProviderListItem
|
|
isActive: boolean
|
|
dragLabel: string
|
|
title: ReactNode
|
|
subtitle: ReactNode
|
|
badges?: ReactNode
|
|
result?: ReactNode
|
|
actions?: ReactNode
|
|
details?: ReactNode
|
|
onActivate?: () => void
|
|
}
|
|
|
|
function SortableProviderCard({
|
|
item,
|
|
isActive,
|
|
dragLabel,
|
|
title,
|
|
subtitle,
|
|
badges,
|
|
result,
|
|
actions,
|
|
details,
|
|
onActivate,
|
|
}: SortableProviderCardProps) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: item.id })
|
|
const style: CSSProperties = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
zIndex: isDragging ? 20 : undefined,
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
data-testid={providerItemTestId(item)}
|
|
className={`group relative flex flex-col rounded-[8px] border transition-colors ${
|
|
isActive
|
|
? 'border-[var(--color-border-focus)] bg-[var(--color-surface-container-low)]'
|
|
: 'border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
|
|
} ${isDragging ? 'shadow-[var(--shadow-dropdown)] opacity-90' : ''}`}
|
|
>
|
|
<div className="flex items-center gap-2 px-3 py-3">
|
|
<button
|
|
type="button"
|
|
{...attributes}
|
|
{...listeners}
|
|
aria-label={dragLabel}
|
|
title={dragLabel}
|
|
className="flex h-8 w-8 shrink-0 cursor-grab items-center justify-center rounded-[6px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-secondary)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:cursor-grabbing"
|
|
style={{ touchAction: 'none' }}
|
|
>
|
|
<GripVertical className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onActivate}
|
|
aria-disabled={!onActivate}
|
|
className={`flex min-w-0 flex-1 items-center gap-3 rounded-[6px] text-left focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
|
|
onActivate ? 'cursor-pointer' : 'cursor-default'
|
|
}`}
|
|
>
|
|
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{title}</span>
|
|
{badges}
|
|
</span>
|
|
<span className="mt-0.5 block truncate text-xs text-[var(--color-text-tertiary)]">{subtitle}</span>
|
|
{result}
|
|
</span>
|
|
</button>
|
|
{actions && (
|
|
<div className="flex shrink-0 items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100">
|
|
{actions}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{details}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Provider Form Modal ──────────────────────────────────────
|
|
|
|
type ProviderFormProps = {
|
|
open: boolean
|
|
onClose: () => void
|
|
mode: 'create' | 'edit'
|
|
provider?: SavedProvider
|
|
presets: ProviderPreset[]
|
|
}
|
|
|
|
function requirePreset(preset: ProviderPreset | undefined): ProviderPreset {
|
|
if (!preset) {
|
|
throw new Error('Provider presets are not configured')
|
|
}
|
|
return preset
|
|
}
|
|
|
|
const AUTO_COMPACT_WINDOW_ENV_KEY = 'CLAUDE_CODE_AUTO_COMPACT_WINDOW'
|
|
const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS'
|
|
const DISABLE_EXPERIMENTAL_BETAS_ENV_KEY = 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS'
|
|
const MODEL_CONTEXT_WINDOW_MIN = 16000
|
|
const MODEL_CONTEXT_WINDOW_MAX = 10000000
|
|
const MODEL_1M_CONTEXT_WINDOW = 1000000
|
|
const MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const
|
|
const DEFAULT_MODEL_1M_SUPPORT: Model1mSupport = {
|
|
main: false,
|
|
haiku: false,
|
|
sonnet: false,
|
|
opus: false,
|
|
}
|
|
const DEFAULT_PROVIDER_AUTH_STRATEGY: ProviderAuthStrategy = 'auth_token'
|
|
const AUTH_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'])
|
|
type ModelSlot = typeof MODEL_SLOTS[number]
|
|
type ModelContextInputs = Record<ModelSlot, string>
|
|
|
|
function formatContextWindow(value: number): string {
|
|
return value.toLocaleString('en-US')
|
|
}
|
|
|
|
function getPresetAutoCompactWindow(preset: ProviderPreset): string {
|
|
return preset.defaultEnv?.[AUTO_COMPACT_WINDOW_ENV_KEY] ?? ''
|
|
}
|
|
|
|
function getPresetAuthStrategy(preset: ProviderPreset): ProviderAuthStrategy {
|
|
return preset.authStrategy ?? DEFAULT_PROVIDER_AUTH_STRATEGY
|
|
}
|
|
|
|
function omitAuthEnv(env: Record<string, string> | undefined): Record<string, string> {
|
|
if (!env) return {}
|
|
return Object.fromEntries(
|
|
Object.entries(env).filter(([key]) => !AUTH_ENV_KEYS.has(key.toUpperCase())),
|
|
)
|
|
}
|
|
|
|
function getProviderAuthValue(apiKey: string, preset: ProviderPreset): string {
|
|
return apiKey || preset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || preset.defaultEnv?.ANTHROPIC_API_KEY || (preset.needsApiKey ? '(your API key)' : '')
|
|
}
|
|
|
|
function buildSettingsJsonAuthEnv(
|
|
apiFormat: ApiFormat,
|
|
authStrategy: ProviderAuthStrategy,
|
|
apiKey: string,
|
|
preset: ProviderPreset,
|
|
): Record<string, string> {
|
|
if (apiFormat !== 'anthropic') {
|
|
return { ANTHROPIC_API_KEY: 'proxy-managed' }
|
|
}
|
|
|
|
const value = getProviderAuthValue(apiKey, preset)
|
|
switch (authStrategy) {
|
|
case 'api_key':
|
|
return value ? { ANTHROPIC_API_KEY: value } : {}
|
|
case 'auth_token':
|
|
return value ? { ANTHROPIC_AUTH_TOKEN: value } : {}
|
|
case 'auth_token_empty_api_key':
|
|
return {
|
|
ANTHROPIC_API_KEY: '',
|
|
...(value ? { ANTHROPIC_AUTH_TOKEN: value } : {}),
|
|
}
|
|
case 'dual_same_token':
|
|
return value ? { ANTHROPIC_API_KEY: value, ANTHROPIC_AUTH_TOKEN: value } : {}
|
|
case 'dual_dummy':
|
|
return { ANTHROPIC_API_KEY: 'dummy', ANTHROPIC_AUTH_TOKEN: 'dummy' }
|
|
}
|
|
}
|
|
|
|
function inferAuthStrategyFromEnv(env: Record<string, string>): ProviderAuthStrategy | null {
|
|
if (env.ANTHROPIC_API_KEY === 'dummy' && env.ANTHROPIC_AUTH_TOKEN === 'dummy') return 'dual_dummy'
|
|
if (env.ANTHROPIC_API_KEY === '' && env.ANTHROPIC_AUTH_TOKEN) return 'auth_token_empty_api_key'
|
|
if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_API_KEY === env.ANTHROPIC_AUTH_TOKEN) return 'dual_same_token'
|
|
if (env.ANTHROPIC_AUTH_TOKEN) return 'auth_token'
|
|
if (env.ANTHROPIC_API_KEY) return 'api_key'
|
|
return null
|
|
}
|
|
|
|
function parseAutoCompactWindowInput(value: string): number | undefined {
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return undefined
|
|
const parsed = Number(trimmed)
|
|
if (!Number.isInteger(parsed)) return undefined
|
|
if (parsed < MODEL_CONTEXT_WINDOW_MIN || parsed > MODEL_CONTEXT_WINDOW_MAX) return undefined
|
|
return parsed
|
|
}
|
|
|
|
function getAutoCompactWindowErrorKey(value: string): 'number' | 'range' | null {
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return null
|
|
const parsed = Number(trimmed)
|
|
if (!Number.isInteger(parsed)) return 'number'
|
|
if (parsed < MODEL_CONTEXT_WINDOW_MIN || parsed > MODEL_CONTEXT_WINDOW_MAX) return 'range'
|
|
return null
|
|
}
|
|
|
|
function parseModelContextWindowsInput(value: string): number | undefined {
|
|
return parseAutoCompactWindowInput(value)
|
|
}
|
|
|
|
function getModelContextWindowErrorKey(value: string): 'number' | 'range' | null {
|
|
return getAutoCompactWindowErrorKey(value)
|
|
}
|
|
|
|
function getModelContextInputValue(
|
|
model: string | undefined,
|
|
preset: ProviderPreset,
|
|
provider?: SavedProvider,
|
|
): string {
|
|
const trimmedModel = model?.trim()
|
|
if (!trimmedModel) return ''
|
|
const value = provider?.modelContextWindows?.[trimmedModel] ?? preset.modelContextWindows?.[trimmedModel]
|
|
return value !== undefined ? String(value) : ''
|
|
}
|
|
|
|
function getModelContextInputs(
|
|
models: ModelMapping,
|
|
preset: ProviderPreset,
|
|
provider?: SavedProvider,
|
|
): ModelContextInputs {
|
|
const inputs = {} as ModelContextInputs
|
|
for (const slot of MODEL_SLOTS) {
|
|
inputs[slot] = getModelContextInputValue(models[slot], preset, provider)
|
|
}
|
|
return inputs
|
|
}
|
|
|
|
function buildModelContextWindows(
|
|
models: ModelMapping,
|
|
inputs: ModelContextInputs,
|
|
): Record<string, number> {
|
|
const windows: Record<string, number> = {}
|
|
for (const slot of MODEL_SLOTS) {
|
|
const model = models[slot]?.trim()
|
|
const parsed = parseModelContextWindowsInput(inputs[slot])
|
|
if (model && parsed !== undefined) {
|
|
windows[model] = parsed
|
|
}
|
|
}
|
|
return windows
|
|
}
|
|
|
|
function hasModel1mMarker(model: string): boolean {
|
|
return /\[1m\]$/i.test(model.trim()) || /:1m$/i.test(model.trim())
|
|
}
|
|
|
|
function stripModel1mMarker(model: string): string {
|
|
return model.trim().replace(/\[1m\]$/i, '').replace(/:1m$/i, '').trim()
|
|
}
|
|
|
|
function stripModel1mMarkers(models: ModelMapping): ModelMapping {
|
|
return {
|
|
main: stripModel1mMarker(models.main),
|
|
haiku: stripModel1mMarker(models.haiku),
|
|
sonnet: stripModel1mMarker(models.sonnet),
|
|
opus: stripModel1mMarker(models.opus),
|
|
}
|
|
}
|
|
|
|
function getInitialModel1mSupport(
|
|
models: ModelMapping,
|
|
provider?: SavedProvider,
|
|
): Model1mSupport {
|
|
return {
|
|
main: provider?.model1mSupport?.main === true || hasModel1mMarker(models.main),
|
|
haiku: provider?.model1mSupport?.haiku === true || hasModel1mMarker(models.haiku),
|
|
sonnet: provider?.model1mSupport?.sonnet === true || hasModel1mMarker(models.sonnet),
|
|
opus: provider?.model1mSupport?.opus === true || hasModel1mMarker(models.opus),
|
|
}
|
|
}
|
|
|
|
function applyModel1mSupport(model: string, enabled: boolean): string {
|
|
const stripped = stripModel1mMarker(model)
|
|
return enabled && stripped ? `${stripped}[1m]` : stripped
|
|
}
|
|
|
|
function applyModel1mSupportMapping(
|
|
models: ModelMapping,
|
|
model1mSupport: Model1mSupport,
|
|
): ModelMapping {
|
|
return {
|
|
main: applyModel1mSupport(models.main, model1mSupport.main),
|
|
haiku: applyModel1mSupport(models.haiku, model1mSupport.haiku),
|
|
sonnet: applyModel1mSupport(models.sonnet, model1mSupport.sonnet),
|
|
opus: applyModel1mSupport(models.opus, model1mSupport.opus),
|
|
}
|
|
}
|
|
|
|
function hasAnyModel1mSupport(model1mSupport: Model1mSupport): boolean {
|
|
return MODEL_SLOTS.some((slot) => model1mSupport[slot])
|
|
}
|
|
|
|
function shouldFill1mContextWindow(value: string): boolean {
|
|
const parsed = parseModelContextWindowsInput(value)
|
|
return parsed === undefined || parsed < MODEL_1M_CONTEXT_WINDOW
|
|
}
|
|
|
|
function apply1mSupportToContextInput(
|
|
inputs: ModelContextInputs,
|
|
slot: ModelSlot,
|
|
enabled: boolean,
|
|
): ModelContextInputs {
|
|
if (!enabled || !shouldFill1mContextWindow(inputs[slot])) return inputs
|
|
return { ...inputs, [slot]: String(MODEL_1M_CONTEXT_WINDOW) }
|
|
}
|
|
|
|
function apply1mSupportToContextInputs(
|
|
inputs: ModelContextInputs,
|
|
model1mSupport: Model1mSupport,
|
|
): ModelContextInputs {
|
|
let nextInputs = inputs
|
|
for (const slot of MODEL_SLOTS) {
|
|
nextInputs = apply1mSupportToContextInput(nextInputs, slot, model1mSupport[slot])
|
|
}
|
|
return nextInputs
|
|
}
|
|
|
|
function normalizeModelMapping(models: ModelMapping): ModelMapping {
|
|
const main = models.main.trim()
|
|
return {
|
|
main,
|
|
haiku: models.haiku.trim() || main,
|
|
sonnet: models.sonnet.trim() || main,
|
|
opus: models.opus.trim() || main,
|
|
}
|
|
}
|
|
|
|
function readSettingsEnvString(env: Record<string, unknown>, key: string): string | undefined {
|
|
const value = env[key]
|
|
if (typeof value !== 'string') return undefined
|
|
const trimmed = value.trim()
|
|
return trimmed || undefined
|
|
}
|
|
|
|
function readModelMappingFromSettingsEnv(env: Record<string, unknown>): Partial<ModelMapping> {
|
|
const haiku = readSettingsEnvString(env, 'ANTHROPIC_DEFAULT_HAIKU_MODEL')
|
|
const sonnet = readSettingsEnvString(env, 'ANTHROPIC_DEFAULT_SONNET_MODEL')
|
|
const opus = readSettingsEnvString(env, 'ANTHROPIC_DEFAULT_OPUS_MODEL')
|
|
const main = readSettingsEnvString(env, 'ANTHROPIC_MODEL') ?? sonnet ?? haiku ?? opus
|
|
|
|
return {
|
|
...(main ? { main } : {}),
|
|
...(haiku ? { haiku } : {}),
|
|
...(sonnet ? { sonnet } : {}),
|
|
...(opus ? { opus } : {}),
|
|
}
|
|
}
|
|
|
|
function applyToolSearchEnv(
|
|
env: Record<string, unknown>,
|
|
apiFormat: ApiFormat,
|
|
toolSearchEnabled: boolean,
|
|
): void {
|
|
delete env.ENABLE_TOOL_SEARCH
|
|
if (apiFormat === 'anthropic') {
|
|
env.ENABLE_TOOL_SEARCH = toolSearchEnabled ? 'true' : 'false'
|
|
}
|
|
}
|
|
|
|
function applyDisableExperimentalBetasEnv(
|
|
env: Record<string, unknown>,
|
|
disableExperimentalBetas: boolean,
|
|
): void {
|
|
if (disableExperimentalBetas) {
|
|
env[DISABLE_EXPERIMENTAL_BETAS_ENV_KEY] = '1'
|
|
} else {
|
|
delete env[DISABLE_EXPERIMENTAL_BETAS_ENV_KEY]
|
|
}
|
|
}
|
|
|
|
function updateSettingsJsonToolSearch(
|
|
raw: string,
|
|
apiFormat: ApiFormat,
|
|
toolSearchEnabled: boolean,
|
|
): string {
|
|
try {
|
|
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
|
|
const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
|
|
? parsed.env
|
|
: {}
|
|
const env = { ...existingEnv }
|
|
applyToolSearchEnv(env, apiFormat, toolSearchEnabled)
|
|
parsed.env = env
|
|
return JSON.stringify(parsed, null, 2)
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
function updateSettingsJsonDisableExperimentalBetas(
|
|
raw: string,
|
|
disableExperimentalBetas: boolean,
|
|
): string {
|
|
try {
|
|
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
|
|
const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
|
|
? parsed.env
|
|
: {}
|
|
const env = { ...existingEnv }
|
|
applyDisableExperimentalBetasEnv(env, disableExperimentalBetas)
|
|
parsed.env = env
|
|
return JSON.stringify(parsed, null, 2)
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
function readToolSearchEnabledFromEnv(env: Record<string, unknown>): boolean {
|
|
const value = env.ENABLE_TOOL_SEARCH
|
|
if (typeof value === 'boolean') return value
|
|
if (typeof value === 'number') return value !== 0
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toLowerCase()
|
|
if (['0', 'false', 'off', 'no'].includes(normalized)) return false
|
|
if (['1', 'true', 'on', 'yes', 'auto'].includes(normalized) || normalized.startsWith('auto:')) {
|
|
return true
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
function readDisableExperimentalBetasFromEnv(env: Record<string, unknown>): boolean {
|
|
const value = env[DISABLE_EXPERIMENTAL_BETAS_ENV_KEY]
|
|
if (typeof value === 'boolean') return value
|
|
if (typeof value === 'number') return value !== 0
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toLowerCase()
|
|
if (['0', 'false', 'off', 'no'].includes(normalized)) return false
|
|
if (['1', 'true', 'on', 'yes'].includes(normalized)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function updateSettingsJsonAutoCompactWindow(raw: string, value: string): string {
|
|
try {
|
|
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
|
|
const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
|
|
? parsed.env
|
|
: {}
|
|
const env = { ...existingEnv }
|
|
const trimmed = value.trim()
|
|
if (trimmed) {
|
|
env[AUTO_COMPACT_WINDOW_ENV_KEY] = trimmed
|
|
} else {
|
|
delete env[AUTO_COMPACT_WINDOW_ENV_KEY]
|
|
}
|
|
parsed.env = env
|
|
return JSON.stringify(parsed, null, 2)
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
function updateSettingsJsonModelContextWindows(
|
|
raw: string,
|
|
modelContextWindows: Record<string, number>,
|
|
): string {
|
|
try {
|
|
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
|
|
const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
|
|
? parsed.env
|
|
: {}
|
|
const env = { ...existingEnv }
|
|
if (Object.keys(modelContextWindows).length > 0) {
|
|
env[MODEL_CONTEXT_WINDOWS_ENV_KEY] = JSON.stringify(modelContextWindows)
|
|
} else {
|
|
delete env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
|
|
}
|
|
parsed.env = env
|
|
return JSON.stringify(parsed, null, 2)
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
function updateSettingsJsonModels(
|
|
raw: string,
|
|
models: ModelMapping,
|
|
model1mSupport: Model1mSupport = DEFAULT_MODEL_1M_SUPPORT,
|
|
): string {
|
|
try {
|
|
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
|
|
const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
|
|
? parsed.env
|
|
: {}
|
|
const runtimeModels = applyModel1mSupportMapping(models, model1mSupport)
|
|
parsed.env = {
|
|
...existingEnv,
|
|
ANTHROPIC_MODEL: runtimeModels.main,
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: runtimeModels.haiku,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: runtimeModels.sonnet,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: runtimeModels.opus,
|
|
}
|
|
return JSON.stringify(parsed, null, 2)
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
function updateSettingsJsonProviderConnection(
|
|
raw: string,
|
|
apiFormat: ApiFormat,
|
|
authStrategy: ProviderAuthStrategy,
|
|
apiKey: string,
|
|
preset: ProviderPreset,
|
|
baseUrl: string,
|
|
proxyBaseUrl: string,
|
|
toolSearchEnabled = true,
|
|
disableExperimentalBetas = false,
|
|
): string {
|
|
try {
|
|
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
|
|
const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
|
|
? parsed.env
|
|
: {}
|
|
const env = { ...existingEnv }
|
|
delete env.ANTHROPIC_API_KEY
|
|
delete env.ANTHROPIC_AUTH_TOKEN
|
|
applyToolSearchEnv(env, apiFormat, toolSearchEnabled)
|
|
applyDisableExperimentalBetasEnv(env, disableExperimentalBetas)
|
|
env.ANTHROPIC_BASE_URL = apiFormat !== 'anthropic' ? proxyBaseUrl : baseUrl
|
|
Object.assign(env, buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, preset))
|
|
parsed.env = env
|
|
return JSON.stringify(parsed, null, 2)
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
function getProviderProxyBaseUrl(): string {
|
|
return `${getBaseUrl().replace(/\/$/, '')}/proxy`
|
|
}
|
|
|
|
function buildFallbackPreset(provider?: SavedProvider): ProviderPreset {
|
|
return {
|
|
id: provider?.presetId ?? 'custom',
|
|
name: provider?.name ?? 'Custom',
|
|
baseUrl: provider?.baseUrl ?? '',
|
|
apiFormat: provider?.apiFormat ?? 'anthropic',
|
|
authStrategy: provider?.authStrategy,
|
|
defaultModels: provider?.models ?? { main: '', haiku: '', sonnet: '', opus: '' },
|
|
modelContextWindows: provider?.modelContextWindows,
|
|
defaultEnv: provider?.autoCompactWindow !== undefined
|
|
? { [AUTO_COMPACT_WINDOW_ENV_KEY]: String(provider.autoCompactWindow) }
|
|
: undefined,
|
|
needsApiKey: true,
|
|
websiteUrl: '',
|
|
}
|
|
}
|
|
|
|
function openExternalUrl(url: string) {
|
|
void getDesktopHost().shell.open(url)
|
|
.catch(() => window.open(url, '_blank', 'noopener,noreferrer'))
|
|
}
|
|
|
|
function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) {
|
|
const { createProvider, updateProvider, testConfig } = useProviderStore()
|
|
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
|
const t = useTranslation()
|
|
|
|
const availablePresets = presets.filter((p) => p.id !== 'official')
|
|
const regularPresets = availablePresets.filter((p) => !p.featured)
|
|
const featuredPresets = availablePresets.filter((p) => p.featured)
|
|
const presetDefaultEnvKeys = useMemo(
|
|
() => presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {})),
|
|
[presets],
|
|
)
|
|
const fallbackPreset = provider
|
|
? buildFallbackPreset(provider)
|
|
: requirePreset(availablePresets[availablePresets.length - 1])
|
|
const initialPreset = requirePreset(
|
|
provider
|
|
? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
|
|
: availablePresets[0] ?? fallbackPreset,
|
|
)
|
|
const initialModels = stripModel1mMarkers(provider?.models ?? initialPreset.defaultModels)
|
|
const initialModel1mSupport = getInitialModel1mSupport(
|
|
provider?.models ?? initialPreset.defaultModels,
|
|
provider,
|
|
)
|
|
const initialModelContextInputs = apply1mSupportToContextInputs(
|
|
getModelContextInputs(initialModels, initialPreset, provider),
|
|
initialModel1mSupport,
|
|
)
|
|
|
|
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 [authStrategy, setAuthStrategy] = useState<ProviderAuthStrategy>(provider?.authStrategy ?? getPresetAuthStrategy(initialPreset))
|
|
const [apiKey, setApiKey] = useState(provider?.apiKey ?? '')
|
|
const [showApiKey, setShowApiKey] = useState(false)
|
|
const [notes, setNotes] = useState(provider?.notes ?? '')
|
|
const [models, setModels] = useState<ModelMapping>(initialModels)
|
|
const [model1mSupport, setModel1mSupport] = useState<Model1mSupport>(initialModel1mSupport)
|
|
const [modelContextInputs, setModelContextInputs] = useState<ModelContextInputs>(initialModelContextInputs)
|
|
const [autoCompactWindow, setAutoCompactWindow] = useState(
|
|
provider?.autoCompactWindow !== undefined
|
|
? String(provider.autoCompactWindow)
|
|
: getPresetAutoCompactWindow(initialPreset),
|
|
)
|
|
const [toolSearchEnabled, setToolSearchEnabled] = useState(provider?.toolSearchEnabled ?? true)
|
|
const [disableExperimentalBetas, setDisableExperimentalBetas] = useState(provider?.disableExperimentalBetas ?? false)
|
|
const [showContextSettings, setShowContextSettings] = useState(false)
|
|
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)
|
|
const providerProxyBaseUrl = useMemo(() => getProviderProxyBaseUrl(), [])
|
|
|
|
// 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/providers').then(({ providersApi }) => {
|
|
providersApi.getSettings().then((settings) => {
|
|
const needsProxy = apiFormat !== 'anthropic'
|
|
const autoCompactWindowEnv = autoCompactWindow.trim()
|
|
const modelContextWindows = buildModelContextWindows(models, modelContextInputs)
|
|
const normalizedModels = normalizeModelMapping(models)
|
|
const runtimeModels = applyModel1mSupportMapping(normalizedModels, model1mSupport)
|
|
const existingEnv = (settings.env as Record<string, string>) || {}
|
|
const cleanedEnv = stripProviderSettingsJsonEnv(existingEnv, presetDefaultEnvKeys)
|
|
const mergedEnv: Record<string, unknown> = {
|
|
...cleanedEnv,
|
|
...omitAuthEnv(selectedPreset.defaultEnv),
|
|
...(autoCompactWindowEnv ? { [AUTO_COMPACT_WINDOW_ENV_KEY]: autoCompactWindowEnv } : {}),
|
|
...(Object.keys(modelContextWindows).length > 0
|
|
? { [MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows) }
|
|
: {}),
|
|
ANTHROPIC_BASE_URL: needsProxy ? providerProxyBaseUrl : baseUrl,
|
|
...buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, selectedPreset),
|
|
ANTHROPIC_MODEL: runtimeModels.main,
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: runtimeModels.haiku,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: runtimeModels.sonnet,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: runtimeModels.opus,
|
|
}
|
|
applyToolSearchEnv(mergedEnv, apiFormat, toolSearchEnabled)
|
|
applyDisableExperimentalBetasEnv(mergedEnv, disableExperimentalBetas)
|
|
const merged = {
|
|
...settings,
|
|
skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true,
|
|
env: mergedEnv,
|
|
}
|
|
setSettingsJson(JSON.stringify(merged, null, 2))
|
|
}).catch(() => {
|
|
setSettingsJson(JSON.stringify({}, null, 2))
|
|
})
|
|
})
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedPreset.id, providerProxyBaseUrl])
|
|
|
|
const handlePresetChange = (preset: ProviderPreset) => {
|
|
setSelectedPreset(preset)
|
|
setName(preset.name)
|
|
setBaseUrl(preset.baseUrl)
|
|
setApiFormat(preset.apiFormat ?? 'anthropic')
|
|
setAuthStrategy(getPresetAuthStrategy(preset))
|
|
const nextModels = stripModel1mMarkers(preset.defaultModels)
|
|
const nextModel1mSupport = getInitialModel1mSupport(preset.defaultModels)
|
|
const nextModelContextInputs = apply1mSupportToContextInputs(
|
|
getModelContextInputs(nextModels, preset),
|
|
nextModel1mSupport,
|
|
)
|
|
setModels(nextModels)
|
|
setModel1mSupport(nextModel1mSupport)
|
|
setModelContextInputs(nextModelContextInputs)
|
|
setAutoCompactWindow(getPresetAutoCompactWindow(preset))
|
|
setToolSearchEnabled(true)
|
|
setDisableExperimentalBetas(false)
|
|
setShowContextSettings(false)
|
|
setTestResult(null)
|
|
}
|
|
|
|
const isCustom = selectedPreset.id === 'custom'
|
|
const requiresApiKey = selectedPreset.needsApiKey !== false
|
|
const autoCompactWindowErrorKey = getAutoCompactWindowErrorKey(autoCompactWindow)
|
|
const modelContextWindowErrorSlots = MODEL_SLOTS.filter((slot) => getModelContextWindowErrorKey(modelContextInputs[slot]))
|
|
const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || !requiresApiKey || apiKey.trim()) && models.main.trim() && !settingsJsonError && !autoCompactWindowErrorKey && modelContextWindowErrorSlots.length === 0
|
|
const apiKeyUrl = selectedPreset.apiKeyUrl?.trim()
|
|
const promoText = selectedPreset.promoText?.trim()
|
|
const displayedSettingsJson = showApiKey
|
|
? settingsJson
|
|
: maskSettingsJsonSecrets(settingsJson)
|
|
const apiFormatItems = [
|
|
{
|
|
value: 'anthropic' as const,
|
|
label: t('settings.providers.apiFormatAnthropic'),
|
|
icon: <span className="material-symbols-outlined text-[17px]">hub</span>,
|
|
},
|
|
{
|
|
value: 'openai_chat' as const,
|
|
label: t('settings.providers.apiFormatOpenaiChat'),
|
|
icon: <span className="material-symbols-outlined text-[17px]">forum</span>,
|
|
},
|
|
{
|
|
value: 'openai_responses' as const,
|
|
label: t('settings.providers.apiFormatOpenaiResponses'),
|
|
icon: <span className="material-symbols-outlined text-[17px]">route</span>,
|
|
},
|
|
]
|
|
const selectedApiFormatLabel = apiFormatItems.find((item) => item.value === apiFormat)?.label ?? t('settings.providers.apiFormatAnthropic')
|
|
const authStrategyItems = [
|
|
{
|
|
value: 'auth_token' as const,
|
|
label: t('settings.providers.authStrategyAuthToken'),
|
|
description: t('settings.providers.authStrategyAuthTokenDesc'),
|
|
icon: <span className="material-symbols-outlined text-[17px]">key</span>,
|
|
},
|
|
{
|
|
value: 'auth_token_empty_api_key' as const,
|
|
label: t('settings.providers.authStrategyAuthTokenEmptyApiKey'),
|
|
description: t('settings.providers.authStrategyAuthTokenEmptyApiKeyDesc'),
|
|
icon: <span className="material-symbols-outlined text-[17px]">key_off</span>,
|
|
},
|
|
{
|
|
value: 'api_key' as const,
|
|
label: t('settings.providers.authStrategyApiKey'),
|
|
description: t('settings.providers.authStrategyApiKeyDesc'),
|
|
icon: <span className="material-symbols-outlined text-[17px]">vpn_key</span>,
|
|
},
|
|
{
|
|
value: 'dual_same_token' as const,
|
|
label: t('settings.providers.authStrategyDualSameToken'),
|
|
description: t('settings.providers.authStrategyDualSameTokenDesc'),
|
|
icon: <span className="material-symbols-outlined text-[17px]">sync_alt</span>,
|
|
},
|
|
{
|
|
value: 'dual_dummy' as const,
|
|
label: t('settings.providers.authStrategyDualDummy'),
|
|
description: t('settings.providers.authStrategyDualDummyDesc'),
|
|
icon: <span className="material-symbols-outlined text-[17px]">construction</span>,
|
|
},
|
|
] satisfies Array<{ value: ProviderAuthStrategy; label: string; description: string; icon: ReactNode }>
|
|
const selectedAuthStrategyLabel = authStrategyItems.find((item) => item.value === authStrategy)?.label ?? t('settings.providers.authStrategyAuthToken')
|
|
const toolSearchUnsupported = apiFormat !== 'anthropic'
|
|
const toolSearchDescription = toolSearchUnsupported
|
|
? t('settings.providers.toolSearchUnsupported')
|
|
: t('settings.providers.toolSearchDesc')
|
|
const configuredContextWindows = buildModelContextWindows(models, modelContextInputs)
|
|
const configuredContextSummary = Object.entries(configuredContextWindows)
|
|
.filter(([model], index, entries) => entries.findIndex(([candidate]) => candidate === model) === index)
|
|
.map(([model, value]) => `${model}: ${formatContextWindow(value)}`)
|
|
const parsedFallbackContextWindow = parseAutoCompactWindowInput(autoCompactWindow)
|
|
const fallbackContextSummary = parsedFallbackContextWindow !== undefined
|
|
? t('settings.providers.contextFallbackSummary', {
|
|
tokens: formatContextWindow(parsedFallbackContextWindow),
|
|
})
|
|
: t('settings.providers.contextFallbackAuto')
|
|
const contextSummary = configuredContextSummary.length > 0
|
|
? [...configuredContextSummary, fallbackContextSummary].join(' · ')
|
|
: t('settings.providers.contextSummaryAuto')
|
|
const shouldShowContextFields = showContextSettings || modelContextWindowErrorSlots.length > 0 || !!autoCompactWindowErrorKey
|
|
const handleAutoCompactWindowChange = (value: string) => {
|
|
setAutoCompactWindow(value)
|
|
setSettingsJson((current) => updateSettingsJsonAutoCompactWindow(current, value))
|
|
}
|
|
const handleBaseUrlChange = (value: string) => {
|
|
setBaseUrl(value)
|
|
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, apiKey, selectedPreset, value, providerProxyBaseUrl, toolSearchEnabled, disableExperimentalBetas))
|
|
}
|
|
const handleApiKeyChange = (value: string) => {
|
|
setApiKey(value)
|
|
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, value, selectedPreset, baseUrl, providerProxyBaseUrl, toolSearchEnabled, disableExperimentalBetas))
|
|
}
|
|
const handleApiFormatChange = (value: ApiFormat) => {
|
|
setApiFormat(value)
|
|
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, value, authStrategy, apiKey, selectedPreset, baseUrl, providerProxyBaseUrl, toolSearchEnabled, disableExperimentalBetas))
|
|
}
|
|
const handleAuthStrategyChange = (value: ProviderAuthStrategy) => {
|
|
setAuthStrategy(value)
|
|
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, value, apiKey, selectedPreset, baseUrl, providerProxyBaseUrl, toolSearchEnabled, disableExperimentalBetas))
|
|
}
|
|
const handleToolSearchToggle = (enabled: boolean) => {
|
|
if (toolSearchUnsupported) return
|
|
setToolSearchEnabled(enabled)
|
|
setSettingsJson((current) => updateSettingsJsonToolSearch(current, apiFormat, enabled))
|
|
}
|
|
const handleDisableExperimentalBetasToggle = (disabled: boolean) => {
|
|
setDisableExperimentalBetas(disabled)
|
|
setSettingsJson((current) => updateSettingsJsonDisableExperimentalBetas(current, disabled))
|
|
}
|
|
const handleModelChange = (slot: ModelSlot, value: string) => {
|
|
const hasMarker = hasModel1mMarker(value)
|
|
const nextModels = { ...models, [slot]: stripModel1mMarker(value) }
|
|
const nextModel1mSupport = hasMarker
|
|
? { ...model1mSupport, [slot]: true }
|
|
: model1mSupport
|
|
const nextInputs = {
|
|
...modelContextInputs,
|
|
[slot]: getModelContextInputValue(nextModels[slot], selectedPreset, provider),
|
|
}
|
|
const nextInputsWith1mSupport = apply1mSupportToContextInput(
|
|
nextInputs,
|
|
slot,
|
|
nextModel1mSupport[slot],
|
|
)
|
|
setModels(nextModels)
|
|
setModel1mSupport(nextModel1mSupport)
|
|
setModelContextInputs(nextInputsWith1mSupport)
|
|
setSettingsJson((current) => updateSettingsJsonModelContextWindows(
|
|
updateSettingsJsonModels(current, normalizeModelMapping(nextModels), nextModel1mSupport),
|
|
buildModelContextWindows(nextModels, nextInputsWith1mSupport),
|
|
))
|
|
}
|
|
const handleModel1mSupportChange = (slot: ModelSlot, enabled: boolean) => {
|
|
const nextModel1mSupport = { ...model1mSupport, [slot]: enabled }
|
|
const nextInputs = apply1mSupportToContextInput(modelContextInputs, slot, enabled)
|
|
setModel1mSupport(nextModel1mSupport)
|
|
setModelContextInputs(nextInputs)
|
|
setSettingsJson((current) => updateSettingsJsonModelContextWindows(
|
|
updateSettingsJsonModels(current, normalizeModelMapping(models), nextModel1mSupport),
|
|
buildModelContextWindows(models, nextInputs),
|
|
))
|
|
}
|
|
const handleModelContextWindowChange = (slot: ModelSlot, value: string) => {
|
|
const nextInputs = { ...modelContextInputs, [slot]: value }
|
|
setModelContextInputs(nextInputs)
|
|
setSettingsJson((current) => updateSettingsJsonModelContextWindows(
|
|
current,
|
|
buildModelContextWindows(models, nextInputs),
|
|
))
|
|
}
|
|
const renderPresetButton = (preset: ProviderPreset) => (
|
|
<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
|
|
? 'border-[var(--color-brand)] bg-[var(--color-surface-container-high)] text-[var(--color-brand)] shadow-[var(--shadow-focus-ring)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
{preset.name}
|
|
</button>
|
|
)
|
|
|
|
const handleSubmit = async () => {
|
|
if (!canSubmit || isSubmitting) return
|
|
const normalizedModels = normalizeModelMapping(models)
|
|
const parsedAutoCompactWindow = parseAutoCompactWindowInput(autoCompactWindow)
|
|
const parsedModelContextWindows = buildModelContextWindows(models, modelContextInputs)
|
|
const storedModel1mSupport = hasAnyModel1mSupport(model1mSupport)
|
|
? model1mSupport
|
|
: undefined
|
|
setIsSubmitting(true)
|
|
try {
|
|
// Write the edited cc-haha settings.json first so provider-specific model
|
|
// settings never conflict with the user's global ~/.claude/settings.json.
|
|
if (settingsJson.trim()) {
|
|
try {
|
|
const parsed = restoreSettingsJsonSecrets(JSON.parse(settingsJson), settingsJson, apiKey)
|
|
const { providersApi } = await import('../api/providers')
|
|
await providersApi.updateSettings(parsed)
|
|
} catch {
|
|
// JSON validation already prevents this
|
|
}
|
|
}
|
|
|
|
if (mode === 'create') {
|
|
await createProvider({
|
|
presetId: selectedPreset.id,
|
|
name: name.trim(),
|
|
apiKey: apiKey.trim(),
|
|
authStrategy,
|
|
baseUrl: baseUrl.trim(),
|
|
apiFormat,
|
|
models: normalizedModels,
|
|
...(storedModel1mSupport !== undefined && { model1mSupport: storedModel1mSupport }),
|
|
...(parsedAutoCompactWindow !== undefined && { autoCompactWindow: parsedAutoCompactWindow }),
|
|
...(Object.keys(parsedModelContextWindows).length > 0 && { modelContextWindows: parsedModelContextWindows }),
|
|
toolSearchEnabled,
|
|
...(disableExperimentalBetas && { disableExperimentalBetas }),
|
|
notes: notes.trim() || undefined,
|
|
})
|
|
} else if (provider) {
|
|
const input: UpdateProviderInput = {
|
|
name: name.trim(),
|
|
baseUrl: baseUrl.trim(),
|
|
authStrategy,
|
|
apiFormat,
|
|
models: normalizedModels,
|
|
model1mSupport: storedModel1mSupport ?? null,
|
|
autoCompactWindow: parsedAutoCompactWindow ?? null,
|
|
modelContextWindows: Object.keys(parsedModelContextWindows).length > 0
|
|
? parsedModelContextWindows
|
|
: null,
|
|
toolSearchEnabled,
|
|
disableExperimentalBetas,
|
|
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 handleClose = () => {
|
|
if (isSubmitting) return
|
|
onClose()
|
|
}
|
|
|
|
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,
|
|
authStrategy,
|
|
})
|
|
} else {
|
|
if (requiresApiKey && !apiKey.trim()) return
|
|
result = await testConfig({
|
|
baseUrl: baseUrl.trim(),
|
|
apiKey: apiKey.trim() || selectedPreset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || 'local',
|
|
modelId: models.main.trim(),
|
|
authStrategy,
|
|
apiFormat,
|
|
})
|
|
}
|
|
setTestResult(result)
|
|
} catch {
|
|
setTestResult({ connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } })
|
|
} finally {
|
|
setIsTesting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onClose={handleClose}
|
|
title={mode === 'create' ? t('settings.providers.addTitle') : t('settings.providers.editTitle')}
|
|
width={720}
|
|
footer={
|
|
<>
|
|
<Button variant="secondary" onClick={handleClose} disabled={isSubmitting}>{t('common.cancel')}</Button>
|
|
<Button onClick={handleSubmit} disabled={!canSubmit || isSubmitting} 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-col gap-2">
|
|
<div className="flex flex-wrap gap-2">
|
|
{regularPresets.map(renderPresetButton)}
|
|
</div>
|
|
{featuredPresets.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 border-t border-[var(--color-border)]/60 pt-2">
|
|
{featuredPresets.map(renderPresetButton)}
|
|
</div>
|
|
)}
|
|
</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')} />
|
|
|
|
<Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => handleBaseUrlChange(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} />
|
|
|
|
{/* 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>
|
|
<Dropdown<ApiFormat>
|
|
items={apiFormatItems}
|
|
value={apiFormat}
|
|
onChange={handleApiFormatChange}
|
|
width="100%"
|
|
className="block w-full"
|
|
trigger={
|
|
<button
|
|
type="button"
|
|
className="flex h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{selectedApiFormatLabel}</span>
|
|
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
|
|
</button>
|
|
}
|
|
/>
|
|
{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}
|
|
|
|
{apiFormat === 'anthropic' && (
|
|
<div>
|
|
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.authStrategy')}</label>
|
|
<Dropdown<ProviderAuthStrategy>
|
|
items={authStrategyItems}
|
|
value={authStrategy}
|
|
onChange={handleAuthStrategyChange}
|
|
width="100%"
|
|
className="block w-full"
|
|
trigger={
|
|
<button
|
|
type="button"
|
|
className="flex min-h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{selectedAuthStrategyLabel}</span>
|
|
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
|
|
</button>
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<label
|
|
className={`relative flex items-start gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-3 transition-colors ${
|
|
toolSearchUnsupported
|
|
? 'cursor-not-allowed opacity-70'
|
|
: 'cursor-pointer hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
aria-label={t('settings.providers.toolSearchEnabled')}
|
|
checked={toolSearchEnabled && !toolSearchUnsupported}
|
|
disabled={toolSearchUnsupported}
|
|
onChange={(e) => handleToolSearchToggle(e.target.checked)}
|
|
className={SETTINGS_CHECKBOX_INPUT_CLASS}
|
|
/>
|
|
<SettingsCheckboxMark checked={toolSearchEnabled && !toolSearchUnsupported} disabled={toolSearchUnsupported} />
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.providers.toolSearchEnabled')}
|
|
</div>
|
|
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
|
{toolSearchDescription}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
|
|
<label className="relative flex cursor-pointer items-start gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-3 transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]">
|
|
<input
|
|
type="checkbox"
|
|
aria-label={t('settings.providers.disableExperimentalBetas')}
|
|
checked={disableExperimentalBetas}
|
|
onChange={(e) => handleDisableExperimentalBetasToggle(e.target.checked)}
|
|
className={SETTINGS_CHECKBOX_INPUT_CLASS}
|
|
/>
|
|
<SettingsCheckboxMark checked={disableExperimentalBetas} />
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.providers.disableExperimentalBetas')}
|
|
</div>
|
|
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
|
{t('settings.providers.disableExperimentalBetasDesc')}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="provider-api-key" className="text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.providers.apiKey')}
|
|
{mode === 'create' && requiresApiKey && <span className="text-[var(--color-error)] ml-0.5">*</span>}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
id="provider-api-key"
|
|
type={showApiKey ? 'text' : 'password'}
|
|
value={apiKey}
|
|
onChange={(e) => handleApiKeyChange(e.target.value)}
|
|
placeholder="sk-..."
|
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 pr-10 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowApiKey((visible) => !visible)}
|
|
aria-label={showApiKey ? 'Hide API Key' : 'Show API Key'}
|
|
className="absolute right-1.5 top-1/2 flex h-7 w-7 -translate-y-1/2 cursor-pointer items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">
|
|
{showApiKey ? 'visibility_off' : 'visibility'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{(apiKeyUrl || promoText) && (
|
|
<div className="-mt-2 flex flex-col gap-1.5">
|
|
{apiKeyUrl && (
|
|
<button
|
|
type="button"
|
|
onClick={() => openExternalUrl(apiKeyUrl)}
|
|
className="group inline-flex h-6 w-fit cursor-pointer items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 text-[11px] font-medium leading-none text-[var(--color-brand)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
|
|
>
|
|
<span className="material-symbols-outlined text-[13px]">key</span>
|
|
{t('settings.providers.getApiKey')}
|
|
<span className="material-symbols-outlined text-[9px] opacity-60 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5">arrow_outward</span>
|
|
</button>
|
|
)}
|
|
{promoText && (
|
|
<button
|
|
type="button"
|
|
onClick={() => apiKeyUrl && openExternalUrl(apiKeyUrl)}
|
|
disabled={!apiKeyUrl}
|
|
className="group flex w-full cursor-pointer items-start gap-1.5 rounded-[var(--radius-sm)] border border-[var(--color-brand)]/25 bg-[var(--color-brand)]/8 px-2.5 py-1.5 text-left text-[11px] leading-5 text-[var(--color-text-primary)] transition-colors hover:border-[var(--color-brand)]/45 hover:bg-[var(--color-brand)]/12 focus:outline-none focus:shadow-[var(--shadow-focus-ring)] disabled:cursor-default disabled:hover:border-[var(--color-brand)]/25 disabled:hover:bg-[var(--color-brand)]/8"
|
|
>
|
|
<span className="material-symbols-outlined mt-0.5 text-[13px] text-[var(--color-brand)]">tips_and_updates</span>
|
|
<span>{promoText}</span>
|
|
{apiKeyUrl && (
|
|
<span className="material-symbols-outlined ml-auto mt-1 text-[10px] text-[var(--color-brand)] opacity-45 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5">arrow_outward</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 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">
|
|
{MODEL_SLOTS.map((slot) => {
|
|
const labelKey = slot === 'main'
|
|
? 'settings.providers.mainModel'
|
|
: slot === 'haiku'
|
|
? 'settings.providers.haikuModel'
|
|
: slot === 'sonnet'
|
|
? 'settings.providers.sonnetModel'
|
|
: 'settings.providers.opusModel'
|
|
const label = t(labelKey)
|
|
return (
|
|
<div key={slot} className="min-w-0">
|
|
<Input
|
|
label={label}
|
|
required={slot === 'main'}
|
|
value={models[slot]}
|
|
onChange={(e) => handleModelChange(slot, e.target.value)}
|
|
placeholder={slot === 'main' ? t('settings.providers.modelIdPlaceholder') : t('settings.providers.sameAsMain')}
|
|
/>
|
|
<label className="mt-1 inline-flex h-6 w-fit cursor-pointer items-center gap-1.5 rounded-[var(--radius-sm)] px-1 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]">
|
|
<input
|
|
type="checkbox"
|
|
checked={model1mSupport[slot]}
|
|
onChange={(e) => handleModel1mSupportChange(slot, e.target.checked)}
|
|
aria-label={`1M support: ${slot}`}
|
|
className="h-3.5 w-3.5 rounded border-[var(--color-border)] text-[var(--color-brand)] focus:ring-[var(--color-brand)]"
|
|
/>
|
|
<span>{t('settings.providers.model1mSupportShort')}</span>
|
|
</label>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowContextSettings((visible) => !visible)}
|
|
className="flex w-full items-start gap-3 px-3 py-3 text-left outline-none transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:shadow-[var(--shadow-focus-ring)]"
|
|
aria-expanded={shouldShowContextFields}
|
|
>
|
|
<span className="material-symbols-outlined mt-0.5 text-[18px] text-[var(--color-brand)]">compress</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.providers.contextSettingsTitle')}
|
|
</span>
|
|
<span className="mt-1 block truncate text-xs text-[var(--color-text-secondary)]">
|
|
{contextSummary}
|
|
</span>
|
|
<span className="mt-1 block text-[11px] leading-5 text-[var(--color-text-tertiary)]">
|
|
{t('settings.providers.contextSettingsDesc')}
|
|
</span>
|
|
</span>
|
|
<span className="mt-0.5 inline-flex items-center gap-1 text-xs font-medium text-[var(--color-brand)]">
|
|
{shouldShowContextFields
|
|
? t('settings.providers.contextSettingsHide')
|
|
: t('settings.providers.contextSettingsEdit')}
|
|
<span className="material-symbols-outlined text-[16px]">
|
|
{shouldShowContextFields ? 'expand_less' : 'expand_more'}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
|
|
{shouldShowContextFields && (
|
|
<div className="border-t border-[var(--color-border)] px-3 pb-3 pt-3">
|
|
<div>
|
|
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelContextWindows')}</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{MODEL_SLOTS.map((slot) => {
|
|
const errorKey = getModelContextWindowErrorKey(modelContextInputs[slot])
|
|
const labelKey = slot === 'main'
|
|
? 'settings.providers.mainContextWindow'
|
|
: slot === 'haiku'
|
|
? 'settings.providers.haikuContextWindow'
|
|
: slot === 'sonnet'
|
|
? 'settings.providers.sonnetContextWindow'
|
|
: 'settings.providers.opusContextWindow'
|
|
return (
|
|
<div key={slot}>
|
|
<Input
|
|
label={t(labelKey)}
|
|
value={modelContextInputs[slot]}
|
|
onChange={(e) => handleModelContextWindowChange(slot, e.target.value)}
|
|
placeholder={t('settings.providers.contextWindowPlaceholder')}
|
|
/>
|
|
{errorKey && (
|
|
<p className="text-[11px] text-[var(--color-error)] mt-1">
|
|
{errorKey === 'number'
|
|
? t('settings.providers.modelContextWindowNumberError')
|
|
: t('settings.providers.modelContextWindowRangeError')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
|
|
{t('settings.providers.modelContextWindowsDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-3">
|
|
<Input
|
|
label={t('settings.providers.autoCompactWindow')}
|
|
value={autoCompactWindow}
|
|
onChange={(e) => handleAutoCompactWindowChange(e.target.value)}
|
|
placeholder={t('settings.providers.autoCompactWindowPlaceholder')}
|
|
/>
|
|
{autoCompactWindowErrorKey ? (
|
|
<p className="text-[11px] text-[var(--color-error)] mt-1">
|
|
{autoCompactWindowErrorKey === 'number'
|
|
? t('settings.providers.autoCompactWindowNumberError')
|
|
: t('settings.providers.autoCompactWindowRangeError')}
|
|
</p>
|
|
) : (
|
|
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
|
|
{t('settings.providers.autoCompactWindowDesc')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</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={displayedSettingsJson}
|
|
onChange={(e) => {
|
|
const raw = e.target.value
|
|
try {
|
|
const parsed = restoreSettingsJsonSecrets(JSON.parse(raw), settingsJson, apiKey)
|
|
setSettingsJson(JSON.stringify(parsed, null, 2))
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
const nextApiKey = env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY
|
|
if (nextApiKey && nextApiKey !== '(your API key)' && nextApiKey !== API_KEY_JSON_PLACEHOLDER) {
|
|
setApiKey(nextApiKey)
|
|
}
|
|
const nextAuthStrategy = inferAuthStrategyFromEnv(env)
|
|
if (nextAuthStrategy) {
|
|
setAuthStrategy(nextAuthStrategy)
|
|
}
|
|
setToolSearchEnabled(readToolSearchEnabledFromEnv(env))
|
|
setDisableExperimentalBetas(readDisableExperimentalBetasFromEnv(env))
|
|
if (env[AUTO_COMPACT_WINDOW_ENV_KEY] !== undefined) {
|
|
setAutoCompactWindow(String(env[AUTO_COMPACT_WINDOW_ENV_KEY]))
|
|
} else {
|
|
setAutoCompactWindow('')
|
|
}
|
|
let parsedContextWindows: Record<string, number> = {}
|
|
if (typeof env[MODEL_CONTEXT_WINDOWS_ENV_KEY] === 'string') {
|
|
try {
|
|
const parsedContext = JSON.parse(env[MODEL_CONTEXT_WINDOWS_ENV_KEY]) as Record<string, unknown>
|
|
parsedContextWindows = Object.fromEntries(
|
|
Object.entries(parsedContext)
|
|
.filter(([, value]) => typeof value === 'number' && Number.isInteger(value)),
|
|
) as Record<string, number>
|
|
} catch {
|
|
parsedContextWindows = {}
|
|
}
|
|
}
|
|
const newModels = readModelMappingFromSettingsEnv(env)
|
|
if (Object.keys(newModels).length > 0) {
|
|
setModels((prev) => {
|
|
const mergedModels = { ...prev, ...newModels }
|
|
const nextModel1mSupport = {
|
|
main: hasModel1mMarker(mergedModels.main),
|
|
haiku: hasModel1mMarker(mergedModels.haiku),
|
|
sonnet: hasModel1mMarker(mergedModels.sonnet),
|
|
opus: hasModel1mMarker(mergedModels.opus),
|
|
}
|
|
const nextModels = stripModel1mMarkers(mergedModels)
|
|
setModel1mSupport(nextModel1mSupport)
|
|
setModelContextInputs(apply1mSupportToContextInputs(
|
|
getModelContextInputs(nextModels, {
|
|
...selectedPreset,
|
|
modelContextWindows: parsedContextWindows,
|
|
}),
|
|
nextModel1mSupport,
|
|
))
|
|
return nextModels
|
|
})
|
|
} else if (Object.keys(parsedContextWindows).length > 0) {
|
|
setModelContextInputs(getModelContextInputs(models, {
|
|
...selectedPreset,
|
|
modelContextWindows: parsedContextWindows,
|
|
}))
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setSettingsJson(raw)
|
|
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>
|
|
)
|
|
}
|
|
|
|
|
|
// ─── General Settings ──────────────────────────────────────
|
|
|
|
export function GeneralSettings() {
|
|
const {
|
|
thinkingEnabled,
|
|
setThinkingEnabled,
|
|
permissionMode,
|
|
setPermissionMode,
|
|
autoDreamEnabled,
|
|
setAutoDreamEnabled,
|
|
locale,
|
|
setLocale,
|
|
theme,
|
|
setTheme,
|
|
chatSendBehavior,
|
|
setChatSendBehavior,
|
|
outputStyle,
|
|
outputStyles,
|
|
outputStyleScope,
|
|
outputStylesLoading,
|
|
outputStyleError,
|
|
fetchOutputStyles,
|
|
setOutputStyle,
|
|
skipWebFetchPreflight,
|
|
setSkipWebFetchPreflight,
|
|
desktopNotificationsEnabled,
|
|
setDesktopNotificationsEnabled,
|
|
webSearch,
|
|
setWebSearch,
|
|
network,
|
|
setNetwork,
|
|
traceCapture,
|
|
setTraceCaptureEnabled,
|
|
responseLanguage,
|
|
setResponseLanguage,
|
|
appMode,
|
|
appModeRequiresRestart,
|
|
fetchAppMode,
|
|
setAppMode: setAppModeAction,
|
|
uiZoom,
|
|
setUiZoom,
|
|
} = useSettingsStore()
|
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
|
const sessions = useSessionStore((s) => s.sessions)
|
|
const t = useTranslation()
|
|
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
|
|
const [networkDraft, setNetworkDraft] = useState(network)
|
|
const [networkTimeoutInput, setNetworkTimeoutInput] = useState(String(Math.round(network.aiRequestTimeoutMs / 1000)))
|
|
const [networkSaveError, setNetworkSaveError] = useState<string | null>(null)
|
|
const [isSavingNetwork, setIsSavingNetwork] = useState(false)
|
|
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('default')
|
|
const [notificationActionRunning, setNotificationActionRunning] = useState(false)
|
|
const [autoDreamConfirmOpen, setAutoDreamConfirmOpen] = useState(false)
|
|
const [autoDreamActionRunning, setAutoDreamActionRunning] = useState(false)
|
|
const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false)
|
|
const [pendingMode, setPendingMode] = useState<AppMode | null>(null)
|
|
const [pendingPortableDir, setPendingPortableDir] = useState<string | null>(null)
|
|
const [portableDirDraft, setPortableDirDraft] = useState('')
|
|
const [modeActionRunning, setModeActionRunning] = useState(false)
|
|
const [modeError, setModeError] = useState<string | null>(null)
|
|
const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom)
|
|
const [isUiZoomDragging, setIsUiZoomDragging] = useState(false)
|
|
const isUiZoomDraggingRef = useRef(false)
|
|
const addToast = useUIStore((s) => s.addToast)
|
|
const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch)
|
|
const uiZoomPercent = Math.round(uiZoomDraft * 100)
|
|
const uiZoomRangeProgress = `${Math.round(((uiZoomDraft - UI_ZOOM_MIN) / (UI_ZOOM_MAX - UI_ZOOM_MIN)) * 1000) / 10}%`
|
|
const activeConfigDir = appMode.activeConfigDir ?? (appMode.mode === 'portable' ? appMode.portableDir : null)
|
|
const configDirSource = appMode.configDirSource ?? (appMode.mode === 'portable' ? 'portable' : 'system')
|
|
const isEnvironmentConfigDir = configDirSource === 'environment'
|
|
const activeSession = useMemo(
|
|
() => sessions.find((session) => session.id === activeSessionId),
|
|
[activeSessionId, sessions],
|
|
)
|
|
const outputStyleWorkDir =
|
|
activeSession?.workDirExists === false
|
|
? null
|
|
: activeSession?.workDir ?? activeSession?.projectRoot ?? null
|
|
|
|
useEffect(() => {
|
|
setWebSearchDraft(webSearch)
|
|
}, [webSearch])
|
|
|
|
useEffect(() => {
|
|
void fetchOutputStyles(outputStyleWorkDir)
|
|
}, [fetchOutputStyles, outputStyleWorkDir])
|
|
|
|
useEffect(() => {
|
|
setNetworkDraft(network)
|
|
setNetworkTimeoutInput(String(Math.round(network.aiRequestTimeoutMs / 1000)))
|
|
setNetworkSaveError(null)
|
|
}, [network])
|
|
|
|
useEffect(() => {
|
|
if (!isUiZoomDragging) {
|
|
setUiZoomDraft(uiZoom)
|
|
}
|
|
}, [isUiZoomDragging, uiZoom])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
getDesktopNotificationPermission().then((permission) => {
|
|
if (!cancelled) setNotificationPermission(permission)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!isDesktopRuntime()) return
|
|
void fetchAppMode()
|
|
}, [fetchAppMode])
|
|
|
|
useEffect(() => {
|
|
setPortableDirDraft(appMode.portableDir ?? appMode.defaultPortableDir ?? '')
|
|
}, [appMode.defaultPortableDir, appMode.portableDir])
|
|
|
|
const LANGUAGES: Array<{ value: Locale; label: string }> = [
|
|
{ value: 'en', label: 'English' },
|
|
{ value: 'zh', label: '简体中文' },
|
|
{ value: 'zh-TW', label: '繁體中文' },
|
|
{ value: 'jp', label: '日本語' },
|
|
{ value: 'kr', label: '한국어' },
|
|
]
|
|
|
|
|
|
const RESPONSE_LANGUAGES: Array<{ value: string; label: string }> = [
|
|
{ value: '', label: t('settings.general.responseLangDefault') },
|
|
{ value: 'english', label: 'English' },
|
|
{ value: 'chinese', label: '中文 (Chinese)' },
|
|
{ value: 'japanese', label: '日本語 (Japanese)' },
|
|
{ value: 'korean', label: '한국어 (Korean)' },
|
|
{ value: 'spanish', label: 'Español (Spanish)' },
|
|
{ value: 'french', label: 'Français (French)' },
|
|
{ value: 'german', label: 'Deutsch (German)' },
|
|
{ value: 'portuguese', label: 'Português (Portuguese)' },
|
|
{ value: 'italian', label: 'Italiano (Italian)' },
|
|
{ value: 'russian', label: 'Русский (Russian)' },
|
|
{ value: 'dutch', label: 'Nederlands (Dutch)' },
|
|
{ value: 'polish', label: 'Polski (Polish)' },
|
|
{ value: 'turkish', label: 'Türkçe (Turkish)' },
|
|
{ value: 'hindi', label: 'हिन्दी (Hindi)' },
|
|
{ value: 'indonesian', label: 'Bahasa Indonesia' },
|
|
{ value: 'ukrainian', label: 'Українська (Ukrainian)' },
|
|
{ value: 'greek', label: 'Ελληνικά (Greek)' },
|
|
{ value: 'czech', label: 'Čeština (Czech)' },
|
|
{ value: 'danish', label: 'Dansk (Danish)' },
|
|
{ value: 'swedish', label: 'Svenska (Swedish)' },
|
|
{ value: 'norwegian', label: 'Norsk (Norwegian)' },
|
|
]
|
|
const selectedResponseLanguageLabel =
|
|
RESPONSE_LANGUAGES.find(({ value }) => value === responseLanguage)?.label ?? RESPONSE_LANGUAGES[0]!.label
|
|
const outputStyleItems = outputStyles.map((style) => ({
|
|
value: style.value,
|
|
label: getOutputStyleLabel(style, t),
|
|
description: `${getOutputStyleDescription(style, t)} · ${getOutputStyleSourceLabel(style.source, t)}`,
|
|
}))
|
|
const selectedOutputStyle =
|
|
outputStyles.find((style) => style.value === outputStyle) ?? outputStyles[0]
|
|
const selectedOutputStyleLabel = selectedOutputStyle
|
|
? getOutputStyleLabel(selectedOutputStyle, t)
|
|
: outputStyle
|
|
const selectedOutputStyleDescription = selectedOutputStyle
|
|
? getOutputStyleDescription(selectedOutputStyle, t)
|
|
: ''
|
|
const outputStyleScopeLabel = outputStyleScope === 'localSettings'
|
|
? t('settings.general.outputStyleScopeLocal')
|
|
: t('settings.general.outputStyleScopeUser')
|
|
const outputStyleScopeHint = outputStyleScope === 'localSettings'
|
|
? t('settings.general.outputStyleScopeLocalHint')
|
|
: t('settings.general.outputStyleScopeUserHint')
|
|
|
|
const THEMES: Array<{ value: ThemeMode; label: string }> = [
|
|
{ value: 'white', label: t('settings.general.appearance.white') },
|
|
{ value: 'light', label: t('settings.general.appearance.light') },
|
|
{ value: 'dark', label: t('settings.general.appearance.dark') },
|
|
]
|
|
|
|
const WEB_SEARCH_MODES: Array<{ value: WebSearchMode; label: string }> = [
|
|
{ value: 'auto', label: t('settings.general.webSearch.mode.auto') },
|
|
{ value: 'tavily', label: t('settings.general.webSearch.mode.tavily') },
|
|
{ value: 'brave', label: t('settings.general.webSearch.mode.brave') },
|
|
{ value: 'anthropic', label: t('settings.general.webSearch.mode.anthropic') },
|
|
{ value: 'disabled', label: t('settings.general.webSearch.mode.disabled') },
|
|
]
|
|
|
|
const NETWORK_PROXY_MODES: Array<{ value: NetworkProxyMode; label: string; description: string }> = [
|
|
{
|
|
value: 'direct',
|
|
label: t('settings.general.networkProxyModeDirect'),
|
|
description: t('settings.general.networkProxyModeDirectDescription'),
|
|
},
|
|
{
|
|
value: 'system',
|
|
label: t('settings.general.networkProxyModeSystem'),
|
|
description: t('settings.general.networkProxyModeSystemDescription'),
|
|
},
|
|
{
|
|
value: 'manual',
|
|
label: t('settings.general.networkProxyModeManual'),
|
|
description: t('settings.general.networkProxyModeManualDescription'),
|
|
},
|
|
]
|
|
|
|
const CHAT_SEND_BEHAVIORS: Array<{ value: ChatSendBehavior; label: string; description: string }> = [
|
|
{
|
|
value: 'enter',
|
|
label: t('settings.general.chatSendBehaviorEnter'),
|
|
description: t('settings.general.chatSendBehaviorEnterDescription'),
|
|
},
|
|
{
|
|
value: 'modifierEnter',
|
|
label: t('settings.general.chatSendBehaviorModifier'),
|
|
description: t('settings.general.chatSendBehaviorModifierDescription'),
|
|
},
|
|
]
|
|
|
|
const notificationStatusLabel: Record<DesktopNotificationPermission, string> = {
|
|
granted: t('settings.general.notificationsStatusGranted'),
|
|
denied: t('settings.general.notificationsStatusDenied'),
|
|
default: t('settings.general.notificationsStatusDefault'),
|
|
unsupported: t('settings.general.notificationsStatusUnsupported'),
|
|
}
|
|
|
|
const handleDesktopNotificationsToggle = async (enabled: boolean) => {
|
|
await setDesktopNotificationsEnabled(enabled)
|
|
if (!enabled) return
|
|
|
|
setNotificationActionRunning(true)
|
|
try {
|
|
const permission = await requestDesktopNotificationPermission()
|
|
setNotificationPermission(permission)
|
|
if (permission === 'granted' && getDesktopNotificationPlatform() !== 'win32') {
|
|
void notifyDesktop({
|
|
title: t('settings.general.notificationsTestTitle'),
|
|
body: t('settings.general.notificationsTestBody'),
|
|
})
|
|
}
|
|
} finally {
|
|
setNotificationActionRunning(false)
|
|
}
|
|
}
|
|
|
|
const handleAutoDreamToggle = (enabled: boolean) => {
|
|
if (enabled) {
|
|
setAutoDreamConfirmOpen(true)
|
|
return
|
|
}
|
|
void setAutoDreamEnabled(false)
|
|
}
|
|
|
|
const confirmAutoDreamEnable = async () => {
|
|
setAutoDreamActionRunning(true)
|
|
try {
|
|
await setAutoDreamEnabled(true)
|
|
setAutoDreamConfirmOpen(false)
|
|
} finally {
|
|
setAutoDreamActionRunning(false)
|
|
}
|
|
}
|
|
|
|
const handleNotificationPermissionAction = async () => {
|
|
setNotificationActionRunning(true)
|
|
try {
|
|
if (notificationPermission === 'denied') {
|
|
await openDesktopNotificationSettings()
|
|
} else {
|
|
const permission = await requestDesktopNotificationPermission()
|
|
setNotificationPermission(permission)
|
|
if (permission === 'granted') {
|
|
void notifyDesktop({
|
|
title: t('settings.general.notificationsTestTitle'),
|
|
body: t('settings.general.notificationsTestBody'),
|
|
})
|
|
}
|
|
if (permission === 'denied') {
|
|
await openDesktopNotificationSettings()
|
|
}
|
|
}
|
|
} finally {
|
|
setNotificationActionRunning(false)
|
|
}
|
|
}
|
|
|
|
const networkProxyUrl = networkDraft.proxy.url.trim()
|
|
const networkProxyError =
|
|
networkDraft.proxy.mode === 'manual' && !networkProxyUrl
|
|
? t('settings.general.networkProxyUrlRequired')
|
|
: networkDraft.proxy.mode === 'manual' && !isValidHttpProxyUrl(networkProxyUrl)
|
|
? t('settings.general.networkProxyUrlInvalid')
|
|
: null
|
|
const timeoutSeconds = Math.round(networkDraft.aiRequestTimeoutMs / 1000)
|
|
const parsedNetworkTimeoutSeconds = (() => {
|
|
const trimmed = networkTimeoutInput.trim()
|
|
if (!/^\d+$/.test(trimmed)) return null
|
|
const seconds = Number(trimmed)
|
|
if (!Number.isFinite(seconds) || seconds < NETWORK_TIMEOUT_MIN_SECONDS || seconds > NETWORK_TIMEOUT_MAX_SECONDS) return null
|
|
return seconds
|
|
})()
|
|
const networkTimeoutError =
|
|
networkTimeoutInput.trim().length === 0
|
|
? t('settings.general.networkTimeoutRequired')
|
|
: parsedNetworkTimeoutSeconds === null
|
|
? t('settings.general.networkTimeoutRange', {
|
|
min: String(NETWORK_TIMEOUT_MIN_SECONDS),
|
|
max: String(NETWORK_TIMEOUT_MAX_SECONDS),
|
|
})
|
|
: null
|
|
const networkDirty =
|
|
networkDraft.aiRequestTimeoutMs !== network.aiRequestTimeoutMs ||
|
|
networkDraft.proxy.mode !== network.proxy.mode ||
|
|
networkDraft.proxy.url.trim() !== network.proxy.url.trim()
|
|
|
|
const setNetworkTimeoutSeconds = (seconds: number) => {
|
|
const nextSeconds = Math.min(Math.max(Math.round(seconds), NETWORK_TIMEOUT_MIN_SECONDS), NETWORK_TIMEOUT_MAX_SECONDS)
|
|
setNetworkTimeoutInput(String(nextSeconds))
|
|
setNetworkDraft((current) => ({
|
|
...current,
|
|
aiRequestTimeoutMs: nextSeconds * 1000,
|
|
}))
|
|
setNetworkSaveError(null)
|
|
}
|
|
|
|
const saveNetworkSettings = async () => {
|
|
if (networkProxyError) {
|
|
setNetworkSaveError(networkProxyError)
|
|
return
|
|
}
|
|
if (networkTimeoutError || parsedNetworkTimeoutSeconds === null) {
|
|
setNetworkSaveError(networkTimeoutError ?? t('settings.general.networkTimeoutRange', {
|
|
min: String(NETWORK_TIMEOUT_MIN_SECONDS),
|
|
max: String(NETWORK_TIMEOUT_MAX_SECONDS),
|
|
}))
|
|
return
|
|
}
|
|
|
|
setIsSavingNetwork(true)
|
|
setNetworkSaveError(null)
|
|
try {
|
|
await setNetwork({
|
|
aiRequestTimeoutMs: parsedNetworkTimeoutSeconds * 1000,
|
|
proxy: {
|
|
mode: networkDraft.proxy.mode,
|
|
url: networkDraft.proxy.mode === 'manual' ? networkProxyUrl : '',
|
|
},
|
|
})
|
|
addToast({
|
|
type: 'success',
|
|
message: t('settings.general.networkSaved'),
|
|
})
|
|
} catch (error) {
|
|
setNetworkSaveError(error instanceof Error ? error.message : String(error))
|
|
} finally {
|
|
setIsSavingNetwork(false)
|
|
}
|
|
}
|
|
|
|
const handleOutputStyleChange = async (value: string) => {
|
|
try {
|
|
await setOutputStyle(value, outputStyleWorkDir)
|
|
addToast({
|
|
type: 'success',
|
|
message: t('settings.general.outputStyleSaved'),
|
|
})
|
|
} catch {
|
|
// The store exposes outputStyleError below; keep the interaction local.
|
|
}
|
|
}
|
|
|
|
const openPortableDirPicker = async () => {
|
|
setModeError(null)
|
|
const host = getDesktopHost()
|
|
if (!host.capabilities.dialogs) {
|
|
setModeError(t('settings.general.storagePickerError'))
|
|
return
|
|
}
|
|
try {
|
|
const selected = await host.dialogs.open({
|
|
directory: true,
|
|
multiple: false,
|
|
title: t('settings.general.storageChooseDirTitle'),
|
|
})
|
|
if (typeof selected === 'string') {
|
|
setPortableDirDraft(selected)
|
|
}
|
|
} catch {
|
|
setModeError(t('settings.general.storagePickerError'))
|
|
}
|
|
}
|
|
|
|
const openModeSwitchConfirm = (mode: AppMode) => {
|
|
if (isEnvironmentConfigDir) {
|
|
setModeError(t('settings.general.storageEnvironmentSwitchBlocked'))
|
|
return
|
|
}
|
|
|
|
const portableDir = portableDirDraft.trim()
|
|
if (mode === 'portable' && !portableDir) {
|
|
setModeError(t('settings.general.storageNoDirError'))
|
|
return
|
|
}
|
|
|
|
setModeError(null)
|
|
setPendingMode(mode)
|
|
setPendingPortableDir(mode === 'portable' ? portableDir : null)
|
|
setModeSwitchConfirmOpen(true)
|
|
}
|
|
|
|
const closeModeSwitchConfirm = () => {
|
|
if (modeActionRunning) return
|
|
setModeSwitchConfirmOpen(false)
|
|
setPendingMode(null)
|
|
setPendingPortableDir(null)
|
|
}
|
|
|
|
const confirmModeSwitch = async () => {
|
|
if (!pendingMode) return
|
|
|
|
setModeActionRunning(true)
|
|
setModeError(null)
|
|
try {
|
|
await setAppModeAction(pendingMode, pendingPortableDir)
|
|
const host = getDesktopHost()
|
|
await host.appMode.prepareRestart()
|
|
await host.appMode.restart()
|
|
} catch (error) {
|
|
setModeError(
|
|
error instanceof Error
|
|
? error.message
|
|
: t('settings.general.storageRestartError'),
|
|
)
|
|
setModeSwitchConfirmOpen(false)
|
|
setPendingMode(null)
|
|
setPendingPortableDir(null)
|
|
setModeActionRunning(false)
|
|
}
|
|
}
|
|
|
|
const setUiZoomDraggingState = (dragging: boolean) => {
|
|
isUiZoomDraggingRef.current = dragging
|
|
setIsUiZoomDragging(dragging)
|
|
}
|
|
|
|
const commitUiZoom = (value: number) => {
|
|
const nextZoom = Number.isFinite(value) ? value : UI_ZOOM_DEFAULT
|
|
setUiZoomDraggingState(false)
|
|
setUiZoomDraft(nextZoom)
|
|
setUiZoom(nextZoom)
|
|
}
|
|
|
|
const uiZoomSection = (
|
|
<div className="mt-8">
|
|
<div className="mb-3 flex items-end justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.uiZoom')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)]">{t('settings.general.uiZoomDescription')}</p>
|
|
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
|
|
<span>{t('settings.general.uiZoomShortcutHint')}</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="font-medium text-[var(--color-text-secondary)]">{t('settings.general.uiZoomShortcutMac')}</span>
|
|
<kbd className="settings-zoom-kbd">⌘</kbd>
|
|
<kbd className="settings-zoom-kbd">+</kbd>
|
|
<span>/</span>
|
|
<kbd className="settings-zoom-kbd">⌘</kbd>
|
|
<kbd className="settings-zoom-kbd">-</kbd>
|
|
<span>/</span>
|
|
<kbd className="settings-zoom-kbd">⌘</kbd>
|
|
<kbd className="settings-zoom-kbd">0</kbd>
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="font-medium text-[var(--color-text-secondary)]">{t('settings.general.uiZoomShortcutWindows')}</span>
|
|
<kbd className="settings-zoom-kbd">Ctrl</kbd>
|
|
<kbd className="settings-zoom-kbd">+</kbd>
|
|
<span>/</span>
|
|
<kbd className="settings-zoom-kbd">Ctrl</kbd>
|
|
<kbd className="settings-zoom-kbd">-</kbd>
|
|
<span>/</span>
|
|
<kbd className="settings-zoom-kbd">Ctrl</kbd>
|
|
<kbd className="settings-zoom-kbd">0</kbd>
|
|
</span>
|
|
<span>{t('settings.general.uiZoomShortcutResetHint')}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-shrink-0 items-center gap-2">
|
|
<span className="min-w-[48px] rounded-md bg-[var(--color-surface-container-low)] px-2 py-1 text-center text-sm font-medium text-[var(--color-text-secondary)]">
|
|
{uiZoomPercent}%
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label={t('settings.general.uiZoomReset')}
|
|
title={t('settings.general.uiZoomReset')}
|
|
onClick={() => {
|
|
setIsUiZoomDragging(false)
|
|
setUiZoomDraft(UI_ZOOM_DEFAULT)
|
|
setUiZoom(UI_ZOOM_DEFAULT)
|
|
}}
|
|
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-xs font-medium text-[var(--color-text-secondary)] transition-colors 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-border-focus)]"
|
|
>
|
|
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
|
100%
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`settings-zoom-control flex items-center gap-3 ${isUiZoomDragging ? 'is-dragging' : ''}`}
|
|
style={{ '--settings-zoom-range-progress': uiZoomRangeProgress } as CSSProperties}
|
|
>
|
|
<span className="w-9 text-right text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MIN * 100)}%</span>
|
|
<div className="settings-zoom-range-wrap flex-1">
|
|
<div className="settings-zoom-preview" aria-hidden="true">
|
|
{uiZoomPercent}%
|
|
</div>
|
|
<input
|
|
type="range"
|
|
aria-label={t('settings.general.uiZoom')}
|
|
min={UI_ZOOM_MIN}
|
|
max={UI_ZOOM_MAX}
|
|
step={UI_ZOOM_STEP}
|
|
value={uiZoomDraft}
|
|
onPointerDown={() => {
|
|
setUiZoomDraggingState(true)
|
|
}}
|
|
onPointerUp={(e) => commitUiZoom(e.currentTarget.valueAsNumber)}
|
|
onPointerCancel={() => {
|
|
setUiZoomDraggingState(false)
|
|
setUiZoomDraft(uiZoom)
|
|
}}
|
|
onChange={(e) => {
|
|
const nextZoom = Number.isFinite(e.currentTarget.valueAsNumber)
|
|
? e.currentTarget.valueAsNumber
|
|
: UI_ZOOM_DEFAULT
|
|
setUiZoomDraft(nextZoom)
|
|
if (!isUiZoomDraggingRef.current) {
|
|
setUiZoom(nextZoom)
|
|
}
|
|
}}
|
|
onBlur={(e) => {
|
|
if (uiZoomDraft !== uiZoom) {
|
|
commitUiZoom(e.currentTarget.valueAsNumber)
|
|
} else {
|
|
setUiZoomDraggingState(false)
|
|
}
|
|
}}
|
|
className="settings-zoom-range w-full"
|
|
/>
|
|
</div>
|
|
<span className="w-9 text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MAX * 100)}%</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div className="max-w-xl">
|
|
{/* Appearance selector */}
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.appearanceTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.appearanceDescription')}</p>
|
|
<div className="flex gap-2 mb-8">
|
|
{THEMES.map(({ value, label }) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => void setTheme(value)}
|
|
aria-pressed={theme === value}
|
|
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
|
|
theme === value
|
|
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 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-[var(--color-on-primary)] border-[var(--color-brand)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Response Language */}
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.responseLangTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.responseLangDescription')}</p>
|
|
<Dropdown<string>
|
|
items={RESPONSE_LANGUAGES}
|
|
value={responseLanguage}
|
|
onChange={(value) => void setResponseLanguage(value)}
|
|
width="100%"
|
|
maxHeight={320}
|
|
className="mb-8 block w-full"
|
|
trigger={
|
|
<button
|
|
type="button"
|
|
aria-label={t('settings.general.responseLangTitle')}
|
|
className="flex h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{selectedResponseLanguageLabel}</span>
|
|
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{/* Output style */}
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.outputStyleTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.outputStyleDescription')}</p>
|
|
<div className="mb-8 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
|
|
<Dropdown<string>
|
|
items={outputStyleItems}
|
|
value={outputStyle}
|
|
onChange={(value) => void handleOutputStyleChange(value)}
|
|
width="100%"
|
|
maxHeight={360}
|
|
className="block w-full"
|
|
trigger={
|
|
<button
|
|
type="button"
|
|
aria-label={t('settings.general.outputStyleSelectLabel')}
|
|
disabled={outputStylesLoading}
|
|
className="flex min-h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)] disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">format_paint</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate font-medium">
|
|
{outputStylesLoading
|
|
? t('settings.general.outputStyleLoading')
|
|
: selectedOutputStyleLabel}
|
|
</span>
|
|
{selectedOutputStyleDescription && (
|
|
<span className="mt-0.5 block truncate text-xs text-[var(--color-text-tertiary)]">
|
|
{selectedOutputStyleDescription}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
|
|
</button>
|
|
}
|
|
/>
|
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
|
|
<span className="inline-flex items-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-medium text-[var(--color-text-secondary)]">
|
|
{outputStyleScopeLabel}
|
|
</span>
|
|
{selectedOutputStyle && (
|
|
<span className="inline-flex items-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1">
|
|
{getOutputStyleSourceLabel(selectedOutputStyle.source, t)}
|
|
</span>
|
|
)}
|
|
<span className="min-w-0 flex-1 leading-5">{outputStyleScopeHint}</span>
|
|
</div>
|
|
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.outputStyleRestartHint')}
|
|
</p>
|
|
{outputStyleError && (
|
|
<p className="mt-2 text-xs leading-5 text-[var(--color-error)]">
|
|
{outputStyleError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.defaultPermissionTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.defaultPermissionDescription')}</p>
|
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.general.defaultPermissionLabel')}
|
|
</div>
|
|
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.defaultPermissionHint')}
|
|
</div>
|
|
</div>
|
|
<PermissionModeSelector
|
|
value={permissionMode}
|
|
onChange={(mode) => void setPermissionMode(mode)}
|
|
workDir={t('settings.general.defaultPermissionScope')}
|
|
menuPlacement="bottom"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.thinkingTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.thinkingDescription')}</p>
|
|
<label className="relative flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
|
|
<input
|
|
type="checkbox"
|
|
aria-label={t('settings.general.thinkingEnabled')}
|
|
checked={thinkingEnabled}
|
|
onChange={(e) => void setThinkingEnabled(e.target.checked)}
|
|
className={SETTINGS_CHECKBOX_INPUT_CLASS}
|
|
/>
|
|
<SettingsCheckboxMark checked={thinkingEnabled} />
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.general.thinkingEnabled')}
|
|
</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
|
|
{t('settings.general.thinkingHint')}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.autoDreamTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.autoDreamDescription')}</p>
|
|
<label className="relative flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
|
|
<input
|
|
type="checkbox"
|
|
aria-label={t('settings.general.autoDreamEnabled')}
|
|
checked={autoDreamEnabled}
|
|
onChange={(e) => handleAutoDreamToggle(e.target.checked)}
|
|
className={SETTINGS_CHECKBOX_INPUT_CLASS}
|
|
/>
|
|
<SettingsCheckboxMark checked={autoDreamEnabled} />
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.general.autoDreamEnabled')}
|
|
</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
|
|
{autoDreamEnabled
|
|
? t('settings.general.autoDreamHintOn')
|
|
: t('settings.general.autoDreamHintOff')}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.traceTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.traceDescription')}</p>
|
|
<label className="relative flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
|
|
<input
|
|
type="checkbox"
|
|
aria-label={t('settings.general.traceEnabled')}
|
|
checked={traceCapture.enabled}
|
|
onChange={(e) => void setTraceCaptureEnabled(e.target.checked)}
|
|
className={SETTINGS_CHECKBOX_INPUT_CLASS}
|
|
/>
|
|
<SettingsCheckboxMark checked={traceCapture.enabled} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.general.traceEnabled')}
|
|
</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
|
|
{traceCapture.enabled ? t('settings.general.traceHintOn') : t('settings.general.traceHintOff')}
|
|
</div>
|
|
{traceCapture.storageDir && (
|
|
<div className="mt-2 truncate rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[11px] text-[var(--color-text-secondary)]">
|
|
{traceCapture.storageDir}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.notificationsTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.notificationsDescription')}</p>
|
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
|
<label className="relative flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
aria-label={t('settings.general.notificationsEnabled')}
|
|
checked={desktopNotificationsEnabled}
|
|
onChange={(e) => void handleDesktopNotificationsToggle(e.target.checked)}
|
|
className={SETTINGS_CHECKBOX_INPUT_CLASS}
|
|
/>
|
|
<SettingsCheckboxMark checked={desktopNotificationsEnabled} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.general.notificationsEnabled')}
|
|
</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
|
|
{desktopNotificationsEnabled
|
|
? t('settings.general.notificationsHintOn')
|
|
: t('settings.general.notificationsHintOff')}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
{desktopNotificationsEnabled && (
|
|
<div className="mt-3 flex items-center justify-between gap-3 border-t border-[var(--color-border)]/60 pt-3">
|
|
<div className="min-w-0 text-xs text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.notificationsStatus')}: {notificationStatusLabel[notificationPermission]}
|
|
</div>
|
|
{notificationPermission !== 'granted' && notificationPermission !== 'unsupported' && (
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
className="px-3 whitespace-nowrap"
|
|
disabled={notificationActionRunning}
|
|
onClick={() => void handleNotificationPermissionAction()}
|
|
>
|
|
{notificationPermission === 'denied'
|
|
? t('settings.general.notificationsOpenSettings')
|
|
: t('settings.general.notificationsAuthorize')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.chatSendBehaviorTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.chatSendBehaviorDescription')}</p>
|
|
<div className="grid grid-cols-2 gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-2">
|
|
{CHAT_SEND_BEHAVIORS.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => void setChatSendBehavior(option.value)}
|
|
aria-pressed={chatSendBehavior === option.value}
|
|
className={`rounded-lg border px-3 py-2 text-left transition-colors ${
|
|
chatSendBehavior === option.value
|
|
? 'border-[var(--color-brand)] bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
<div className="text-xs font-semibold">{option.label}</div>
|
|
<div className="mt-1 text-[11px] leading-4 text-[var(--color-text-tertiary)]">
|
|
{option.description}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{uiZoomSection}
|
|
|
|
<div className="mt-8">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.networkTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.networkDescription')}</p>
|
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{NETWORK_PROXY_MODES.map((mode) => (
|
|
<button
|
|
key={mode.value}
|
|
type="button"
|
|
onClick={() => {
|
|
setNetworkDraft((current) => ({
|
|
...current,
|
|
proxy: { ...current.proxy, mode: mode.value },
|
|
}))
|
|
setNetworkSaveError(null)
|
|
}}
|
|
aria-pressed={networkDraft.proxy.mode === mode.value}
|
|
className={`rounded-lg border px-3 py-2 text-left transition-colors ${
|
|
networkDraft.proxy.mode === mode.value
|
|
? 'border-[var(--color-brand)] bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
<div className="text-xs font-semibold">{mode.label}</div>
|
|
<div className="mt-1 text-[11px] leading-4 text-[var(--color-text-tertiary)]">
|
|
{mode.description}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{networkDraft.proxy.mode === 'manual' && (
|
|
<div className="mt-4">
|
|
<Input
|
|
id="network-proxy-url"
|
|
label={t('settings.general.networkProxyUrl')}
|
|
value={networkDraft.proxy.url}
|
|
placeholder="http://127.0.0.1:7890"
|
|
autoComplete="off"
|
|
onChange={(event) => {
|
|
setNetworkDraft((current) => ({
|
|
...current,
|
|
proxy: { ...current.proxy, url: event.target.value },
|
|
}))
|
|
setNetworkSaveError(null)
|
|
}}
|
|
/>
|
|
<p className={`mt-1 text-[11px] leading-4 ${networkProxyError ? 'text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]'}`}>
|
|
{networkProxyError ?? t('settings.general.networkProxyUrlHint')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4">
|
|
<div className="mb-2 flex items-center justify-between gap-3">
|
|
<label htmlFor="network-timeout-seconds" className="text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.general.networkTimeout')}
|
|
</label>
|
|
<span className="rounded-md bg-[var(--color-surface)] px-2 py-1 text-xs font-medium text-[var(--color-text-secondary)]">
|
|
{t('settings.general.networkTimeoutValue', { seconds: String(timeoutSeconds) })}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
className="h-10 w-10 px-0"
|
|
aria-label={t('settings.general.networkTimeoutDecrease')}
|
|
onClick={() => setNetworkTimeoutSeconds((parsedNetworkTimeoutSeconds ?? timeoutSeconds) - NETWORK_TIMEOUT_STEP_SECONDS)}
|
|
>
|
|
-30
|
|
</Button>
|
|
<div className="relative min-w-0 flex-1">
|
|
<input
|
|
id="network-timeout-seconds"
|
|
type="number"
|
|
min={NETWORK_TIMEOUT_MIN_SECONDS}
|
|
max={NETWORK_TIMEOUT_MAX_SECONDS}
|
|
step={1}
|
|
inputMode="numeric"
|
|
value={networkTimeoutInput}
|
|
aria-invalid={networkTimeoutError ? true : undefined}
|
|
aria-describedby="network-timeout-help"
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value
|
|
if (!/^\d*$/.test(nextValue)) return
|
|
setNetworkTimeoutInput(nextValue)
|
|
const seconds = Number(nextValue)
|
|
if (nextValue.length > 0 && seconds >= NETWORK_TIMEOUT_MIN_SECONDS && seconds <= NETWORK_TIMEOUT_MAX_SECONDS) {
|
|
setNetworkDraft((current) => ({
|
|
...current,
|
|
aiRequestTimeoutMs: seconds * 1000,
|
|
}))
|
|
}
|
|
setNetworkSaveError(null)
|
|
}}
|
|
className={`h-10 w-full rounded-[var(--radius-md)] border bg-[var(--color-surface)] px-3 pr-12 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] ${
|
|
networkTimeoutError
|
|
? 'border-[var(--color-error)] focus:shadow-[var(--shadow-error-ring)]'
|
|
: 'border-[var(--color-border)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]'
|
|
}`}
|
|
/>
|
|
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.networkTimeoutUnit')}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
className="h-10 w-10 px-0"
|
|
aria-label={t('settings.general.networkTimeoutIncrease')}
|
|
onClick={() => setNetworkTimeoutSeconds((parsedNetworkTimeoutSeconds ?? timeoutSeconds) + NETWORK_TIMEOUT_STEP_SECONDS)}
|
|
>
|
|
+30
|
|
</Button>
|
|
</div>
|
|
<p
|
|
id="network-timeout-help"
|
|
className={`mt-2 text-xs leading-5 ${networkTimeoutError ? 'text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]'}`}
|
|
>
|
|
{networkTimeoutError ?? t('settings.general.networkTimeoutHint')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-4 flex items-center justify-between gap-3">
|
|
<p className="min-w-0 text-[11px] leading-4 text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.networkScopeHint')}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
className="min-w-[72px] px-4 whitespace-nowrap"
|
|
disabled={!networkDirty || !!networkProxyError || !!networkTimeoutError || isSavingNetwork}
|
|
loading={isSavingNetwork}
|
|
onClick={() => void saveNetworkSettings()}
|
|
>
|
|
{t('settings.general.networkSave')}
|
|
</Button>
|
|
</div>
|
|
|
|
{networkSaveError && (
|
|
<p className="mt-2 text-[11px] leading-4 text-[var(--color-error)]">
|
|
{networkSaveError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webFetchPreflightTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webFetchPreflightDescription')}</p>
|
|
<label className="relative flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
|
|
<input
|
|
type="checkbox"
|
|
aria-label={t('settings.general.webFetchPreflightEnabled')}
|
|
checked={skipWebFetchPreflight}
|
|
onChange={(e) => void setSkipWebFetchPreflight(e.target.checked)}
|
|
className={SETTINGS_CHECKBOX_INPUT_CLASS}
|
|
/>
|
|
<SettingsCheckboxMark checked={skipWebFetchPreflight} />
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.general.webFetchPreflightEnabled')}
|
|
</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
|
|
{t('settings.general.webFetchPreflightHint')}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webSearchTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webSearchDescription')}</p>
|
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
|
|
<div className="grid grid-cols-5 gap-1.5 mb-4">
|
|
{WEB_SEARCH_MODES.map(({ value, label }) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => setWebSearchDraft({ ...webSearchDraft, mode: value })}
|
|
className={`h-9 px-2 text-xs font-semibold rounded-lg border transition-all truncate ${
|
|
(webSearchDraft.mode ?? 'auto') === value
|
|
? 'bg-[var(--color-brand)] text-[var(--color-on-primary)] border-[var(--color-brand)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
title={label}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3">
|
|
<Input
|
|
id="web-search-tavily-key"
|
|
type="password"
|
|
label={t('settings.general.webSearchTavilyKey')}
|
|
value={webSearchDraft.tavilyApiKey ?? ''}
|
|
placeholder="tvly-..."
|
|
autoComplete="off"
|
|
onChange={(event) =>
|
|
setWebSearchDraft({
|
|
...webSearchDraft,
|
|
tavilyApiKey: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
|
|
<span>{t('settings.general.webSearchTavilyFreeHint')}</span>
|
|
<a
|
|
href="https://app.tavily.com/home"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
aria-label={t('settings.general.webSearchTavilyApiKeyLink')}
|
|
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
|
|
>
|
|
{t('settings.general.webSearchGetApiKey')}
|
|
</a>
|
|
</div>
|
|
<Input
|
|
id="web-search-brave-key"
|
|
type="password"
|
|
label={t('settings.general.webSearchBraveKey')}
|
|
value={webSearchDraft.braveApiKey ?? ''}
|
|
placeholder={t('settings.general.webSearchBravePlaceholder')}
|
|
autoComplete="off"
|
|
onChange={(event) =>
|
|
setWebSearchDraft({
|
|
...webSearchDraft,
|
|
braveApiKey: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
|
|
<span>{t('settings.general.webSearchBraveFreeHint')}</span>
|
|
<a
|
|
href="https://api-dashboard.search.brave.com/app/keys"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
aria-label={t('settings.general.webSearchBraveApiKeyLink')}
|
|
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
|
|
>
|
|
{t('settings.general.webSearchGetApiKey')}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex flex-col gap-3">
|
|
<p className="text-xs text-[var(--color-text-tertiary)] leading-5">
|
|
{t('settings.general.webSearchHint')}
|
|
</p>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
className="min-w-[72px] px-4 whitespace-nowrap"
|
|
disabled={!webSearchDirty}
|
|
onClick={() => void setWebSearch(webSearchDraft)}
|
|
>
|
|
{t('settings.general.webSearchSave')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isDesktopRuntime() && (
|
|
<div className="mt-8 border-t border-[var(--color-border)] pt-8">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.storageTitle')}</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.storageDescription')}</p>
|
|
|
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
|
|
<div className="flex flex-col gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (isEnvironmentConfigDir) {
|
|
setModeError(t('settings.general.storageEnvironmentSwitchBlocked'))
|
|
return
|
|
}
|
|
if (appMode.mode !== 'default') {
|
|
openModeSwitchConfirm('default')
|
|
}
|
|
}}
|
|
aria-pressed={appMode.mode === 'default' && !isEnvironmentConfigDir}
|
|
className={`flex items-start gap-3 rounded-lg border px-3 py-3 text-left transition-all ${
|
|
appMode.mode === 'default' && !isEnvironmentConfigDir
|
|
? 'border-[var(--color-brand)] bg-[var(--color-surface)] shadow-[var(--shadow-focus-ring)]'
|
|
: 'border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-border-focus)]'
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined mt-0.5 text-[20px] text-[var(--color-text-secondary)]">settings_applications</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.general.storageSystemTitle')}</span>
|
|
<span className="mt-1 block text-xs leading-5 text-[var(--color-text-tertiary)]">{t('settings.general.storageSystemDescription')}</span>
|
|
</span>
|
|
</button>
|
|
|
|
<div
|
|
className={`rounded-lg border px-3 py-3 transition-all ${
|
|
appMode.mode === 'portable' && !isEnvironmentConfigDir
|
|
? 'border-[var(--color-brand)] bg-[var(--color-surface)] shadow-[var(--shadow-focus-ring)]'
|
|
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
|
|
}`}
|
|
>
|
|
<div className="mb-3 flex items-start gap-3">
|
|
<span className="material-symbols-outlined mt-0.5 text-[20px] text-[var(--color-text-secondary)]">drive_file_move</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.general.storagePortableTitle')}</div>
|
|
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{t('settings.general.storagePortableDescription')}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-end gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<Input
|
|
id="portable-data-dir"
|
|
label={t('settings.general.storagePortableDirLabel')}
|
|
value={portableDirDraft}
|
|
placeholder={t('settings.general.storagePortableDirPlaceholder')}
|
|
onChange={(event) => {
|
|
setPortableDirDraft(event.target.value)
|
|
setModeError(null)
|
|
}}
|
|
className="w-full font-mono text-xs"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
className="h-10 flex-shrink-0 px-3 whitespace-nowrap"
|
|
onClick={() => void openPortableDirPicker()}
|
|
>
|
|
{t('settings.general.storageChooseDir')}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
|
<button
|
|
type="button"
|
|
className="text-xs font-medium text-[var(--color-brand)] hover:underline"
|
|
onClick={() => {
|
|
setPortableDirDraft(appMode.defaultPortableDir ?? '')
|
|
setModeError(null)
|
|
}}
|
|
>
|
|
{t('settings.general.storageUseDefaultPortableDir')}
|
|
</button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
disabled={modeActionRunning || (appMode.mode === 'portable' && portableDirDraft.trim() === (appMode.portableDir ?? ''))}
|
|
onClick={() => openModeSwitchConfirm('portable')}
|
|
>
|
|
{t('settings.general.storageApplyPortable')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{activeConfigDir && (
|
|
<div className="mt-3 rounded-lg border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-3 py-2">
|
|
<div className="text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-tertiary)]">{t('settings.general.storageActiveDir')}</div>
|
|
<div className="mt-1 break-all font-mono text-xs text-[var(--color-text-secondary)]">{activeConfigDir}</div>
|
|
</div>
|
|
)}
|
|
|
|
{isEnvironmentConfigDir && (
|
|
<div className="mt-3 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning)]/10 px-3 py-2 text-xs leading-5 text-[var(--color-text-secondary)]">
|
|
{t('settings.general.storageEnvironmentHint')}
|
|
</div>
|
|
)}
|
|
|
|
{appModeRequiresRestart && (
|
|
<div className="mt-3 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning)]/10 px-3 py-2 text-xs leading-5 text-[var(--color-text-secondary)]">
|
|
{t('settings.general.storageRestartHint')}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-3 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.storageMoveHint')}
|
|
</div>
|
|
|
|
{modeError && (
|
|
<div className="mt-3 text-xs text-[var(--color-error)]">
|
|
{modeError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Confirm dialog for mode switch */}
|
|
<ConfirmDialog
|
|
open={modeSwitchConfirmOpen}
|
|
onClose={closeModeSwitchConfirm}
|
|
onConfirm={() => void confirmModeSwitch()}
|
|
title={t('settings.general.modeSwitchTitle')}
|
|
body={(
|
|
<div className="space-y-3 text-sm leading-6 text-[var(--color-text-secondary)]">
|
|
<p>
|
|
{pendingMode === 'portable'
|
|
? t('settings.general.storageSwitchPortableBody')
|
|
: t('settings.general.storageSwitchDefaultBody')}
|
|
</p>
|
|
{pendingMode === 'portable' && pendingPortableDir && (
|
|
<div className="rounded-lg bg-[var(--color-surface-container-low)] px-3 py-2 font-mono text-xs break-all text-[var(--color-text-secondary)]">
|
|
{pendingPortableDir}
|
|
</div>
|
|
)}
|
|
<p>{t('settings.general.storageSwitchRestartBody')}</p>
|
|
</div>
|
|
)}
|
|
confirmLabel={t('settings.general.modeSwitchConfirm')}
|
|
cancelLabel={t('common.cancel')}
|
|
confirmVariant="primary"
|
|
loading={modeActionRunning}
|
|
/>
|
|
<ConfirmDialog
|
|
open={autoDreamConfirmOpen}
|
|
onClose={() => {
|
|
if (!autoDreamActionRunning) setAutoDreamConfirmOpen(false)
|
|
}}
|
|
onConfirm={() => void confirmAutoDreamEnable()}
|
|
title={t('settings.general.autoDreamConfirmTitle')}
|
|
body={(
|
|
<div className="space-y-2">
|
|
<p>{t('settings.general.autoDreamConfirmKeepRunning')}</p>
|
|
<p>{t('settings.general.autoDreamConfirmTokenCost')}</p>
|
|
</div>
|
|
)}
|
|
confirmLabel={t('settings.general.autoDreamConfirmEnable')}
|
|
cancelLabel={t('common.cancel')}
|
|
confirmVariant="primary"
|
|
loading={autoDreamActionRunning}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function getBuiltInOutputStyleTranslationKeys(style: {
|
|
value: string
|
|
source: OutputStyleSource
|
|
}) {
|
|
if (style.source !== 'built-in') return null
|
|
return BUILT_IN_OUTPUT_STYLE_TRANSLATION_KEYS[
|
|
style.value as keyof typeof BUILT_IN_OUTPUT_STYLE_TRANSLATION_KEYS
|
|
] ?? null
|
|
}
|
|
|
|
function getOutputStyleLabel(
|
|
style: {
|
|
value: string
|
|
label: string
|
|
source: OutputStyleSource
|
|
},
|
|
t: (key: TranslationKey) => string,
|
|
) {
|
|
const keys = getBuiltInOutputStyleTranslationKeys(style)
|
|
return keys ? t(keys.label) : style.label
|
|
}
|
|
|
|
function getOutputStyleDescription(
|
|
style: {
|
|
value: string
|
|
description: string
|
|
source: OutputStyleSource
|
|
},
|
|
t: (key: TranslationKey) => string,
|
|
) {
|
|
const keys = getBuiltInOutputStyleTranslationKeys(style)
|
|
return keys ? t(keys.description) : style.description
|
|
}
|
|
|
|
function getOutputStyleSourceLabel(
|
|
source: OutputStyleSource,
|
|
t: (key: TranslationKey) => string,
|
|
) {
|
|
switch (source) {
|
|
case 'built-in':
|
|
return t('settings.general.outputStyleSourceBuiltIn')
|
|
case 'userSettings':
|
|
return t('settings.general.outputStyleSourceUser')
|
|
case 'projectSettings':
|
|
return t('settings.general.outputStyleSourceProject')
|
|
case 'localSettings':
|
|
return t('settings.general.outputStyleSourceLocal')
|
|
case 'policySettings':
|
|
return t('settings.general.outputStyleSourcePolicy')
|
|
case 'plugin':
|
|
return t('settings.general.outputStyleSourcePlugin')
|
|
}
|
|
}
|
|
|
|
// ─── H5 Access Settings ──────────────────────────────────────
|
|
|
|
function H5AccessSettings() {
|
|
const {
|
|
h5Access,
|
|
h5AccessDiagnostics,
|
|
h5AccessError,
|
|
enableH5Access,
|
|
disableH5Access,
|
|
regenerateH5AccessToken,
|
|
updateH5AccessSettings,
|
|
} = useSettingsStore()
|
|
const t = useTranslation()
|
|
const addToast = useUIStore((s) => s.addToast)
|
|
const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(extractH5AccessAddressDraft(h5Access.publicBaseUrl))
|
|
const [h5FixedPortDraft, setH5FixedPortDraft] = useState(h5Access.fixedPort != null ? String(h5Access.fixedPort) : '')
|
|
const [h5GraceDraft, setH5GraceDraft] = useState(h5Access.disconnectGraceSeconds != null ? String(h5Access.disconnectGraceSeconds) : '')
|
|
const [h5TokenVisible, setH5TokenVisible] = useState(false)
|
|
const [h5EnableConfirmOpen, setH5EnableConfirmOpen] = useState(false)
|
|
const [h5QrDataUrl, setH5QrDataUrl] = useState<string | null>(null)
|
|
const [h5ActionRunning, setH5ActionRunning] = useState(false)
|
|
const h5AccessUrl = h5Access.publicBaseUrl
|
|
// The token is persisted server-side, so the QR code and copy actions stay
|
|
// available across desktop restarts (issue #767).
|
|
const h5Token = h5Access.token
|
|
const h5LaunchUrl = useMemo(
|
|
() => buildH5LaunchUrl(h5AccessUrl, h5Token),
|
|
[h5AccessUrl, h5Token],
|
|
)
|
|
const h5ActivePort = h5AccessDiagnostics?.activePort != null
|
|
? String(h5AccessDiagnostics.activePort)
|
|
: extractH5AccessPort(h5AccessUrl)
|
|
const h5NextPublicBaseUrl = buildH5PublicBaseUrlFromHostDraft(h5PublicBaseUrlDraft, h5Access.publicBaseUrl)
|
|
const h5NextFixedPort = parseH5FixedPortDraft(h5FixedPortDraft)
|
|
const h5FixedPortInvalid = h5NextFixedPort === 'invalid'
|
|
const h5NextGrace = parseH5GraceDraft(h5GraceDraft)
|
|
const h5GraceInvalid = h5NextGrace === 'invalid'
|
|
const h5AccessDirty = h5NextPublicBaseUrl !== (h5Access.publicBaseUrl ?? null) ||
|
|
(!h5FixedPortInvalid && h5NextFixedPort !== h5Access.fixedPort) ||
|
|
(!h5GraceInvalid && h5NextGrace !== h5Access.disconnectGraceSeconds)
|
|
const h5FixedPortPendingRestart = h5Access.fixedPort != null &&
|
|
h5ActivePort != null &&
|
|
String(h5Access.fixedPort) !== h5ActivePort
|
|
|
|
useEffect(() => {
|
|
setH5PublicBaseUrlDraft(extractH5AccessAddressDraft(h5Access.publicBaseUrl))
|
|
setH5FixedPortDraft(h5Access.fixedPort != null ? String(h5Access.fixedPort) : '')
|
|
setH5GraceDraft(h5Access.disconnectGraceSeconds != null ? String(h5Access.disconnectGraceSeconds) : '')
|
|
}, [h5Access])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
if (!h5Access.enabled || !h5LaunchUrl || !h5Token) {
|
|
setH5QrDataUrl(null)
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}
|
|
|
|
QRCode.toDataURL(h5LaunchUrl, { margin: 1, width: 192 })
|
|
.then((dataUrl) => {
|
|
if (!cancelled) setH5QrDataUrl(dataUrl)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setH5QrDataUrl(null)
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [h5Access.enabled, h5LaunchUrl, h5Token])
|
|
|
|
const runH5Action = async (action: () => Promise<void>) => {
|
|
setH5ActionRunning(true)
|
|
try {
|
|
await action()
|
|
} catch {
|
|
// The store owns H5-specific error state.
|
|
} finally {
|
|
setH5ActionRunning(false)
|
|
}
|
|
}
|
|
|
|
const handleH5SettingsSave = async () => {
|
|
if (h5FixedPortInvalid || h5GraceInvalid) return
|
|
await runH5Action(async () => {
|
|
await updateH5AccessSettings({
|
|
publicBaseUrl: h5NextPublicBaseUrl,
|
|
fixedPort: h5NextFixedPort,
|
|
disconnectGraceSeconds: h5NextGrace,
|
|
})
|
|
})
|
|
}
|
|
|
|
const handleH5SwitchToSuggestedHost = async () => {
|
|
const suggested = h5AccessDiagnostics?.suggestedHost
|
|
if (!suggested) return
|
|
await runH5Action(async () => {
|
|
// Build URL using current port if available, otherwise let backend pick.
|
|
const port = extractH5AccessPort(h5Access.publicBaseUrl)
|
|
const nextUrl = port ? `http://${suggested}:${port}` : `http://${suggested}`
|
|
await updateH5AccessSettings({ publicBaseUrl: nextUrl })
|
|
})
|
|
}
|
|
|
|
const handleH5UrlCopy = async () => {
|
|
if (!h5AccessUrl) return
|
|
const copied = await copyTextToClipboard(h5AccessUrl)
|
|
addToast({
|
|
type: copied ? 'success' : 'error',
|
|
message: copied ? t('settings.general.h5AccessUrlCopied') : t('common.copyFailed'),
|
|
})
|
|
}
|
|
|
|
const handleH5LaunchUrlCopy = async () => {
|
|
if (!h5LaunchUrl) return
|
|
const copied = await copyTextToClipboard(h5LaunchUrl)
|
|
addToast({
|
|
type: copied ? 'success' : 'error',
|
|
message: copied ? t('settings.general.h5AccessLaunchUrlCopied') : t('common.copyFailed'),
|
|
})
|
|
}
|
|
|
|
const handleH5EnableConfirm = async () => {
|
|
await runH5Action(async () => {
|
|
await enableH5Access()
|
|
setH5TokenVisible(false)
|
|
setH5EnableConfirmOpen(false)
|
|
})
|
|
}
|
|
|
|
const handleH5Disable = async () => {
|
|
await runH5Action(async () => {
|
|
await disableH5Access()
|
|
setH5TokenVisible(false)
|
|
})
|
|
}
|
|
|
|
const handleH5Regenerate = async () => {
|
|
await runH5Action(async () => {
|
|
await regenerateH5AccessToken()
|
|
setH5TokenVisible(false)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-3xl">
|
|
<section aria-labelledby="h5-access-title" role="region">
|
|
<div className="mb-5 flex items-start gap-3">
|
|
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-brand)]">
|
|
<QrCode className="h-5 w-5" aria-hidden="true" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h2
|
|
id="h5-access-title"
|
|
className="text-base font-semibold text-[var(--color-text-primary)] mb-1"
|
|
>
|
|
{t('settings.general.h5AccessTitle')}
|
|
</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.h5AccessDescription')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<label className="flex min-w-0 items-start gap-3">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-1 h-4 w-4 rounded border-[var(--color-border)] accent-[var(--color-primary)]"
|
|
checked={h5Access.enabled}
|
|
disabled={h5ActionRunning}
|
|
aria-label={t('settings.general.h5AccessEnabled')}
|
|
onChange={(event) => {
|
|
if (event.target.checked) {
|
|
setH5EnableConfirmOpen(true)
|
|
} else {
|
|
void handleH5Disable()
|
|
}
|
|
}}
|
|
/>
|
|
<span className="min-w-0">
|
|
<span className="block text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.general.h5AccessEnabled')}
|
|
</span>
|
|
<span className="mt-1 block text-xs leading-5 text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.h5AccessEnabledHint')}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
<span
|
|
className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
h5Access.enabled
|
|
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
|
|
: 'bg-[var(--color-surface)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]'
|
|
}`}
|
|
>
|
|
{h5Access.enabled ? t('settings.general.h5AccessStatusEnabled') : t('settings.general.h5AccessDisabledValue')}
|
|
</span>
|
|
</div>
|
|
|
|
{h5AccessDiagnostics?.storedHostStaleness === 'unreachable' && h5AccessDiagnostics.storedPublicBaseUrl ? (
|
|
<div
|
|
data-testid="h5-access-stale-host-banner"
|
|
className="mt-4 rounded-lg border border-[var(--color-warning)]/40 bg-[var(--color-warning)]/10 px-3 py-3 text-xs leading-5 text-[var(--color-text-primary)]"
|
|
>
|
|
<div className="font-semibold">
|
|
{t('settings.general.h5AccessStaleHostTitle')}
|
|
</div>
|
|
<div className="mt-1 text-[var(--color-text-secondary)]">
|
|
{h5AccessDiagnostics.suggestedHost
|
|
? t('settings.general.h5AccessStaleHostBody', {
|
|
storedHost: extractHostnameFromUrl(h5AccessDiagnostics.storedPublicBaseUrl) ?? h5AccessDiagnostics.storedPublicBaseUrl,
|
|
})
|
|
: t('settings.general.h5AccessStaleHostNoSuggestion', {
|
|
storedHost: extractHostnameFromUrl(h5AccessDiagnostics.storedPublicBaseUrl) ?? h5AccessDiagnostics.storedPublicBaseUrl,
|
|
})}
|
|
</div>
|
|
{h5AccessDiagnostics.suggestedHost && (
|
|
<div className="mt-2">
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
loading={h5ActionRunning}
|
|
onClick={() => void handleH5SwitchToSuggestedHost()}
|
|
data-testid="h5-access-stale-host-apply"
|
|
>
|
|
{t('settings.general.h5AccessStaleHostApply', {
|
|
suggestedHost: h5AccessDiagnostics.suggestedHost,
|
|
})}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{h5AccessDiagnostics?.storedHostStaleness === 'proxy' ? (
|
|
<div
|
|
data-testid="h5-access-proxy-note"
|
|
className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2 text-xs leading-5 text-[var(--color-text-tertiary)]"
|
|
>
|
|
{t('settings.general.h5AccessProxyNote')}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-4 grid grid-cols-1 gap-3">
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem_9rem]">
|
|
<Input
|
|
id="h5-access-public-url"
|
|
label={t('settings.general.h5AccessPublicHost')}
|
|
value={h5PublicBaseUrlDraft}
|
|
placeholder={t('settings.general.h5AccessPublicHostPlaceholder')}
|
|
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
|
|
/>
|
|
<Input
|
|
id="h5-access-fixed-port"
|
|
label={t('settings.general.h5AccessFixedPort')}
|
|
value={h5FixedPortDraft}
|
|
placeholder={t('settings.general.h5AccessFixedPortPlaceholder')}
|
|
inputMode="numeric"
|
|
error={h5FixedPortInvalid ? t('settings.general.h5AccessFixedPortInvalid') : undefined}
|
|
onChange={(event) => setH5FixedPortDraft(event.target.value)}
|
|
/>
|
|
<Input
|
|
id="h5-access-current-port"
|
|
label={t('settings.general.h5AccessCurrentPort')}
|
|
value={h5ActivePort ?? t('settings.general.h5AccessCurrentPortUnknown')}
|
|
readOnly
|
|
className="text-[var(--color-text-tertiary)]"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-start">
|
|
<Input
|
|
id="h5-access-disconnect-grace"
|
|
label={t('settings.general.h5AccessDisconnectGrace')}
|
|
value={h5GraceDraft}
|
|
placeholder={t('settings.general.h5AccessDisconnectGracePlaceholder')}
|
|
inputMode="numeric"
|
|
error={h5GraceInvalid ? t('settings.general.h5AccessDisconnectGraceInvalid') : undefined}
|
|
onChange={(event) => setH5GraceDraft(event.target.value)}
|
|
/>
|
|
<p className="text-xs leading-5 text-[var(--color-text-tertiary)] sm:pt-7">
|
|
{t('settings.general.h5AccessDisconnectGraceHint')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-xs text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.h5AccessOpenHint')}
|
|
{' '}
|
|
{t('settings.general.h5AccessFixedPortHint')}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => void handleH5SettingsSave()}
|
|
disabled={!h5AccessDirty || h5FixedPortInvalid || h5GraceInvalid || h5ActionRunning}
|
|
aria-label={t('settings.general.h5AccessSave')}
|
|
>
|
|
{t('settings.general.h5AccessSave')}
|
|
</Button>
|
|
</div>
|
|
{h5FixedPortPendingRestart && (
|
|
<div
|
|
data-testid="h5-access-fixed-port-restart-note"
|
|
className="rounded-lg border border-[var(--color-warning)]/40 bg-[var(--color-warning)]/10 px-3 py-2 text-xs leading-5 text-[var(--color-text-primary)]"
|
|
>
|
|
{t('settings.general.h5AccessFixedPortRestartNote', {
|
|
fixedPort: String(h5Access.fixedPort),
|
|
activePort: h5ActivePort ?? '',
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{h5AccessUrl && (
|
|
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.h5AccessUrl')}
|
|
</div>
|
|
<div className="mt-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] break-all">
|
|
{h5AccessUrl}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
className="shrink-0"
|
|
icon={<Copy className="h-3.5 w-3.5" aria-hidden="true" />}
|
|
aria-label={t('settings.general.h5AccessCopyUrl')}
|
|
onClick={() => void handleH5UrlCopy()}
|
|
>
|
|
{t('settings.general.h5AccessCopy')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{h5Access.enabled && h5AccessUrl && (
|
|
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
|
|
<div className="flex flex-col gap-4 sm:flex-row">
|
|
<div className="flex h-48 w-48 shrink-0 items-center justify-center rounded-lg border border-[var(--color-border)] bg-white p-3">
|
|
{h5QrDataUrl ? (
|
|
<img
|
|
src={h5QrDataUrl}
|
|
alt={t('settings.general.h5AccessQrAlt')}
|
|
className="h-full w-full"
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-3 px-4 text-center">
|
|
<QrCode className="h-12 w-12 text-neutral-400" aria-hidden="true" />
|
|
<p className="text-xs leading-5 text-neutral-500">
|
|
{t('settings.general.h5AccessQrEmptyHint')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.h5AccessQrTitle')}
|
|
</div>
|
|
<p className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
|
{h5Token
|
|
? t('settings.general.h5AccessQrHint')
|
|
: t('settings.general.h5AccessQrRefreshHint')}
|
|
</p>
|
|
{h5LaunchUrl && (
|
|
<div className="mt-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] break-all">
|
|
{h5LaunchUrl}
|
|
</div>
|
|
)}
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
icon={<Copy className="h-3.5 w-3.5" aria-hidden="true" />}
|
|
disabled={!h5LaunchUrl || !h5Token}
|
|
onClick={() => void handleH5LaunchUrlCopy()}
|
|
>
|
|
{t('settings.general.h5AccessCopyLaunchUrl')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={h5Token ? 'secondary' : 'primary'}
|
|
icon={<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />}
|
|
loading={h5ActionRunning}
|
|
onClick={() => void handleH5Regenerate()}
|
|
>
|
|
{h5Token ? t('settings.general.h5AccessRegenerate') : t('settings.general.h5AccessGenerateToken')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{h5Access.enabled && (
|
|
<div className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
|
|
{t('settings.general.h5AccessTokenPreview')}
|
|
</div>
|
|
<div className="mt-1 break-all text-sm text-[var(--color-text-primary)]">
|
|
{h5TokenVisible && h5Token
|
|
? h5Token
|
|
: h5Access.tokenPreview || t('settings.general.h5AccessTokenNotAvailable')}
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
icon={h5TokenVisible ? <EyeOff className="h-3.5 w-3.5" aria-hidden="true" /> : <Eye className="h-3.5 w-3.5" aria-hidden="true" />}
|
|
disabled={!h5Token}
|
|
onClick={() => setH5TokenVisible((visible) => !visible)}
|
|
>
|
|
{h5TokenVisible ? t('settings.general.h5AccessHideToken') : t('settings.general.h5AccessShowToken')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
icon={<PowerOff className="h-3.5 w-3.5" aria-hidden="true" />}
|
|
loading={h5ActionRunning}
|
|
onClick={() => void handleH5Disable()}
|
|
>
|
|
{t('settings.general.h5AccessDisable')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<p className="mt-4 text-xs text-[var(--color-text-tertiary)] leading-5">
|
|
{t('settings.general.h5AccessSafetyNote')}
|
|
</p>
|
|
{h5AccessError && (
|
|
<p className="mt-2 text-xs text-[var(--color-error)]">
|
|
{h5AccessError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<ConfirmDialog
|
|
open={h5EnableConfirmOpen}
|
|
onClose={() => {
|
|
if (!h5ActionRunning) setH5EnableConfirmOpen(false)
|
|
}}
|
|
onConfirm={handleH5EnableConfirm}
|
|
title={t('settings.general.h5AccessConfirmTitle')}
|
|
body={t('settings.general.h5AccessConfirmBody')}
|
|
confirmLabel={t('settings.general.h5AccessConfirmEnable')}
|
|
cancelLabel={t('common.cancel')}
|
|
confirmVariant="danger"
|
|
loading={h5ActionRunning}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SettingsCheckboxMark({ checked, disabled = false }: { checked: boolean; disabled?: boolean }) {
|
|
return (
|
|
<span
|
|
aria-hidden="true"
|
|
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-all peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--color-brand)]/40 ${
|
|
checked
|
|
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-[var(--color-on-primary)] shadow-[var(--shadow-button-primary)]'
|
|
: 'border-[var(--color-border-focus)] bg-[var(--color-surface)] text-transparent'
|
|
} ${disabled ? 'opacity-50' : ''}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px] leading-none" style={{ fontVariationSettings: "'FILL' 1" }}>
|
|
check
|
|
</span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ─── 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,
|
|
selectedAgentReturnTab,
|
|
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
|
|
|
|
const handleAgentBack = () => {
|
|
const returnTab = selectedAgentReturnTab
|
|
selectAgent(null)
|
|
if (returnTab === 'plugins') {
|
|
useUIStore.getState().setPendingSettingsTab('plugins')
|
|
}
|
|
}
|
|
|
|
if (selectedAgent) {
|
|
return (
|
|
<div className="w-full min-w-0">
|
|
<AgentDetailView agent={selectedAgent} onBack={handleAgentBack} />
|
|
</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 min-w-0 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,1fr)] xl: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 min-w-0 sm: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 sm: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, 'agents')}
|
|
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 min-w-0 ${className}`}>
|
|
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
|
|
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
|
|
<span className="truncate">{label}</span>
|
|
</div>
|
|
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
|
|
{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>
|
|
)
|
|
}
|
|
|
|
function PluginSettings() {
|
|
const selectedPlugin = usePluginStore((s) => s.selectedPlugin)
|
|
const t = useTranslation()
|
|
|
|
if (selectedPlugin) {
|
|
return (
|
|
<div className="w-full min-w-0">
|
|
<PluginDetail />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="w-full min-w-0">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
|
|
{t('settings.plugins.title')}
|
|
</h2>
|
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
|
|
{t('settings.plugins.description')}
|
|
</p>
|
|
<PluginList />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── About Settings ──────────────────────────────────────
|
|
|
|
const GITHUB_REPO = 'https://github.com/NanmiCoder/cc-haha'
|
|
const GITHUB_ISSUES = `${GITHUB_REPO}/issues`
|
|
const GITHUB_RELEASES = `${GITHUB_REPO}/releases`
|
|
const AUTHOR_GITHUB = 'https://github.com/NanmiCoder'
|
|
const SOCIAL_LINKS = [
|
|
{ name: 'Bilibili', icon: '/icons/bilibili.svg', url: 'https://space.bilibili.com/434377496', label: '程序员阿江-Relakkes' },
|
|
{ name: 'Douyin', icon: '/icons/douyin.svg', url: 'https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE', label: '程序员阿江-Relakkes' },
|
|
{ name: 'Xiaohongshu', icon: '/icons/xiaohongshu.svg', url: 'https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753', label: '程序员阿江-Relakkes' },
|
|
] as const
|
|
|
|
function isValidHttpProxyUrl(value: string) {
|
|
try {
|
|
const url = new URL(value)
|
|
return url.protocol === 'http:' || url.protocol === 'https:'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function AboutSettings() {
|
|
const t = useTranslation()
|
|
const [version, setVersion] = useState('')
|
|
const updateProxy = useSettingsStore((s) => s.updateProxy)
|
|
const setUpdateProxy = useSettingsStore((s) => s.setUpdateProxy)
|
|
const updateStatus = useUpdateStore((s) => s.status)
|
|
const availableVersion = useUpdateStore((s) => s.availableVersion)
|
|
const releaseNotes = useUpdateStore((s) => s.releaseNotes)
|
|
const progressPercent = useUpdateStore((s) => s.progressPercent)
|
|
const downloadedBytes = useUpdateStore((s) => s.downloadedBytes)
|
|
const totalBytes = useUpdateStore((s) => s.totalBytes)
|
|
const error = useUpdateStore((s) => s.error)
|
|
const checkedAt = useUpdateStore((s) => s.checkedAt)
|
|
const checkForUpdates = useUpdateStore((s) => s.checkForUpdates)
|
|
const installUpdate = useUpdateStore((s) => s.installUpdate)
|
|
const initialize = useUpdateStore((s) => s.initialize)
|
|
const [showUpdateProxyAdvanced, setShowUpdateProxyAdvanced] = useState(false)
|
|
const [updateProxyDraft, setUpdateProxyDraft] = useState(updateProxy)
|
|
const [updateProxySaveError, setUpdateProxySaveError] = useState<string | null>(null)
|
|
const [isSavingUpdateProxy, setIsSavingUpdateProxy] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
getDesktopHost().app.getVersion()
|
|
.then((value) => {
|
|
if (!cancelled) setVersion(value)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setVersion('')
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
void initialize()
|
|
}, [initialize])
|
|
|
|
useEffect(() => {
|
|
setUpdateProxyDraft(updateProxy)
|
|
setUpdateProxySaveError(null)
|
|
}, [updateProxy])
|
|
|
|
const openUrl = (url: string) => {
|
|
void getDesktopHost().shell.open(url).catch(() => window.open(url, '_blank'))
|
|
}
|
|
|
|
const checkedAtText =
|
|
checkedAt
|
|
? new Date(checkedAt).toLocaleString(undefined, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})
|
|
: null
|
|
const updateProxyModes: Array<{ value: UpdateProxyMode; label: string; description: string }> = [
|
|
{
|
|
value: 'system',
|
|
label: t('update.proxyModeSystem'),
|
|
description: t('update.proxyModeSystemDescription'),
|
|
},
|
|
{
|
|
value: 'manual',
|
|
label: t('update.proxyModeManual'),
|
|
description: t('update.proxyModeManualDescription'),
|
|
},
|
|
]
|
|
const manualProxyUrl = updateProxyDraft.url.trim()
|
|
const manualProxyError =
|
|
updateProxyDraft.mode === 'manual' && !manualProxyUrl
|
|
? t('update.proxyUrlRequired')
|
|
: updateProxyDraft.mode === 'manual' && !isValidHttpProxyUrl(manualProxyUrl)
|
|
? t('update.proxyUrlInvalid')
|
|
: null
|
|
const updateProxyDirty =
|
|
updateProxyDraft.mode !== updateProxy.mode ||
|
|
updateProxyDraft.url.trim() !== updateProxy.url.trim()
|
|
|
|
const saveUpdateProxy = async () => {
|
|
if (manualProxyError) {
|
|
setUpdateProxySaveError(manualProxyError)
|
|
return
|
|
}
|
|
|
|
setIsSavingUpdateProxy(true)
|
|
setUpdateProxySaveError(null)
|
|
try {
|
|
await setUpdateProxy({
|
|
mode: updateProxyDraft.mode,
|
|
url: manualProxyUrl,
|
|
})
|
|
} catch (error) {
|
|
setUpdateProxySaveError(error instanceof Error ? error.message : String(error))
|
|
} finally {
|
|
setIsSavingUpdateProxy(false)
|
|
}
|
|
}
|
|
|
|
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
|
|
const downloadedText = formatBytes(downloadedBytes)
|
|
const updateDescription = (() => {
|
|
if (updateStatus === 'checking') return t('update.checking')
|
|
if (error) return t('update.failed', { error })
|
|
if (updateStatus === 'downloading') {
|
|
return hasKnownProgress
|
|
? t('update.progress', { progress: String(progressPercent) })
|
|
: t('update.progressBytes', { downloaded: downloadedText })
|
|
}
|
|
if (updateStatus === 'downloaded') return t('update.downloaded')
|
|
if (updateStatus === 'installing') return t('update.installing')
|
|
if (updateStatus === 'restarting') return t('update.restarting')
|
|
if (updateStatus === 'available' && availableVersion) return t('update.newVersion', { version: availableVersion })
|
|
if (updateStatus === 'up-to-date') return t('update.upToDate', { version: version || t('update.currentVersionUnknown') })
|
|
return t('update.idle')
|
|
})()
|
|
|
|
return (
|
|
<div className="w-full min-w-0 max-w-2xl mx-auto flex flex-col items-center py-6">
|
|
{/* Logo + App Name + Version */}
|
|
<img src={publicAssetPath('app-icon.png')} alt="Claude Code Haha" className="w-20 h-20 mb-4" />
|
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Claude Code Haha</h1>
|
|
{version && (
|
|
<div className="mt-1 flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
|
|
<span>{t('settings.about.version')} {version}</span>
|
|
<span className="text-[var(--color-border)]">·</span>
|
|
<button
|
|
onClick={() => openUrl(GITHUB_RELEASES)}
|
|
className="rounded-[var(--radius-sm)] text-[var(--color-text-accent)] transition-colors hover:text-[var(--color-brand)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
|
|
>
|
|
{t('settings.about.changelog')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* GitHub Repo */}
|
|
<div className="mt-6 w-full">
|
|
<button
|
|
onClick={() => openUrl(GITHUB_REPO)}
|
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
|
|
>
|
|
<img src={publicAssetPath('icons/github.svg')} alt="GitHub" className="w-5 h-5 opacity-70" />
|
|
<div className="flex-1 text-left">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)]">NanmiCoder/cc-haha</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)]">{t('settings.about.starHint')}</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-4 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.about.updates')}</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
|
{t('settings.about.updatesDesc')}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => void checkForUpdates()}
|
|
loading={updateStatus === 'checking'}
|
|
>
|
|
{t('update.checkNow')}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
|
|
{t('settings.about.version')}
|
|
</div>
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)] mt-1">
|
|
{version || t('update.currentVersionUnknown')}
|
|
</div>
|
|
</div>
|
|
|
|
{availableVersion && (
|
|
<div className="text-right">
|
|
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
|
|
{t('update.availableLabel')}
|
|
</div>
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)] mt-1">
|
|
{availableVersion}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<p className={`mt-3 text-sm ${error ? 'text-[var(--color-error)]' : 'text-[var(--color-text-secondary)]'}`}>
|
|
{updateDescription}
|
|
</p>
|
|
|
|
{checkedAtText && (
|
|
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
|
|
{t('update.checkedAt', { time: checkedAtText })}
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-3 border-t border-[var(--color-border)]/60 pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowUpdateProxyAdvanced((value) => !value)}
|
|
className="flex w-full items-center justify-between gap-3 rounded-md text-left text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
|
aria-expanded={showUpdateProxyAdvanced}
|
|
>
|
|
<span>{t('update.proxyAdvanced')}</span>
|
|
<span className="material-symbols-outlined text-[18px]">
|
|
{showUpdateProxyAdvanced ? 'expand_less' : 'expand_more'}
|
|
</span>
|
|
</button>
|
|
|
|
{showUpdateProxyAdvanced && (
|
|
<div className="mt-3 space-y-3">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{updateProxyModes.map((mode) => (
|
|
<button
|
|
key={mode.value}
|
|
type="button"
|
|
onClick={() => {
|
|
setUpdateProxyDraft((current) => ({ ...current, mode: mode.value }))
|
|
setUpdateProxySaveError(null)
|
|
}}
|
|
aria-pressed={updateProxyDraft.mode === mode.value}
|
|
className={`rounded-lg border px-3 py-2 text-left transition-colors ${
|
|
updateProxyDraft.mode === mode.value
|
|
? 'border-[var(--color-brand)] bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
<div className="text-xs font-semibold">{mode.label}</div>
|
|
<div className="mt-1 text-[11px] leading-4 text-[var(--color-text-tertiary)]">
|
|
{mode.description}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{updateProxyDraft.mode === 'manual' && (
|
|
<div>
|
|
<Input
|
|
id="update-proxy-url"
|
|
label={t('update.proxyUrl')}
|
|
value={updateProxyDraft.url}
|
|
placeholder="http://127.0.0.1:7890"
|
|
autoComplete="off"
|
|
onChange={(event) => {
|
|
setUpdateProxyDraft((current) => ({ ...current, url: event.target.value }))
|
|
setUpdateProxySaveError(null)
|
|
}}
|
|
/>
|
|
<p className={`mt-1 text-[11px] leading-4 ${manualProxyError ? 'text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]'}`}>
|
|
{manualProxyError ?? t('update.proxyUrlHint')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="min-w-0 text-[11px] leading-4 text-[var(--color-text-tertiary)]">
|
|
{t('update.proxyScopeHint')}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
className="min-w-[72px] px-4 whitespace-nowrap"
|
|
disabled={!updateProxyDirty || !!manualProxyError || isSavingUpdateProxy}
|
|
loading={isSavingUpdateProxy}
|
|
onClick={() => void saveUpdateProxy()}
|
|
>
|
|
{t('update.proxySave')}
|
|
</Button>
|
|
</div>
|
|
|
|
{updateProxySaveError && (
|
|
<p className="text-[11px] leading-4 text-[var(--color-error)]">
|
|
{updateProxySaveError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{(updateStatus === 'downloading' || updateStatus === 'restarting') && (
|
|
<div className="mt-3">
|
|
<div className="h-1.5 bg-[var(--color-surface-container-low)] rounded-full overflow-hidden">
|
|
{hasKnownProgress || updateStatus === 'restarting' ? (
|
|
<div
|
|
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
|
|
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
|
/>
|
|
) : (
|
|
<div className="h-full w-1/3 rounded-full bg-[var(--color-text-accent)]/75 animate-pulse" />
|
|
)}
|
|
</div>
|
|
{!hasKnownProgress && updateStatus === 'downloading' && downloadedBytes > 0 && (
|
|
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
|
|
{downloadedText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{releaseNotes && availableVersion && (
|
|
<div className="mt-3 rounded-lg bg-[var(--color-surface-container-low)] px-3 py-3">
|
|
<div className="text-[11px] uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
|
|
{t('update.releaseNotes')}
|
|
</div>
|
|
<MarkdownRenderer
|
|
content={releaseNotes}
|
|
variant="document"
|
|
className="mt-2 text-[13px] leading-6 text-[var(--color-text-secondary)] [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_p]:text-[13px] [&_p]:leading-6"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{availableVersion && (
|
|
<div className="mt-3 flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void installUpdate()}
|
|
loading={updateStatus === 'downloading' || updateStatus === 'installing' || updateStatus === 'restarting'}
|
|
disabled={updateStatus === 'checking' || updateStatus === 'downloading'}
|
|
>
|
|
{updateStatus === 'downloaded'
|
|
? t('update.installAndRestart')
|
|
: updateStatus === 'installing'
|
|
? t('update.installing')
|
|
: updateStatus === 'restarting'
|
|
? t('update.restarting')
|
|
: t('update.now')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="w-full border-t border-[var(--color-border)]/40 my-6" />
|
|
|
|
{/* Author */}
|
|
<div className="w-full">
|
|
<h3 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">{t('settings.about.author')}</h3>
|
|
<button
|
|
onClick={() => openUrl(AUTHOR_GITHUB)}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
|
|
>
|
|
<img src={publicAssetPath('icons/github.svg')} alt="GitHub" className="w-4 h-4 opacity-60" />
|
|
<span className="text-sm text-[var(--color-text-primary)]">程序员阿江-Relakkes</span>
|
|
<span className="text-xs text-[var(--color-text-tertiary)] ml-auto">GitHub</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Social Media */}
|
|
<div className="w-full mt-4">
|
|
<h3 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">{t('settings.about.socialMedia')}</h3>
|
|
<div className="flex flex-col gap-0.5">
|
|
{SOCIAL_LINKS.map((link) => (
|
|
<button
|
|
key={link.name}
|
|
onClick={() => openUrl(link.url)}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
|
|
>
|
|
<img src={publicAssetPath(link.icon)} alt={link.name} className="w-4 h-4 opacity-60" />
|
|
<span className="text-sm text-[var(--color-text-primary)]">{link.label}</span>
|
|
<span className="text-xs text-[var(--color-text-tertiary)] ml-auto">{link.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 w-full">
|
|
<button
|
|
onClick={() => openUrl(GITHUB_ISSUES)}
|
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-tertiary)]">feedback</span>
|
|
<div className="flex-1 text-left">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.about.feedback')}</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)]">{t('settings.about.feedbackDesc')}</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|