feat(robot): connect provisioning hotspots in app
This commit is contained in:
@@ -35,8 +35,15 @@ const SAFE_ERROR_MESSAGES: Record<string, string> = {
|
||||
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',
|
||||
AI_HARDWARE_HOTSPOT_UNSUPPORTED: 'Robot hotspot connection is not supported on this platform',
|
||||
AI_HARDWARE_HOTSPOT_PERMISSION_DENIED: 'Permission to access nearby Robot hotspots was denied',
|
||||
AI_HARDWARE_HOTSPOT_BUSY: 'Another Robot hotspot operation is in progress',
|
||||
AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED: 'The Robot hotspot selection expired; scan again',
|
||||
AI_HARDWARE_HOTSPOT_SCAN_FAILED: 'Robot hotspots could not be scanned',
|
||||
AI_HARDWARE_HOTSPOT_CONNECT_FAILED: 'The Robot hotspot could not be connected',
|
||||
};
|
||||
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;
|
||||
const HOTSPOT_CANDIDATE_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
|
||||
export type AiHardwareStatus =
|
||||
| 'unprovisioned'
|
||||
@@ -67,6 +74,24 @@ export type AiHardwareProvisioningCapabilities = {
|
||||
guidedHotspotBinding: boolean;
|
||||
};
|
||||
|
||||
export type AiHardwareProvisioningHotspot = {
|
||||
candidateId: string;
|
||||
ssid: string;
|
||||
signalPercent: number;
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
export type AiHardwareProvisioningHotspotScan = {
|
||||
platform: 'windows' | 'macos';
|
||||
hotspots: AiHardwareProvisioningHotspot[];
|
||||
};
|
||||
|
||||
export type AiHardwareProvisioningHotspotConnection = {
|
||||
connected: true;
|
||||
candidateId: string;
|
||||
ssid: string;
|
||||
};
|
||||
|
||||
export type AiHardwareAgentConfiguration = AiHardwareAgent & {
|
||||
system_prompt: string | null;
|
||||
lang_code: string | null;
|
||||
@@ -360,6 +385,41 @@ function readPortalOpened(value: unknown): { opened: true } {
|
||||
return { opened: true };
|
||||
}
|
||||
|
||||
function readProvisioningHotspot(value: unknown): AiHardwareProvisioningHotspot {
|
||||
if (!isRecord(value)
|
||||
|| !hasOnlyKeys(value, ['candidate_id', 'ssid', 'signal_percent', 'connected'])
|
||||
|| typeof value.candidate_id !== 'string' || !HOTSPOT_CANDIDATE_ID.test(value.candidate_id)
|
||||
|| typeof value.ssid !== 'string' || value.ssid.length < 1 || value.ssid.length > 32
|
||||
|| !isInteger(value.signal_percent, 0, 100)
|
||||
|| typeof value.connected !== 'boolean') invalidPayload();
|
||||
return {
|
||||
candidateId: value.candidate_id,
|
||||
ssid: value.ssid,
|
||||
signalPercent: value.signal_percent,
|
||||
connected: value.connected,
|
||||
};
|
||||
}
|
||||
|
||||
function readProvisioningHotspotScan(value: unknown): AiHardwareProvisioningHotspotScan {
|
||||
if (!isRecord(value)
|
||||
|| !hasOnlyKeys(value, ['platform', 'hotspots'])
|
||||
|| (value.platform !== 'windows' && value.platform !== 'macos')
|
||||
|| !Array.isArray(value.hotspots) || value.hotspots.length > 64) invalidPayload();
|
||||
return {
|
||||
platform: value.platform,
|
||||
hotspots: value.hotspots.map(readProvisioningHotspot),
|
||||
};
|
||||
}
|
||||
|
||||
function readProvisioningHotspotConnection(value: unknown): AiHardwareProvisioningHotspotConnection {
|
||||
if (!isRecord(value)
|
||||
|| !hasOnlyKeys(value, ['connected', 'candidate_id', 'ssid'])
|
||||
|| value.connected !== true
|
||||
|| typeof value.candidate_id !== 'string' || !HOTSPOT_CANDIDATE_ID.test(value.candidate_id)
|
||||
|| typeof value.ssid !== 'string' || value.ssid.length < 1 || value.ssid.length > 32) invalidPayload();
|
||||
return { connected: true, candidateId: value.candidate_id, ssid: value.ssid };
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -536,6 +596,25 @@ export async function openAiHardwareProvisioningPortal(): Promise<{ opened: true
|
||||
) as Promise<{ opened: true }>;
|
||||
}
|
||||
|
||||
export async function scanAiHardwareProvisioningHotspots(): Promise<AiHardwareProvisioningHotspotScan> {
|
||||
return request(
|
||||
'/provisioning-hotspots/scan',
|
||||
jsonInit('POST', {}),
|
||||
readProvisioningHotspotScan,
|
||||
) as Promise<AiHardwareProvisioningHotspotScan>;
|
||||
}
|
||||
|
||||
export async function connectAiHardwareProvisioningHotspot(
|
||||
candidateId: string,
|
||||
): Promise<AiHardwareProvisioningHotspotConnection> {
|
||||
if (!HOTSPOT_CANDIDATE_ID.test(candidateId)) throw new TypeError('candidateId is invalid');
|
||||
return request(
|
||||
'/provisioning-hotspots/connect',
|
||||
jsonInit('POST', { candidate_id: candidateId }),
|
||||
readProvisioningHotspotConnection,
|
||||
) as Promise<AiHardwareProvisioningHotspotConnection>;
|
||||
}
|
||||
|
||||
export async function getAiHardwareConfigurationCatalog(
|
||||
ttsModelId?: string,
|
||||
): Promise<AiHardwareConfigurationCatalog> {
|
||||
|
||||
@@ -32,8 +32,10 @@ import {
|
||||
getAiHardwareAssignment,
|
||||
getAiHardwareOverview,
|
||||
getAiHardwareProvisioningCapabilities,
|
||||
connectAiHardwareProvisioningHotspot,
|
||||
openAiHardwareProvisioningPortal,
|
||||
recoverAiHardwareCredential,
|
||||
scanAiHardwareProvisioningHotspots,
|
||||
updateAiHardwareAgentConfiguration,
|
||||
updateAiHardwareAssignment,
|
||||
type AiHardwareAgent,
|
||||
@@ -58,6 +60,13 @@ type BindStep =
|
||||
| 'binding'
|
||||
| 'bound';
|
||||
type BindRetryIntent = { agentId: string; fingerprint: ArrayBuffer; operationId: string };
|
||||
type ProvisioningHotspot = {
|
||||
candidateId: string;
|
||||
ssid: string;
|
||||
signalPercent: number;
|
||||
connected: boolean;
|
||||
};
|
||||
type HotspotState = 'idle' | 'scanning' | 'ready' | 'connecting' | 'error';
|
||||
type ConfigDraft = {
|
||||
agent_name: string; system_prompt: string; language: string; lang_code: string;
|
||||
asr_model_id: string; vad_model_id: string; llm_model_id: string; slm_model_id: string;
|
||||
@@ -114,6 +123,18 @@ function safeMessage(error: unknown): string {
|
||||
}
|
||||
return messages[error.code] ?? (error.retryable ? '服务暂时不可用,请稍后重试。' : '操作没有完成,请稍后重试。');
|
||||
}
|
||||
function safeHotspotMessage(error: unknown): string {
|
||||
if (!(error instanceof AiHardwareApiError)) return '暂时无法检查设备热点,请重新扫描或手动连接。';
|
||||
const messages: Record<string, string> = {
|
||||
AI_HARDWARE_HOTSPOT_UNSUPPORTED: '当前系统不支持在 Makelore 内连接热点,请使用系统 Wi-Fi 设置手动连接。',
|
||||
AI_HARDWARE_HOTSPOT_PERMISSION_DENIED: 'Makelore 没有检查附近热点所需的系统权限,请授权后重新扫描,或手动连接。',
|
||||
AI_HARDWARE_HOTSPOT_BUSY: '系统正在处理其他 Wi-Fi 操作,请稍后重新扫描。',
|
||||
AI_HARDWARE_HOTSPOT_SCAN_FAILED: '暂时无法检查设备热点,请重新扫描或手动连接。',
|
||||
AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED: '这个热点候选已失效,请重新扫描后再连接。',
|
||||
AI_HARDWARE_HOTSPOT_CONNECT_FAILED: '没有成功连接并验证这个设备热点,请重试或手动连接。',
|
||||
};
|
||||
return messages[error.code] ?? '暂时无法检查设备热点,请重新扫描或手动连接。';
|
||||
}
|
||||
const retryOperationId = (error: unknown): string | null => error instanceof AiHardwareApiError
|
||||
&& (error.retryable || error.code === 'ai_hardware_operation_in_progress') ? error.operationId : null;
|
||||
|
||||
@@ -208,6 +229,10 @@ export function AiHardware() {
|
||||
const [portalOpened, setPortalOpened] = useState(false);
|
||||
const [portalOpenFailed, setPortalOpenFailed] = useState(false);
|
||||
const [portalAddressCopied, setPortalAddressCopied] = useState(false);
|
||||
const [hotspotState, setHotspotState] = useState<HotspotState>('idle');
|
||||
const [hotspots, setHotspots] = useState<ProvisioningHotspot[]>([]);
|
||||
const [selectedHotspotId, setSelectedHotspotId] = useState<string | null>(null);
|
||||
const [hotspotError, setHotspotError] = useState<string | null>(null);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [catalog, setCatalog] = useState<AiHardwareConfigurationCatalog | null>(null);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
@@ -231,6 +256,7 @@ export function AiHardware() {
|
||||
const bindFingerprintKeyRef = useRef<CryptoKey | null>(null);
|
||||
const bindRetryIntentRef = useRef<BindRetryIntent | null>(null);
|
||||
const catalogRequestRef = useRef(0);
|
||||
const hotspotSessionRef = useRef(0);
|
||||
|
||||
const clearCreateOperation = () => { setCreateOperationId(null); setCreateOperationBody(null); };
|
||||
const clearBindOperation = () => {
|
||||
@@ -241,6 +267,55 @@ export function AiHardware() {
|
||||
const clearConfigOperation = () => { setConfigOperationId(null); setConfigOperationBody(null); };
|
||||
const clearAssignmentOperation = () => { setAssignmentOperationId(null); setAssignmentOperationBody(null); };
|
||||
|
||||
const clearHotspotSession = () => {
|
||||
hotspotSessionRef.current += 1;
|
||||
setHotspotState('idle');
|
||||
setHotspots([]);
|
||||
setSelectedHotspotId(null);
|
||||
setHotspotError(null);
|
||||
};
|
||||
|
||||
const scanProvisioningHotspots = useCallback(async () => {
|
||||
const session = hotspotSessionRef.current + 1;
|
||||
hotspotSessionRef.current = session;
|
||||
setHotspotState('scanning');
|
||||
setHotspots([]);
|
||||
setSelectedHotspotId(null);
|
||||
setHotspotError(null);
|
||||
try {
|
||||
const result = await scanAiHardwareProvisioningHotspots();
|
||||
if (hotspotSessionRef.current !== session) return;
|
||||
setHotspots(result.hotspots);
|
||||
setHotspotState('ready');
|
||||
} catch (error) {
|
||||
if (hotspotSessionRef.current !== session) return;
|
||||
setHotspotState('error');
|
||||
setHotspotError(safeHotspotMessage(error));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connectProvisioningHotspot = async () => {
|
||||
if (!selectedHotspotId || hotspotState === 'scanning' || hotspotState === 'connecting') return;
|
||||
const session = hotspotSessionRef.current;
|
||||
setHotspotState('connecting');
|
||||
setHotspotError(null);
|
||||
try {
|
||||
await connectAiHardwareProvisioningHotspot(selectedHotspotId);
|
||||
if (hotspotSessionRef.current !== session) return;
|
||||
clearHotspotSession();
|
||||
setBindStep('configure_wifi');
|
||||
} catch (error) {
|
||||
if (hotspotSessionRef.current !== session) return;
|
||||
setHotspotState('error');
|
||||
setHotspotError(safeHotspotMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const continueAfterManualHotspotConnection = () => {
|
||||
clearHotspotSession();
|
||||
setBindStep('configure_wifi');
|
||||
};
|
||||
|
||||
const loadConfigurationCatalog = useCallback(async (ttsModelId?: string) => {
|
||||
const requestId = catalogRequestRef.current + 1;
|
||||
catalogRequestRef.current = requestId;
|
||||
@@ -299,6 +374,10 @@ export function AiHardware() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadOverview(); }, [loadOverview]);
|
||||
useEffect(() => {
|
||||
if (bindOpen && bindStep === 'connect_device_ap') void scanProvisioningHotspots();
|
||||
}, [bindOpen, bindStep, scanProvisioningHotspots]);
|
||||
useEffect(() => () => { hotspotSessionRef.current += 1; }, []);
|
||||
useEffect(() => {
|
||||
if (!selectedAgentId) {
|
||||
setConfig(null);
|
||||
@@ -333,6 +412,7 @@ export function AiHardware() {
|
||||
const resetDialog = () => {
|
||||
setDialogError(null); setBusy(false);
|
||||
clearCreateOperation(); clearBindOperation(); clearConfigOperation(); clearAssignmentOperation();
|
||||
clearHotspotSession();
|
||||
setBindPath('direct');
|
||||
setBindStep(guidedHotspotBinding ? 'choose_path' : 'enter_activation_code');
|
||||
setPortalOpening(false);
|
||||
@@ -350,7 +430,7 @@ export function AiHardware() {
|
||||
};
|
||||
|
||||
const closeBindingDialog = () => {
|
||||
if (busy || portalOpening) return;
|
||||
if (busy || portalOpening || hotspotState === 'connecting') return;
|
||||
setBindOpen(false);
|
||||
resetDialog();
|
||||
};
|
||||
@@ -384,10 +464,10 @@ export function AiHardware() {
|
||||
};
|
||||
|
||||
const backBindingStep = () => {
|
||||
if (busy || bindStep === 'binding' || bindStep === 'bound') return;
|
||||
if (busy || hotspotState === 'connecting' || 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 === 'connect_device_ap') { clearHotspotSession(); 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');
|
||||
@@ -536,7 +616,7 @@ export function AiHardware() {
|
||||
binding: '正在绑定设备',
|
||||
bound: '绑定成功',
|
||||
};
|
||||
const canGoBack = !busy && !['choose_path', 'binding', 'bound'].includes(bindStep)
|
||||
const canGoBack = !busy && hotspotState !== 'connecting' && !['choose_path', 'binding', 'bound'].includes(bindStep)
|
||||
&& !(bindStep === 'enter_activation_code' && !guidedHotspotBinding);
|
||||
const bindingContent = (() => {
|
||||
if (bindStep === 'choose_path') {
|
||||
@@ -578,9 +658,52 @@ export function AiHardware() {
|
||||
}
|
||||
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 className="space-y-4 text-sm">
|
||||
<div className="rounded-xl bg-amber-500/10 p-4 text-amber-900">
|
||||
<ShieldAlert className="mr-2 inline h-4 w-4" />
|
||||
设备热点未加密;连接后电脑可能暂时无法访问互联网。热点名称不是设备身份认证。
|
||||
</div>
|
||||
{hotspotState === 'scanning' ? (
|
||||
<div role="status" className="flex items-center gap-2 rounded-xl bg-surface-subtle p-4 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />正在检查附近的设备热点…
|
||||
</div>
|
||||
) : null}
|
||||
{hotspotState === 'ready' && hotspots.length === 0 ? (
|
||||
<div role="status" className="rounded-xl bg-surface-subtle p-4">
|
||||
<p className="font-medium">没有发现设备热点</p>
|
||||
<p className="mt-1 text-muted-foreground">请确认机器人仍在配网模式,然后重新扫描。</p>
|
||||
</div>
|
||||
) : null}
|
||||
{hotspots.length > 0 ? (
|
||||
<div className="space-y-2" aria-label="附近的设备热点">
|
||||
{hotspots.map((hotspot) => (
|
||||
<button
|
||||
key={hotspot.candidateId}
|
||||
type="button"
|
||||
aria-label={`选择 ${hotspot.ssid}`}
|
||||
aria-pressed={selectedHotspotId === hotspot.candidateId}
|
||||
disabled={hotspotState === 'connecting'}
|
||||
className={`motion-press flex min-h-14 w-full items-center justify-between gap-3 rounded-xl border p-3 text-left ${selectedHotspotId === hotspot.candidateId ? 'border-brand bg-brand-soft' : 'border-border bg-surface-subtle hover:bg-surface-tertiary'}`}
|
||||
onClick={() => setSelectedHotspotId(hotspot.candidateId)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold">{hotspot.ssid}</span>
|
||||
<span className="text-xs text-muted-foreground">信号 {hotspot.signalPercent}%</span>
|
||||
</span>
|
||||
{hotspot.connected ? <span className="shrink-0 text-xs font-medium text-emerald-700">已连接</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{hotspotError ? <p role="alert" className="rounded-xl bg-destructive/10 p-3 text-destructive">{hotspotError}</p> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" disabled={hotspotState === 'scanning' || hotspotState === 'connecting'} onClick={() => void scanProvisioningHotspots()}>
|
||||
{hotspotState === 'scanning' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}重新扫描
|
||||
</Button>
|
||||
<Button type="button" variant="outline" disabled={hotspotState === 'connecting'} onClick={continueAfterManualHotspotConnection}>
|
||||
电脑已连接设备热点
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -650,7 +773,7 @@ export function AiHardware() {
|
||||
|
||||
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 === 'connect_device_ap') return <Button aria-busy={hotspotState === 'connecting'} disabled={!selectedHotspotId || hotspotState === 'scanning' || hotspotState === 'connecting'} onClick={() => void connectProvisioningHotspot()}>{hotspotState === 'connecting' && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}连接所选热点</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>;
|
||||
@@ -683,7 +806,7 @@ export function AiHardware() {
|
||||
)}
|
||||
|
||||
<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 (!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={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>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user