From b7a1590ca132c971ffff177cb1e2aa47f96358cf Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 16 Aug 2026 14:27:43 +0800 Subject: [PATCH] feat(robot): add guided hotspot binding flow --- .../20260816-guided-hotspot-binding-7c4d2e.md | 82 ++++++ electron/api/routes/ai-hardware.ts | 59 +++- src/lib/ai-hardware.ts | 33 +++ src/pages/AiHardware/index.tsx | 268 +++++++++++++++++- tests/unit/ai-hardware-api.test.ts | 33 +++ tests/unit/ai-hardware-page.test.tsx | 225 ++++++++++++++- tests/unit/ai-hardware-routes.test.ts | 173 ++++++++++- 7 files changed, 855 insertions(+), 18 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260816-guided-hotspot-binding-7c4d2e.md diff --git a/.project-docs/30-worklog/tasks/20260816-guided-hotspot-binding-7c4d2e.md b/.project-docs/30-worklog/tasks/20260816-guided-hotspot-binding-7c4d2e.md new file mode 100644 index 0000000..7a328c2 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260816-guided-hotspot-binding-7c4d2e.md @@ -0,0 +1,82 @@ +# Task: Implement Robot Guided Hotspot Binding V1 + +## Identity + +- Task ID: 20260816-guided-hotspot-binding-7c4d2e +- Mode: Feature +- Branch: codex/20260816-guided-hotspot-binding-7c4d2e-guided-hotspot-binding +- Worktree: D:\Datas\OthersProjects\makelore-guided-hotspot-binding-7c4d2e +- Base commit: 54443232dd6a07dc1f3df5b33df00f665540497a +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Add the accepted default-off Guided Hotspot Binding V1 to the existing Robot binding experience. +- Add a strict Renderer facade for reading the Main-owned capability and asking Main to open the fixed firmware portal in the system browser. +- Add local Host API branches that return before Works authentication/upstream access, own the fixed portal URL, redact native opener failures, and force invalid activation-code errors to non-retryable. +- Add the in-memory guided/manual binding states, security/recovery copy, and success wording without claiming device online readiness. +- Add focused Renderer API, Main route, and page regressions. Keep Electron/physical-device smoke as release gates while the capability remains false by default. + +## Intent And Constraints + +- Implement ADR-002 and source proposal `14afe4a` without modifying firmware, adding BLE/Wi-Fi discovery, adding cloud claim routes, or changing the positional six-digit Binding interface. +- Preserve `Renderer -> Host API -> Electron Main -> Works Square`; Renderer must not call direct IPC, accept an arbitrary portal URL, or collect/log/persist Wi-Fi credentials or activation codes. +- Capability ownership remains in Electron Main and defaults to false. Renderer can read but cannot override it; disabled/public behavior remains the existing direct six-digit Binding dialog. +- Main owns literal `http://192.168.4.1/`, validates exact local wire shapes, checks the capability before calling the opener, and returns before access-token/upstream code. +- Preserve same-process same-code/Agent operation identity retry. Invalid/expired code clears both code and retry identity; application restart persists neither. +- Treat Binding success as account association only and show that the device may still be offline. +- Do not modify canonical project memory from this feature task; record only this task's progress and promotion candidates. + +## Project Context Loaded + +- Task: `20260816-guided-hotspot-binding-7c4d2e`, Feature mode, isolated worktree and branch, base `54443232dd6a07dc1f3df5b33df00f665540497a`. +- Read: active task record, Concurrent/Planning/Coding gates, project memory index, project positioning, current state, decision index, ADR-002, system overview, evidence/reflection/commitment indexes, source proposal `14afe4a`, related Robot source/tests, and every registered peer task's Scope/Intent/Promotion sections. +- Peer assessment: prior Robot configuration tasks are completed and do not overlap the new local portal or wizard seams. The earlier Security 2/automatic-claim proposal conflicts semantically but is explicitly superseded for V1 by accepted ADR-002. No active peer owns this task's worktree or is implementing the same seam. +- Gate result: Concurrent Task Gate and Planning Gate passed. The feature remains default-off because exact shipped firmware, issuer/validator behavior, Electron external-open E2E, and physical smoke are not yet verified. + +## Plan + +1. Extend the typed Renderer API with strict capability/open response readers and freeze their exact Host requests in unit tests. +2. Add dependency-injected, default-off local Main routes for capability and fixed portal open; prove no token/upstream access, exact body/query handling, error redaction, and non-retryable invalid-code projection. +3. Embed a small in-memory guided/manual state machine in the current Binding dialog, keep cancellation/back disabled during Binding, and preserve current direct Binding when gated off. +4. Cover both paths, legal transitions, portal failures, cleanup, retry identity, and bound-without-online copy in page tests. +5. Run focused tests, typecheck, scoped lint, production build, project-document gates, and independent Sol final review. Do not enable the capability or claim physical acceptance. + +## Outcome + +- Implemented the accepted Guided Hotspot Binding V1 entirely in Makelore; the firmware repository was not modified. +- Added strict Renderer facade methods for the Main-owned capability and fixed portal-open action. Expanded or malformed DTOs fail closed. +- Added default-off local Host API branches that run before Works token/upstream handling, accept only the exact method/query/body shapes, open only `http://192.168.4.1/`, and redact native opener failures. +- Added the guided/manual Binding state machine to the existing Robot dialog, including legal back/cancel behavior, unencrypted-hotspot and credential-boundary warnings, secret cleanup, an explicit non-cancellable Binding state, and a Bound result that does not claim the robot is online. +- Native portal-open failures expose only the same fixed address with a copy fallback; no caller-provided or user-entered URL is accepted. Binding conflict/already-bound outcomes refresh the current overview without replacing the safe upstream message. +- Preserved the current direct six-digit Binding path when the capability is disabled. Same-process ambiguous retries retain identity only for the same code/Agent; invalid codes discard it even if an upstream response incorrectly marks them retryable. +- The capability remains disabled by default. No BLE/Wi-Fi discovery, cloud claim flow, arbitrary URL input, credential collection, or firmware changes were introduced. + +## Verification + +- PASS: `pnpm vitest run tests/unit/ai-hardware-page.test.tsx tests/unit/ai-hardware-api.test.ts tests/unit/ai-hardware-routes.test.ts` — 3 files, 90 tests. +- PASS: `pnpm run typecheck`. +- PASS: scoped ESLint over all six changed source/test files. +- PASS: `pnpm run build:vite` — Renderer, Electron Main, and Preload production builds completed; existing chunk-size/dynamic-import warnings remain non-blocking. +- PASS: `pnpm test` — 156 files, 1754 tests. +- PASS: `pnpm playwright test tests/e2e/main-navigation.spec.ts` — 2 Electron navigation smoke tests, including entry into the Robot route. +- PASS: focused tests cover capability fail-closed behavior, fixed portal ownership, no token/upstream access, opener error redaction and fixed-address copy recovery, exact wire shapes, default-off direct Binding, every legal guided/direct back transition, cancellation before and after portal handoff, Binding navigation lock, conflict/already-bound refresh, retry identity, restart-style remount cleanup, activation-code cleanup, and Bound-without-online copy. +- Independent Sol review initially returned FAIL with two P2 and one P3: missing fixed-address copy recovery, missing conflict/already-bound overview refresh, and incomplete state-machine regression coverage. All three findings were corrected and the complete verification set was rerun before re-review. +- PASS: independent Sol re-review confirmed all three findings closed and reported no remaining P0–P3 findings. +- Not claimed: the existing Electron fixture cannot reach an authenticated Robot Binding workspace or observe the native external opener. Gate-on external-open E2E, exact shipped firmware/issuer validation, and a physical-device smoke remain release gates before enabling the capability. + +## Follow-ups + +- Keep `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING` unset in production until the exact shipped firmware image is confirmed to expose the expected `Xiaozhi-*` SoftAP, portal at `http://192.168.4.1/`, and compatible six-digit issuer/validator behavior. +- Before a pilot, add or extend a real Electron Robot fixture capable of authenticated overview data and native-opener observation, then run Windows/macOS gate-on smoke plus at least one physical-device end-to-end pass. +- Treat nearby-device discovery/selection as a later protocol project. It cannot be delivered honestly with the current unmodified firmware because SoftAP SSID presence is not authenticated device identity. + +## Promotion Candidates + +- Target canonical document: `.project-docs/40-operations/release-acceptance.md` (or the release-guidance document current at promotion time). +- Proposal: after physical and gate-on Electron evidence exists, document the validated enablement procedure, rollback/default-off behavior, and supported firmware matrix. +- Evidence required: exact shipped firmware image and issuer/validator compatibility, native external-open E2E on supported desktop platforms, and at least one physical-device end-to-end pass. +- Future impact: release owners can enable the Main-owned capability only for a verified product/firmware combination without reinterpreting ADR-002. +- Semantic conflicts: none with ADR-002 while default-off remains authoritative; promotion must not imply authenticated nearby-device discovery or device-online readiness. +- Human confirmation required: yes, from the release owner after the physical and desktop evidence is reviewed. diff --git a/electron/api/routes/ai-hardware.ts b/electron/api/routes/ai-hardware.ts index 05aab02..c08f94c 100644 --- a/electron/api/routes/ai-hardware.ts +++ b/electron/api/routes/ai-hardware.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import { shell } from 'electron'; import type { HostApiContext } from '../context'; import { sendJson } from '../route-utils'; import { WORKS_SQUARE_CONFIG } from '../works-config'; @@ -16,6 +17,8 @@ const LOCAL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/; const CATALOG_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,254}$/; 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 MAX_RETRY_AFTER_SECONDS = 2; +const PROVISIONING_PORTAL_URL = 'http://192.168.4.1/'; +const GUIDED_HOTSPOT_BINDING_ENABLED = process.env.NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING === '1'; type TokenGetter = typeof getValidWorksSquareAccessToken; @@ -25,6 +28,8 @@ export type AiHardwareRouteDependencies = { apiBaseUrl?: string; randomUuid?: () => string; timeoutMs?: number; + guidedHotspotBinding?: boolean; + openExternal?: (url: string) => Promise; }; class SafeRouteError extends Error { @@ -386,7 +391,18 @@ function safeUpstreamError(payload: unknown, status: number): { code: string; er ? detail.error_code : null; if (!code) return safeErrorForStatus(status); - return { code, error: SAFE_UPSTREAM_ERRORS[code], retryable: detail?.retryable === true }; + return { + code, + error: SAFE_UPSTREAM_ERRORS[code], + retryable: code === 'ai_hardware_activation_code_invalid' ? false : detail?.retryable === true, + }; +} + +function hasRequestBody(req: IncomingMessage): boolean { + const contentLength = req.headers['content-length']; + const normalizedLength = Array.isArray(contentLength) ? contentLength[0] : contentLength; + return (normalizedLength !== undefined && normalizedLength !== '0') + || req.headers['transfer-encoding'] !== undefined; } function sendFailure( @@ -424,6 +440,8 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); const createUuid = dependencies.randomUuid ?? randomUUID; const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const guidedHotspotBinding = dependencies.guidedHotspotBinding ?? GUIDED_HOTSPOT_BINDING_ENABLED; + const openExternal = dependencies.openExternal ?? ((url: string) => shell.openExternal(url)); return async function handleAiHardwareRoutes( req: IncomingMessage, @@ -439,14 +457,49 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend let operationId: string | undefined; try { + const method = req.method ?? 'GET'; + if (method === 'GET' && url.pathname === `${LOCAL_ROOT}/provisioning-capabilities`) { + if (url.search || hasRequestBody(req)) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + sendJson(res, 200, { + success: true, + data: { guided_hotspot_binding: guidedHotspotBinding }, + }); + return true; + } + if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/provisioning-portal/open`) { + if (url.search) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + const input = await readBoundedJson(req); + ensureExactKeys(input, new Set()); + if (!guidedHotspotBinding) { + throw new SafeRouteError( + 403, + 'AI_HARDWARE_PROVISIONING_DISABLED', + 'AI hardware guided provisioning is not enabled', + ); + } + try { + await openExternal(PROVISIONING_PORTAL_URL); + } catch { + throw new SafeRouteError( + 502, + 'AI_HARDWARE_PORTAL_OPEN_FAILED', + 'AI hardware provisioning portal could not be opened', + ); + } + sendJson(res, 200, { success: true, data: { opened: true } }); + return true; + } + let upstreamPath: string; let body: Record | undefined; let project: (value: unknown) => Record | null; let requireEtag = false; let ifMatch: number | undefined; let expectedStatus: number; - const method = req.method ?? 'GET'; - if (method === 'GET' && url.pathname === LOCAL_ROOT) { upstreamPath = UPSTREAM_ROOT; project = projectOverview; diff --git a/src/lib/ai-hardware.ts b/src/lib/ai-hardware.ts index f58c7d8..3ab52bd 100644 --- a/src/lib/ai-hardware.ts +++ b/src/lib/ai-hardware.ts @@ -33,6 +33,8 @@ const SAFE_ERROR_MESSAGES: Record = { 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 { return request('', undefined, readOverview) as Promise; } +export async function getAiHardwareProvisioningCapabilities(): Promise { + return request( + '/provisioning-capabilities', + undefined, + readProvisioningCapabilities, + ) as Promise; +} + +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 { diff --git a/src/pages/AiHardware/index.tsx b/src/pages/AiHardware/index.tsx index 623f12f..702d4a7 100644 --- a/src/pages/AiHardware/index.tsx +++ b/src/pages/AiHardware/index.tsx @@ -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(null); const [createOpen, setCreateOpen] = useState(false); const [bindOpen, setBindOpen] = useState(false); + const [guidedHotspotBinding, setGuidedHotspotBinding] = useState(false); + const [bindPath, setBindPath] = useState('direct'); + const [bindStep, setBindStep] = useState('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(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
void recoverCredential()}>{recoveryBusy && }恢复设备凭据} />{recoveryError ?

{recoveryError}

: null}
; if (overview.status === 'provisioning') return
void loadOverview()}>刷新状态} />
; + const bindingTitles: Record = { + 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 ( +
+ + +
+ ); + } + if (bindStep === 'prepare_robot') { + return ( +
+

打开机器人并让它进入配网模式。听到配网提示或看到设备热点后再继续。

+
+ + 请在可信、近距离的网络环境中操作;当前设备热点没有加密保护。 +
+
+ ); + } + if (bindStep === 'connect_device_ap') { + return ( +
+

打开电脑的系统 Wi-Fi 设置,连接机器人显示的 Xiaozhi-* 热点。

+

热点名称只用于寻找候选设备,不代表设备身份认证。连接后电脑暂时无法访问互联网是正常现象。

+
+ ); + } + if (bindStep === 'configure_wifi') { + return ( +
+

通过系统浏览器打开设备配网页面,在页面中选择家庭 Wi-Fi 并输入密码。

+
+ Wi-Fi 密码只填写在设备配网页面中,Makelore 不读取或保存。 +
+ {portalOpenFailed ? ( +
+

系统浏览器没有打开时,请手动访问这个固定地址:

+
+ + {PROVISIONING_PORTAL_DISPLAY_URL} + + +
+ {portalAddressCopied ?

固定地址已复制。

: null} +
+ ) : null} + {portalOpened ? ( + + ) : null} +
+ ); + } + if (bindStep === 'reconnect_internet') { + return ( +
+

配网页面提交后,等待机器人连接家庭 Wi-Fi。然后把电脑重新连接到可以访问互联网的网络。

+

返回或取消引导不会撤销机器人已经保存的 Wi-Fi 设置,也不会关闭已打开的浏览器页面。

+
+ ); + } + if (bindStep === 'enter_activation_code') { + return ( +
+ {bindPath === 'guided' ? ( +

等待机器人联网并播报新的 6 位激活码。没有听到时请检查设备网络,或重新开始配网。

+ ) : null} +
{ e.currentTarget.value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6); }} />
+ { setDialogAgentId(value); clearBindOperation(); }} /> +

激活码只用于本次绑定,关闭窗口后会立即清除。

+
+ ); + } + if (bindStep === 'binding') { + return ; + } + return ( +
+ +

机器人已经绑定到所选智能体。

+

绑定成功不代表设备已经上线,请等待机器人完成连接。

+
+ ); + })(); + + const bindingPrimaryAction = (() => { + if (bindStep === 'prepare_robot') return ; + if (bindStep === 'connect_device_ap') return ; + if (bindStep === 'configure_wifi' && !portalOpened) return ; + if (bindStep === 'configure_wifi') return ; + if (bindStep === 'reconnect_internet') return ; + if (bindStep === 'enter_activation_code') return ; + if (bindStep === 'binding') return ; + if (bindStep === 'bound') return ; + return null; + })(); + return (
@@ -416,13 +668,13 @@ export function AiHardware() {
智能体{overview.agents.length} 个
{overview.agents.map((agent) => )}
{selectedAgent?.name ?? '智能体配置'}基础对话和语音设置
{config ?
语言
{config.language || config.lang_code || '未设置'}
语音
{config.tts_voice_id || '未设置'}
系统提示
{config.system_prompt || '未设置'}
: configLoading ? : configLoadFailed ? setConfigReloadKey((value) => value + 1)}>重试读取配置} /> : null}
-
设备{devices.length} 台已绑定设备
{devices.length ?
{devices.map((device) =>
设备 {shortId(device.id)}指派 r{device.assignment_revision}
)}
: }
+
设备{devices.length} 台已绑定设备
{devices.length ?
{devices.map((device) =>
设备 {shortId(device.id)}指派 r{device.assignment_revision}
)}
: }
)} { if (!busy) { setCreateOpen(open); if (!open) { setAgentName(''); resetDialog(); } } }}>创建智能体输入一个容易识别的名称,稍后仍可修改。
{ setAgentName(e.target.value); clearCreateOperation(); }} />
{dialogError ?

{dialogError}

: null}
- { if (!busy) { setBindOpen(open); if (!open) resetDialog(); } }}>绑定设备激活码只用于本次绑定,关闭窗口后会立即清除。
{ e.currentTarget.value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6); }} />
{ setDialogAgentId(value); clearBindOperation(); }} />{dialogError ?

{dialogError}

: null}
+ { if (!open) closeBindingDialog(); }}>{bindingTitles[bindStep]}{bindPath === 'guided' ? '按步骤完成机器人联网与账号绑定。' : '使用机器人播报的激活码绑定到智能体。'}{bindingContent}{dialogError ?

{dialogError}

: null}{canGoBack ? : null}{!['binding', 'bound'].includes(bindStep) ? : null}{bindingPrimaryAction}
{ if (!busy) { setConfigOpen(open); if (!open) { resetDialog(); setCatalog(null); setCatalogError(null); } } }}>编辑智能体配置模型、语言和音色来自小智当前可用目录;选择“使用服务默认值”可清除可选字段。{draft ? { setDraft(next); clearConfigOperation(); }} onTtsModelChange={(value, next) => { setDraft(next); clearConfigOperation(); void loadConfigurationCatalog(value || undefined); }} onRetryCatalog={() => void loadConfigurationCatalog(draft.tts_model_id || undefined)} /> : null}{dialogError ?

{dialogError}

: null}
{ if (!open && !busy) { setAssignmentDevice(null); resetDialog(); } }}>重新指派设备选择接收这台设备的智能体。系统会检查最新修订,避免覆盖其他更改。 { setDialogAgentId(value); clearAssignmentOperation(); }} />{dialogError ?

{dialogError}

: null}
diff --git a/tests/unit/ai-hardware-api.test.ts b/tests/unit/ai-hardware-api.test.ts index d17dba7..20de035 100644 --- a/tests/unit/ai-hardware-api.test.ts +++ b/tests/unit/ai-hardware-api.test.ts @@ -6,11 +6,13 @@ import { getAiHardwareAgentConfiguration, getAiHardwareAssignment, getAiHardwareOverview, + getAiHardwareProvisioningCapabilities, getAiHardwareConfigurationCatalog, updateAiHardwareAgentConfiguration, updateAiHardwareAssignment, createAiHardwareOperationId, recoverAiHardwareCredential, + openAiHardwareProvisioningPortal, } from '@/lib/ai-hardware'; const hostApiFetchMock = vi.hoisted(() => vi.fn()); @@ -57,6 +59,37 @@ describe('AI hardware renderer API', () => { expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/ai-hardware', undefined); }); + it('reads the Main-owned provisioning capability and opens only the fixed portal action', async () => { + hostApiFetchMock + .mockResolvedValueOnce({ success: true, data: { guided_hotspot_binding: true } }) + .mockResolvedValueOnce({ success: true, data: { opened: true } }); + + await expect(getAiHardwareProvisioningCapabilities()).resolves.toEqual({ + guidedHotspotBinding: true, + }); + await expect(openAiHardwareProvisioningPortal()).resolves.toEqual({ opened: true }); + expect(hostApiFetchMock).toHaveBeenNthCalledWith( + 1, + '/api/works/ai-hardware/provisioning-capabilities', + undefined, + ); + expect(hostApiFetchMock).toHaveBeenNthCalledWith( + 2, + '/api/works/ai-hardware/provisioning-portal/open', + { method: 'POST', body: '{}' }, + ); + }); + + it.each([ + { guided_hotspot_binding: 'true' }, + { guided_hotspot_binding: false, portal_url: 'http://unsafe.example' }, + ])('rejects malformed or expanded provisioning capability data', async (data) => { + hostApiFetchMock.mockResolvedValueOnce({ success: true, data }); + await expect(getAiHardwareProvisioningCapabilities()).rejects.toMatchObject({ + code: 'AI_HARDWARE_INVALID_RESPONSE', + }); + }); + it('strictly reads the safe configuration catalog and encodes the TTS dependency', async () => { const catalog = { schema_version: 1, diff --git a/tests/unit/ai-hardware-page.test.tsx b/tests/unit/ai-hardware-page.test.tsx index f93e31a..b3440db 100644 --- a/tests/unit/ai-hardware-page.test.tsx +++ b/tests/unit/ai-hardware-page.test.tsx @@ -10,6 +10,8 @@ import { const api = vi.hoisted(() => ({ getAiHardwareOverview: vi.fn(), + getAiHardwareProvisioningCapabilities: vi.fn(), + openAiHardwareProvisioningPortal: vi.fn(), recoverAiHardwareCredential: vi.fn(), createAiHardwareAgent: vi.fn(), bindAiHardwareDevice: vi.fn(), @@ -88,6 +90,8 @@ async function openConfigurationEditor(): Promise { describe('AI hardware page', () => { beforeEach(() => { api.getAiHardwareOverview.mockResolvedValue(activeOverview); + api.getAiHardwareProvisioningCapabilities.mockResolvedValue({ guidedHotspotBinding: false }); + api.openAiHardwareProvisioningPortal.mockResolvedValue({ opened: true }); api.recoverAiHardwareCredential.mockResolvedValue({ status: 'active', agents: [], devices: [] }); api.getAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 4 }); api.getAiHardwareConfigurationCatalog.mockResolvedValue(catalog); @@ -160,11 +164,144 @@ describe('AI hardware page', () => { fireEvent.change(input, { target: { value: secret } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledWith(secret, agentOne.id)); - await waitFor(() => expect(input).toHaveValue('')); + await waitFor(() => expect(screen.getByLabelText('6 位激活码')).toHaveValue('')); expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); expect(screen.getByRole('alert')).toHaveTextContent('激活码无效'); }); + it('keeps the existing direct-code binding experience when guided provisioning is disabled', async () => { + render(); + fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + + expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '开始引导配网' })).not.toBeInTheDocument(); + expect(api.openAiHardwareProvisioningPortal).not.toHaveBeenCalled(); + }); + + it('guides the existing hotspot flow, locks navigation while binding, and avoids online claims', async () => { + api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); + const binding = deferred(); + api.bindAiHardwareDevice.mockReturnValueOnce(binding.promise); + render(); + await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); + + fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.click(await screen.findByRole('button', { name: '开始引导配网' })); + expect(screen.getByText(/设备热点没有加密保护/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' })); + expect(screen.getByText(/Xiaozhi-\*/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '上一步' })); + expect(screen.getByRole('heading', { name: '准备机器人' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' })); + fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' })); + expect(screen.getByText(/Makelore 不读取或保存/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' })); + await waitFor(() => expect(api.openAiHardwareProvisioningPortal).toHaveBeenCalledOnce()); + fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' })); + expect(screen.getByText(/不会撤销机器人已经保存的 Wi-Fi 设置/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '电脑已恢复联网' })); + + fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } }); + fireEvent.click(screen.getByRole('button', { name: '绑定' })); + expect(await screen.findByRole('heading', { name: '正在绑定设备' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '上一步' })).not.toBeInTheDocument(); + + binding.resolve(device); + expect(await screen.findByRole('heading', { name: '绑定成功' })).toBeInTheDocument(); + expect(screen.getByText('绑定成功不代表设备已经上线,请等待机器人完成连接。')).toBeInTheDocument(); + expect(screen.queryByText(/设备已就绪/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '完成' })); + await waitFor(() => expect(screen.queryByRole('heading', { name: '绑定成功' })).not.toBeInTheDocument()); + }); + + it('supports every legal guided back transition and the direct-path return to the chooser', async () => { + api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); + render(); + await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); + fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + + fireEvent.click(screen.getByRole('button', { name: '我已有 6 位激活码' })); + expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '上一步' })); + expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '开始引导配网' })); + fireEvent.click(screen.getByRole('button', { name: '上一步' })); + expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '开始引导配网' })); + fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' })); + fireEvent.click(screen.getByRole('button', { name: '上一步' })); + expect(screen.getByRole('heading', { name: '准备机器人' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' })); + fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' })); + fireEvent.click(screen.getByRole('button', { name: '上一步' })); + expect(screen.getByRole('heading', { name: '连接设备热点' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' })); + fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' })); + fireEvent.click(await screen.findByRole('button', { name: '我已完成设备配网' })); + fireEvent.click(screen.getByRole('button', { name: '上一步' })); + expect(screen.getByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' })); + fireEvent.click(screen.getByRole('button', { name: '电脑已恢复联网' })); + fireEvent.click(screen.getByRole('button', { name: '上一步' })); + expect(screen.getByRole('heading', { name: '恢复电脑网络' })).toBeInTheDocument(); + }); + + it('cancels before portal handoff and starts a fresh chooser state', async () => { + api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); + render(); + await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); + fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.click(screen.getByRole('button', { name: '开始引导配网' })); + fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' })); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + + fireEvent.click(screen.getByRole('button', { name: '绑定设备' })); + expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument(); + expect(api.openAiHardwareProvisioningPortal).not.toHaveBeenCalled(); + }); + + it('shows a safe portal error and clears guided state when cancelled after portal handoff', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); + api.openAiHardwareProvisioningPortal + .mockRejectedValueOnce(new AiHardwareApiError({ + status: 502, + code: 'AI_HARDWARE_PORTAL_OPEN_FAILED', + message: 'native secret path', + })) + .mockResolvedValueOnce({ opened: true }); + render(); + await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); + fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.click(screen.getByRole('button', { name: '开始引导配网' })); + fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' })); + fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' })); + fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' })); + expect(await screen.findByRole('alert')).toHaveTextContent('无法打开设备配网页面'); + expect(screen.queryByText(/native secret path/)).not.toBeInTheDocument(); + expect(screen.getByText('http://192.168.4.1/')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '复制地址' })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith('http://192.168.4.1/')); + expect(screen.getByRole('status')).toHaveTextContent('固定地址已复制'); + + fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' })); + await waitFor(() => expect(api.openAiHardwareProvisioningPortal).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' })); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + fireEvent.click(screen.getByRole('button', { name: '绑定设备' })); + expect(screen.getByRole('button', { name: '开始引导配网' })).toBeInTheDocument(); + expect(screen.queryByText(/不会撤销机器人已经保存的 Wi-Fi 设置/)).not.toBeInTheDocument(); + }); + it('sends cleared nullable configuration fields through clear_fields and never sends null', async () => { render(); await openConfigurationEditor(); @@ -366,6 +503,26 @@ describe('AI hardware page', () => { expect(await screen.findByRole('alert')).toHaveTextContent('7 秒后重试'); }); + it.each([ + ['ai_hardware_device_already_bound', '设备已被绑定'], + ['ai_hardware_state_conflict', '设备状态已变化'], + ])('refreshes overview after the binding result %s', async (code, message) => { + api.bindAiHardwareDevice.mockRejectedValueOnce(new AiHardwareApiError({ + status: 409, + code, + message: 'private upstream state', + })); + render(); + fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } }); + fireEvent.click(screen.getByRole('button', { name: '绑定' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(message); + await waitFor(() => expect(api.getAiHardwareOverview).toHaveBeenCalledTimes(2)); + expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument(); + expect(screen.queryByText(/private upstream state/)).not.toBeInTheDocument(); + }); + it('reuses the server operation id only when retrying the same binding body', async () => { api.bindAiHardwareDevice .mockRejectedValueOnce(new AiHardwareApiError({ @@ -378,8 +535,9 @@ describe('AI hardware page', () => { const input = screen.getByLabelText('6 位激活码'); fireEvent.change(input, { target: { value: '031425' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); - await waitFor(() => expect(input).toHaveValue('')); - fireEvent.change(input, { target: { value: '031425' } }); + const retryInput = await screen.findByLabelText('6 位激活码'); + expect(retryInput).toHaveValue(''); + fireEvent.change(retryInput, { target: { value: '031425' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2)); expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual([ @@ -387,6 +545,34 @@ describe('AI hardware page', () => { ]); }); + it('refreshes overview on remount and never reuses a binding operation identity across restart', async () => { + const operationId = '123e4567-e89b-42d3-a456-426614174000'; + api.bindAiHardwareDevice + .mockRejectedValueOnce(new AiHardwareApiError({ + status: 409, + code: 'ai_hardware_operation_in_progress', + message: 'private', + retryable: true, + operationId, + })) + .mockResolvedValueOnce(device); + const first = render(); + fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } }); + fireEvent.click(screen.getByRole('button', { name: '绑定' })); + await screen.findByLabelText('6 位激活码'); + first.unmount(); + + render(); + fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '654321' } }); + fireEvent.click(screen.getByRole('button', { name: '绑定' })); + + await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2)); + expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual(['654321', agentOne.id]); + await waitFor(() => expect(api.getAiHardwareOverview).toHaveBeenCalledTimes(3)); + }); + it('does not retain an activation code and starts a fresh operation for a different code', async () => { const operationId = '123e4567-e89b-42d3-a456-426614174000'; api.bindAiHardwareDevice @@ -401,17 +587,44 @@ describe('AI hardware page', () => { fireEvent.change(input, { target: { value: '031425' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); - await waitFor(() => expect(input).toHaveValue('')); + const nextInput = await screen.findByLabelText('6 位激活码'); + expect(nextInput).toHaveValue(''); expect(screen.queryByText(/031425/)).not.toBeInTheDocument(); - fireEvent.change(input, { target: { value: '654321' } }); + fireEvent.change(nextInput, { target: { value: '654321' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2)); expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual(['654321', agentOne.id]); - await waitFor(() => expect(input).toHaveValue('')); + await screen.findByRole('heading', { name: '绑定成功' }); expect(screen.queryByText(/031425|654321/)).not.toBeInTheDocument(); }); + it('drops an invalid-code operation identity even if the error is incorrectly marked retryable', async () => { + const operationId = '123e4567-e89b-42d3-a456-426614174000'; + api.bindAiHardwareDevice + .mockRejectedValueOnce(new AiHardwareApiError({ + status: 422, + code: 'ai_hardware_activation_code_invalid', + message: 'private', + retryable: true, + operationId, + })) + .mockResolvedValueOnce(device); + render(); + fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + const input = screen.getByLabelText('6 位激活码'); + + fireEvent.change(input, { target: { value: '031425' } }); + fireEvent.click(screen.getByRole('button', { name: '绑定' })); + const retryInput = await screen.findByLabelText('6 位激活码'); + expect(retryInput).toHaveValue(''); + fireEvent.change(retryInput, { target: { value: '031425' } }); + fireEvent.click(screen.getByRole('button', { name: '绑定' })); + + await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2)); + expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual(['031425', agentOne.id]); + }); + it('keeps an unset chat history unchanged when saving without edits', async () => { api.getAiHardwareAgentConfiguration.mockResolvedValueOnce({ data: { ...configuration, chat_history_conf: null }, diff --git a/tests/unit/ai-hardware-routes.test.ts b/tests/unit/ai-hardware-routes.test.ts index cb991fb..33d0973 100644 --- a/tests/unit/ai-hardware-routes.test.ts +++ b/tests/unit/ai-hardware-routes.test.ts @@ -39,7 +39,13 @@ function jsonResponse(value: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(value), { ...init, headers }); } -function setup(fetchImpl = vi.fn().mockResolvedValue(jsonResponse(overview))) { +function setup( + fetchImpl = vi.fn().mockResolvedValue(jsonResponse(overview)), + options: { + guidedHotspotBinding?: boolean; + openExternal?: (url: string) => Promise; + } = {}, +) { const getAccessToken = vi.fn().mockResolvedValue('secret-token'); const handler = createAiHardwareRouteHandler({ fetchImpl, @@ -47,6 +53,7 @@ function setup(fetchImpl = vi.fn().mockResolvedValue(jsonResponse( apiBaseUrl: 'https://square.example', randomUuid: () => '11111111-1111-4111-8111-111111111111', timeoutMs: 10, + ...options, }); return { handler, fetchImpl, getAccessToken }; } @@ -58,6 +65,141 @@ async function invoke(handler: ReturnType, } describe('AI hardware Host API route', () => { + it('returns the default-off provisioning capability before credentials or upstream access', async () => { + const { handler, fetchImpl, getAccessToken } = setup(); + const result = await invoke( + handler, + 'GET', + '/api/works/ai-hardware/provisioning-capabilities', + ); + + expect(result.payload).toEqual({ + success: true, + data: { guided_hotspot_binding: false }, + }); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('rejects queried, body-bearing, and wrong-method capability requests locally', async () => { + const { handler, fetchImpl, getAccessToken } = setup(); + const queried = await invoke( + handler, + 'GET', + '/api/works/ai-hardware/provisioning-capabilities?enabled=1', + ); + const bodyBearing = await invoke( + handler, + 'GET', + '/api/works/ai-hardware/provisioning-capabilities', + {}, + ); + const wrongMethod = await invoke( + handler, + 'POST', + '/api/works/ai-hardware/provisioning-capabilities', + {}, + ); + + expect(queried.payload).toMatchObject({ code: 'AI_HARDWARE_INVALID_REQUEST' }); + expect(bodyBearing.payload).toMatchObject({ code: 'AI_HARDWARE_INVALID_REQUEST' }); + expect(wrongMethod.payload).toMatchObject({ code: 'AI_HARDWARE_ROUTE_NOT_FOUND' }); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('opens only the Main-owned portal when enabled and never reads cloud credentials', async () => { + const openExternal = vi.fn().mockResolvedValue(undefined); + const { handler, fetchImpl, getAccessToken } = setup(undefined, { + guidedHotspotBinding: true, + openExternal, + }); + const result = await invoke( + handler, + 'POST', + '/api/works/ai-hardware/provisioning-portal/open', + {}, + ); + + expect(result.payload).toEqual({ success: true, data: { opened: true } }); + expect(openExternal).toHaveBeenCalledWith('http://192.168.4.1/'); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('rejects disabled, expanded, or queried portal opens before invoking native or cloud dependencies', async () => { + const openExternal = vi.fn().mockResolvedValue(undefined); + const disabled = setup(undefined, { openExternal }); + const disabledResult = await invoke( + disabled.handler, + 'POST', + '/api/works/ai-hardware/provisioning-portal/open', + {}, + ); + expect(disabledResult.payload).toMatchObject({ + success: false, + status: 403, + code: 'AI_HARDWARE_PROVISIONING_DISABLED', + retryable: false, + }); + + const enabled = setup(undefined, { guidedHotspotBinding: true, openExternal }); + const expanded = await invoke( + enabled.handler, + 'POST', + '/api/works/ai-hardware/provisioning-portal/open', + { url: 'http://unsafe.example' }, + ); + const queried = await invoke( + enabled.handler, + 'POST', + '/api/works/ai-hardware/provisioning-portal/open?url=unsafe', + {}, + ); + const missingBody = await invoke( + enabled.handler, + 'POST', + '/api/works/ai-hardware/provisioning-portal/open', + ); + const wrongMethod = await invoke( + enabled.handler, + 'GET', + '/api/works/ai-hardware/provisioning-portal/open', + ); + expect(expanded.payload).toMatchObject({ code: 'AI_HARDWARE_INVALID_REQUEST' }); + expect(queried.payload).toMatchObject({ code: 'AI_HARDWARE_INVALID_REQUEST' }); + expect(missingBody.payload).toMatchObject({ code: 'AI_HARDWARE_INVALID_REQUEST' }); + expect(wrongMethod.payload).toMatchObject({ code: 'AI_HARDWARE_ROUTE_NOT_FOUND' }); + expect(openExternal).not.toHaveBeenCalled(); + expect(disabled.getAccessToken).not.toHaveBeenCalled(); + expect(enabled.getAccessToken).not.toHaveBeenCalled(); + expect(disabled.fetchImpl).not.toHaveBeenCalled(); + expect(enabled.fetchImpl).not.toHaveBeenCalled(); + }); + + it('redacts native portal opener failures without reading credentials or calling upstream', async () => { + const openExternal = vi.fn().mockRejectedValue(new Error('native path secret')); + const { handler, fetchImpl, getAccessToken } = setup(undefined, { + guidedHotspotBinding: true, + openExternal, + }); + const result = await invoke( + handler, + 'POST', + '/api/works/ai-hardware/provisioning-portal/open', + {}, + ); + expect(result.payload).toEqual({ + success: false, + status: 502, + code: 'AI_HARDWARE_PORTAL_OPEN_FAILED', + error: 'AI hardware provisioning portal could not be opened', + retryable: false, + }); + expect(JSON.stringify(result.payload)).not.toContain('native path secret'); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); it('proxies only the fixed catalog query and projects its safe DTO', async () => { const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ schema_version: 1, @@ -142,6 +284,35 @@ describe('AI hardware Host API route', () => { expect(fetchImpl.mock.calls[0][0]).toBe('https://square.example/api/ai-hardware/device-bindings'); }); + it('forces invalid activation codes to non-retryable even when upstream says retryable', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ detail: { + error_code: 'ai_hardware_activation_code_invalid', + message: 'secret activation detail', + retryable: true, + } }, { status: 422 })); + const { handler } = setup(fetchImpl); + const result = await invoke( + handler, + 'POST', + '/api/works/ai-hardware/device-bindings', + { + activation_code: '123456', + agent_id: 'a-1', + client_operation_id: '22222222-2222-4222-8222-222222222222', + }, + ); + + expect(result.payload).toEqual({ + success: false, + status: 422, + code: 'ai_hardware_activation_code_invalid', + error: 'AI hardware activation code is invalid', + retryable: false, + operation_id: '22222222-2222-4222-8222-222222222222', + }); + expect(JSON.stringify(result.payload)).not.toContain('secret activation detail'); + }); + it('validates a strong ETag and returns its numeric revision in the envelope', async () => { const config = { id: 'a-1', name: 'Desk', config_revision: 0,