Files
DeskClaw/src/pages/Settings/index.tsx

1096 lines
49 KiB
TypeScript

/**
* Settings Page
* Application configuration
*/
import { useEffect, useMemo, useState } from 'react';
import {
Sun,
Moon,
Monitor,
RefreshCw,
ExternalLink,
Copy,
FileText,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import { useSettingsStore } from '@/stores/settings';
import { useGatewayStore } from '@/stores/gateway';
import { useUpdateStore } from '@/stores/update';
import { UpdateSettings } from '@/components/settings/UpdateSettings';
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';
import { cn } from '@/lib/utils';
type ControlUiInfo = {
url: string;
token: string;
port: number;
};
export function Settings() {
const { t } = useTranslation('settings');
const {
theme,
setTheme,
language,
setLanguage,
launchAtStartup,
setLaunchAtStartup,
gatewayAutoStart,
setGatewayAutoStart,
proxyEnabled,
proxyServer,
proxyHttpServer,
proxyHttpsServer,
proxyAllServer,
proxyBypassRules,
setProxyEnabled,
setProxyServer,
setProxyHttpServer,
setProxyHttpsServer,
setProxyAllServer,
setProxyBypassRules,
autoCheckUpdate,
setAutoCheckUpdate,
autoDownloadUpdate,
setAutoDownloadUpdate,
devModeUnlocked,
setDevModeUnlocked,
telemetryEnabled,
setTelemetryEnabled,
} = useSettingsStore();
const { status: gatewayStatus, restart: restartGateway } = useGatewayStore();
const currentVersion = useUpdateStore((state) => state.currentVersion);
const updateSetAutoDownload = useUpdateStore((state) => state.setAutoDownload);
const [controlUiInfo, setControlUiInfo] = useState<ControlUiInfo | null>(null);
const [openclawCliCommand, setOpenclawCliCommand] = useState('');
const [openclawCliError, setOpenclawCliError] = useState<string | null>(null);
const [proxyServerDraft, setProxyServerDraft] = useState('');
const [proxyHttpServerDraft, setProxyHttpServerDraft] = useState('');
const [proxyHttpsServerDraft, setProxyHttpsServerDraft] = useState('');
const [proxyAllServerDraft, setProxyAllServerDraft] = useState('');
const [proxyBypassRulesDraft, setProxyBypassRulesDraft] = useState('');
const [proxyEnabledDraft, setProxyEnabledDraft] = useState(false);
const [savingProxy, setSavingProxy] = useState(false);
const [wsDiagnosticEnabled, setWsDiagnosticEnabled] = useState(false);
const [showTelemetryViewer, setShowTelemetryViewer] = useState(false);
const [telemetryEntries, setTelemetryEntries] = useState<UiTelemetryEntry[]>([]);
const isWindows = window.electron.platform === 'win32';
const showCliTools = true;
const [showLogs, setShowLogs] = useState(false);
const [logContent, setLogContent] = useState('');
const [doctorRunningMode, setDoctorRunningMode] = useState<'diagnose' | 'fix' | null>(null);
const [doctorResult, setDoctorResult] = useState<{
mode: 'diagnose' | 'fix';
success: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
command: string;
cwd: string;
durationMs: number;
timedOut?: boolean;
error?: string;
} | null>(null);
const handleShowLogs = async () => {
try {
const logs = await hostApiFetch<{ content: string }>('/api/logs?tailLines=100');
setLogContent(logs.content);
setShowLogs(true);
} catch {
setLogContent('(Failed to load logs)');
setShowLogs(true);
}
};
const handleOpenLogDir = async () => {
try {
const { dir: logDir } = await hostApiFetch<{ dir: string | null }>('/api/logs/dir');
if (logDir) {
await invokeIpc('shell:showItemInFolder', logDir);
}
} catch {
// ignore
}
};
const handleRunOpenClawDoctor = async (mode: 'diagnose' | 'fix') => {
setDoctorRunningMode(mode);
try {
const result = await hostApiFetch<{
mode: 'diagnose' | 'fix';
success: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
command: string;
cwd: string;
durationMs: number;
timedOut?: boolean;
error?: string;
}>('/api/app/openclaw-doctor', {
method: 'POST',
body: JSON.stringify({ mode }),
});
setDoctorResult(result);
if (result.success) {
toast.success(mode === 'fix' ? t('developer.doctorFixSucceeded') : t('developer.doctorSucceeded'));
} else {
toast.error(result.error || (mode === 'fix' ? t('developer.doctorFixFailed') : t('developer.doctorFailed')));
}
} catch (error) {
const message = toUserMessage(error) || (mode === 'fix' ? t('developer.doctorFixRunFailed') : t('developer.doctorRunFailed'));
toast.error(message);
setDoctorResult({
mode,
success: false,
exitCode: null,
stdout: '',
stderr: '',
command: 'openclaw doctor',
cwd: '',
durationMs: 0,
error: message,
});
} finally {
setDoctorRunningMode(null);
}
};
const handleCopyDoctorOutput = async () => {
if (!doctorResult) return;
const payload = [
`command: ${doctorResult.command}`,
`cwd: ${doctorResult.cwd}`,
`exitCode: ${doctorResult.exitCode ?? 'null'}`,
`durationMs: ${doctorResult.durationMs}`,
'',
'[stdout]',
doctorResult.stdout.trim() || '(empty)',
'',
'[stderr]',
doctorResult.stderr.trim() || '(empty)',
].join('\n');
try {
await navigator.clipboard.writeText(payload);
toast.success(t('developer.doctorCopied'));
} catch (error) {
toast.error(`Failed to copy doctor output: ${String(error)}`);
}
};
const refreshControlUiInfo = async () => {
try {
const result = await hostApiFetch<{
success: boolean;
url?: string;
token?: string;
port?: number;
}>('/api/gateway/control-ui');
if (result.success && result.url && result.token && typeof result.port === 'number') {
setControlUiInfo({ url: result.url, token: result.token, port: result.port });
}
} catch {
// Ignore refresh errors
}
};
const handleCopyGatewayToken = async () => {
if (!controlUiInfo?.token) return;
try {
await navigator.clipboard.writeText(controlUiInfo.token);
toast.success(t('developer.tokenCopied'));
} catch (error) {
toast.error(`Failed to copy token: ${String(error)}`);
}
};
useEffect(() => {
if (!showCliTools) return;
let cancelled = false;
(async () => {
try {
const result = await invokeIpc<{
success: boolean;
command?: string;
error?: string;
}>('openclaw:getCliCommand');
if (cancelled) return;
if (result.success && result.command) {
setOpenclawCliCommand(result.command);
setOpenclawCliError(null);
} else {
setOpenclawCliCommand('');
setOpenclawCliError(result.error || 'OpenClaw CLI unavailable');
}
} catch (error) {
if (cancelled) return;
setOpenclawCliCommand('');
setOpenclawCliError(String(error));
}
})();
return () => { cancelled = true; };
}, [devModeUnlocked, showCliTools]);
const handleCopyCliCommand = async () => {
if (!openclawCliCommand) return;
try {
await navigator.clipboard.writeText(openclawCliCommand);
toast.success(t('developer.cmdCopied'));
} catch (error) {
toast.error(`Failed to copy command: ${String(error)}`);
}
};
useEffect(() => {
const unsubscribe = window.electron.ipcRenderer.on(
'openclaw:cli-installed',
(...args: unknown[]) => {
const installedPath = typeof args[0] === 'string' ? args[0] : '';
toast.success(`openclaw CLI installed at ${installedPath}`);
},
);
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]);
useEffect(() => {
setProxyServerDraft(proxyServer);
}, [proxyServer]);
useEffect(() => {
setProxyHttpServerDraft(proxyHttpServer);
}, [proxyHttpServer]);
useEffect(() => {
setProxyHttpsServerDraft(proxyHttpsServer);
}, [proxyHttpsServer]);
useEffect(() => {
setProxyAllServerDraft(proxyAllServer);
}, [proxyAllServer]);
useEffect(() => {
setProxyBypassRulesDraft(proxyBypassRules);
}, [proxyBypassRules]);
const handleSaveProxySettings = async () => {
setSavingProxy(true);
try {
const normalizedProxyServer = proxyServerDraft.trim();
const normalizedHttpServer = proxyHttpServerDraft.trim();
const normalizedHttpsServer = proxyHttpsServerDraft.trim();
const normalizedAllServer = proxyAllServerDraft.trim();
const normalizedBypassRules = proxyBypassRulesDraft.trim();
await invokeIpc('settings:setMany', {
proxyEnabled: proxyEnabledDraft,
proxyServer: normalizedProxyServer,
proxyHttpServer: normalizedHttpServer,
proxyHttpsServer: normalizedHttpsServer,
proxyAllServer: normalizedAllServer,
proxyBypassRules: normalizedBypassRules,
});
setProxyServer(normalizedProxyServer);
setProxyHttpServer(normalizedHttpServer);
setProxyHttpsServer(normalizedHttpsServer);
setProxyAllServer(normalizedAllServer);
setProxyBypassRules(normalizedBypassRules);
setProxyEnabled(proxyEnabledDraft);
toast.success(t('gateway.proxySaved'));
trackUiEvent('settings.proxy_saved', { enabled: proxyEnabledDraft });
} catch (error) {
toast.error(`${t('gateway.proxySaveFailed')}: ${toUserMessage(error)}`);
} finally {
setSavingProxy(false);
}
};
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 data-testid="settings-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
<div>
<h1 className="text-5xl md:text-6xl font-serif text-foreground mb-3 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('title')}
</h1>
<p className="text-[17px] text-foreground/70 font-medium">
{t('subtitle')}
</p>
</div>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto pr-2 pb-10 min-h-0 -mr-2 space-y-12">
{/* Appearance */}
<div>
<h2 className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('appearance.title')}
</h2>
<div className="space-y-6">
<div className="space-y-3">
<Label className="text-[15px] font-medium text-foreground/80">{t('appearance.theme')}</Label>
<div className="flex flex-wrap gap-2">
<Button
variant={theme === 'light' ? 'secondary' : 'outline'}
className={cn("rounded-full px-5 h-10 border-black/10 dark:border-white/10", theme === 'light' ? "bg-black/5 dark:bg-white/10 text-foreground" : "bg-transparent text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5")}
onClick={() => setTheme('light')}
>
<Sun className="h-4 w-4 mr-2" />
{t('appearance.light')}
</Button>
<Button
variant={theme === 'dark' ? 'secondary' : 'outline'}
className={cn("rounded-full px-5 h-10 border-black/10 dark:border-white/10", theme === 'dark' ? "bg-black/5 dark:bg-white/10 text-foreground" : "bg-transparent text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5")}
onClick={() => setTheme('dark')}
>
<Moon className="h-4 w-4 mr-2" />
{t('appearance.dark')}
</Button>
<Button
variant={theme === 'system' ? 'secondary' : 'outline'}
className={cn("rounded-full px-5 h-10 border-black/10 dark:border-white/10", theme === 'system' ? "bg-black/5 dark:bg-white/10 text-foreground" : "bg-transparent text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5")}
onClick={() => setTheme('system')}
>
<Monitor className="h-4 w-4 mr-2" />
{t('appearance.system')}
</Button>
</div>
</div>
<div className="space-y-3">
<Label className="text-[15px] font-medium text-foreground/80">{t('appearance.language')}</Label>
<div className="flex flex-wrap gap-2">
{SUPPORTED_LANGUAGES.map((lang) => (
<Button
key={lang.code}
variant={language === lang.code ? 'secondary' : 'outline'}
className={cn("rounded-full px-5 h-10 border-black/10 dark:border-white/10", language === lang.code ? "bg-black/5 dark:bg-white/10 text-foreground" : "bg-transparent text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5")}
onClick={() => setLanguage(lang.code)}
>
{lang.label}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-[15px] font-medium text-foreground/80">{t('appearance.launchAtStartup')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('appearance.launchAtStartupDesc')}
</p>
</div>
<Switch
checked={launchAtStartup}
onCheckedChange={setLaunchAtStartup}
/>
</div>
</div>
</div>
<Separator className="bg-black/5 dark:bg-white/5" />
{/* Gateway */}
<div>
<h2 className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('gateway.title')}
</h2>
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<Label className="text-[15px] font-medium text-foreground">{t('gateway.status')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('gateway.port')}: {gatewayStatus.port}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[13px] font-medium border",
gatewayStatus.state === 'running' ? "bg-green-500/10 text-green-600 dark:text-green-500 border-green-500/20" :
gatewayStatus.state === 'error' ? "bg-red-500/10 text-red-600 dark:text-red-500 border-red-500/20" :
"bg-black/5 dark:bg-white/5 text-muted-foreground border-transparent"
)}>
<div className={cn("w-1.5 h-1.5 rounded-full",
gatewayStatus.state === 'running' ? "bg-green-500" :
gatewayStatus.state === 'error' ? "bg-red-500" : "bg-muted-foreground"
)} />
{gatewayStatus.state}
</div>
<Button variant="outline" size="sm" onClick={restartGateway} className="rounded-full h-8 px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5">
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
{t('common:actions.restart')}
</Button>
<Button variant="outline" size="sm" onClick={handleShowLogs} className="rounded-full h-8 px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5">
<FileText className="h-3.5 w-3.5 mr-1.5" />
{t('gateway.logs')}
</Button>
</div>
</div>
{showLogs && (
<div className="p-4 rounded-2xl bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5">
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-[14px]">{t('gateway.appLogs')}</p>
<div className="flex gap-2">
<Button variant="ghost" size="sm" className="h-7 text-[12px] rounded-full hover:bg-black/5 dark:hover:bg-white/10" onClick={handleOpenLogDir}>
<ExternalLink className="h-3 w-3 mr-1.5" />
{t('gateway.openFolder')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[12px] rounded-full hover:bg-black/5 dark:hover:bg-white/10" onClick={() => setShowLogs(false)}>
{t('common:actions.close')}
</Button>
</div>
</div>
<pre className="text-[12px] text-muted-foreground bg-white dark:bg-card p-4 rounded-xl max-h-60 overflow-auto whitespace-pre-wrap font-mono border border-black/5 dark:border-white/5 shadow-inner">
{logContent || t('chat:noLogs')}
</pre>
</div>
)}
<div className="flex items-center justify-between">
<div>
<Label className="text-[15px] font-medium text-foreground">{t('gateway.autoStart')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('gateway.autoStartDesc')}
</p>
</div>
<Switch
checked={gatewayAutoStart}
onCheckedChange={setGatewayAutoStart}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-[15px] font-medium text-foreground">{t('advanced.devMode')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('advanced.devModeDesc')}
</p>
</div>
<Switch
data-testid="settings-dev-mode-switch"
checked={devModeUnlocked}
onCheckedChange={setDevModeUnlocked}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-[15px] font-medium text-foreground">{t('advanced.telemetry')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('advanced.telemetryDesc')}
</p>
</div>
<Switch
checked={telemetryEnabled}
onCheckedChange={setTelemetryEnabled}
/>
</div>
</div>
</div>
{/* Developer */}
{devModeUnlocked && (
<>
<Separator className="bg-black/5 dark:bg-white/5" />
<div data-testid="settings-developer-section">
<h2 data-testid="settings-developer-title" className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('developer.title')}
</h2>
<div className="space-y-8">
{/* Gateway Proxy */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-[14px] font-medium text-foreground/80">Gateway Proxy</Label>
<p className="text-[13px] text-muted-foreground">
{t('gateway.proxyDesc')}
</p>
</div>
<Switch
checked={proxyEnabledDraft}
onCheckedChange={setProxyEnabledDraft}
/>
</div>
{proxyEnabledDraft && (
<div className="space-y-4 pt-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="proxy-server" className="text-[13px] text-foreground/80">{t('gateway.proxyServer')}</Label>
<Input
id="proxy-server"
value={proxyServerDraft}
onChange={(event) => setProxyServerDraft(event.target.value)}
placeholder="http://127.0.0.1:7890"
className="h-10 rounded-xl bg-black/5 dark:bg-white/5 border-transparent font-mono text-[13px]"
/>
<p className="text-[11px] text-muted-foreground">
{t('gateway.proxyServerHelp')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-http-server" className="text-[13px] text-foreground/80">{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'}
className="h-10 rounded-xl bg-black/5 dark:bg-white/5 border-transparent font-mono text-[13px]"
/>
<p className="text-[11px] text-muted-foreground">
{t('gateway.proxyHttpServerHelp')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-https-server" className="text-[13px] text-foreground/80">{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'}
className="h-10 rounded-xl bg-black/5 dark:bg-white/5 border-transparent font-mono text-[13px]"
/>
<p className="text-[11px] text-muted-foreground">
{t('gateway.proxyHttpsServerHelp')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-all-server" className="text-[13px] text-foreground/80">{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'}
className="h-10 rounded-xl bg-black/5 dark:bg-white/5 border-transparent font-mono text-[13px]"
/>
<p className="text-[11px] text-muted-foreground">
{t('gateway.proxyAllServerHelp')}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-bypass" className="text-[13px] text-foreground/80">{t('gateway.proxyBypass')}</Label>
<Input
id="proxy-bypass"
value={proxyBypassRulesDraft}
onChange={(event) => setProxyBypassRulesDraft(event.target.value)}
placeholder="<local>;localhost;127.0.0.1;::1"
className="h-10 rounded-xl bg-black/5 dark:bg-white/5 border-transparent font-mono text-[13px]"
/>
<p className="text-[11px] text-muted-foreground">
{t('gateway.proxyBypassHelp')}
</p>
</div>
<div className="flex items-center gap-4 pt-2">
<Button
variant="outline"
onClick={handleSaveProxySettings}
disabled={savingProxy}
className="rounded-xl h-10 px-5 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className={`h-4 w-4 mr-2${savingProxy ? ' animate-spin' : ''}`} />
{savingProxy ? t('common:status.saving') : t('common:actions.save')}
</Button>
<p className="text-[12px] text-muted-foreground">
{t('gateway.proxyRestartNote')}
</p>
</div>
</div>
)}
</div>
<div className="space-y-4 pt-4">
<Label className="text-[14px] font-medium text-foreground/80">{t('developer.gatewayToken')}</Label>
<p className="text-[13px] text-muted-foreground">
{t('developer.gatewayTokenDesc')}
</p>
<div className="flex flex-wrap gap-2">
<Input
data-testid="settings-developer-gateway-token"
readOnly
value={controlUiInfo?.token || ''}
placeholder={t('developer.tokenUnavailable')}
className="font-mono text-[13px] h-10 rounded-xl bg-black/5 dark:bg-white/5 border-transparent flex-1 min-w-[200px]"
/>
<Button
type="button"
variant="outline"
onClick={refreshControlUiInfo}
disabled={!devModeUnlocked}
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className="h-4 w-4 mr-2" />
{t('common:actions.load')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleCopyGatewayToken}
disabled={!controlUiInfo?.token}
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
>
<Copy className="h-4 w-4 mr-2" />
{t('common:actions.copy')}
</Button>
</div>
</div>
{showCliTools && (
<div className="space-y-3">
<Label className="text-[15px] font-medium text-foreground">{t('developer.cli')}</Label>
<p className="text-[13px] text-muted-foreground">
{t('developer.cliDesc')}
</p>
{isWindows && (
<p className="text-[12px] text-muted-foreground">
{t('developer.cliPowershell')}
</p>
)}
<div className="flex flex-wrap gap-2">
<Input
readOnly
value={openclawCliCommand}
placeholder={openclawCliError || t('developer.cmdUnavailable')}
className="font-mono text-[13px] h-10 rounded-xl bg-black/5 dark:bg-white/5 border-transparent flex-1 min-w-[200px]"
/>
<Button
type="button"
variant="outline"
onClick={handleCopyCliCommand}
disabled={!openclawCliCommand}
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
>
<Copy className="h-4 w-4 mr-2" />
{t('common:actions.copy')}
</Button>
</div>
</div>
)}
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<Label className="text-[14px] font-medium text-foreground">{t('developer.doctor')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('developer.doctorDesc')}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => void handleRunOpenClawDoctor('diagnose')}
disabled={doctorRunningMode !== null}
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className={`h-4 w-4 mr-2${doctorRunningMode === 'diagnose' ? ' animate-spin' : ''}`} />
{doctorRunningMode === 'diagnose' ? t('common:status.running') : t('developer.runDoctor')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => void handleRunOpenClawDoctor('fix')}
disabled={doctorRunningMode !== null}
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className={`h-4 w-4 mr-2${doctorRunningMode === 'fix' ? ' animate-spin' : ''}`} />
{doctorRunningMode === 'fix' ? t('common:status.running') : t('developer.runDoctorFix')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleCopyDoctorOutput}
disabled={!doctorResult}
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
>
<Copy className="h-4 w-4 mr-2" />
{t('common:actions.copy')}
</Button>
</div>
</div>
{doctorResult && (
<div className="space-y-3 rounded-2xl border border-black/10 dark:border-white/10 p-5 bg-black/5 dark:bg-white/5">
<div className="flex flex-wrap gap-2 text-[12px]">
<Badge variant={doctorResult.success ? 'secondary' : 'destructive'} className="rounded-full px-3 py-1">
{doctorResult.mode === 'fix'
? (doctorResult.success ? t('developer.doctorFixOk') : t('developer.doctorFixIssue'))
: (doctorResult.success ? t('developer.doctorOk') : t('developer.doctorIssue'))}
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1">
{t('developer.doctorExitCode')}: {doctorResult.exitCode ?? 'null'}
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1">
{t('developer.doctorDuration')}: {Math.round(doctorResult.durationMs)}ms
</Badge>
</div>
<div className="space-y-1 text-[12px] text-muted-foreground font-mono break-all">
<p>{t('developer.doctorCommand')}: {doctorResult.command}</p>
<p>{t('developer.doctorWorkingDir')}: {doctorResult.cwd || '-'}</p>
{doctorResult.error && <p>{t('developer.doctorError')}: {doctorResult.error}</p>}
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<p className="text-[12px] font-semibold text-foreground/80">{t('developer.doctorStdout')}</p>
<pre className="max-h-72 overflow-auto rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-card p-3 text-[11px] font-mono whitespace-pre-wrap break-words">
{doctorResult.stdout.trim() || t('developer.doctorOutputEmpty')}
</pre>
</div>
<div className="space-y-2">
<p className="text-[12px] font-semibold text-foreground/80">{t('developer.doctorStderr')}</p>
<pre className="max-h-72 overflow-auto rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-card p-3 text-[11px] font-mono whitespace-pre-wrap break-words">
{doctorResult.stderr.trim() || t('developer.doctorOutputEmpty')}
</pre>
</div>
</div>
</div>
)}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-2xl border border-black/10 dark:border-white/10 p-5 bg-transparent">
<div>
<Label className="text-[14px] font-medium text-foreground">{t('developer.wsDiagnostic')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('developer.wsDiagnosticDesc')}
</p>
</div>
<Switch
checked={wsDiagnosticEnabled}
onCheckedChange={handleWsDiagnosticToggle}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-[14px] font-medium text-foreground">{t('developer.telemetryViewer')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('developer.telemetryViewerDesc')}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowTelemetryViewer((prev) => !prev)}
className="rounded-full px-5 h-9 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
>
{showTelemetryViewer
? t('common:actions.hide')
: t('common:actions.show')}
</Button>
</div>
{showTelemetryViewer && (
<div className="space-y-4 rounded-2xl border border-black/10 dark:border-white/10 p-5 bg-black/5 dark:bg-white/5">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="rounded-full px-3 py-1 bg-white dark:bg-card border border-black/5 dark:border-white/5">{t('developer.telemetryTotal')}: {telemetryStats.total}</Badge>
<Badge variant={telemetryStats.errorCount > 0 ? 'destructive' : 'secondary'} className={cn("rounded-full px-3 py-1", telemetryStats.errorCount === 0 && "bg-white dark:bg-card border border-black/5 dark:border-white/5")}>
{t('developer.telemetryErrors')}: {telemetryStats.errorCount}
</Badge>
<Badge variant={telemetryStats.slowCount > 0 ? 'secondary' : 'outline'} className={cn("rounded-full px-3 py-1", telemetryStats.slowCount === 0 && "bg-white dark:bg-card border border-black/5 dark:border-white/5")}>
{t('developer.telemetrySlow')}: {telemetryStats.slowCount}
</Badge>
<div className="ml-auto flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleCopyTelemetry} className="rounded-full h-8 px-4 bg-white dark:bg-card border-black/5 dark:border-white/5 hover:bg-black/5 dark:hover:bg-white/10">
<Copy className="h-3.5 w-3.5 mr-1.5" />
{t('common:actions.copy')}
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleClearTelemetry} className="rounded-full h-8 px-4 bg-white dark:bg-card border-black/5 dark:border-white/5 hover:bg-black/5 dark:hover:bg-white/10">
{t('common:actions.clear')}
</Button>
</div>
</div>
<div className="max-h-80 overflow-auto rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-card shadow-inner">
{telemetryByEvent.length > 0 && (
<div className="border-b border-black/5 dark:border-white/5 bg-black/5 dark:bg-white/5 p-3">
<p className="mb-3 text-[12px] font-semibold text-muted-foreground">
{t('developer.telemetryAggregated')}
</p>
<div className="space-y-1.5 text-[12px]">
{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-lg border border-black/5 dark:border-white/5 bg-white dark:bg-card px-3 py-2"
>
<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-2 p-3 font-mono text-[12px]">
{telemetryEntries.length === 0 ? (
<div className="text-muted-foreground text-center py-4">{t('developer.telemetryEmpty')}</div>
) : (
telemetryEntries
.slice()
.reverse()
.map((entry) => (
<div key={entry.id} className="rounded-lg border border-black/5 dark:border-white/5 bg-black/5 dark:bg-white/5 p-3">
<div className="flex items-center justify-between gap-3 mb-2">
<span className="font-semibold text-foreground">{entry.event}</span>
<span className="text-muted-foreground text-[11px]">{entry.ts}</span>
</div>
<pre className="whitespace-pre-wrap text-[11px] text-muted-foreground overflow-x-auto">
{JSON.stringify({ count: entry.count, ...entry.payload }, null, 2)}
</pre>
</div>
))
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
</>
)}
<Separator className="bg-black/5 dark:bg-white/5" />
{/* Updates */}
<div>
<h2 className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('updates.title')}
</h2>
<div className="space-y-6">
<UpdateSettings />
<div className="flex items-center justify-between">
<div>
<Label className="text-[15px] font-medium text-foreground">{t('updates.autoCheck')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('updates.autoCheckDesc')}
</p>
</div>
<Switch
checked={autoCheckUpdate}
onCheckedChange={setAutoCheckUpdate}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-[15px] font-medium text-foreground">{t('updates.autoDownload')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('updates.autoDownloadDesc')}
</p>
</div>
<Switch
checked={autoDownloadUpdate}
onCheckedChange={(value) => {
setAutoDownloadUpdate(value);
updateSetAutoDownload(value);
}}
/>
</div>
</div>
</div>
<Separator className="bg-black/5 dark:bg-white/5" />
{/* About */}
<div>
<h2 className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('about.title')}
</h2>
<div className="space-y-3 text-[14px] text-muted-foreground">
<p>
<strong className="text-foreground font-semibold">{t('about.appName')}</strong> - {t('about.tagline')}
</p>
<p>{t('about.basedOn')}</p>
<p>{t('about.version', { version: currentVersion })}</p>
<div className="flex gap-4 pt-3">
<Button
variant="link"
className="h-auto p-0 text-[14px] text-blue-500 hover:text-blue-600 font-medium"
onClick={() => window.electron.openExternal('https://claw-x.com')}
>
{t('about.docs')}
</Button>
<Button
variant="link"
className="h-auto p-0 text-[14px] text-blue-500 hover:text-blue-600 font-medium"
onClick={() => window.electron.openExternal('https://github.com/ValueCell-ai/ClawX')}
>
{t('about.github')}
</Button>
<Button
variant="link"
className="h-auto p-0 text-[14px] text-blue-500 hover:text-blue-600 font-medium"
onClick={() => window.electron.openExternal('https://icnnp7d0dymg.feishu.cn/wiki/UyfOwQ2cAiJIP6kqUW8cte5Bnlc')}
>
{t('about.faq')}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default Settings;