refactor/channel & ipc (#349)

Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-03-09 19:04:00 +08:00
committed by GitHub
Unverified
parent 8b45960662
commit e28eba01e1
47 changed files with 4160 additions and 543 deletions

View File

@@ -2,7 +2,7 @@
* Settings Page
* Application configuration
*/
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
Sun,
Moon,
@@ -30,8 +30,19 @@ import { useGatewayStore } from '@/stores/gateway';
import { useUpdateStore } from '@/stores/update';
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
import { UpdateSettings } from '@/components/settings/UpdateSettings';
import { invokeIpc, toUserMessage } from '@/lib/api-client';
import { trackUiEvent } from '@/lib/telemetry';
import {
getGatewayWsDiagnosticEnabled,
invokeIpc,
setGatewayWsDiagnosticEnabled,
toUserMessage,
} from '@/lib/api-client';
import {
clearUiTelemetry,
getUiTelemetrySnapshot,
subscribeUiTelemetry,
trackUiEvent,
type UiTelemetryEntry,
} from '@/lib/telemetry';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES } from '@/i18n';
import { hostApiFetch } from '@/lib/host-api';
@@ -41,8 +52,6 @@ type ControlUiInfo = {
port: number;
};
type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
export function Settings() {
const { t } = useTranslation('settings');
const {
@@ -58,14 +67,12 @@ export function Settings() {
proxyHttpsServer,
proxyAllServer,
proxyBypassRules,
gatewayTransportPreference,
setProxyEnabled,
setProxyServer,
setProxyHttpServer,
setProxyHttpsServer,
setProxyAllServer,
setProxyBypassRules,
setGatewayTransportPreference,
autoCheckUpdate,
setAutoCheckUpdate,
autoDownloadUpdate,
@@ -88,14 +95,9 @@ export function Settings() {
const [proxyEnabledDraft, setProxyEnabledDraft] = useState(false);
const [showAdvancedProxy, setShowAdvancedProxy] = useState(false);
const [savingProxy, setSavingProxy] = useState(false);
const transportOptions: Array<{ value: GatewayTransportPreference; labelKey: string; descKey: string }> = [
{ value: 'ws-first', labelKey: 'advanced.transport.options.wsFirst', descKey: 'advanced.transport.descriptions.wsFirst' },
{ value: 'http-first', labelKey: 'advanced.transport.options.httpFirst', descKey: 'advanced.transport.descriptions.httpFirst' },
{ value: 'ws-only', labelKey: 'advanced.transport.options.wsOnly', descKey: 'advanced.transport.descriptions.wsOnly' },
{ value: 'http-only', labelKey: 'advanced.transport.options.httpOnly', descKey: 'advanced.transport.descriptions.httpOnly' },
{ value: 'ipc-only', labelKey: 'advanced.transport.options.ipcOnly', descKey: 'advanced.transport.descriptions.ipcOnly' },
];
const [wsDiagnosticEnabled, setWsDiagnosticEnabled] = useState(false);
const [showTelemetryViewer, setShowTelemetryViewer] = useState(false);
const [telemetryEntries, setTelemetryEntries] = useState<UiTelemetryEntry[]>([]);
const isWindows = window.electron.platform === 'win32';
const showCliTools = true;
@@ -222,6 +224,25 @@ export function Settings() {
return () => { unsubscribe?.(); };
}, []);
useEffect(() => {
setWsDiagnosticEnabled(getGatewayWsDiagnosticEnabled());
}, []);
useEffect(() => {
if (!devModeUnlocked) return;
setTelemetryEntries(getUiTelemetrySnapshot(200));
const unsubscribe = subscribeUiTelemetry((entry) => {
setTelemetryEntries((prev) => {
const next = [...prev, entry];
if (next.length > 200) {
next.splice(0, next.length - 200);
}
return next;
});
});
return unsubscribe;
}, [devModeUnlocked]);
useEffect(() => {
setProxyEnabledDraft(proxyEnabled);
}, [proxyEnabled]);
@@ -279,8 +300,99 @@ export function Settings() {
}
};
const telemetryStats = useMemo(() => {
let errorCount = 0;
let slowCount = 0;
for (const entry of telemetryEntries) {
if (entry.event.endsWith('_error') || entry.event.includes('request_error')) {
errorCount += 1;
}
const durationMs = typeof entry.payload.durationMs === 'number'
? entry.payload.durationMs
: Number.NaN;
if (Number.isFinite(durationMs) && durationMs >= 800) {
slowCount += 1;
}
}
return { total: telemetryEntries.length, errorCount, slowCount };
}, [telemetryEntries]);
const telemetryByEvent = useMemo(() => {
const map = new Map<string, {
event: string;
count: number;
errorCount: number;
slowCount: number;
totalDuration: number;
timedCount: number;
lastTs: string;
}>();
for (const entry of telemetryEntries) {
const current = map.get(entry.event) ?? {
event: entry.event,
count: 0,
errorCount: 0,
slowCount: 0,
totalDuration: 0,
timedCount: 0,
lastTs: entry.ts,
};
current.count += 1;
current.lastTs = entry.ts;
if (entry.event.endsWith('_error') || entry.event.includes('request_error')) {
current.errorCount += 1;
}
const durationMs = typeof entry.payload.durationMs === 'number'
? entry.payload.durationMs
: Number.NaN;
if (Number.isFinite(durationMs)) {
current.totalDuration += durationMs;
current.timedCount += 1;
if (durationMs >= 800) {
current.slowCount += 1;
}
}
map.set(entry.event, current);
}
return [...map.values()]
.sort((a, b) => b.count - a.count)
.slice(0, 12);
}, [telemetryEntries]);
const handleCopyTelemetry = async () => {
try {
const serialized = telemetryEntries.map((entry) => JSON.stringify(entry)).join('\n');
await navigator.clipboard.writeText(serialized);
toast.success(t('developer.telemetryCopied'));
} catch (error) {
toast.error(`${t('common:status.error')}: ${String(error)}`);
}
};
const handleClearTelemetry = () => {
clearUiTelemetry();
setTelemetryEntries([]);
toast.success(t('developer.telemetryCleared'));
};
const handleWsDiagnosticToggle = (enabled: boolean) => {
setGatewayWsDiagnosticEnabled(enabled);
setWsDiagnosticEnabled(enabled);
toast.success(
enabled
? t('developer.wsDiagnosticEnabled')
: t('developer.wsDiagnosticDisabled'),
);
};
return (
<div className="space-y-6 p-6">
<div className="flex flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-muted-foreground">
@@ -289,7 +401,7 @@ export function Settings() {
</div>
{/* Appearance */}
<Card>
<Card className="order-2">
<CardHeader>
<CardTitle>{t('appearance.title')}</CardTitle>
<CardDescription>{t('appearance.description')}</CardDescription>
@@ -343,7 +455,7 @@ export function Settings() {
</Card>
{/* AI Providers */}
<Card>
<Card className="order-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
@@ -357,7 +469,7 @@ export function Settings() {
</Card>
{/* Gateway */}
<Card>
<Card className="order-1">
<CardHeader>
<CardTitle>{t('gateway.title')}</CardTitle>
<CardDescription>{t('gateway.description')}</CardDescription>
@@ -430,34 +542,8 @@ export function Settings() {
<Separator />
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>{t('gateway.proxyTitle')}</Label>
<p className="text-sm text-muted-foreground">
{t('gateway.proxyDesc')}
</p>
</div>
<Switch
checked={proxyEnabledDraft}
onCheckedChange={setProxyEnabledDraft}
/>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-server">{t('gateway.proxyServer')}</Label>
<Input
id="proxy-server"
value={proxyServerDraft}
onChange={(event) => setProxyServerDraft(event.target.value)}
placeholder="http://127.0.0.1:7890"
/>
<p className="text-xs text-muted-foreground">
{t('gateway.proxyServerHelp')}
</p>
</div>
{devModeUnlocked && (
{devModeUnlocked ? (
<div className="space-y-4">
<div className="rounded-md border border-border/60 p-3">
<Button
variant="ghost"
@@ -474,81 +560,111 @@ export function Settings() {
</Button>
{showAdvancedProxy && (
<div className="mt-3 space-y-4">
<div className="space-y-2">
<Label htmlFor="proxy-http-server">{t('gateway.proxyHttpServer')}</Label>
<Input
id="proxy-http-server"
value={proxyHttpServerDraft}
onChange={(event) => setProxyHttpServerDraft(event.target.value)}
placeholder={proxyServerDraft || 'http://127.0.0.1:7890'}
/>
<p className="text-xs text-muted-foreground">
{t('gateway.proxyHttpServerHelp')}
</p>
</div>
<div className="flex items-center justify-between">
<div>
<Label>{t('gateway.proxyTitle')}</Label>
<p className="text-sm text-muted-foreground">
{t('gateway.proxyDesc')}
</p>
</div>
<Switch
checked={proxyEnabledDraft}
onCheckedChange={setProxyEnabledDraft}
/>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-https-server">{t('gateway.proxyHttpsServer')}</Label>
<Input
id="proxy-https-server"
value={proxyHttpsServerDraft}
onChange={(event) => setProxyHttpsServerDraft(event.target.value)}
placeholder={proxyServerDraft || 'http://127.0.0.1:7890'}
/>
<p className="text-xs text-muted-foreground">
{t('gateway.proxyHttpsServerHelp')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-server">{t('gateway.proxyServer')}</Label>
<Input
id="proxy-server"
value={proxyServerDraft}
onChange={(event) => setProxyServerDraft(event.target.value)}
placeholder="http://127.0.0.1:7890"
/>
<p className="text-xs text-muted-foreground">
{t('gateway.proxyServerHelp')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-all-server">{t('gateway.proxyAllServer')}</Label>
<Input
id="proxy-all-server"
value={proxyAllServerDraft}
onChange={(event) => setProxyAllServerDraft(event.target.value)}
placeholder={proxyServerDraft || 'socks5://127.0.0.1:7891'}
/>
<p className="text-xs text-muted-foreground">
{t('gateway.proxyAllServerHelp')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-http-server">{t('gateway.proxyHttpServer')}</Label>
<Input
id="proxy-http-server"
value={proxyHttpServerDraft}
onChange={(event) => setProxyHttpServerDraft(event.target.value)}
placeholder={proxyServerDraft || 'http://127.0.0.1:7890'}
/>
<p className="text-xs text-muted-foreground">
{t('gateway.proxyHttpServerHelp')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-https-server">{t('gateway.proxyHttpsServer')}</Label>
<Input
id="proxy-https-server"
value={proxyHttpsServerDraft}
onChange={(event) => setProxyHttpsServerDraft(event.target.value)}
placeholder={proxyServerDraft || 'http://127.0.0.1:7890'}
/>
<p className="text-xs text-muted-foreground">
{t('gateway.proxyHttpsServerHelp')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-all-server">{t('gateway.proxyAllServer')}</Label>
<Input
id="proxy-all-server"
value={proxyAllServerDraft}
onChange={(event) => setProxyAllServerDraft(event.target.value)}
placeholder={proxyServerDraft || 'socks5://127.0.0.1:7891'}
/>
<p className="text-xs text-muted-foreground">
{t('gateway.proxyAllServerHelp')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-bypass">{t('gateway.proxyBypass')}</Label>
<Input
id="proxy-bypass"
value={proxyBypassRulesDraft}
onChange={(event) => setProxyBypassRulesDraft(event.target.value)}
placeholder="<local>;localhost;127.0.0.1;::1"
/>
<p className="text-xs text-muted-foreground">
{t('gateway.proxyBypassHelp')}
</p>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-background/40 p-3">
<p className="text-sm text-muted-foreground">
{t('gateway.proxyRestartNote')}
</p>
<Button
variant="outline"
onClick={handleSaveProxySettings}
disabled={savingProxy}
>
<RefreshCw className={`h-4 w-4 mr-2${savingProxy ? ' animate-spin' : ''}`} />
{savingProxy ? t('common:status.saving') : t('common:actions.save')}
</Button>
</div>
</div>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="proxy-bypass">{t('gateway.proxyBypass')}</Label>
<Input
id="proxy-bypass"
value={proxyBypassRulesDraft}
onChange={(event) => setProxyBypassRulesDraft(event.target.value)}
placeholder="<local>;localhost;127.0.0.1;::1"
/>
<p className="text-xs text-muted-foreground">
{t('gateway.proxyBypassHelp')}
</p>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-background/40 p-3">
<p className="text-sm text-muted-foreground">
{t('gateway.proxyRestartNote')}
</p>
<Button
variant="outline"
onClick={handleSaveProxySettings}
disabled={savingProxy}
>
<RefreshCw className={`h-4 w-4 mr-2${savingProxy ? ' animate-spin' : ''}`} />
{savingProxy ? t('common:status.saving') : t('common:actions.save')}
</Button>
) : (
<div className="rounded-md border border-border/60 bg-muted/30 p-4 text-sm text-muted-foreground">
{t('advanced.devModeDesc')}
</div>
</div>
)}
</CardContent>
</Card>
{/* Updates */}
<Card>
<Card className="order-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
@@ -593,7 +709,7 @@ export function Settings() {
</Card>
{/* Advanced */}
<Card>
<Card className="order-2">
<CardHeader>
<CardTitle>{t('advanced.title')}</CardTitle>
<CardDescription>{t('advanced.description')}</CardDescription>
@@ -616,40 +732,12 @@ export function Settings() {
{/* Developer */}
{devModeUnlocked && (
<Card>
<Card className="order-2">
<CardHeader>
<CardTitle>{t('developer.title')}</CardTitle>
<CardDescription>{t('developer.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div>
<Label>{t('advanced.transport.label')}</Label>
<p className="text-sm text-muted-foreground">
{t('advanced.transport.desc')}
</p>
</div>
<div className="grid gap-2">
{transportOptions.map((option) => (
<Button
key={option.value}
type="button"
variant={gatewayTransportPreference === option.value ? 'default' : 'outline'}
className="justify-between"
onClick={() => {
setGatewayTransportPreference(option.value);
toast.success(t('advanced.transport.saved'));
}}
>
<span>{t(option.labelKey)}</span>
<span className="text-xs opacity-80">{t(option.descKey)}</span>
</Button>
))}
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>{t('developer.console')}</Label>
<p className="text-sm text-muted-foreground">
@@ -729,12 +817,116 @@ export function Settings() {
</div>
</>
)}
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between rounded-md border border-border/60 p-3">
<div>
<Label>{t('developer.wsDiagnostic')}</Label>
<p className="text-sm text-muted-foreground">
{t('developer.wsDiagnosticDesc')}
</p>
</div>
<Switch
checked={wsDiagnosticEnabled}
onCheckedChange={handleWsDiagnosticToggle}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>{t('developer.telemetryViewer')}</Label>
<p className="text-sm text-muted-foreground">
{t('developer.telemetryViewerDesc')}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowTelemetryViewer((prev) => !prev)}
>
{showTelemetryViewer
? t('common:actions.hide')
: t('common:actions.show')}
</Button>
</div>
{showTelemetryViewer && (
<div className="space-y-3 rounded-lg border border-border/60 p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{t('developer.telemetryTotal')}: {telemetryStats.total}</Badge>
<Badge variant={telemetryStats.errorCount > 0 ? 'destructive' : 'secondary'}>
{t('developer.telemetryErrors')}: {telemetryStats.errorCount}
</Badge>
<Badge variant={telemetryStats.slowCount > 0 ? 'secondary' : 'outline'}>
{t('developer.telemetrySlow')}: {telemetryStats.slowCount}
</Badge>
<div className="ml-auto flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleCopyTelemetry}>
<Copy className="h-4 w-4 mr-2" />
{t('common:actions.copy')}
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleClearTelemetry}>
{t('common:actions.clear')}
</Button>
</div>
</div>
<div className="max-h-72 overflow-auto rounded-md border border-border/50 bg-muted/20">
{telemetryByEvent.length > 0 && (
<div className="border-b border-border/50 bg-background/70 p-2">
<p className="mb-2 text-[11px] font-semibold text-muted-foreground">
{t('developer.telemetryAggregated')}
</p>
<div className="space-y-1 text-[11px]">
{telemetryByEvent.map((item) => (
<div
key={item.event}
className="grid grid-cols-[minmax(0,1.6fr)_0.7fr_0.9fr_0.8fr_1fr] gap-2 rounded border border-border/40 px-2 py-1"
>
<span className="truncate font-medium" title={item.event}>{item.event}</span>
<span className="text-muted-foreground">n={item.count}</span>
<span className="text-muted-foreground">
avg={item.timedCount > 0 ? Math.round(item.totalDuration / item.timedCount) : 0}ms
</span>
<span className="text-muted-foreground">slow={item.slowCount}</span>
<span className="text-muted-foreground">err={item.errorCount}</span>
</div>
))}
</div>
</div>
)}
<div className="space-y-1 p-2 font-mono text-xs">
{telemetryEntries.length === 0 ? (
<div className="text-muted-foreground">{t('developer.telemetryEmpty')}</div>
) : (
telemetryEntries
.slice()
.reverse()
.map((entry) => (
<div key={entry.id} className="rounded border border-border/40 bg-background/60 p-2">
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{entry.event}</span>
<span className="text-muted-foreground">{entry.ts}</span>
</div>
<pre className="mt-1 whitespace-pre-wrap text-[11px] text-muted-foreground">
{JSON.stringify({ count: entry.count, ...entry.payload }, null, 2)}
</pre>
</div>
))
)}
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* About */}
<Card>
<Card className="order-2">
<CardHeader>
<CardTitle>{t('about.title')}</CardTitle>
</CardHeader>