diff --git a/.project-docs/10-decisions/adr-002-robot-guided-hotspot-binding-v1.md b/.project-docs/10-decisions/adr-002-robot-guided-hotspot-binding-v1.md index 22ed3292..74c1f92f 100644 --- a/.project-docs/10-decisions/adr-002-robot-guided-hotspot-binding-v1.md +++ b/.project-docs/10-decisions/adr-002-robot-guided-hotspot-binding-v1.md @@ -4,7 +4,7 @@ Accepted -Implementation status: planned; the capability must remain disabled by default until the pilot release gates pass. +Implementation status: implemented; the capability remains disabled by default until all pilot release gates pass. ## Date diff --git a/.project-docs/10-decisions/decision-index.md b/.project-docs/10-decisions/decision-index.md index d4d59298..c73799bd 100644 --- a/.project-docs/10-decisions/decision-index.md +++ b/.project-docs/10-decisions/decision-index.md @@ -5,7 +5,7 @@ | ID | Decision | Status | Date | Applies To | Detail | |---|---|---|---|---|---| | ADR-001 | AI 绘画采用 Workspace / Conversation / Task 分层状态与服务端持久 Conversation Session | Accepted | 2026-08-11 | AI 绘画客户端、Main 适配器、Works Square API | `adr-001-ai-design-conversation-ownership.md` | -| ADR-002 | Robot V1 采用 Main 门控的引导式热点配网并衔接现有六位 Binding | Accepted / planned, default off | 2026-08-16 | Robot Renderer、Host API、Electron Main、现有固件热点入口 | `adr-002-robot-guided-hotspot-binding-v1.md` | +| ADR-002 | Robot V1 采用 Main 门控的引导式热点配网并衔接现有六位 Binding | Accepted / implemented, default off | 2026-08-16 | Robot Renderer、Host API、Electron Main、现有固件热点入口 | `adr-002-robot-guided-hotspot-binding-v1.md` | ## Superseded Decisions diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 54569c8b..0186da61 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -24,7 +24,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | Robot Workspace | Account-scoped agent configuration, device activation/binding, assignment, and credential-recovery UI | Renderer receives only safe Works Square projections. Configuration choices come from the USER-scoped safe catalog; unavailable current values remain editable without exposing provider credentials or configuration internals. | | AI Hardware Main Route | Fixed `/api/works/ai-hardware` Host API to Works Square proxy | Main owns Bearer auth, stable operation IDs, bounded retry, ETag/If-Match, request/response limits, error redaction, and the fixed no-store configuration-catalog proxy. Versioned responses accept only canonical strong or weak numeric ETags that equal the DTO revision; mutations always emit strong `If-Match`. It never forwards Renderer authorization headers. | -| Guided Hotspot Binding V1 | Planned, default-off Renderer journey over the current firmware Hotspot portal and six-digit Binding | System Wi-Fi selection and portal credential entry remain user/firmware-owned. Main exposes only a boolean capability and a fixed system-browser open action; no BLE, automatic claim, or firmware change is part of V1. | +| Guided Hotspot Binding V1 | Implemented, default-off Renderer journey over the current firmware Hotspot portal and six-digit Binding | System Wi-Fi selection and portal credential entry remain user/firmware-owned. Main exposes only a boolean capability and a fixed system-browser open action; no BLE, automatic claim, or firmware change is part of V1. | ## Important Boundaries @@ -33,7 +33,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 - Robot model, language, and voice choices are dynamically projected from the Xiaozhi USER catalog through Works Square and Electron Main; the catalog is bounded, account-scoped, and `private, no-store` at each public hop. - One local account maps to one server-side Xiaozhi account binding. Agents and devices are resources beneath that account binding, not separate Xiaozhi users. - Robot/Canvas/module-selection routes must not initialize AI Programming projects or providers. -- Guided Hotspot Binding is an accepted but not-yet-implemented V1 boundary. Its Main-owned capability remains false by default; disabled/public builds keep the current direct six-digit Binding UI. +- Guided Hotspot Binding is implemented behind a Main-owned capability that remains false by default; disabled/public builds keep the current direct six-digit Binding UI. The guided state is process-local, opener failures expose only the same fixed address for manual copy, and Binding conflicts refresh the safe account overview. - The fixed portal action may open only `http://192.168.4.1/` in the system browser and must not acquire cloud credentials or call Works Square. Renderer never supplies a portal URL and never handles Wi-Fi credentials. - A successful Binding means account ownership was established; it is not evidence that the Robot is currently online or protocol-ready. diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 98a485c6..00c283d8 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,7 +4,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through -- `14afe4a`: accepted design for a firmware-zero-change, default-off Guided Hotspot Binding V1; implementation is not yet present on `main`. +- `b7a1590` / `14afe4a`: implemented and accepted firmware-zero-change Guided Hotspot Binding V1 with a Main-owned default-off capability, fixed portal action, in-memory Renderer journey, and existing six-digit Binding contract. - `ea75b06`: Robot configuration reads accept canonical weak numeric response ETags introduced by public response compression only when the numeric revision exactly matches the strictly projected DTO; configuration and assignment writes continue to emit strong `If-Match`. - `fe55dee`: Robot configuration editing uses the safe Xiaozhi/Works catalog for model, language, and voice selections, with bounded sliders for TTS numeric controls and no-store catalog responses. - `fd9b5b46a913c515e94e4e26f185d43866c2581f` / `7a811590c4943b7b1b7ea5f3b4d3ce3ce05622a5`: Codex-style persistent AI Programming context-compaction timeline, run-lifecycle separation, polling-idle completion, and cold-hydration hardening. @@ -34,7 +34,7 @@ Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失 ## Recently Completed -- 2026-08-16: Accepted ADR-002 for a default-off Robot onboarding V1 that guides the existing firmware Hotspot portal and then reuses six-digit Binding. This is an architecture decision only: no product behavior or firmware was changed, and the capability must remain off until implementation and pilot gates pass. +- 2026-08-16: Implemented ADR-002's Robot onboarding V1 in Makelore without changing firmware. Main owns a default-off capability and the fixed portal opener; Renderer guides manual hotspot/Wi-Fi handoff and reuses six-digit Binding, clears secrets/retry identity safely, and does not equate Binding with online readiness. Pilot enablement still requires the documented firmware, issuer/validator, gate-on Electron, and physical-device evidence. - 2026-08-15: Corrected the deployed Robot configuration-read contract after the compressed public Works response was observed with `ETag: W/\"0\"` and matching numeric `config_revision: 0`. Electron Main now accepts only canonical strong or weak numeric response tags, still requires exact DTO revision equality, and always sends strong `If-Match` for mutations. No production client rollout is claimed. - 2026-08-16: Integrated selection-oriented Robot configuration editing. Enabled model and caller-safe voice metadata now flows from Xiaozhi through Works Square and Electron Main without exposing provider secrets; unavailable current values and `clear_fields` semantics remain intact. Production deployment of the matching service endpoints is still required. - 2026-08-15:AI 编程上下文压缩改为 Codex 风格的会话内时间线事件;手动与自动压缩原位展示并持久保留,历史回放去重且状态只允许从 running 单调进入 completed,压缩完成不再冒充整个 run idle。 @@ -57,10 +57,9 @@ Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失 ## Next Recommended Steps -1. 在独立 feature worktree 实现 ADR-002 的 default-off capability、固定 portal-open Host action、Renderer 引导状态机和聚焦测试,不修改固件。 -2. 核对指定固件镜像与六位码发行/消费契约,完成 Electron E2E 和真机 smoke 后再决定是否只为内部试点开启 capability。 -3. 成组核对客户端 source+built+contract 上传 → 服务端逐字节校验 → OSS immutable Release → CDN/Edge 的发布契约与客户端 `play_url` 消费契约。 -4. 配置真实生产环境,分别执行“小游戏/小程序创建 → 客户端本地构建与同字节预检 → 提交 → 服务端合同/摘要校验与不可变 Release 固化 → 运营批准 → App 播放”。 +1. 核对指定固件镜像与六位码发行/消费契约,补齐 capability-on 原生 opener Electron E2E 和真机 smoke 后,再决定是否只为内部试点开启 Guided Hotspot Binding。 +2. 成组核对客户端 source+built+contract 上传 → 服务端逐字节校验 → OSS immutable Release → CDN/Edge 的发布契约与客户端 `play_url` 消费契约。 +3. 配置真实生产环境,分别执行“小游戏/小程序创建 → 客户端本地构建与同字节预检 → 提交 → 服务端合同/摘要校验与不可变 Release 固化 → 运营批准 → App 播放”。 ## Open Questions / Blockers @@ -87,4 +86,4 @@ Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失 ## Last Updated -2026-08-15 +2026-08-16 diff --git a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md index 85e2ef5c..12114258 100644 --- a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md +++ b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md @@ -12,6 +12,7 @@ ## Scope +- On 2026-08-16, resume the existing Integration owner to merge reviewed Robot Guided Hotspot Binding implementation commit `b7a1590` into local `main`, reconcile canonical memory from planned to implemented/default-off, and keep firmware edits, capability enablement, and remote push outside this resumption. - On 2026-08-16, resume the existing Integration owner to accept reviewed Robot Guided Hotspot Binding V1 design commit `14afe4a`, promote only its confirmed minimal-firmware decision into canonical memory, and keep product implementation, firmware changes, and remote push outside this integration step. - On 2026-08-16, resume the existing Integration owner to merge reviewed Robot configuration-catalog/editor source `fe55dee` into local `main`, promote the accepted dynamic catalog boundary, and keep remote push outside this resumption. - On 2026-08-15, resume the existing Integration owner to merge reviewed Robot configuration-schema fix `ea75b06` into local `main`, accepting canonical weak numeric response ETags produced by the deployed compression layer while keeping strong `If-Match` writes. @@ -44,6 +45,7 @@ - The 2026-08-14 user request authorizes the local `main` merge only. It does not expand this resumption into a remote push; the existing authentication blocker remains a separate follow-up. - The 2026-08-15 context-compaction request likewise authorizes only a local `main` merge. It does not authorize a remote push or changing the existing OpenCode/model compaction threshold. - The 2026-08-16 Robot onboarding confirmation accepts the current-firmware Hotspot + six-digit Binding V1. It does not authorize firmware edits, claim automatic nearby discovery, enable the pilot capability by default, or revive the unready Security 2/automatic-claim proposal as a V1 contract. +- The reviewed implementation may move canonical truth from planned to present, but release guidance must retain the exact shipped-firmware, issuer/validator, gate-on Electron, and physical-device smoke prerequisites. The source task record remains read-only and must stay on its feature history. ## Project Context Loaded @@ -125,8 +127,23 @@ Gate result: - The source changes only Electron Main response-revision parsing and its focused route tests. It still requires DTO/revision equality and emits only strong `If-Match` for writes; no authentication, idempotency, response-bound, or redaction boundary changes. - Gate result: Passed for the local Robot configuration-schema merge. Remote push remains outside this resumption. +### 2026-08-16 Robot Guided Hotspot Binding Implementation Integration Resume + +- Reused the existing Integration owner because it exclusively owns clean local `main` at `54443232dd6a07dc1f3df5b33df00f665540497a`; `task_context.py touch` refreshed the reservation and registry doctor passed. +- Verified source task `20260816-guided-hotspot-binding-7c4d2e` is `ready_for_integration`, source commit `b7a1590ca132c971ffff177cb1e2aa47f96358cf` is a direct descendant of current `main`, its worktree is clean, and independent Sol re-review returned PASS with no P0-P3 findings. +- Read the source task outcome, verification, follow-ups, and promotion candidate against ADR-002 and current canonical Robot memory. The implementation preserves the accepted zero-firmware/default-off boundary; its only promotion candidate remains deferred until release-gate evidence exists. +- No registered peer owns `main` or contradicts this implementation. The prior Security 2/automatic-claim proposal remains a deferred future direction and is not part of this merge. +- Gate result: Passed for the local implementation merge. Firmware changes, capability enablement, physical acceptance, and remote push remain outside this resumption. + ## Plan +### 2026-08-16 Robot Guided Hotspot Binding Implementation Integration Plan + +1. Merge reviewed source commit `b7a1590` into local `main` with a normal no-ff merge, preserve the source parent, and exclude the source-owned task record from the final main tree. +2. Update only canonical statements that still say the accepted V1 is planned/not implemented; record `b7a1590` under `Integrated Through` while retaining default-off and release-gate wording. +3. Re-run the 90 focused tests, full unit suite, typecheck, scoped lint, production build, Electron Robot navigation smoke, project-document gates, and whitespace/topology checks on the merged tree. +4. Obtain an independent read-only Sol PASS/FAIL integration review, commit the verified no-ff merge, and leave firmware, capability enablement, physical acceptance, and remote push untouched. + ### 2026-08-16 Robot Guided Hotspot Binding V1 Decision Integration Plan 1. Verify source commit `14afe4a`, its independent PASS reviews, the existing Robot implementation facts, and explicit human acceptance of the minimal-firmware V1. @@ -171,6 +188,10 @@ Gate result: ## Outcome +- On 2026-08-16, started a normal `--no-ff --no-commit` merge of reviewed Guided Hotspot Binding source `b7a1590`; Git reported no textual conflicts. The source task record remains reachable on the source commit/feature branch and is excluded from the local `main` result. +- Integrated the default-off Main capability and strict local Host actions, fixed system-browser portal ownership, in-memory Renderer guided/direct Binding journey, safe conflict/retry/secret cleanup, and Bound-without-online semantics. No firmware file, BLE/Wi-Fi discovery, cloud claim route, or capability enablement was added. +- Reconciled canonical current state, system overview, and decision index from planned/not implemented to implemented/default off. Release gates remain exact firmware and issuer/validator verification, gate-on native-opener Electron coverage, and physical-device smoke. +- Final no-ff merge topology is prepared with first parent `54443232dd6a07dc1f3df5b33df00f665540497a` and reviewed source second parent `b7a1590ca132c971ffff177cb1e2aa47f96358cf`. This resumption is complete locally; the long-lived integration task remains blocked only on its separate pre-existing authenticated remote-push scope. - Accepted source design commit `14afe4a` as ADR-002: V1 guides the user through the current firmware Hotspot portal and then reuses the existing six-digit Binding facade. The former Security 2/automatic-claim design remains deferred rather than silently mixed into V1. - Promoted a narrow planned interface: Main-owned `guidedHotspotBinding` default false, fixed system-browser portal action for `http://192.168.4.1/`, Renderer-only guidance state, no Wi-Fi credential handling, and no claim that `bound` means online/ready. - Kept implementation truth explicit: this integration step changes canonical documents only. Product behavior and firmware remain unchanged; pilot enablement is gated on exact shipped-image checks, activation-code contract checks, automated/Electron coverage, and physical-device smoke. @@ -258,6 +279,13 @@ Gate result: ## Verification +- 2026-08-16 Guided Hotspot source final Sol re-review — `PASS`, no P0-P3 findings after fixed-address copy recovery, conflict/already-bound overview refresh, and complete state-machine/remount coverage were added. +- Staged local-main merge Robot selection — 3 files / 91 tests passed. +- Staged local-main merge `pnpm test` — 156 files / 1755 tests passed. +- Staged local-main merge `pnpm run typecheck`, scoped ESLint, and `pnpm run build:vite` — passed; the production build retains only existing chunk-size and mixed-import warnings. +- Staged local-main merge Electron module-navigation smoke — 2/2 passed, including entry into the Robot route. Gate-on native external-open observation remains an explicit release prerequisite rather than a claimed test. +- Independent staged-merge review initially returned `FAIL` with one P2 and two P3 findings: ADR implementation status remained planned, current-state's update date was stale, and Escape/overlay dismissal during a pending native opener could let an old promise affect a reopened wizard. The documents now state implemented/default-off with the current date; portal-opening sessions reject all close attempts, with an Escape/remount regression test. Focused/full/typecheck/lint/build checks were rerun before re-review. +- Independent staged-merge re-review — `PASS`, no P0-P3 findings. It confirmed all three findings closed, source-task-record exclusion, exact merge parents, default-off/release-gate truth, and no old opener promise can affect a reopened guided session. - 2026-08-16 ADR-002 integration `check_project_docs.py`, task-aware `check_doc_drift.py`, and `git diff --check` — passed in the Integration owner worktree. - Independent ADR-002 canonical integration review initially returned `FAIL` on two P2 documentation inaccuracies: a shortened Host wire envelope and an overclaim about deployed firmware. Both were corrected; final re-review returned `PASS` with no P0-P3 findings. - The final review confirmed only canonical documents and this integration task record changed; no product source, source-task record, or firmware file was modified in this acceptance step. @@ -328,7 +356,7 @@ Gate result: ## Follow-ups -- Implement ADR-002 from a new feature worktree with the capability still false by default; do not touch `D:\Datas\HardwareProjects\xiaozhi-esp32-firmware`. +- Keep `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING` unset/default false in production; do not touch `D:\Datas\HardwareProjects\xiaozhi-esp32-firmware` for this V1. - Before any pilot enablement, identify the exact shipped Robot component/firmware image, verify that the deployed issuer produces six ASCII digits with compatible freshness/consumption semantics, and pass a real device smoke through the Host API/Electron flow. - Deploy matching Xiaozhi and Works Square catalog endpoints before releasing this client; otherwise the editor preserves current values but cannot populate dynamic choices. - The Robot integration is complete on local `main`. Production still requires matching Works Square/Xiaozhi deployment, feature configuration, credentials, and a real one-time activation-code smoke. @@ -347,5 +375,6 @@ Gate result: ## Promotion Candidates +- The implementation truth from `b7a1590` was promoted from planned to implemented/default-off in canonical current state, system overview, and ADR index. Its release-enablement procedure/support matrix candidate remains deferred because the mandatory gate-on Electron and physical/firmware evidence does not yet exist. - The Guided Hotspot Binding V1 candidate from `14afe4a` was promoted into ADR-002, success criteria, system/module/data-flow architecture, business rules, glossary, and current state. No unresolved candidate remains for this design acceptance. - The context-compaction source candidate was promoted into current state, module map, data flow, evidence, and upgrade commitments. No unresolved candidate remains for this local merge. diff --git a/electron/api/routes/ai-hardware.ts b/electron/api/routes/ai-hardware.ts index 05aab025..c08f94ca 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 f58c7d88..3ab52bd0 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 623f12f4..a909f695 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 || portalOpening) 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 d17dba79..20de035a 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 f93e31a2..5053eb8b 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,167 @@ 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('keeps the guided session mounted while the native portal opener is pending', async () => { + const opening = deferred<{ opened: true }>(); + api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); + api.openAiHardwareProvisioningPortal.mockReturnValueOnce(opening.promise); + 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: '打开设备配网页面' })); + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }); + expect(screen.getByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '打开设备配网页面' })).toBeDisabled(); + + opening.resolve({ opened: true }); + fireEvent.click(await screen.findByRole('button', { name: '我已完成设备配网' })); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + fireEvent.click(screen.getByRole('button', { name: '绑定设备' })); + expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument(); + }); + + 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 +526,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 +558,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 +568,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 +610,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 cb991fb0..33d0973e 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,