perf: optimize app startup and background lifecycles

This commit is contained in:
inman
2026-08-19 00:56:15 +08:00
parent 31332f6d76
commit 927e13349b
121 changed files with 4359 additions and 1400 deletions

View File

@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowLeft,
Bot,
AudioLines,
CheckCircle2,
Copy,
ExternalLink,
FileText,
Languages,
Link2,
Loader2,
Plus,
@@ -47,6 +49,14 @@ import {
type AiHardwareDevice,
type AiHardwareOverview,
} from '@/lib/ai-hardware';
import {
AI_HARDWARE_NAVIGATION_EVENT,
announceAiHardwareNavigation,
announceAiHardwareOverview,
readAiHardwareAgentId,
syncAiHardwareAgentUrl,
type AiHardwareNavigationDetail,
} from '@/lib/ai-hardware-navigation';
type PageState = 'loading' | 'ready' | 'disabled' | 'auth' | 'error';
type BindPath = 'direct' | 'guided';
@@ -193,6 +203,103 @@ function replayConfigurationUpdate(
return next;
}
function configValue(value: string | null | undefined, fallback = '未设置'): string {
const normalized = value?.trim();
return normalized || fallback;
}
function BasicSettingsPreview({
agentName,
config,
configRevision,
configLoading,
configLoadFailed,
onRetry,
onEdit,
}: {
agentName: string | null;
config: AiHardwareAgentConfiguration | null;
configRevision: number | null;
configLoading: boolean;
configLoadFailed: boolean;
onRetry: () => void;
onEdit: () => void;
}) {
const prompt = config?.system_prompt?.trim() ?? '';
const promptLines = prompt ? prompt.split(/\r?\n/).length : 0;
const language = config ? configValue(config.language?.trim() || config.lang_code?.trim()) : '';
const languageCode = config?.lang_code && config.lang_code.trim() !== language ? config.lang_code.trim() : null;
const voice = config ? configValue(config.tts_voice_id) : '';
const voiceLanguage = config?.tts_language?.trim() ? `语言:${config.tts_language.trim()}` : '使用服务默认语言';
return (
<Card data-testid="ai-hardware-basic-settings" className="overflow-hidden">
<CardHeader className="border-b border-border/70 bg-surface-subtle/25 px-4 py-3 sm:px-5 sm:py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<CardTitle className="text-lg">基础设置</CardTitle>
{config ? <span className="text-[11px] font-medium text-muted-foreground">已读取</span> : null}
</div>
<CardDescription className="mt-0.5 text-xs">{agentName ?? '当前智能体'} 的基础对话和语音设置</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
{configRevision !== null ? <span className="hidden text-[11px] font-mono tabular-nums text-muted-foreground sm:inline">配置版本 r{configRevision}</span> : null}
<Button className="h-8 px-3 text-xs" variant="outline" disabled={configLoading || !config} onClick={onEdit}>
{configLoading ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <Settings2 className="mr-1.5 h-3.5 w-3.5" />}编辑配置
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-4 sm:p-5">
{config ? (
<div className="space-y-4">
<section aria-labelledby="ai-hardware-summary-heading">
<div className="flex items-center justify-between gap-2">
<h3 id="ai-hardware-summary-heading" className="text-xs font-semibold">对话与声音</h3>
<span className="text-[11px] text-muted-foreground">当前配置</span>
</div>
<div className="mt-2 grid gap-px overflow-hidden rounded-lg border border-border/70 bg-border/70 sm:grid-cols-2">
<div data-testid="ai-hardware-basic-settings-language" className="bg-background px-3 py-2.5 transition-colors hover:bg-surface-subtle">
<div className="flex min-w-0 items-center gap-2.5">
<span aria-hidden="true" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-subtle text-brand"><Languages className="h-3.5 w-3.5" /></span>
<div className="min-w-0">
<p className="text-[11px] font-medium text-muted-foreground">对话语言</p>
<p className="mt-0.5 truncate text-sm font-semibold leading-5 text-foreground" title={language}>{language}</p>
<p className="text-[11px] text-muted-foreground">{languageCode ?? '使用默认语言代码'}</p>
</div>
</div>
</div>
<div data-testid="ai-hardware-basic-settings-voice" className="bg-background px-3 py-2.5 transition-colors hover:bg-surface-subtle">
<div className="flex min-w-0 items-center gap-2.5">
<span aria-hidden="true" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-subtle text-brand"><AudioLines className="h-3.5 w-3.5" /></span>
<div className="min-w-0">
<p className="text-[11px] font-medium text-muted-foreground">语音音色</p>
<p className="mt-0.5 truncate font-mono text-xs font-semibold leading-5 text-foreground" title={voice}>{voice}</p>
<p className="text-[11px] text-muted-foreground">{voiceLanguage}</p>
</div>
</div>
</div>
</div>
</section>
<section aria-labelledby="ai-hardware-prompt-heading">
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h3 id="ai-hardware-prompt-heading" className="flex items-center gap-2 text-sm font-semibold"><FileText className="h-4 w-4 text-brand" />系统提示预览</h3>
<p className="mt-0.5 text-[11px] text-muted-foreground">机器人回应时遵循的角色与行为规则。</p>
</div>
<span className="text-xs tabular-nums text-muted-foreground">{prompt ? `${prompt.length} 字 · ${promptLines} 行` : '未设置'}</span>
</div>
<pre data-testid="ai-hardware-system-prompt-preview" tabIndex={0} aria-label="系统提示内容预览" className="mt-2 max-h-44 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-border/70 bg-surface-subtle/55 px-3 py-2.5 font-mono text-[11px] leading-5 text-foreground/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35">{prompt || '尚未设置系统提示。点击“编辑配置”添加角色与行为规则。'}</pre>
</section>
</div>
) : configLoading ? <FeedbackState state="loading" title="正在读取配置" description="正在同步当前智能体的基础设置。" /> : configLoadFailed ? <FeedbackState state="error" title="无法读取智能体配置" description="请检查服务连接后重试。" action={<Button variant="outline" onClick={onRetry}>重试读取配置</Button>} /> : null}
</CardContent>
</Card>
);
}
async function createBindFingerprintKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
}
@@ -213,7 +320,7 @@ function equalFingerprint(left: ArrayBuffer, right: ArrayBuffer): boolean {
export function AiHardware() {
const [pageState, setPageState] = useState<PageState>('loading');
const [overview, setOverview] = useState<AiHardwareOverview | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(() => readAiHardwareAgentId());
const [config, setConfig] = useState<AiHardwareAgentConfiguration | null>(null);
const [configRevision, setConfigRevision] = useState<number | null>(null);
const [configLoading, setConfigLoading] = useState(false);
@@ -257,6 +364,12 @@ export function AiHardware() {
const bindRetryIntentRef = useRef<BindRetryIntent | null>(null);
const catalogRequestRef = useRef(0);
const hotspotSessionRef = useRef(0);
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
const clearCreateOperation = () => { setCreateOperationId(null); setCreateOperationBody(null); };
const clearBindOperation = () => {
@@ -342,7 +455,11 @@ export function AiHardware() {
? await recoverAiHardwareCredential({ operationId: recoveryOperationId })
: await recoverAiHardwareCredential();
setRecoveryOperationId(null); setOverview(value); setPageState('ready');
setSelectedAgentId(value.agents[0]?.id ?? null);
const nextAgentId = value.agents[0]?.id ?? null;
setSelectedAgentId(nextAgentId);
syncAiHardwareAgentUrl(nextAgentId);
announceAiHardwareOverview(value);
announceAiHardwareNavigation({ agentId: nextAgentId });
} catch (error) {
if (error instanceof AiHardwareApiError && error.code === 'ai_hardware_credential_recovery_unavailable') {
setRecoveryOperationId(null);
@@ -361,11 +478,19 @@ export function AiHardware() {
getAiHardwareOverview(),
getAiHardwareProvisioningCapabilities().catch(() => ({ guidedHotspotBinding: false })),
]);
if (!mountedRef.current) return;
setGuidedHotspotBinding(capabilities.guidedHotspotBinding);
setOverview(value); setPageState('ready');
setSelectedAgentId((current) => value.agents.some((item) => item.id === current) ? current : value.agents[0]?.id ?? null);
const requestedAgentId = readAiHardwareAgentId();
const nextAgentId = value.agents.some((item) => item.id === requestedAgentId)
? requestedAgentId
: value.agents[0]?.id ?? null;
setSelectedAgentId(nextAgentId);
syncAiHardwareAgentUrl(nextAgentId);
announceAiHardwareOverview(value);
announceAiHardwareNavigation({ agentId: nextAgentId });
} catch (error) {
if (options.preserveCurrent) return;
if (!mountedRef.current || options.preserveCurrent) return;
setOverview(null);
if (error instanceof AiHardwareApiError && error.code === 'AI_HARDWARE_AUTH_REQUIRED') setPageState('auth');
else if (error instanceof AiHardwareApiError && ['AI_HARDWARE_DISABLED', 'ai_hardware_unconfigured'].includes(error.code)) setPageState('disabled');
@@ -374,6 +499,27 @@ export function AiHardware() {
}, []);
useEffect(() => { void loadOverview(); }, [loadOverview]);
useEffect(() => {
const handleNavigation = (event: Event) => {
const detail = (event as CustomEvent<AiHardwareNavigationDetail>).detail;
if (!detail) return;
if (!detail.openCreate && detail.agentId !== undefined) {
const nextAgentId = detail.agentId ?? overview?.agents[0]?.id ?? null;
setSelectedAgentId(nextAgentId);
syncAiHardwareAgentUrl(nextAgentId);
}
if (detail.openCreate) {
setDialogError(null);
setAgentName('');
setCreateOpen(true);
}
};
window.addEventListener(AI_HARDWARE_NAVIGATION_EVENT, handleNavigation);
if (new URLSearchParams(window.location.search).get('create') === '1') {
setCreateOpen(true);
}
return () => window.removeEventListener(AI_HARDWARE_NAVIGATION_EVENT, handleNavigation);
}, [overview]);
useEffect(() => {
if (bindOpen && bindStep === 'connect_device_ap') void scanProvisioningHotspots();
}, [bindOpen, bindStep, scanProvisioningHotspots]);
@@ -494,7 +640,7 @@ export function AiHardware() {
const agent = canRetry ? await createAiHardwareAgent(name, { operationId: createOperationId }) : await createAiHardwareAgent(name);
setCreateOperationId(null);
setCreateOperationBody(null);
setCreateOpen(false); setAgentName(''); await loadOverview(); setSelectedAgentId(agent.id);
setCreateOpen(false); setAgentName(''); await loadOverview(); setSelectedAgentId(agent.id); syncAiHardwareAgentUrl(agent.id); announceAiHardwareNavigation({ agentId: agent.id });
} catch (error) { setCreateOperationId(retryOperationId(error)); setCreateOperationBody(name); setDialogError(safeMessage(error)); } finally { setBusy(false); }
};
@@ -793,19 +939,47 @@ export function AiHardware() {
{overview.agents.length === 0 ? (
<Card className="mx-auto max-w-md text-center"><CardHeader><CardTitle>创建第一个智能体</CardTitle><CardDescription>{overview.status === 'unprovisioned' ? '创建智能体将同时开通你的机器人工作台。' : '智能体创建后,才能把机器人设备绑定给它。'}</CardDescription></CardHeader><CardContent><Button onClick={() => { resetDialog(); setCreateOpen(true); }}><Plus className="mr-2 h-4 w-4" />创建智能体</Button></CardContent></Card>
) : (
<div className="grid gap-5 lg:grid-cols-[minmax(240px,0.7fr)_minmax(0,1.3fr)]">
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>智能体</CardTitle><CardDescription>{overview.agents.length} 个</CardDescription></div><Button size="icon" aria-label="创建智能体" onClick={() => { resetDialog(); setCreateOpen(true); }}><Plus className="h-4 w-4" /></Button></CardHeader><CardContent className="space-y-2">{overview.agents.map((agent) => {
const deviceCount = overview.devices.filter((device) => device.agent_id === agent.id).length;
return <button key={agent.id} type="button" aria-pressed={agent.id === selectedAgentId} onClick={() => setSelectedAgentId(agent.id)} className={`motion-press flex min-h-12 w-full items-center gap-3 rounded-xl px-3 py-2 text-left ${agent.id === selectedAgentId ? 'bg-brand-soft' : 'bg-surface-subtle hover:bg-surface-tertiary'}`}><Bot className="h-4 w-4 shrink-0" /><span className="min-w-0 flex-1"><span className="block truncate text-sm font-semibold">{agent.name}</span><span title={agent.id} className="block text-xs tabular-nums text-muted-foreground">{shortId(agent.id)} · r{agent.config_revision} · {deviceCount} 台设备</span></span></button>;
})}</CardContent></Card>
<div className="space-y-5">
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>{selectedAgent?.name ?? '智能体配置'}</CardTitle><CardDescription>基础对话和语音设置</CardDescription></div><Button variant="outline" disabled={configLoading || !config} onClick={openConfigurationEditor}>{configLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Settings2 className="mr-2 h-4 w-4" />}编辑配置</Button></CardHeader><CardContent>{config ? <dl className="grid gap-3 text-sm sm:grid-cols-2"><div><dt className="text-muted-foreground">语言</dt><dd>{config.language || config.lang_code || '未设置'}</dd></div><div><dt className="text-muted-foreground">语音</dt><dd>{config.tts_voice_id || '未设置'}</dd></div><div className="sm:col-span-2"><dt className="text-muted-foreground">系统提示</dt><dd className="mt-1 whitespace-pre-wrap">{config.system_prompt || '未设置'}</dd></div></dl> : configLoading ? <FeedbackState state="loading" title="正在读取配置" /> : configLoadFailed ? <FeedbackState state="error" title="无法读取智能体配置" description="请检查服务连接后重试。" action={<Button variant="outline" onClick={() => setConfigReloadKey((value) => value + 1)}>重试读取配置</Button>} /> : null}</CardContent></Card>
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>当前智能体设备</CardTitle><CardDescription>{devices.length} 台设备绑定到当前智能体</CardDescription></div><Button onClick={openBindingDialog}><Link2 className="mr-2 h-4 w-4" />为当前智能体绑定设备</Button></CardHeader><CardContent>{devices.length ? <div className="space-y-2">{devices.map((device) => <div key={device.id} className="flex min-h-12 items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2"><span className="min-w-0"><span className="block text-sm font-semibold">设备 {shortId(device.id)}</span><span title={device.id} className="text-xs tabular-nums text-muted-foreground">指派 r{device.assignment_revision}</span></span><Button variant="outline" size="sm" onClick={() => void openAssignment(device)}>重新指派</Button></div>)}</div> : <FeedbackState state="empty" title="当前智能体还没有绑定设备" description="使用设备上的 6 位激活码绑定到当前智能体。" />}</CardContent></Card>
</div>
<div className="mx-auto max-w-5xl space-y-5">
<Card>
<CardHeader className="flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>绑定设备</CardTitle>
<CardDescription>{selectedAgent?.name ?? '当前智能体'} · {devices.length} 台设备已绑定</CardDescription>
</div>
<Button onClick={openBindingDialog}>
<Link2 className="mr-2 h-4 w-4" />为当前智能体绑定设备
</Button>
</CardHeader>
<CardContent>
{devices.length ? (
<div className="space-y-2">
{devices.map((device) => (
<div key={device.id} className="flex min-h-12 items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2">
<span className="min-w-0">
<span className="block text-sm font-semibold">设备 {shortId(device.id)}</span>
<span title={device.id} className="text-xs tabular-nums text-muted-foreground">指派 r{device.assignment_revision}</span>
</span>
<Button variant="outline" size="sm" onClick={() => void openAssignment(device)}>重新指派</Button>
</div>
))}
</div>
) : <FeedbackState state="empty" title="当前智能体还没有绑定设备" description="使用设备上的 6 位激活码绑定到当前智能体。" />}
</CardContent>
</Card>
<BasicSettingsPreview
agentName={selectedAgent?.name ?? null}
config={config}
configRevision={configRevision}
configLoading={configLoading}
configLoadFailed={configLoadFailed}
onRetry={() => setConfigReloadKey((value) => value + 1)}
onEdit={openConfigurationEditor}
/>
</div>
)}
<Dialog open={createOpen} onOpenChange={(open) => { if (!busy) { setCreateOpen(open); if (!open) { setAgentName(''); resetDialog(); } } }}><DialogContent><DialogHeader><DialogTitle>创建智能体</DialogTitle><DialogDescription>输入一个容易识别的名称,稍后仍可修改。</DialogDescription></DialogHeader><div><Label htmlFor="hardware-agent-name">名称</Label><Input id="hardware-agent-name" autoFocus maxLength={64} value={agentName} onChange={(e) => { setAgentName(e.target.value); clearCreateOperation(); }} /></div>{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setCreateOpen(false); resetDialog(); }}>取消</Button><Button aria-busy={busy} disabled={busy} onClick={() => void createAgent()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}创建</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={createOpen} onOpenChange={(open) => { if (!busy) { setCreateOpen(open); if (!open) { setAgentName(''); syncAiHardwareAgentUrl(selectedAgentId); resetDialog(); } } }}><DialogContent><DialogHeader><DialogTitle>创建智能体</DialogTitle><DialogDescription>输入一个容易识别的名称,稍后仍可修改。</DialogDescription></DialogHeader><div><Label htmlFor="hardware-agent-name">名称</Label><Input id="hardware-agent-name" autoFocus maxLength={64} value={agentName} onChange={(e) => { setAgentName(e.target.value); clearCreateOperation(); }} /></div>{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setCreateOpen(false); syncAiHardwareAgentUrl(selectedAgentId); resetDialog(); }}>取消</Button><Button aria-busy={busy} disabled={busy} onClick={() => void createAgent()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}创建</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={bindOpen} onOpenChange={(open) => { if (!open) closeBindingDialog(); }}><DialogContent><DialogHeader><DialogTitle>{bindingTitles[bindStep]}</DialogTitle><DialogDescription>{bindPath === 'guided' ? '按步骤完成机器人联网与账号绑定。' : '使用机器人播报的激活码绑定到智能体。'}</DialogDescription></DialogHeader>{bindingContent}{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter>{canGoBack ? <Button variant="outline" onClick={backBindingStep}><ArrowLeft className="mr-2 h-4 w-4" />上一步</Button> : null}{!['binding', 'bound'].includes(bindStep) ? <Button variant="outline" disabled={busy || portalOpening || hotspotState === 'connecting'} onClick={closeBindingDialog}>取消</Button> : null}{bindingPrimaryAction}</DialogFooter></DialogContent></Dialog>
<Dialog open={configOpen} onOpenChange={(open) => { if (!busy) { setConfigOpen(open); if (!open) { resetDialog(); setCatalog(null); setCatalogError(null); } } }}><DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto"><DialogHeader><DialogTitle>编辑智能体配置</DialogTitle><DialogDescription>模型、语言和音色来自小智当前可用目录;选择“使用服务默认值”可清除可选字段。</DialogDescription></DialogHeader>{draft ? <ConfigFields draft={draft} catalog={catalog} catalogLoading={catalogLoading} catalogError={catalogError} setDraft={(next) => { setDraft(next); clearConfigOperation(); }} onTtsModelChange={(value, next) => { setDraft(next); clearConfigOperation(); void loadConfigurationCatalog(value || undefined); }} onRetryCatalog={() => void loadConfigurationCatalog(draft.tts_model_id || undefined)} /> : null}{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setConfigOpen(false); resetDialog(); }}>取消</Button><Button aria-busy={busy} disabled={busy} onClick={() => void saveConfiguration()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}保存</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={Boolean(assignmentDevice)} onOpenChange={(open) => { if (!open && !busy) { setAssignmentDevice(null); resetDialog(); } }}><DialogContent><DialogHeader><DialogTitle>重新指派设备</DialogTitle><DialogDescription>选择接收这台设备的智能体。系统会检查最新修订,避免覆盖其他更改。</DialogDescription></DialogHeader><AgentSelect agents={overview.agents} value={dialogAgentId} onChange={(value) => { setDialogAgentId(value); clearAssignmentOperation(); }} />{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setAssignmentDevice(null); resetDialog(); }}>取消</Button><Button aria-busy={busy} disabled={busy} onClick={() => void saveAssignment()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}确认指派</Button></DialogFooter></DialogContent></Dialog>