feat(robot): add guided hotspot binding flow

This commit is contained in:
2026-08-16 14:27:43 +08:00
parent 54443232dd
commit b7a1590ca1
7 changed files with 855 additions and 18 deletions

View File

@@ -33,6 +33,8 @@ const SAFE_ERROR_MESSAGES: Record<string, string> = {
AI_HARDWARE_RATE_LIMITED: 'AI hardware service is busy; retry later',
AI_HARDWARE_INVALID_REQUEST: 'Invalid AI hardware request',
AI_HARDWARE_INVALID_RESPONSE: 'AI hardware service returned an invalid response',
AI_HARDWARE_PROVISIONING_DISABLED: 'AI hardware guided provisioning is not enabled',
AI_HARDWARE_PORTAL_OPEN_FAILED: 'AI hardware provisioning portal could not be opened',
};
const OPERATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -61,6 +63,10 @@ export type AiHardwareOverview = {
devices: AiHardwareDevice[];
};
export type AiHardwareProvisioningCapabilities = {
guidedHotspotBinding: boolean;
};
export type AiHardwareAgentConfiguration = AiHardwareAgent & {
system_prompt: string | null;
lang_code: string | null;
@@ -343,6 +349,17 @@ function readEnvelope(value: unknown): MainEnvelope {
return value;
}
function readProvisioningCapabilities(value: unknown): AiHardwareProvisioningCapabilities {
if (!isRecord(value) || !hasOnlyKeys(value, ['guided_hotspot_binding'])
|| typeof value.guided_hotspot_binding !== 'boolean') invalidPayload();
return { guidedHotspotBinding: value.guided_hotspot_binding };
}
function readPortalOpened(value: unknown): { opened: true } {
if (!isRecord(value) || !hasOnlyKeys(value, ['opened']) || value.opened !== true) invalidPayload();
return { opened: true };
}
function throwEnvelopeError(envelope: MainEnvelope): never {
const status = isInteger(envelope.status, 400, 599) ? envelope.status : 502;
const code = typeof envelope.code === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(envelope.code)
@@ -503,6 +520,22 @@ export async function getAiHardwareOverview(): Promise<AiHardwareOverview> {
return request('', undefined, readOverview) as Promise<AiHardwareOverview>;
}
export async function getAiHardwareProvisioningCapabilities(): Promise<AiHardwareProvisioningCapabilities> {
return request(
'/provisioning-capabilities',
undefined,
readProvisioningCapabilities,
) as Promise<AiHardwareProvisioningCapabilities>;
}
export async function openAiHardwareProvisioningPortal(): Promise<{ opened: true }> {
return request(
'/provisioning-portal/open',
jsonInit('POST', {}),
readPortalOpened,
) as Promise<{ opened: true }>;
}
export async function getAiHardwareConfigurationCatalog(
ttsModelId?: string,
): Promise<AiHardwareConfigurationCatalog> {

View File

@@ -1,5 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Bot, Link2, Loader2, Plus, RefreshCw, Settings2 } from 'lucide-react';
import {
ArrowLeft,
Bot,
CheckCircle2,
Copy,
ExternalLink,
Link2,
Loader2,
Plus,
RefreshCw,
Settings2,
ShieldAlert,
Wifi,
} from 'lucide-react';
import { FeedbackState } from '@/components/common/FeedbackState';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -18,6 +31,8 @@ import {
getAiHardwareConfigurationCatalog,
getAiHardwareAssignment,
getAiHardwareOverview,
getAiHardwareProvisioningCapabilities,
openAiHardwareProvisioningPortal,
recoverAiHardwareCredential,
updateAiHardwareAgentConfiguration,
updateAiHardwareAssignment,
@@ -32,6 +47,16 @@ import {
} from '@/lib/ai-hardware';
type PageState = 'loading' | 'ready' | 'disabled' | 'auth' | 'error';
type BindPath = 'direct' | 'guided';
type BindStep =
| 'choose_path'
| 'prepare_robot'
| 'connect_device_ap'
| 'configure_wifi'
| 'reconnect_internet'
| 'enter_activation_code'
| 'binding'
| 'bound';
type BindRetryIntent = { agentId: string; fingerprint: ArrayBuffer; operationId: string };
type ConfigDraft = {
agent_name: string; system_prompt: string; language: string; lang_code: string;
@@ -48,9 +73,20 @@ const nullableTextFields = [
'mem_model_id', 'intent_model_id',
] as const;
const nullableNumberFields = ['tts_volume', 'tts_rate', 'tts_pitch'] as const;
const PROVISIONING_PORTAL_DISPLAY_URL = 'http://192.168.4.1/';
const BINDING_OVERVIEW_REFRESH_CODES = new Set([
'ai_hardware_device_already_bound',
'ai_hardware_idempotency_conflict',
'ai_hardware_revision_conflict',
'ai_hardware_state_conflict',
'ai_hardware_provider_state_conflict',
'AI_HARDWARE_REVISION_CONFLICT',
]);
const shortId = (value: string) => value.length > 12 ? `${value.slice(0, 6)}${value.slice(-4)}` : value;
const isRevisionConflict = (error: unknown) => error instanceof AiHardwareApiError
&& ['ai_hardware_revision_conflict', 'REVISION_MISMATCH', 'AI_HARDWARE_REVISION_CONFLICT'].includes(error.code);
const shouldRefreshOverviewAfterBinding = (error: unknown) => error instanceof AiHardwareApiError
&& BINDING_OVERVIEW_REFRESH_CODES.has(error.code);
function safeMessage(error: unknown): string {
if (!(error instanceof AiHardwareApiError)) return '操作没有完成,请稍后重试。';
if (isRevisionConflict(error)) return '内容已在其他位置更新,请重新核对后再保存。';
@@ -65,7 +101,11 @@ function safeMessage(error: unknown): string {
ai_hardware_activation_code_invalid: '激活码无效或已过期,请从设备上获取新激活码。',
ai_hardware_device_already_bound: '设备已被绑定,无法重复绑定。',
ai_hardware_idempotency_conflict: '本次输入与待重试操作不一致,请再次提交以启动新操作。',
ai_hardware_state_conflict: '设备状态已变化,已刷新列表,请核对后继续。',
ai_hardware_provider_state_conflict: '设备服务状态已变化,已刷新列表,请核对后继续。',
AI_HARDWARE_RATE_LIMITED: '请求过于频繁,请稍后重试。',
AI_HARDWARE_PROVISIONING_DISABLED: '当前版本未开启引导配网,请直接使用设备上的激活码绑定。',
AI_HARDWARE_PORTAL_OPEN_FAILED: '无法打开设备配网页面,请确认电脑已连接设备热点后重试。',
};
if (error.code === 'ai_hardware_operation_in_progress') {
return error.retryAfterSeconds === null
@@ -161,6 +201,13 @@ export function AiHardware() {
const [notice, setNotice] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [bindOpen, setBindOpen] = useState(false);
const [guidedHotspotBinding, setGuidedHotspotBinding] = useState(false);
const [bindPath, setBindPath] = useState<BindPath>('direct');
const [bindStep, setBindStep] = useState<BindStep>('enter_activation_code');
const [portalOpening, setPortalOpening] = useState(false);
const [portalOpened, setPortalOpened] = useState(false);
const [portalOpenFailed, setPortalOpenFailed] = useState(false);
const [portalAddressCopied, setPortalAddressCopied] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const [catalog, setCatalog] = useState<AiHardwareConfigurationCatalog | null>(null);
const [catalogLoading, setCatalogLoading] = useState(false);
@@ -232,13 +279,18 @@ export function AiHardware() {
} finally { setRecoveryBusy(false); }
};
const loadOverview = useCallback(async () => {
setPageState('loading'); setNotice(null);
const loadOverview = useCallback(async (options: { preserveCurrent?: boolean } = {}) => {
if (!options.preserveCurrent) { setPageState('loading'); setNotice(null); }
try {
const value = await getAiHardwareOverview();
const [value, capabilities] = await Promise.all([
getAiHardwareOverview(),
getAiHardwareProvisioningCapabilities().catch(() => ({ guidedHotspotBinding: false })),
]);
setGuidedHotspotBinding(capabilities.guidedHotspotBinding);
setOverview(value); setPageState('ready');
setSelectedAgentId((current) => value.agents.some((item) => item.id === current) ? current : value.agents[0]?.id ?? null);
} catch (error) {
if (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');
@@ -278,6 +330,65 @@ export function AiHardware() {
const resetDialog = () => {
setDialogError(null); setBusy(false);
clearCreateOperation(); clearBindOperation(); clearConfigOperation(); clearAssignmentOperation();
setBindPath('direct');
setBindStep(guidedHotspotBinding ? 'choose_path' : 'enter_activation_code');
setPortalOpening(false);
setPortalOpened(false);
setPortalOpenFailed(false);
setPortalAddressCopied(false);
if (activationCodeInputRef.current) activationCodeInputRef.current.value = '';
};
const openBindingDialog = () => {
resetDialog();
setDialogAgentId(selectedAgentId ?? overview?.agents[0]?.id ?? '');
setBindStep(guidedHotspotBinding ? 'choose_path' : 'enter_activation_code');
setBindOpen(true);
};
const closeBindingDialog = () => {
if (busy) return;
setBindOpen(false);
resetDialog();
};
const openProvisioningPortal = async () => {
if (portalOpening) return;
setPortalOpening(true);
setPortalOpenFailed(false);
setPortalAddressCopied(false);
setDialogError(null);
try {
await openAiHardwareProvisioningPortal();
setPortalOpened(true);
} catch (error) {
setPortalOpenFailed(true);
setDialogError(safeMessage(error));
} finally {
setPortalOpening(false);
}
};
const copyProvisioningPortalAddress = async () => {
setPortalAddressCopied(false);
try {
if (typeof navigator.clipboard?.writeText !== 'function') throw new Error('clipboard unavailable');
await navigator.clipboard.writeText(PROVISIONING_PORTAL_DISPLAY_URL);
setPortalAddressCopied(true);
} catch {
setDialogError('复制失败,请手动选择并复制上方固定地址。');
}
};
const backBindingStep = () => {
if (busy || bindStep === 'binding' || bindStep === 'bound') return;
setDialogError(null);
if (bindStep === 'prepare_robot') setBindStep('choose_path');
else if (bindStep === 'connect_device_ap') setBindStep('prepare_robot');
else if (bindStep === 'configure_wifi') setBindStep('connect_device_ap');
else if (bindStep === 'reconnect_internet') setBindStep('configure_wifi');
else if (bindStep === 'enter_activation_code' && bindPath === 'guided') setBindStep('reconnect_internet');
else if (bindStep === 'enter_activation_code' && guidedHotspotBinding) setBindStep('choose_path');
};
const openConfigurationEditor = () => {
@@ -308,7 +419,9 @@ export function AiHardware() {
const activationCode = activationCodeInputRef.current?.value ?? '';
if (!/^[0-9]{6}$/.test(activationCode)) { setDialogError('请输入 6 位数字激活码。'); return; }
if (!dialogAgentId) { setDialogError('请选择要绑定的智能体。'); return; }
if (activationCodeInputRef.current) activationCodeInputRef.current.value = '';
setBusy(true); setDialogError(null);
setBindStep('binding');
try {
const key = bindFingerprintKeyRef.current ?? await createBindFingerprintKey();
bindFingerprintKeyRef.current = key;
@@ -319,9 +432,13 @@ export function AiHardware() {
}
else await bindAiHardwareDevice(activationCode, dialogAgentId);
clearBindOperation();
setBindOpen(false); await loadOverview();
await loadOverview();
setBindStep('bound');
} catch (error) {
const operationId = retryOperationId(error);
const operationId = error instanceof AiHardwareApiError
&& error.code === 'ai_hardware_activation_code_invalid'
? null
: retryOperationId(error);
if (operationId && bindFingerprintKeyRef.current) {
bindRetryIntentRef.current = {
agentId: dialogAgentId,
@@ -330,6 +447,10 @@ export function AiHardware() {
};
} else bindRetryIntentRef.current = null;
setDialogError(safeMessage(error));
setBindStep('enter_activation_code');
if (shouldRefreshOverviewAfterBinding(error)) {
await loadOverview({ preserveCurrent: true });
}
if (activationCodeInputRef.current) activationCodeInputRef.current.value = '';
} finally { setBusy(false); }
};
@@ -402,6 +523,137 @@ export function AiHardware() {
if (overview.status === 'credential_recovery_required' || overview.status === 'invalid') return <main data-testid="ai-hardware-page" role="alert" className="flex min-h-full items-center justify-center"><div className="space-y-3 text-center"><FeedbackState state="error" title="需要恢复设备凭据" description="可在此安全地重新签发设备凭据;凭据内容不会显示在客户端。" action={<Button variant="outline" aria-busy={recoveryBusy} disabled={recoveryBusy} onClick={() => void recoverCredential()}>{recoveryBusy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button>} />{recoveryError ? <p className="text-sm text-destructive">{recoveryError}</p> : null}</div></main>;
if (overview.status === 'provisioning') return <main data-testid="ai-hardware-page" className="flex min-h-full items-center justify-center"><FeedbackState state="empty" title="正在准备机器人工作台" description="完成开通后即可创建智能体并绑定机器人设备。" action={<Button variant="outline" onClick={() => void loadOverview()}></Button>} /></main>;
const bindingTitles: Record<BindStep, string> = {
choose_path: '绑定机器人设备',
prepare_robot: '准备机器人',
connect_device_ap: '连接设备热点',
configure_wifi: '配置机器人 Wi-Fi',
reconnect_internet: '恢复电脑网络',
enter_activation_code: '输入 6 位激活码',
binding: '正在绑定设备',
bound: '绑定成功',
};
const canGoBack = !busy && !['choose_path', 'binding', 'bound'].includes(bindStep)
&& !(bindStep === 'enter_activation_code' && !guidedHotspotBinding);
const bindingContent = (() => {
if (bindStep === 'choose_path') {
return (
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
aria-label="开始引导配网"
className="motion-press min-h-28 rounded-xl border border-border bg-surface-subtle p-4 text-left hover:bg-surface-tertiary"
onClick={() => { setBindPath('guided'); setBindStep('prepare_robot'); }}
>
<Wifi className="mb-3 h-5 w-5 text-brand" />
<span className="block font-semibold"></span>
<span className="mt-1 block text-sm text-muted-foreground">使 Wi-Fi</span>
</button>
<button
type="button"
aria-label="我已有 6 位激活码"
className="motion-press min-h-28 rounded-xl border border-border bg-surface-subtle p-4 text-left hover:bg-surface-tertiary"
onClick={() => { setBindPath('direct'); setBindStep('enter_activation_code'); }}
>
<Link2 className="mb-3 h-5 w-5 text-brand" />
<span className="block font-semibold"> 6 </span>
<span className="mt-1 block text-sm text-muted-foreground"></span>
</button>
</div>
);
}
if (bindStep === 'prepare_robot') {
return (
<div className="space-y-4">
<p className="text-sm"></p>
<div className="rounded-xl bg-amber-500/10 p-4 text-sm text-amber-900">
<ShieldAlert className="mr-2 inline h-4 w-4" />
</div>
</div>
);
}
if (bindStep === 'connect_device_ap') {
return (
<div className="space-y-3 text-sm">
<p> Wi-Fi <strong>Xiaozhi-*</strong> </p>
<p className="text-muted-foreground">访</p>
</div>
);
}
if (bindStep === 'configure_wifi') {
return (
<div className="space-y-4 text-sm">
<p> Wi-Fi </p>
<div className="rounded-xl bg-brand-soft p-4">
Wi-Fi Makelore
</div>
{portalOpenFailed ? (
<div className="space-y-2 rounded-xl border border-border bg-surface-subtle p-4">
<p>访</p>
<div className="flex flex-wrap items-center gap-2">
<code className="select-all rounded bg-background px-2 py-1 tabular-nums">
{PROVISIONING_PORTAL_DISPLAY_URL}
</code>
<Button type="button" variant="outline" size="sm" onClick={() => void copyProvisioningPortalAddress()}>
<Copy className="mr-2 h-4 w-4" />
</Button>
</div>
{portalAddressCopied ? <p role="status" className="text-xs text-emerald-700"></p> : null}
</div>
) : null}
{portalOpened ? (
<Button variant="outline" onClick={() => void openProvisioningPortal()}>
<ExternalLink className="mr-2 h-4 w-4" />
</Button>
) : null}
</div>
);
}
if (bindStep === 'reconnect_internet') {
return (
<div className="space-y-3 text-sm">
<p> Wi-Fi访</p>
<p className="text-muted-foreground"> Wi-Fi </p>
</div>
);
}
if (bindStep === 'enter_activation_code') {
return (
<div className="space-y-4">
{bindPath === 'guided' ? (
<p className="text-sm text-muted-foreground"> 6 </p>
) : null}
<div><Label htmlFor="hardware-activation-code">6 </Label><Input ref={activationCodeInputRef} id="hardware-activation-code" autoFocus type="text" inputMode="numeric" autoComplete="off" maxLength={6} onInput={(e) => { e.currentTarget.value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6); }} /></div>
<AgentSelect agents={overview.agents} value={dialogAgentId} onChange={(value) => { setDialogAgentId(value); clearBindOperation(); }} />
<p className="text-xs text-muted-foreground"></p>
</div>
);
}
if (bindStep === 'binding') {
return <FeedbackState state="loading" title="正在提交绑定" description="请保持窗口打开,结果明确前不要重复操作。" />;
}
return (
<div className="space-y-4 text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-600" />
<p className="font-medium"></p>
<p className="text-sm text-muted-foreground">线</p>
</div>
);
})();
const bindingPrimaryAction = (() => {
if (bindStep === 'prepare_robot') return <Button onClick={() => setBindStep('connect_device_ap')}></Button>;
if (bindStep === 'connect_device_ap') return <Button onClick={() => setBindStep('configure_wifi')}></Button>;
if (bindStep === 'configure_wifi' && !portalOpened) return <Button aria-busy={portalOpening} disabled={portalOpening} onClick={() => void openProvisioningPortal()}>{portalOpening ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ExternalLink className="mr-2 h-4 w-4" />}</Button>;
if (bindStep === 'configure_wifi') return <Button onClick={() => setBindStep('reconnect_internet')}></Button>;
if (bindStep === 'reconnect_internet') return <Button onClick={() => setBindStep('enter_activation_code')}></Button>;
if (bindStep === 'enter_activation_code') return <Button aria-busy={busy} disabled={busy} onClick={() => void bindDevice()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button>;
if (bindStep === 'binding') return <Button disabled aria-busy="true"><Loader2 className="mr-2 h-4 w-4 animate-spin" /></Button>;
if (bindStep === 'bound') return <Button onClick={closeBindingDialog}></Button>;
return null;
})();
return (
<main data-testid="ai-hardware-page" className="-m-5 min-h-full bg-background p-5 text-foreground sm:-m-6 sm:p-6">
<header className="mb-6 flex flex-wrap items-start justify-between gap-4">
@@ -416,13 +668,13 @@ export function AiHardware() {
<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) => <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}</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={() => { resetDialog(); setDialogAgentId(selectedAgentId ?? overview.agents[0].id); setBindOpen(true); }}><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>
<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>
)}
<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={bindOpen} onOpenChange={(open) => { if (!busy) { setBindOpen(open); if (!open) resetDialog(); } }}><DialogContent><DialogHeader><DialogTitle></DialogTitle><DialogDescription></DialogDescription></DialogHeader><div><Label htmlFor="hardware-activation-code">6 </Label><Input ref={activationCodeInputRef} id="hardware-activation-code" autoFocus type="text" inputMode="numeric" autoComplete="off" maxLength={6} onInput={(e) => { e.currentTarget.value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6); }} /></div><AgentSelect agents={overview.agents} value={dialogAgentId} onChange={(value) => { setDialogAgentId(value); clearBindOperation(); }} />{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setBindOpen(false); resetDialog(); }}></Button><Button aria-busy={busy} disabled={busy} onClick={() => void bindDevice()}>{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} 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>
</main>