From a1eb4e244cafb5ba43c33727ffdf5fccdcf50a1a Mon Sep 17 00:00:00 2001 From: apale7 <1092377056@qq.com> Date: Mon, 20 Apr 2026 00:45:21 +0800 Subject: [PATCH] fix(desktop): stabilize proxy and trace flows --- frontend/src/App.tsx | 1433 ++++++++++++++++++-------- frontend/src/api.ts | 17 + frontend/src/env.d.ts | 3 + frontend/src/i18n/locales/en.json | 57 +- frontend/src/i18n/locales/zh-CN.json | 57 +- frontend/src/styles.css | 278 +++-- frontend/src/types.ts | 56 + frontend/wailsjs/go/desktop/App.d.ts | 6 + frontend/wailsjs/go/desktop/App.js | 12 + frontend/wailsjs/go/models.ts | 210 +++- internal/app/service.go | 98 +- internal/app/service_test.go | 199 +++- internal/app/types.go | 125 ++- internal/config/config.go | 65 +- internal/desktop/app.go | 31 + internal/desktop/bindings.go | 24 + internal/desktop/http.go | 26 + internal/desktop/http_test.go | 255 ++++- internal/proxy/server.go | 232 ++++- internal/proxy/server_test.go | 97 +- internal/proxy/traces.go | 260 +++++ 21 files changed, 2910 insertions(+), 631 deletions(-) create mode 100644 internal/proxy/traces.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d32f36b..997bfca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,14 +8,17 @@ import { exportConfig, getMeta, getOverview, + getProxySettings, importConfig, importProviders, + listRequestTraces, listAliases, listProviders, previewSync, runDoctor, saveAlias, saveDesktopPrefs, + saveProxySettings, saveProvider, setAliasTargetState, setProviderState, @@ -38,6 +41,9 @@ import type { ProviderSaveResult, ProviderUpsertInput, ProviderView, + ProxySettingsSaveResult, + ProxySettingsView, + RequestTrace, SyncInput, SyncPreview, SyncResult, @@ -67,26 +73,36 @@ type AliasFormState = { disabled: boolean } -type TabKey = 'overview' | 'providers' | 'aliases' | 'sync' | 'settings' +type TabKey = 'overview' | 'providers' | 'aliases' | 'log' | 'network' | 'sync' | 'settings' type FilterState = 'all' | 'enabled' | 'disabled' type ResolvedTheme = 'light' | 'dark' -type ModalKey = 'provider-form' | 'provider-import' | 'alias-form' | 'alias-target' | null +type ModalKey = 'provider-import' | 'alias-target' | null +type DetailMode = 'empty' | 'create' | 'edit' type ConfigImportMode = 'text' | 'file' type ConfirmIntent = | { kind: 'delete-provider'; id: string } | { kind: 'delete-alias'; alias: string } | { kind: 'unbind-target'; alias: string; provider: string; model: string } -const tabs: TabKey[] = ['overview', 'providers', 'aliases', 'sync', 'settings'] +const tabs: TabKey[] = ['overview', 'providers', 'aliases', 'log', 'network', 'sync', 'settings'] const emptyPrefs: DesktopPrefsView = { launchAtLogin: false, + autoStartProxy: false, minimizeToTray: false, notifications: false, theme: 'system', language: 'system', } +const emptyProxySettings: ProxySettingsView = { + connectTimeoutMs: 10000, + responseHeaderTimeoutMs: 15000, + firstByteTimeoutMs: 15000, + requestReadTimeoutMs: 30000, + streamIdleTimeoutMs: 60000, +} + const emptySync: SyncInput = { target: '', setModel: '', @@ -174,6 +190,27 @@ function parseHeadersText(input: string): Record | undefined { return headers } +function providerFormFromView(provider: ProviderView): ProviderFormState { + return { + id: provider.id, + name: provider.name || '', + baseUrl: provider.baseUrl, + apiKey: '', + headersText: headersTextFromMap(provider.headers), + disabled: provider.disabled, + skipModels: false, + clearHeaders: false, + } +} + +function aliasFormFromView(alias: AliasView): AliasFormState { + return { + alias: alias.alias, + displayName: alias.displayName || '', + disabled: !alias.enabled, + } +} + function joinWarnings(warnings?: string[]): string { if (!warnings || warnings.length === 0) { return '' @@ -204,6 +241,10 @@ function desktopPrefsSaveStatus(result: DesktopPrefsSaveResult): string { return withWarnings(i18n.t('messages.saved'), result.warnings) } +function proxySettingsSaveStatus(result: ProxySettingsSaveResult): string { + 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' @@ -221,6 +262,31 @@ function configFileName(configPath: string): string { return parts[parts.length - 1] || 'ocswitch-config.json' } +function formatDateTime(value?: string): string { + if (!value) { + return '-' + } + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return value + } + return date.toLocaleString() +} + +function formatDuration(value?: number): string { + if (value == null) { + return '-' + } + return `${value} ms` +} + +function tracePrimaryText(trace: RequestTrace): string { + if (trace.finalProvider && trace.finalModel) { + return `${trace.finalProvider}/${trace.finalModel}` + } + return trace.error || i18n.t('messages.noData') +} + function downloadTextFile(filename: string, content: string) { const blob = new Blob([content], { type: 'application/json;charset=utf-8' }) const url = URL.createObjectURL(blob) @@ -273,7 +339,9 @@ export default function App() { const [providers, setProviders] = useState([]) const [aliases, setAliases] = useState([]) const [prefs, setPrefs] = useState(emptyPrefs) + const [proxySettings, setProxySettings] = useState(emptyProxySettings) const [prefsStatus, setPrefsStatus] = useState('') + const [proxySettingsStatus, setProxySettingsStatus] = useState('') const [doctorStatus, setDoctorStatus] = useState('') const [doctorResult, setDoctorResult] = useState(null) const [syncStatus, setSyncStatus] = useState('') @@ -285,12 +353,19 @@ export default function App() { const [aliasStatus, setAliasStatus] = useState('') const [aliasForm, setAliasForm] = useState(emptyAliasForm) const [targetForm, setTargetForm] = useState(emptyTargetForm) + const [requestTraces, setRequestTraces] = useState([]) + const [traceStatus, setTraceStatus] = useState('') + const [selectedTraceId, setSelectedTraceId] = useState(null) const [loading, setLoading] = useState(false) const [activeTab, setActiveTab] = useState('overview') const [providerQuery, setProviderQuery] = useState('') const [providerFilter, setProviderFilter] = useState('all') const [aliasQuery, setAliasQuery] = useState('') + const [selectedProviderId, setSelectedProviderId] = useState(null) + const [providerDetailMode, setProviderDetailMode] = useState('empty') const [editingProviderId, setEditingProviderId] = useState('') + const [selectedAliasId, setSelectedAliasId] = useState(null) + const [aliasDetailMode, setAliasDetailMode] = useState('empty') const [editingAliasId, setEditingAliasId] = useState('') const [systemTheme, setSystemTheme] = useState('dark') const [systemLanguage, setSystemLanguage] = useState('en-US') @@ -305,20 +380,29 @@ export default function App() { setLoading(true) setPrefsStatus(i18n.t('messages.refreshing')) try { - const [metaData, overviewData, providerData, aliasData] = await Promise.all([ + const [metaData, overviewData, providerData, aliasData, proxySettingsData] = await Promise.all([ getMeta(), getOverview(), listProviders(), listAliases(), + getProxySettings(), ]) setMeta(metaData) setOverview(overviewData) setProviders(providerData) setAliases(aliasData) setPrefs(overviewData.desktop) + setProxySettings(proxySettingsData) + const traces = await listRequestTraces() + setRequestTraces(traces) + setSelectedTraceId((current) => current ?? traces[0]?.id ?? null) setPrefsStatus(i18n.t('messages.fresh')) + setProxySettingsStatus(i18n.t('messages.fresh')) + setTraceStatus(i18n.t('messages.fresh')) } catch (error) { setPrefsStatus(formatError(error)) + setProxySettingsStatus(formatError(error)) + setTraceStatus(formatError(error)) } finally { setLoading(false) } @@ -366,6 +450,10 @@ export default function App() { const aliasSearch = aliasQuery.trim().toLowerCase() const filteredProviders = providers.filter((provider) => providerMatches(provider, providerSearch, providerFilter)) const filteredAliases = aliases.filter((alias) => aliasMatches(alias, aliasSearch)) + const selectedProvider = providers.find((provider) => provider.id === selectedProviderId) || null + const selectedAlias = aliases.find((alias) => alias.alias === selectedAliasId) || null + const selectedTrace = requestTraces.find((trace) => trace.id === selectedTraceId) || requestTraces[0] || null + const proxyRunning = overview?.proxy.running ?? false const stats = overview ? [ ['overview.providers', String(overview.providerCount)], @@ -396,6 +484,84 @@ export default function App() { ;(firstFocusable || modal).focus() }, [activeModal, confirmIntent]) + useEffect(() => { + if (activeTab !== 'log' && activeTab !== 'network') { + return + } + let cancelled = false + const tick = async () => { + try { + const traces = await listRequestTraces() + if (cancelled) { + return + } + setRequestTraces(traces) + setSelectedTraceId((current) => { + if (current && traces.some((trace) => trace.id === current)) { + return current + } + return traces[0]?.id ?? null + }) + setTraceStatus(i18n.t('messages.fresh')) + } catch (error) { + if (!cancelled) { + setTraceStatus(formatError(error)) + } + } + } + void tick() + const timer = window.setInterval(() => { + void tick() + }, 3000) + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [activeTab]) + + useEffect(() => { + if (providerDetailMode === 'create') { + return + } + if (selectedProviderId && filteredProviders.some((provider) => provider.id === selectedProviderId)) { + return + } + if (filteredProviders.length > 0) { + const next = filteredProviders[0] + setSelectedProviderId(next.id) + setEditingProviderId(next.id) + setProviderForm(providerFormFromView(next)) + setProviderDetailMode('edit') + return + } + setSelectedProviderId(null) + setEditingProviderId('') + setProviderForm(emptyProviderForm) + setProviderDetailMode('empty') + }, [filteredProviders, providerDetailMode, selectedProviderId]) + + useEffect(() => { + if (aliasDetailMode === 'create') { + return + } + if (selectedAliasId && filteredAliases.some((alias) => alias.alias === selectedAliasId)) { + return + } + if (filteredAliases.length > 0) { + const next = filteredAliases[0] + setSelectedAliasId(next.alias) + setEditingAliasId(next.alias) + setAliasForm(aliasFormFromView(next)) + setTargetForm((current) => ({ ...current, alias: next.alias })) + setAliasDetailMode('edit') + return + } + setSelectedAliasId(null) + setEditingAliasId('') + setAliasForm(emptyAliasForm) + setAliasDetailMode('empty') + }, [aliasDetailMode, filteredAliases, selectedAliasId]) + function selectTab(tab: TabKey) { window.location.hash = tab } @@ -410,6 +576,21 @@ export default function App() { setAliasForm(emptyAliasForm) } + function selectProviderDetail(provider: ProviderView) { + setSelectedProviderId(provider.id) + setEditingProviderId(provider.id) + setProviderForm(providerFormFromView(provider)) + setProviderDetailMode('edit') + } + + function selectAliasDetail(alias: AliasView) { + setSelectedAliasId(alias.alias) + setEditingAliasId(alias.alias) + setAliasForm(aliasFormFromView(alias)) + setTargetForm((current) => ({ ...current, alias: alias.alias })) + setAliasDetailMode('edit') + } + function closeModal() { setActiveModal(null) } @@ -420,7 +601,8 @@ export default function App() { function openProviderCreateModal() { resetProviderForm() - setActiveModal('provider-form') + setSelectedProviderId(null) + setProviderDetailMode('create') } function openProviderImportModal() { @@ -429,7 +611,8 @@ export default function App() { function openAliasCreateModal() { resetAliasForm() - setActiveModal('alias-form') + setSelectedAliasId(null) + setAliasDetailMode('create') } function openAliasTargetModal(alias?: string) { @@ -488,6 +671,18 @@ export default function App() { } } + async function onSaveProxySettings() { + setProxySettingsStatus(i18n.t('messages.saving')) + try { + const saved = await saveProxySettings(proxySettings) + setProxySettings(saved.settings) + await refreshAll() + setProxySettingsStatus(proxySettingsSaveStatus(saved)) + } catch (error) { + setProxySettingsStatus(formatError(error)) + } + } + async function onRunDoctor() { setDoctorStatus(i18n.t('messages.running')) try { @@ -564,8 +759,10 @@ export default function App() { clearHeaders: providerForm.clearHeaders, } const result = await saveProvider(input) - resetProviderForm() - closeModal() + setSelectedProviderId(result.provider.id) + setEditingProviderId(result.provider.id) + setProviderForm(providerFormFromView(result.provider)) + setProviderDetailMode('edit') setProviderStatus(providerSaveStatus(result)) await refreshAll() } catch (error) { @@ -591,19 +788,8 @@ export default function App() { } function onEditProvider(provider: ProviderView) { - setEditingProviderId(provider.id) - setProviderForm({ - id: provider.id, - name: provider.name || '', - baseUrl: provider.baseUrl, - apiKey: '', - headersText: headersTextFromMap(provider.headers), - disabled: provider.disabled, - skipModels: false, - clearHeaders: false, - }) + selectProviderDetail(provider) setProviderStatus(i18n.t('providers.statusEditing', { id: provider.id })) - setActiveModal('provider-form') } async function onToggleProvider(provider: ProviderView) { @@ -625,8 +811,10 @@ export default function App() { setProviderStatus(i18n.t('providers.statusDeleting', { id })) try { await deleteProvider(id) - if (editingProviderId === id) { + if (selectedProviderId === id) { + setSelectedProviderId(null) resetProviderForm() + setProviderDetailMode('empty') } setProviderStatus(i18n.t('providers.statusDeleted', { id })) await refreshAll() @@ -645,8 +833,10 @@ export default function App() { disabled: aliasForm.disabled, } await saveAlias(input) - resetAliasForm() - closeModal() + setSelectedAliasId(input.alias) + setEditingAliasId(input.alias) + setTargetForm((current) => ({ ...current, alias: input.alias })) + setAliasDetailMode('edit') setAliasStatus(i18n.t('aliases.statusSaved', { alias: input.alias })) await refreshAll() } catch (error) { @@ -655,23 +845,18 @@ export default function App() { } function onEditAlias(alias: AliasView) { - setEditingAliasId(alias.alias) - setAliasForm({ - alias: alias.alias, - displayName: alias.displayName || '', - disabled: !alias.enabled, - }) - setTargetForm((current) => ({ ...current, alias: alias.alias })) + selectAliasDetail(alias) setAliasStatus(i18n.t('aliases.statusEditing', { alias: alias.alias })) - setActiveModal('alias-form') } async function onDeleteAlias(alias: string) { setAliasStatus(i18n.t('aliases.statusDeleting', { alias })) try { await deleteAlias(alias) - if (editingAliasId === alias) { + if (selectedAliasId === alias) { + setSelectedAliasId(null) resetAliasForm() + setAliasDetailMode('empty') } setAliasStatus(i18n.t('aliases.statusDeleted', { alias })) await refreshAll() @@ -747,6 +932,10 @@ export default function App() { async function onImportConfig() { const content = configImportText.trim() + if (configImportMode === 'file' && (!configImportFileName || !content)) { + setConfigTransferStatus(i18n.t('settings.fileEmpty')) + return + } if (!content) { setConfigTransferStatus(i18n.t('settings.importEmpty')) return @@ -818,9 +1007,7 @@ export default function App() { : '' const importModeLabelId = useId() - const providerFormTitleId = useId() const providerImportTitleId = useId() - const aliasFormTitleId = useId() const aliasTargetTitleId = useId() const confirmTitleId = useId() @@ -851,43 +1038,6 @@ export default function App() {
-
-
-

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

-

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

-

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

-
- {activeTab === 'overview' ? ( -
-
-
- {t('overview.proxy')} - {overview?.proxy.running ? t('status.proxyRunning') : t('status.proxyIdle')} -
-
- {t('overview.lastError')} - {overview?.proxy.lastError || '-'} -
-
-
- - - -
-
- ) : null} -
- {activeTab === 'overview' ? (
@@ -928,6 +1078,9 @@ export default function App() { + @@ -999,8 +1152,8 @@ export default function App() { ) : null} {activeTab === 'providers' ? ( -
-
+
+

{t('providers.title')}

@@ -1020,120 +1173,226 @@ export default function App() {
-
-
+
+ + +
+ +
+ {providers.length === 0 ? ( +
+ {t('providers.title')} +

{t('providers.empty')}

+

{t('providers.emptyHint')}

+
+ + +
+
+ ) : null} + {providers.length > 0 && filteredProviders.length === 0 ? ( +
+

{t('providers.noMatches')}

+

{t('providers.noMatchesHint')}

+
+ ) : null} + {filteredProviders.map((provider) => ( + + ))} +
+
+ +
+
+
+

+ {providerDetailMode === 'create' + ? t('providers.formCreateTitle') + : editingProviderId + ? t('providers.formEditTitle', { id: editingProviderId }) + : t('providers.listTitle')} +

+

+ {providerDetailMode === 'empty' + ? t('providers.selectHint') + : providerStatus || t('providers.detailHint')} +

+
+ {providerDetailMode !== 'empty' ? ( +
+ {providerDetailMode === 'edit' && selectedProvider ? ( + + ) : null} + {providerDetailMode === 'edit' && selectedProvider ? ( + + ) : null} +
+ ) : null} +
+ + {providerDetailMode === 'empty' ? ( +
+

{t('providers.selectTitle')}

+

{t('providers.selectHint')}

+
+ ) : ( +
void onSaveProvider(event)}> +
+
+ {t('providers.baseUrl')} + {selectedProvider?.baseUrl || providerForm.baseUrl || '-'} +
+
+ {t('providers.models')} + {selectedProvider?.models?.join(', ') || t('providers.modelsNone')} +
+
- - -
- {providers.length === 0 ? ( -
- {t('providers.title')} -

{t('providers.empty')}

-

{t('providers.emptyHint')}

-
- - -
-
- ) : null} - {providers.length > 0 && filteredProviders.length === 0 ? ( -
-

{t('providers.noMatches')}

-

{t('providers.noMatchesHint')}

-
- ) : null} - {filteredProviders.map((provider) => ( -
-
-
- {provider.name || provider.id} - {provider.id} -
- - {provider.disabled ? t('status.disabled') : t('status.enabled')} - -
-
- {provider.baseUrl} - {t('providers.modelsCount', { count: provider.models?.length || 0 })} -
-
-
- {t('providers.baseUrl')} - {provider.baseUrl} -
-
- {t('providers.apiKeyMasked')} - {provider.apiKeyMasked || t('providers.apiKeyNotSet')} -
-
- {t('providers.headers')} - {headersTextFromMap(provider.headers) || t('providers.headersNone')} -
-
- {t('providers.models')} - {provider.models?.join(', ') || t('providers.modelsNone')} -
-
- {provider.modelsSource ? ( -
- {t('providers.sourceLabel')}: {provider.modelsSource} -
- ) : null} -
-
- - -
-
- -
-
-
- ))} -
- + + +