From f3b211f745327945f64a246fed8650cc0844d5e7 Mon Sep 17 00:00:00 2001 From: apale7 <1092377056@qq.com> Date: Sun, 19 Apr 2026 10:07:46 +0800 Subject: [PATCH] feat(desktop): refresh localized control panel Rebuild the Wails desktop shell into a localized tabbed control panel so provider, alias, sync, and settings workflows are easier to manage in one place. Extend desktop preferences with theme and language, document the desktop build flow, and keep the browser fallback shell aligned with the same frontend. --- .../task.json | 72 + .trellis/workspace/OpenCode/index.md | 9 +- .trellis/workspace/OpenCode/journal-1.md | 31 + README.md | 57 + README_EN.md | 59 + frontend/package-lock.json | 92 +- frontend/package.json | 2 + frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 1513 ++++++++++++----- frontend/src/i18n/index.ts | 34 + frontend/src/i18n/locales/en.json | 216 +++ frontend/src/i18n/locales/zh-CN.json | 216 +++ frontend/src/main.tsx | 1 + frontend/src/styles.css | 605 +++++-- frontend/src/types.ts | 6 + frontend/wailsjs/go/models.ts | 8 + internal/app/service.go | 24 + internal/app/service_test.go | 24 +- internal/app/types.go | 16 +- internal/config/config.go | 8 +- internal/desktop/http_test.go | 4 +- 21 files changed, 2351 insertions(+), 648 deletions(-) create mode 100644 .trellis/tasks/04-19-desktop-gui-i18n-ux-refresh-impl/task.json create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/locales/en.json create mode 100644 frontend/src/i18n/locales/zh-CN.json 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)}> - - - - -