583 lines
22 KiB
TypeScript
583 lines
22 KiB
TypeScript
/**
|
|
* Settings Page
|
|
* Makelore application configuration.
|
|
*/
|
|
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
|
import { useBlocker, useLocation, useNavigate } from 'react-router-dom';
|
|
import {
|
|
ArrowLeft,
|
|
ChevronDown,
|
|
ExternalLink,
|
|
FileText,
|
|
KeyRound,
|
|
RefreshCw,
|
|
} from 'lucide-react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { DisclosureContent } from '@/components/ui/disclosure';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { UpdateSettings } from '@/components/settings/UpdateSettings';
|
|
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
|
import { invokeIpc, toUserMessage } from '@/lib/api-client';
|
|
import {
|
|
clearUiTelemetry,
|
|
getUiTelemetrySnapshot,
|
|
subscribeUiTelemetry,
|
|
type UiTelemetryEntry,
|
|
} from '@/lib/telemetry';
|
|
import { hostApiFetch } from '@/lib/host-api';
|
|
import { cn } from '@/lib/utils';
|
|
import { useSettingsStore } from '@/stores/settings';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { toast } from 'sonner';
|
|
import { canLeaveCurrentPage, getSettingsReturnPath } from '@/lib/settings-navigation';
|
|
|
|
export function Settings() {
|
|
const { t } = useTranslation('settings');
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const {
|
|
setLanguage,
|
|
launchAtStartup,
|
|
setLaunchAtStartup,
|
|
proxyEnabled,
|
|
proxyServer,
|
|
proxyHttpServer,
|
|
proxyHttpsServer,
|
|
proxyAllServer,
|
|
proxyBypassRules,
|
|
setProxyEnabled,
|
|
setProxyServer,
|
|
setProxyHttpServer,
|
|
setProxyHttpsServer,
|
|
setProxyAllServer,
|
|
setProxyBypassRules,
|
|
devModeUnlocked,
|
|
unlockDevMode,
|
|
lockDevMode,
|
|
telemetryEnabled,
|
|
setTelemetryEnabled,
|
|
} = useSettingsStore();
|
|
|
|
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 [showLogs, setShowLogs] = useState(false);
|
|
const [logContent, setLogContent] = useState('');
|
|
const [showTelemetryViewer, setShowTelemetryViewer] = useState(false);
|
|
const [telemetryEntries, setTelemetryEntries] = useState<UiTelemetryEntry[]>([]);
|
|
const [showAdditionalSettings, setShowAdditionalSettings] = useState(false);
|
|
const [adminDialogOpen, setAdminDialogOpen] = useState(false);
|
|
const [adminPassword, setAdminPassword] = useState('');
|
|
const [adminPasswordError, setAdminPasswordError] = useState<string | null>(null);
|
|
const [adminUnlocking, setAdminUnlocking] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setProxyEnabledDraft(proxyEnabled);
|
|
}, [proxyEnabled]);
|
|
|
|
useEffect(() => {
|
|
setProxyServerDraft(proxyServer);
|
|
}, [proxyServer]);
|
|
|
|
useEffect(() => {
|
|
setProxyHttpServerDraft(proxyHttpServer);
|
|
}, [proxyHttpServer]);
|
|
|
|
useEffect(() => {
|
|
setProxyHttpsServerDraft(proxyHttpsServer);
|
|
}, [proxyHttpsServer]);
|
|
|
|
useEffect(() => {
|
|
setProxyAllServerDraft(proxyAllServer);
|
|
}, [proxyAllServer]);
|
|
|
|
useEffect(() => {
|
|
setProxyBypassRulesDraft(proxyBypassRules);
|
|
}, [proxyBypassRules]);
|
|
|
|
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]);
|
|
|
|
const proxySettingsDirty = useMemo(() => {
|
|
return (
|
|
proxyEnabledDraft !== proxyEnabled ||
|
|
proxyServerDraft.trim() !== proxyServer ||
|
|
proxyHttpServerDraft.trim() !== proxyHttpServer ||
|
|
proxyHttpsServerDraft.trim() !== proxyHttpsServer ||
|
|
proxyAllServerDraft.trim() !== proxyAllServer ||
|
|
proxyBypassRulesDraft.trim() !== proxyBypassRules
|
|
);
|
|
}, [
|
|
proxyAllServer,
|
|
proxyAllServerDraft,
|
|
proxyBypassRules,
|
|
proxyBypassRulesDraft,
|
|
proxyEnabled,
|
|
proxyEnabledDraft,
|
|
proxyHttpServer,
|
|
proxyHttpServerDraft,
|
|
proxyHttpsServer,
|
|
proxyHttpsServerDraft,
|
|
proxyServer,
|
|
proxyServerDraft,
|
|
]);
|
|
const blocker = useBlocker(proxySettingsDirty || savingProxy);
|
|
|
|
useEffect(() => {
|
|
if (blocker.state !== 'blocked') return;
|
|
blocker.reset();
|
|
toast.info('请先保存或还原当前修改,再离开设置。');
|
|
}, [blocker]);
|
|
|
|
useEffect(() => {
|
|
if (!proxySettingsDirty && !savingProxy) return;
|
|
const protectUnsavedSettings = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ''; };
|
|
window.addEventListener('beforeunload', protectUnsavedSettings);
|
|
return () => window.removeEventListener('beforeunload', protectUnsavedSettings);
|
|
}, [proxySettingsDirty, savingProxy]);
|
|
|
|
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,
|
|
});
|
|
setProxyEnabled(proxyEnabledDraft);
|
|
setProxyServer(normalizedProxyServer);
|
|
setProxyHttpServer(normalizedHttpServer);
|
|
setProxyHttpsServer(normalizedHttpsServer);
|
|
setProxyAllServer(normalizedAllServer);
|
|
setProxyBypassRules(normalizedBypassRules);
|
|
toast.success('Proxy settings saved');
|
|
} catch (error) {
|
|
toast.error(`Failed to save proxy settings: ${toUserMessage(error)}`);
|
|
} finally {
|
|
setSavingProxy(false);
|
|
}
|
|
};
|
|
|
|
const handleRevealAdditionalSettings = () => {
|
|
if (showAdditionalSettings) {
|
|
setShowAdditionalSettings(false);
|
|
return;
|
|
}
|
|
if (devModeUnlocked) {
|
|
setShowAdditionalSettings(true);
|
|
return;
|
|
}
|
|
setAdminPassword('');
|
|
setAdminPasswordError(null);
|
|
setAdminDialogOpen(true);
|
|
};
|
|
|
|
const handleUnlockDevMode = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
setAdminUnlocking(true);
|
|
setAdminPasswordError(null);
|
|
try {
|
|
const unlocked = await unlockDevMode(adminPassword);
|
|
if (!unlocked) {
|
|
setAdminPasswordError(t('admin.invalidPassword', 'The administrator password is incorrect.'));
|
|
return;
|
|
}
|
|
setAdminDialogOpen(false);
|
|
setAdminPassword('');
|
|
setShowAdditionalSettings(true);
|
|
} finally {
|
|
setAdminUnlocking(false);
|
|
}
|
|
};
|
|
|
|
const handleLockDevMode = async () => {
|
|
await lockDevMode();
|
|
setShowAdditionalSettings(false);
|
|
};
|
|
|
|
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
|
|
}
|
|
};
|
|
|
|
return (
|
|
<main data-testid="settings-page" data-settings-dirty={proxySettingsDirty || savingProxy} className="min-h-full bg-background">
|
|
<div className="mx-auto flex max-w-4xl flex-col gap-8 px-8 py-8">
|
|
<div className="flex items-center gap-3">
|
|
<Button type="button" variant="ghost" aria-label="返回上一页" title="返回上一级" data-testid="settings-back-button"
|
|
className="shrink-0 gap-2" onClick={() => {
|
|
if (!canLeaveCurrentPage()) { toast.info('请先保存或还原当前修改,再返回。'); return; }
|
|
navigate(getSettingsReturnPath(location.state));
|
|
}}><ArrowLeft className="h-4 w-4" />返回</Button>
|
|
<h1 className="text-2xl font-semibold tracking-normal">{t('title')}</h1>
|
|
</div>
|
|
|
|
<section className="space-y-5" data-testid="settings-general-section">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-lg font-medium">{t('appearance.title')}</h2>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 bg-transparent text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground"
|
|
aria-label={t('appearance.showMoreSettings')}
|
|
aria-expanded={showAdditionalSettings}
|
|
data-testid="settings-reveal-additional"
|
|
onClick={handleRevealAdditionalSettings}
|
|
>
|
|
<ChevronDown
|
|
className={cn(
|
|
'h-4 w-4',
|
|
showAdditionalSettings && 'rotate-180'
|
|
)}
|
|
/>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3 rounded-lg border bg-card p-4">
|
|
<Label htmlFor="language">{t('appearance.language')}</Label>
|
|
<select
|
|
id="language"
|
|
value="zh"
|
|
onChange={(event) => setLanguage(event.target.value)}
|
|
className="h-10 rounded-md border bg-background px-3 text-sm"
|
|
>
|
|
{SUPPORTED_LANGUAGES.map((item) => (
|
|
<option key={item.code} value={item.code}>
|
|
{item.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
|
|
<div>
|
|
<Label className="text-sm font-medium">{t('appearance.launchAtStartup')}</Label>
|
|
<p className="text-sm text-muted-foreground">{t('appearance.launchAtStartupDesc')}</p>
|
|
</div>
|
|
<Switch checked={launchAtStartup} onCheckedChange={setLaunchAtStartup} />
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-5" data-testid="settings-updates">
|
|
<div>
|
|
<h2 className="text-lg font-medium">{t('updates.title')}</h2>
|
|
</div>
|
|
<div className="rounded-lg border bg-card p-4">
|
|
<UpdateSettings />
|
|
</div>
|
|
</section>
|
|
|
|
<Dialog
|
|
open={adminDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setAdminDialogOpen(open);
|
|
if (!open) {
|
|
setAdminPassword('');
|
|
setAdminPasswordError(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent data-testid="settings-admin-password-dialog" className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<KeyRound className="h-5 w-5 text-brand" />
|
|
{t('admin.title', '管理员验证')}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t('admin.description', '请输入管理员密码以管理开发者设置。')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form className="space-y-4" onSubmit={handleUnlockDevMode}>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="settings-admin-password">{t('admin.password', '管理员密码')}</Label>
|
|
<Input
|
|
id="settings-admin-password"
|
|
type="password"
|
|
value={adminPassword}
|
|
onChange={(event) => setAdminPassword(event.target.value)}
|
|
autoFocus
|
|
autoComplete="current-password"
|
|
data-testid="settings-admin-password-input"
|
|
aria-invalid={Boolean(adminPasswordError)}
|
|
/>
|
|
{adminPasswordError && (
|
|
<p className="text-sm text-destructive" role="alert">
|
|
{adminPasswordError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => setAdminDialogOpen(false)}>
|
|
{t('admin.cancel', '取消')}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={!adminPassword.trim() || adminUnlocking}
|
|
data-testid="settings-admin-unlock-button"
|
|
>
|
|
{adminUnlocking && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('admin.unlock', '解锁管理设置')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{showAdditionalSettings && devModeUnlocked && (
|
|
<DisclosureContent open innerClassName="space-y-8">
|
|
<Separator />
|
|
|
|
<section className="space-y-4" data-testid="settings-models">
|
|
<div>
|
|
<h2 className="text-lg font-medium">Models</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Configure provider accounts and inspect local token usage.
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" onClick={() => navigate('/models')}>
|
|
Configure models
|
|
</Button>
|
|
</section>
|
|
|
|
<Separator />
|
|
|
|
<section className="space-y-5" data-testid="settings-proxy-section">
|
|
<div>
|
|
<h2 className="text-lg font-medium">Network</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Configure proxy settings used by Makelore runtime.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-4 rounded-lg border bg-card p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-sm font-medium">Proxy</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Route local runtime traffic through a proxy.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
data-testid="settings-proxy-toggle"
|
|
checked={proxyEnabledDraft}
|
|
onCheckedChange={setProxyEnabledDraft}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="proxy-server">Proxy Server</Label>
|
|
<Input
|
|
id="proxy-server"
|
|
value={proxyServerDraft}
|
|
onChange={(event) => setProxyServerDraft(event.target.value)}
|
|
placeholder="http://127.0.0.1:7890"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="proxy-http-server">HTTP Proxy</Label>
|
|
<Input
|
|
id="proxy-http-server"
|
|
value={proxyHttpServerDraft}
|
|
onChange={(event) => setProxyHttpServerDraft(event.target.value)}
|
|
placeholder="http://127.0.0.1:7890"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="proxy-https-server">HTTPS Proxy</Label>
|
|
<Input
|
|
id="proxy-https-server"
|
|
value={proxyHttpsServerDraft}
|
|
onChange={(event) => setProxyHttpsServerDraft(event.target.value)}
|
|
placeholder="http://127.0.0.1:7890"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="proxy-all-server">ALL_PROXY / SOCKS</Label>
|
|
<Input
|
|
id="proxy-all-server"
|
|
value={proxyAllServerDraft}
|
|
onChange={(event) => setProxyAllServerDraft(event.target.value)}
|
|
placeholder="socks5://127.0.0.1:7890"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="proxy-bypass">Bypass Rules</Label>
|
|
<Input
|
|
id="proxy-bypass"
|
|
value={proxyBypassRulesDraft}
|
|
onChange={(event) => setProxyBypassRulesDraft(event.target.value)}
|
|
placeholder="localhost,127.0.0.1"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
data-testid="settings-proxy-save-button"
|
|
onClick={handleSaveProxySettings}
|
|
disabled={!proxySettingsDirty || savingProxy}
|
|
>
|
|
{savingProxy ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
Save Proxy
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<Separator />
|
|
|
|
<section className="space-y-5" data-testid="settings-advanced-section">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-lg font-medium">{t('advanced.title')}</h2>
|
|
<p className="text-sm text-muted-foreground">{t('advanced.description')}</p>
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={() => void handleLockDevMode()}>
|
|
{t('admin.lock', '锁定')}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
|
|
<div>
|
|
<Label className="text-sm font-medium">{t('advanced.devMode')}</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('advanced.sessionUnlocked', '管理员权限已在本次应用运行期间解锁。')}
|
|
</p>
|
|
</div>
|
|
<Badge variant="secondary">{t('admin.unlocked', '已解锁')}</Badge>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
|
|
<div>
|
|
<Label className="text-sm font-medium">{t('advanced.telemetry')}</Label>
|
|
<p className="text-sm text-muted-foreground">{t('advanced.telemetryDesc')}</p>
|
|
</div>
|
|
<Switch checked={telemetryEnabled} onCheckedChange={setTelemetryEnabled} />
|
|
</div>
|
|
|
|
<div className="rounded-lg border bg-card p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-sm font-medium">Application Logs</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Inspect recent local application logs.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleShowLogs}>
|
|
<FileText className="mr-2 h-4 w-4" />
|
|
View Logs
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={handleOpenLogDir}>
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
Open Folder
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<DisclosureContent open={showLogs} className="mt-4" innerClassName="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs">
|
|
{logContent || '(No logs available yet)'}
|
|
</DisclosureContent>
|
|
</div>
|
|
|
|
<div className="rounded-lg border bg-card p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-sm font-medium">Telemetry Viewer</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Local-only UI and performance events.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowTelemetryViewer((value) => !value)}
|
|
>
|
|
{showTelemetryViewer ? 'Hide' : 'Show'}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
clearUiTelemetry();
|
|
setTelemetryEntries([]);
|
|
}}
|
|
>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<DisclosureContent open={showTelemetryViewer} className="mt-4" innerClassName="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs">
|
|
{telemetryEntries.length > 0
|
|
? JSON.stringify(telemetryEntries, null, 2)
|
|
: '(No telemetry entries)'}
|
|
</DisclosureContent>
|
|
</div>
|
|
</section>
|
|
</DisclosureContent>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default Settings;
|