diff --git a/.trellis/tasks/04-19-desktop-gui-i18n-ux-refresh-impl/task.json b/.trellis/tasks/04-19-desktop-gui-i18n-ux-refresh-impl/task.json new file mode 100644 index 0000000..74b6db6 --- /dev/null +++ b/.trellis/tasks/04-19-desktop-gui-i18n-ux-refresh-impl/task.json @@ -0,0 +1,72 @@ +{ + "id": "desktop-gui-i18n-ux-refresh-impl", + "name": "desktop-gui-i18n-ux-refresh-impl", + "title": "Implement desktop GUI i18n and UX refresh", + "description": "", + "status": "completed", + "dev_type": "feature", + "scope": "desktop", + "package": null, + "priority": "P2", + "creator": "OpenCode", + "assignee": "OpenCode", + "createdAt": "2026-04-19", + "completedAt": "2026-04-19", + "branch": "master", + "base_branch": "master", + "worktree_path": null, + "current_phase": 6, + "next_action": [ + { + "phase": 1, + "action": "brainstorm" + }, + { + "phase": 2, + "action": "research" + }, + { + "phase": 3, + "action": "implement" + }, + { + "phase": 4, + "action": "check" + }, + { + "phase": 5, + "action": "update-spec" + }, + { + "phase": 6, + "action": "record-session" + } + ], + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "Completed the desktop GUI i18n and UX refresh. Extended desktop preferences with theme and language across Go and frontend, added i18next-based localization with en-US/zh-CN/system support, rebuilt the desktop shell into a tabbed Wails/browser-shared control panel, refreshed layout and theme tokens, updated the generated Wails desktop prefs models, documented desktop build and usage in the README files, and verified with `npm run build`, `go test ./internal/app ./internal/desktop`, and `wails build -tags desktop_wails`.", + "meta": { + "desktopTabs": [ + "overview", + "providers", + "aliases", + "sync", + "settings" + ], + "languages": [ + "en-US", + "zh-CN", + "system" + ], + "themes": [ + "light", + "dark", + "system" + ], + "desktopExe": "build/bin/ocswitch-desktop.exe" + } +} diff --git a/.trellis/workspace/OpenCode/index.md b/.trellis/workspace/OpenCode/index.md index 077b956..4a23332 100644 --- a/.trellis/workspace/OpenCode/index.md +++ b/.trellis/workspace/OpenCode/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 0 -- **Last Active**: - +- **Total Sessions**: 1 +- **Last Active**: 2026-04-19 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~0 | Active | +| `journal-1.md` | ~101 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 1 | 2026-04-19 | Desktop GUI i18n and UX refresh | - | `master` | --- @@ -37,4 +38,4 @@ - Sessions are appended to journal files - New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions +- Use `add_session.py` to record sessions \ No newline at end of file diff --git a/.trellis/workspace/OpenCode/journal-1.md b/.trellis/workspace/OpenCode/journal-1.md index 59b36c5..2a650e8 100644 --- a/.trellis/workspace/OpenCode/journal-1.md +++ b/.trellis/workspace/OpenCode/journal-1.md @@ -68,3 +68,34 @@ Completed the Windows-focused desktop integration pass for `ocswitch desktop` by - If needed, run a full desktop binary build path (`wails build` or Windows packaging) on a machine with final packaging prerequisites. + + +## Session 1: Desktop GUI i18n and UX refresh + +**Date**: 2026-04-19 +**Task**: Desktop GUI i18n and UX refresh +**Branch**: `master` + +### Summary + +Added desktop theme/language preferences across Go and frontend, rebuilt the app into a tabbed localized shell, refreshed CSS theming/layout, updated Wails desktop prefs models, and verified with npm run build plus go test ./internal/app ./internal/desktop. + +### Main Changes + +(Add details) + +### Git Commits + +(No commits - planning session) + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete diff --git a/README.md b/README.md index ddc6d59..2c056ba 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,63 @@ go build -o ocswitch ./cmd/ocswitch go run ./cmd/ocswitch --help ``` +## 桌面 GUI + +仓库同时包含一个基于 Wails 的桌面控制台,适合直接在 Windows 上管理 provider、alias、同步和桌面偏好。 + +当前桌面界面提供: + +- 左侧导航页签:`Overview` / `Providers` / `Aliases` / `Sync` / `Settings` +- 中英文界面切换:`en-US` / `zh-CN` / `system` +- 主题偏好:`light` / `dark` / `system` +- 与浏览器 fallback shell 共用同一套前端 + +### 构建桌面可执行文件 + +先安装前端依赖并确认前端能正常构建: + +```bash +cd frontend +npm install +npm run build +``` + +然后在仓库根目录构建桌面应用: + +```bash +wails build -tags desktop_wails +``` + +Windows 下默认产物路径: + +```text +build/bin/ocswitch-desktop.exe +``` + +### 开发模式 + +如果你想本地调试桌面 GUI: + +```bash +wails dev -tags desktop_wails +``` + +### 使用方式 + +启动桌面应用后,可以直接在界面里完成这些操作: + +- 查看当前代理状态、配置路径和 Doctor 摘要 +- 管理 provider,包括搜索、筛选、编辑和从 OpenCode 导入 +- 管理 alias 和 target 绑定关系 +- 预览并应用 `ocswitch opencode sync` +- 保存桌面偏好,包括开机启动、托盘行为、通知、主题和语言 + +如果你已经构建出可执行文件,也可以直接运行: + +```bash +./build/bin/ocswitch-desktop.exe +``` + ## 5 分钟快速上手 ### 1. 添加上游 provider diff --git a/README_EN.md b/README_EN.md index 9350eee..d39a23b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -21,6 +21,65 @@ Protocol: OpenAI Responses (`POST /v1/responses`) only. Streaming supported. go build -o ocswitch ./cmd/ocswitch ``` +## Desktop GUI + +The repository also ships a Wails-based desktop control panel for managing +providers, aliases, sync flows, and desktop preferences on Windows. + +Current desktop capabilities: + +- Sidebar tabs: `Overview` / `Providers` / `Aliases` / `Sync` / `Settings` +- UI language preference: `en-US` / `zh-CN` / `system` +- Theme preference: `light` / `dark` / `system` +- Shared frontend between the desktop shell and the browser fallback shell + +### Build the desktop executable + +Install frontend dependencies and verify the frontend build first: + +```bash +cd frontend +npm install +npm run build +``` + +Then build the desktop app from the repository root: + +```bash +wails build -tags desktop_wails +``` + +On Windows, the default output path is: + +```text +build/bin/ocswitch-desktop.exe +``` + +### Development mode + +To run the desktop GUI in local development mode: + +```bash +wails dev -tags desktop_wails +``` + +### Usage + +After launching the desktop app, you can: + +- inspect proxy state, config path, and doctor summary +- manage providers with search, filtering, editing, and OpenCode import +- manage aliases and target bindings +- preview and apply `ocswitch opencode sync` +- save desktop preferences including launch-at-login, tray behavior, + notifications, theme, and language + +If you already built the executable, you can run it directly: + +```bash +./build/bin/ocswitch-desktop.exe +``` + ## Quick start ```bash diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 64fac26..76038ca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "ocswitch-desktop-frontend", "version": "0.0.0", "dependencies": { + "i18next": "^25.0.2", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-i18next": "^15.5.3" }, "devDependencies": { "@types/react": "^18.3.18", @@ -254,6 +256,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", @@ -1456,6 +1467,47 @@ "node": ">=6.9.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1619,6 +1671,32 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "15.7.4", + "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-15.7.4.tgz", + "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.4.0", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1724,8 +1802,9 @@ "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1841,6 +1920,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 43d92f3..ebbe27e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,9 @@ "preview": "vite preview" }, "dependencies": { + "i18next": "^25.0.2", "react": "^18.3.1", + "react-i18next": "^15.5.3", "react-dom": "^18.3.1" }, "devDependencies": { diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 77f4d31..6fce2bb 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -2f0b51396a8b6c44ea214228c533bdbe \ No newline at end of file +30ca7bcbca69c2b717e8a2e8cbadd5c5 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ebbe0d..075c5b4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { FormEvent, useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { applySync, bindAliasTarget, @@ -20,6 +21,7 @@ import { stopProxy, unbindAliasTarget, } from './api' +import i18n, { resolveLanguagePreference } from './i18n' import type { AliasTargetInput, AliasView, @@ -27,6 +29,7 @@ import type { DesktopPrefsSaveResult, DesktopPrefsView, DoctorRunResult, + LanguagePreference, Overview, ProviderImportInput, ProviderImportResult, @@ -36,6 +39,7 @@ import type { SyncInput, SyncPreview, SyncResult, + ThemePreference, } from './types' type MetaState = { @@ -61,10 +65,18 @@ type AliasFormState = { disabled: boolean } +type TabKey = 'overview' | 'providers' | 'aliases' | 'sync' | 'settings' +type FilterState = 'all' | 'enabled' | 'disabled' +type ResolvedTheme = 'light' | 'dark' + +const tabs: TabKey[] = ['overview', 'providers', 'aliases', 'sync', 'settings'] + const emptyPrefs: DesktopPrefsView = { launchAtLogin: false, minimizeToTray: false, notifications: false, + theme: 'system', + language: 'system', } const emptySync: SyncInput = { @@ -106,6 +118,10 @@ function pretty(value: unknown): string { return JSON.stringify(value, null, 2) } +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + function headersTextFromMap(headers?: Record): string { if (!headers || Object.keys(headers).length === 0) { return '' @@ -128,12 +144,12 @@ function parseHeadersText(input: string): Record | undefined { for (const line of lines) { const index = line.indexOf('=') if (index <= 0) { - throw new Error(`invalid header ${JSON.stringify(line)}; use KEY=VALUE`) + throw new Error(i18n.t('errors.invalidHeaderFormat', { line })) } const key = line.slice(0, index).trim().toLowerCase() const value = line.slice(index + 1).trim() if (!key) { - throw new Error(`invalid header ${JSON.stringify(line)}; header name must not be empty`) + throw new Error(i18n.t('errors.invalidHeaderName', { line })) } headers[key] = value } @@ -147,24 +163,68 @@ function joinWarnings(warnings?: string[]): string { return warnings.join(' | ') } +function withWarnings(base: string, warnings?: string[]): string { + const merged = joinWarnings(warnings) + if (!merged) { + return base + } + return `${base}. ${i18n.t('messages.warningsSuffix', { warnings: merged })}` +} + function providerSaveStatus(result: ProviderSaveResult): string { - const base = `Saved provider ${result.provider.id}` - const warnings = joinWarnings(result.warnings) - return warnings ? `${base}. Warnings: ${warnings}` : base + return withWarnings(i18n.t('providers.statusSaved', { id: result.provider.id }), result.warnings) } function providerImportStatus(result: ProviderImportResult): string { - const base = `Import done: imported=${result.imported}, skipped=${result.skipped}` - const warnings = joinWarnings(result.warnings) - return warnings ? `${base}. Warnings: ${warnings}` : base + return withWarnings( + i18n.t('providers.statusImportDone', { imported: result.imported, skipped: result.skipped }), + result.warnings, + ) } function desktopPrefsSaveStatus(result: DesktopPrefsSaveResult): string { - const warnings = joinWarnings(result.warnings) - return warnings ? `Saved. Warnings: ${warnings}` : 'Saved' + return withWarnings(i18n.t('messages.saved'), result.warnings) +} + +function normalizeTab(hash: string): TabKey { + const value = hash.replace(/^#/, '') + return tabs.includes(value as TabKey) ? (value as TabKey) : 'overview' +} + +function resolveThemePreference(theme: ThemePreference, systemTheme: ResolvedTheme): ResolvedTheme { + if (theme === 'light' || theme === 'dark') { + return theme + } + return systemTheme +} + +function providerMatches(provider: ProviderView, query: string, filter: FilterState): boolean { + if (filter === 'enabled' && provider.disabled) { + return false + } + if (filter === 'disabled' && !provider.disabled) { + return false + } + if (!query) { + return true + } + const haystack = [provider.id, provider.name || '', provider.baseUrl, provider.models?.join(' ') || ''] + .join(' ') + .toLowerCase() + return haystack.includes(query) +} + +function aliasMatches(alias: AliasView, query: string): boolean { + if (!query) { + return true + } + const targets = alias.targets.map((target) => `${target.provider} ${target.model}`).join(' ') + const haystack = [alias.alias, alias.displayName || '', targets].join(' ').toLowerCase() + return haystack.includes(query) } export default function App() { + const { t } = useTranslation() const [meta, setMeta] = useState({ version: 'dev', shell: 'loading' }) const [overview, setOverview] = useState(null) const [providers, setProviders] = useState([]) @@ -183,10 +243,18 @@ export default function App() { const [aliasForm, setAliasForm] = useState(emptyAliasForm) const [targetForm, setTargetForm] = useState(emptyTargetForm) const [loading, setLoading] = useState(false) + const [activeTab, setActiveTab] = useState('overview') + const [providerQuery, setProviderQuery] = useState('') + const [providerFilter, setProviderFilter] = useState('all') + const [aliasQuery, setAliasQuery] = useState('') + const [editingProviderId, setEditingProviderId] = useState('') + const [editingAliasId, setEditingAliasId] = useState('') + const [systemTheme, setSystemTheme] = useState('dark') + const [systemLanguage, setSystemLanguage] = useState('en-US') const refreshAll = useCallback(async () => { setLoading(true) - setPrefsStatus('Refreshing...') + setPrefsStatus(i18n.t('messages.refreshing')) try { const [metaData, overviewData, providerData, aliasData] = await Promise.all([ getMeta(), @@ -199,9 +267,9 @@ export default function App() { setProviders(providerData) setAliases(aliasData) setPrefs(overviewData.desktop) - setPrefsStatus('Fresh') + setPrefsStatus(i18n.t('messages.fresh')) } catch (error) { - setPrefsStatus(error instanceof Error ? error.message : String(error)) + setPrefsStatus(formatError(error)) } finally { setLoading(false) } @@ -211,58 +279,131 @@ export default function App() { void refreshAll() }, [refreshAll]) + useEffect(() => { + const applyHash = () => { + const tab = normalizeTab(window.location.hash) + setActiveTab(tab) + if (window.location.hash !== `#${tab}`) { + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${tab}`) + } + } + applyHash() + window.addEventListener('hashchange', applyHash) + return () => window.removeEventListener('hashchange', applyHash) + }, []) + + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)') + const updateTheme = () => setSystemTheme(media.matches ? 'dark' : 'light') + updateTheme() + if (typeof media.addEventListener === 'function') { + media.addEventListener('change', updateTheme) + return () => media.removeEventListener('change', updateTheme) + } + media.addListener(updateTheme) + return () => media.removeListener(updateTheme) + }, []) + + useEffect(() => { + const updateLanguage = () => setSystemLanguage(window.navigator.language || 'en-US') + updateLanguage() + window.addEventListener('languagechange', updateLanguage) + return () => window.removeEventListener('languagechange', updateLanguage) + }, []) + + const resolvedTheme = resolveThemePreference(prefs.theme, systemTheme) + const resolvedLanguage = resolveLanguagePreference(prefs.language, systemLanguage as LanguagePreference) + const providerSearch = providerQuery.trim().toLowerCase() + const aliasSearch = aliasQuery.trim().toLowerCase() + const filteredProviders = providers.filter((provider) => providerMatches(provider, providerSearch, providerFilter)) + const filteredAliases = aliases.filter((alias) => aliasMatches(alias, aliasSearch)) + const stats = overview + ? [ + ['overview.providers', String(overview.providerCount)], + ['overview.aliases', String(overview.aliasCount)], + ['overview.routableAliases', String(overview.availableAliases.length)], + ['overview.proxy', overview.proxy.running ? t('status.proxyRunning') : t('status.proxyIdle')], + ] + : [] + + useEffect(() => { + document.documentElement.dataset.theme = resolvedTheme + document.documentElement.style.colorScheme = resolvedTheme + }, [resolvedTheme]) + + useEffect(() => { + void i18n.changeLanguage(resolvedLanguage) + }, [resolvedLanguage]) + + function selectTab(tab: TabKey) { + window.location.hash = tab + } + + function resetProviderForm() { + setEditingProviderId('') + setProviderForm(emptyProviderForm) + } + + function resetAliasForm() { + setEditingAliasId('') + setAliasForm(emptyAliasForm) + } + async function onSavePrefs(event: FormEvent) { event.preventDefault() - setPrefsStatus('Saving...') + setPrefsStatus(i18n.t('messages.saving')) try { const saved = await saveDesktopPrefs(prefs) setPrefs(saved.prefs) await refreshAll() setPrefsStatus(desktopPrefsSaveStatus(saved)) } catch (error) { - setPrefsStatus(error instanceof Error ? error.message : String(error)) + setPrefsStatus(formatError(error)) } } async function onRunDoctor() { - setDoctorStatus('Running...') + setDoctorStatus(i18n.t('messages.running')) try { const result = await runDoctor() setDoctorResult(result) - setDoctorStatus(result.error || 'Doctor OK') + setDoctorStatus(result.error || i18n.t('messages.doctorOk')) } catch (error) { - const message = error instanceof Error ? error.message : String(error) setDoctorResult(null) - setDoctorStatus(message) + setDoctorStatus(formatError(error)) } } async function onStartProxy() { + setPrefsStatus(i18n.t('messages.running')) try { await startProxy() + setPrefsStatus(i18n.t('messages.proxyStarted')) await refreshAll() } catch (error) { - setPrefsStatus(error instanceof Error ? error.message : String(error)) + setPrefsStatus(formatError(error)) } } async function onStopProxy() { + setPrefsStatus(i18n.t('messages.running')) try { await stopProxy() + setPrefsStatus(i18n.t('messages.proxyStopped')) await refreshAll() } catch (error) { - setPrefsStatus(error instanceof Error ? error.message : String(error)) + setPrefsStatus(formatError(error)) } } async function onPreviewSync() { - setSyncStatus('Previewing...') + setSyncStatus(i18n.t('messages.previewing')) try { const result = await previewSync(syncInput) setSyncOutput(result) - setSyncStatus(result.wouldChange ? 'Preview shows changes' : 'Preview shows no changes') + setSyncStatus(result.wouldChange ? i18n.t('messages.previewChanges') : i18n.t('messages.previewNoChanges')) } catch (error) { - const message = error instanceof Error ? error.message : String(error) + const message = formatError(error) setSyncOutput(message) setSyncStatus(message) } @@ -270,13 +411,13 @@ export default function App() { async function onApplySync(event: FormEvent) { event.preventDefault() - setSyncStatus('Applying...') + setSyncStatus(i18n.t('messages.applying')) try { const result = await applySync(syncInput) setSyncOutput(result) - setSyncStatus(result.changed ? 'Sync applied' : 'Already up to date') + setSyncStatus(result.changed ? i18n.t('messages.syncApplied') : i18n.t('messages.syncUpToDate')) } catch (error) { - const message = error instanceof Error ? error.message : String(error) + const message = formatError(error) setSyncOutput(message) setSyncStatus(message) } @@ -284,7 +425,7 @@ export default function App() { async function onSaveProvider(event: FormEvent) { event.preventDefault() - setProviderStatus('Saving provider...') + setProviderStatus(i18n.t('messages.saving')) try { const input: ProviderUpsertInput = { id: providerForm.id.trim(), @@ -297,17 +438,17 @@ export default function App() { clearHeaders: providerForm.clearHeaders, } const result = await saveProvider(input) - setProviderForm(emptyProviderForm) + resetProviderForm() setProviderStatus(providerSaveStatus(result)) await refreshAll() } catch (error) { - setProviderStatus(error instanceof Error ? error.message : String(error)) + setProviderStatus(formatError(error)) } } async function onImportProviders(event: FormEvent) { event.preventDefault() - setProviderStatus('Importing providers...') + setProviderStatus(i18n.t('messages.importing')) try { const result = await importProviders({ sourcePath: providerImportForm.sourcePath?.trim(), @@ -316,11 +457,12 @@ export default function App() { setProviderStatus(providerImportStatus(result)) await refreshAll() } catch (error) { - setProviderStatus(error instanceof Error ? error.message : String(error)) + setProviderStatus(formatError(error)) } } function onEditProvider(provider: ProviderView) { + setEditingProviderId(provider.id) setProviderForm({ id: provider.id, name: provider.name || '', @@ -331,34 +473,41 @@ export default function App() { skipModels: false, clearHeaders: false, }) - setProviderStatus(`Editing provider ${provider.id}. Leave API key blank to keep current value.`) + setProviderStatus(i18n.t('providers.statusEditing', { id: provider.id })) } async function onToggleProvider(provider: ProviderView) { - setProviderStatus(`${provider.disabled ? 'Enabling' : 'Disabling'} provider ${provider.id}...`) + setProviderStatus( + i18n.t(provider.disabled ? 'providers.statusEnabling' : 'providers.statusDisabling', { id: provider.id }), + ) try { await setProviderState({ id: provider.id, disabled: !provider.disabled }) - setProviderStatus(`${provider.disabled ? 'Enabled' : 'Disabled'} provider ${provider.id}`) + setProviderStatus( + i18n.t(provider.disabled ? 'providers.statusEnabled' : 'providers.statusDisabled', { id: provider.id }), + ) await refreshAll() } catch (error) { - setProviderStatus(error instanceof Error ? error.message : String(error)) + setProviderStatus(formatError(error)) } } async function onDeleteProvider(id: string) { - setProviderStatus(`Deleting provider ${id}...`) + setProviderStatus(i18n.t('providers.statusDeleting', { id })) try { await deleteProvider(id) - setProviderStatus(`Deleted provider ${id}`) + if (editingProviderId === id) { + resetProviderForm() + } + setProviderStatus(i18n.t('providers.statusDeleted', { id })) await refreshAll() } catch (error) { - setProviderStatus(error instanceof Error ? error.message : String(error)) + setProviderStatus(formatError(error)) } } async function onSaveAlias(event: FormEvent) { event.preventDefault() - setAliasStatus('Saving alias...') + setAliasStatus(i18n.t('messages.saving')) try { const input: AliasUpsertInput = { alias: aliasForm.alias.trim(), @@ -366,38 +515,42 @@ export default function App() { disabled: aliasForm.disabled, } await saveAlias(input) - setAliasForm(emptyAliasForm) - setAliasStatus(`Saved alias ${input.alias}`) + resetAliasForm() + setAliasStatus(i18n.t('aliases.statusSaved', { alias: input.alias })) await refreshAll() } catch (error) { - setAliasStatus(error instanceof Error ? error.message : String(error)) + setAliasStatus(formatError(error)) } } function onEditAlias(alias: AliasView) { + setEditingAliasId(alias.alias) setAliasForm({ alias: alias.alias, displayName: alias.displayName || '', disabled: !alias.enabled, }) setTargetForm((current) => ({ ...current, alias: alias.alias })) - setAliasStatus(`Editing alias ${alias.alias}`) + setAliasStatus(i18n.t('aliases.statusEditing', { alias: alias.alias })) } async function onDeleteAlias(alias: string) { - setAliasStatus(`Deleting alias ${alias}...`) + setAliasStatus(i18n.t('aliases.statusDeleting', { alias })) try { await deleteAlias(alias) - setAliasStatus(`Deleted alias ${alias}`) + if (editingAliasId === alias) { + resetAliasForm() + } + setAliasStatus(i18n.t('aliases.statusDeleted', { alias })) await refreshAll() } catch (error) { - setAliasStatus(error instanceof Error ? error.message : String(error)) + setAliasStatus(formatError(error)) } } async function onBindTarget(event: FormEvent) { event.preventDefault() - setAliasStatus('Binding target...') + setAliasStatus(i18n.t('aliases.statusBinding')) try { const input: AliasTargetInput = { alias: targetForm.alias.trim(), @@ -407,474 +560,892 @@ export default function App() { } await bindAliasTarget(input) setTargetForm((current) => ({ ...emptyTargetForm, alias: current.alias })) - setAliasStatus(`Bound ${input.provider}/${input.model} to ${input.alias}`) + setAliasStatus(i18n.t('aliases.statusBound', input)) await refreshAll() } catch (error) { - setAliasStatus(error instanceof Error ? error.message : String(error)) + setAliasStatus(formatError(error)) } } async function onUnbindTarget(alias: string, provider: string, model: string) { - setAliasStatus(`Removing ${provider}/${model} from ${alias}...`) + setAliasStatus(i18n.t('aliases.statusRemoving', { alias, provider, model })) try { await unbindAliasTarget({ alias, provider, model, disabled: false }) - setAliasStatus(`Removed ${provider}/${model} from ${alias}`) + setAliasStatus(i18n.t('aliases.statusRemoved', { alias, provider, model })) await refreshAll() } catch (error) { - setAliasStatus(error instanceof Error ? error.message : String(error)) + setAliasStatus(formatError(error)) } } - async function onToggleTarget(alias: string, provider: string, model: string, currentlyDisabled: boolean) { - setAliasStatus(`${currentlyDisabled ? 'Enabling' : 'Disabling'} ${provider}/${model} on ${alias}...`) + async function onToggleTarget(alias: string, provider: string, model: string, enabled: boolean) { + setAliasStatus( + i18n.t(enabled ? 'aliases.statusDisabling' : 'aliases.statusEnabling', { alias, provider, model }), + ) try { - await setAliasTargetState({ alias, provider, model, disabled: !currentlyDisabled }) - setAliasStatus(`${currentlyDisabled ? 'Enabled' : 'Disabled'} ${provider}/${model} on ${alias}`) + await setAliasTargetState({ alias, provider, model, disabled: enabled }) + setAliasStatus( + i18n.t(enabled ? 'aliases.statusDisabled' : 'aliases.statusEnabled', { alias, provider, model }), + ) await refreshAll() } catch (error) { - setAliasStatus(error instanceof Error ? error.message : String(error)) + setAliasStatus(formatError(error)) } } - const stats = overview - ? [ - ['Providers', String(overview.providerCount)], - ['Aliases', String(overview.aliasCount)], - ['Routable aliases', String(overview.availableAliases.length)], - ['Proxy', overview.proxy.running ? 'Running' : 'Idle'], - ] - : [] - return ( -
-
-
-

ocswitch desktop

-

Native control panel

+
+
-
-
-
-

Overview

+ + +
+
+ {t('overview.statsTitle')} - {overview?.proxy.running ? 'Proxy running' : 'Proxy idle'} + {overview?.proxy.running ? t('status.proxyRunning') : t('status.proxyIdle')}
-
+
+ {stats.length === 0 ? {t('messages.loading')} : null} {stats.map(([label, value]) => ( -
- {label} - {value} +
+ {t(label)} + {value}
))}
-
-
+ + +
+
+
+

{t(`nav.${activeTab}.title`)}

+

{t(`nav.${activeTab}.title`)}

+

{t(`nav.${activeTab}.description`)}

+
+
+ -
-
{overview ? pretty(overview) : 'Loading overview...'}
-
+ -
-
-

Desktop prefs

- {prefsStatus} -
-
void onSavePrefs(event)}> - - - - -
-
- -
-
-

OpenCode sync

- {syncStatus} -
-
void onApplySync(event)}> - - - -
- - -
-
-
{typeof syncOutput === 'string' ? syncOutput : pretty(syncOutput)}
-
- -
-
-

Doctor report

- {doctorStatus} -
-
{doctorResult ? pretty(doctorResult) : 'Run doctor to inspect config and OpenCode wiring.'}
-
- -
-
-

Providers

- {providerStatus || `${providers.length} total`} -
-
-
-
void onSaveProvider(event)}> - - - - -