merge: integrate guided hotspot binding v1

This commit is contained in:
2026-08-16 14:55:29 +08:00
11 changed files with 836 additions and 30 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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<void>;
};
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<string, unknown> | undefined;
let project: (value: unknown) => Record<string, unknown> | 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;

View File

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

View File

@@ -1,5 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Bot, Link2, Loader2, Plus, RefreshCw, Settings2 } from 'lucide-react';
import {
ArrowLeft,
Bot,
CheckCircle2,
Copy,
ExternalLink,
Link2,
Loader2,
Plus,
RefreshCw,
Settings2,
ShieldAlert,
Wifi,
} from 'lucide-react';
import { FeedbackState } from '@/components/common/FeedbackState';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -18,6 +31,8 @@ import {
getAiHardwareConfigurationCatalog,
getAiHardwareAssignment,
getAiHardwareOverview,
getAiHardwareProvisioningCapabilities,
openAiHardwareProvisioningPortal,
recoverAiHardwareCredential,
updateAiHardwareAgentConfiguration,
updateAiHardwareAssignment,
@@ -32,6 +47,16 @@ import {
} from '@/lib/ai-hardware';
type PageState = 'loading' | 'ready' | 'disabled' | 'auth' | 'error';
type BindPath = 'direct' | 'guided';
type BindStep =
| 'choose_path'
| 'prepare_robot'
| 'connect_device_ap'
| 'configure_wifi'
| 'reconnect_internet'
| 'enter_activation_code'
| 'binding'
| 'bound';
type BindRetryIntent = { agentId: string; fingerprint: ArrayBuffer; operationId: string };
type ConfigDraft = {
agent_name: string; system_prompt: string; language: string; lang_code: string;
@@ -48,9 +73,20 @@ const nullableTextFields = [
'mem_model_id', 'intent_model_id',
] as const;
const nullableNumberFields = ['tts_volume', 'tts_rate', 'tts_pitch'] as const;
const PROVISIONING_PORTAL_DISPLAY_URL = 'http://192.168.4.1/';
const BINDING_OVERVIEW_REFRESH_CODES = new Set([
'ai_hardware_device_already_bound',
'ai_hardware_idempotency_conflict',
'ai_hardware_revision_conflict',
'ai_hardware_state_conflict',
'ai_hardware_provider_state_conflict',
'AI_HARDWARE_REVISION_CONFLICT',
]);
const shortId = (value: string) => value.length > 12 ? `${value.slice(0, 6)}…${value.slice(-4)}` : value;
const isRevisionConflict = (error: unknown) => error instanceof AiHardwareApiError
&& ['ai_hardware_revision_conflict', 'REVISION_MISMATCH', 'AI_HARDWARE_REVISION_CONFLICT'].includes(error.code);
const shouldRefreshOverviewAfterBinding = (error: unknown) => error instanceof AiHardwareApiError
&& BINDING_OVERVIEW_REFRESH_CODES.has(error.code);
function safeMessage(error: unknown): string {
if (!(error instanceof AiHardwareApiError)) return '操作没有完成,请稍后重试。';
if (isRevisionConflict(error)) return '内容已在其他位置更新,请重新核对后再保存。';
@@ -65,7 +101,11 @@ function safeMessage(error: unknown): string {
ai_hardware_activation_code_invalid: '激活码无效或已过期,请从设备上获取新激活码。',
ai_hardware_device_already_bound: '设备已被绑定,无法重复绑定。',
ai_hardware_idempotency_conflict: '本次输入与待重试操作不一致,请再次提交以启动新操作。',
ai_hardware_state_conflict: '设备状态已变化,已刷新列表,请核对后继续。',
ai_hardware_provider_state_conflict: '设备服务状态已变化,已刷新列表,请核对后继续。',
AI_HARDWARE_RATE_LIMITED: '请求过于频繁,请稍后重试。',
AI_HARDWARE_PROVISIONING_DISABLED: '当前版本未开启引导配网,请直接使用设备上的激活码绑定。',
AI_HARDWARE_PORTAL_OPEN_FAILED: '无法打开设备配网页面,请确认电脑已连接设备热点后重试。',
};
if (error.code === 'ai_hardware_operation_in_progress') {
return error.retryAfterSeconds === null
@@ -161,6 +201,13 @@ export function AiHardware() {
const [notice, setNotice] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [bindOpen, setBindOpen] = useState(false);
const [guidedHotspotBinding, setGuidedHotspotBinding] = useState(false);
const [bindPath, setBindPath] = useState<BindPath>('direct');
const [bindStep, setBindStep] = useState<BindStep>('enter_activation_code');
const [portalOpening, setPortalOpening] = useState(false);
const [portalOpened, setPortalOpened] = useState(false);
const [portalOpenFailed, setPortalOpenFailed] = useState(false);
const [portalAddressCopied, setPortalAddressCopied] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const [catalog, setCatalog] = useState<AiHardwareConfigurationCatalog | null>(null);
const [catalogLoading, setCatalogLoading] = useState(false);
@@ -232,13 +279,18 @@ export function AiHardware() {
} finally { setRecoveryBusy(false); }
};
const loadOverview = useCallback(async () => {
setPageState('loading'); setNotice(null);
const loadOverview = useCallback(async (options: { preserveCurrent?: boolean } = {}) => {
if (!options.preserveCurrent) { setPageState('loading'); setNotice(null); }
try {
const value = await getAiHardwareOverview();
const [value, capabilities] = await Promise.all([
getAiHardwareOverview(),
getAiHardwareProvisioningCapabilities().catch(() => ({ guidedHotspotBinding: false })),
]);
setGuidedHotspotBinding(capabilities.guidedHotspotBinding);
setOverview(value); setPageState('ready');
setSelectedAgentId((current) => value.agents.some((item) => item.id === current) ? current : value.agents[0]?.id ?? null);
} catch (error) {
if (options.preserveCurrent) return;
setOverview(null);
if (error instanceof AiHardwareApiError && error.code === 'AI_HARDWARE_AUTH_REQUIRED') setPageState('auth');
else if (error instanceof AiHardwareApiError && ['AI_HARDWARE_DISABLED', 'ai_hardware_unconfigured'].includes(error.code)) setPageState('disabled');
@@ -278,6 +330,65 @@ export function AiHardware() {
const resetDialog = () => {
setDialogError(null); setBusy(false);
clearCreateOperation(); clearBindOperation(); clearConfigOperation(); clearAssignmentOperation();
setBindPath('direct');
setBindStep(guidedHotspotBinding ? 'choose_path' : 'enter_activation_code');
setPortalOpening(false);
setPortalOpened(false);
setPortalOpenFailed(false);
setPortalAddressCopied(false);
if (activationCodeInputRef.current) activationCodeInputRef.current.value = '';
};
const openBindingDialog = () => {
resetDialog();
setDialogAgentId(selectedAgentId ?? overview?.agents[0]?.id ?? '');
setBindStep(guidedHotspotBinding ? 'choose_path' : 'enter_activation_code');
setBindOpen(true);
};
const closeBindingDialog = () => {
if (busy || 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 <main data-testid="ai-hardware-page" role="alert" className="flex min-h-full items-center justify-center"><div className="space-y-3 text-center"><FeedbackState state="error" title="需要恢复设备凭据" description="可在此安全地重新签发设备凭据;凭据内容不会显示在客户端。" action={<Button variant="outline" aria-busy={recoveryBusy} disabled={recoveryBusy} onClick={() => void recoverCredential()}>{recoveryBusy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}恢复设备凭据</Button>} />{recoveryError ? <p className="text-sm text-destructive">{recoveryError}</p> : null}</div></main>;
if (overview.status === 'provisioning') return <main data-testid="ai-hardware-page" className="flex min-h-full items-center justify-center"><FeedbackState state="empty" title="正在准备机器人工作台" description="完成开通后即可创建智能体并绑定机器人设备。" action={<Button variant="outline" onClick={() => void loadOverview()}>刷新状态</Button>} /></main>;
const bindingTitles: Record<BindStep, string> = {
choose_path: '绑定机器人设备',
prepare_robot: '准备机器人',
connect_device_ap: '连接设备热点',
configure_wifi: '配置机器人 Wi-Fi',
reconnect_internet: '恢复电脑网络',
enter_activation_code: '输入 6 位激活码',
binding: '正在绑定设备',
bound: '绑定成功',
};
const canGoBack = !busy && !['choose_path', 'binding', 'bound'].includes(bindStep)
&& !(bindStep === 'enter_activation_code' && !guidedHotspotBinding);
const bindingContent = (() => {
if (bindStep === 'choose_path') {
return (
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
aria-label="开始引导配网"
className="motion-press min-h-28 rounded-xl border border-border bg-surface-subtle p-4 text-left hover:bg-surface-tertiary"
onClick={() => { setBindPath('guided'); setBindStep('prepare_robot'); }}
>
<Wifi className="mb-3 h-5 w-5 text-brand" />
<span className="block font-semibold">开始引导配网</span>
<span className="mt-1 block text-sm text-muted-foreground">第一次使用,先让机器人连接 Wi-Fi,再完成绑定。</span>
</button>
<button
type="button"
aria-label="我已有 6 位激活码"
className="motion-press min-h-28 rounded-xl border border-border bg-surface-subtle p-4 text-left hover:bg-surface-tertiary"
onClick={() => { setBindPath('direct'); setBindStep('enter_activation_code'); }}
>
<Link2 className="mb-3 h-5 w-5 text-brand" />
<span className="block font-semibold">我已有 6 位激活码</span>
<span className="mt-1 block text-sm text-muted-foreground">机器人已经联网并播报了激活码。</span>
</button>
</div>
);
}
if (bindStep === 'prepare_robot') {
return (
<div className="space-y-4">
<p className="text-sm">打开机器人并让它进入配网模式。听到配网提示或看到设备热点后再继续。</p>
<div className="rounded-xl bg-amber-500/10 p-4 text-sm text-amber-900">
<ShieldAlert className="mr-2 inline h-4 w-4" />
请在可信、近距离的网络环境中操作;当前设备热点没有加密保护。
</div>
</div>
);
}
if (bindStep === 'connect_device_ap') {
return (
<div className="space-y-3 text-sm">
<p>打开电脑的系统 Wi-Fi 设置,连接机器人显示的 <strong>Xiaozhi-*</strong> 热点。</p>
<p className="text-muted-foreground">热点名称只用于寻找候选设备,不代表设备身份认证。连接后电脑暂时无法访问互联网是正常现象。</p>
</div>
);
}
if (bindStep === 'configure_wifi') {
return (
<div className="space-y-4 text-sm">
<p>通过系统浏览器打开设备配网页面,在页面中选择家庭 Wi-Fi 并输入密码。</p>
<div className="rounded-xl bg-brand-soft p-4">
Wi-Fi 密码只填写在设备配网页面中,Makelore 不读取或保存。
</div>
{portalOpenFailed ? (
<div className="space-y-2 rounded-xl border border-border bg-surface-subtle p-4">
<p>系统浏览器没有打开时,请手动访问这个固定地址:</p>
<div className="flex flex-wrap items-center gap-2">
<code className="select-all rounded bg-background px-2 py-1 tabular-nums">
{PROVISIONING_PORTAL_DISPLAY_URL}
</code>
<Button type="button" variant="outline" size="sm" onClick={() => void copyProvisioningPortalAddress()}>
<Copy className="mr-2 h-4 w-4" />复制地址
</Button>
</div>
{portalAddressCopied ? <p role="status" className="text-xs text-emerald-700">固定地址已复制。</p> : null}
</div>
) : null}
{portalOpened ? (
<Button variant="outline" onClick={() => void openProvisioningPortal()}>
<ExternalLink className="mr-2 h-4 w-4" />重新打开设备配网页面
</Button>
) : null}
</div>
);
}
if (bindStep === 'reconnect_internet') {
return (
<div className="space-y-3 text-sm">
<p>配网页面提交后,等待机器人连接家庭 Wi-Fi。然后把电脑重新连接到可以访问互联网的网络。</p>
<p className="text-muted-foreground">返回或取消引导不会撤销机器人已经保存的 Wi-Fi 设置,也不会关闭已打开的浏览器页面。</p>
</div>
);
}
if (bindStep === 'enter_activation_code') {
return (
<div className="space-y-4">
{bindPath === 'guided' ? (
<p className="text-sm text-muted-foreground">等待机器人联网并播报新的 6 位激活码。没有听到时请检查设备网络,或重新开始配网。</p>
) : null}
<div><Label htmlFor="hardware-activation-code">6 位激活码</Label><Input ref={activationCodeInputRef} id="hardware-activation-code" autoFocus type="text" inputMode="numeric" autoComplete="off" maxLength={6} onInput={(e) => { e.currentTarget.value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6); }} /></div>
<AgentSelect agents={overview.agents} value={dialogAgentId} onChange={(value) => { setDialogAgentId(value); clearBindOperation(); }} />
<p className="text-xs text-muted-foreground">激活码只用于本次绑定,关闭窗口后会立即清除。</p>
</div>
);
}
if (bindStep === 'binding') {
return <FeedbackState state="loading" title="正在提交绑定" description="请保持窗口打开,结果明确前不要重复操作。" />;
}
return (
<div className="space-y-4 text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-600" />
<p className="font-medium">机器人已经绑定到所选智能体。</p>
<p className="text-sm text-muted-foreground">绑定成功不代表设备已经上线,请等待机器人完成连接。</p>
</div>
);
})();
const bindingPrimaryAction = (() => {
if (bindStep === 'prepare_robot') return <Button onClick={() => setBindStep('connect_device_ap')}>机器人已进入配网模式</Button>;
if (bindStep === 'connect_device_ap') return <Button onClick={() => setBindStep('configure_wifi')}>电脑已连接设备热点</Button>;
if (bindStep === 'configure_wifi' && !portalOpened) return <Button aria-busy={portalOpening} disabled={portalOpening} onClick={() => void openProvisioningPortal()}>{portalOpening ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ExternalLink className="mr-2 h-4 w-4" />}打开设备配网页面</Button>;
if (bindStep === 'configure_wifi') return <Button onClick={() => setBindStep('reconnect_internet')}>我已完成设备配网</Button>;
if (bindStep === 'reconnect_internet') return <Button onClick={() => setBindStep('enter_activation_code')}>电脑已恢复联网</Button>;
if (bindStep === 'enter_activation_code') return <Button aria-busy={busy} disabled={busy} onClick={() => void bindDevice()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}绑定</Button>;
if (bindStep === 'binding') return <Button disabled aria-busy="true"><Loader2 className="mr-2 h-4 w-4 animate-spin" />正在绑定</Button>;
if (bindStep === 'bound') return <Button onClick={closeBindingDialog}>完成</Button>;
return null;
})();
return (
<main data-testid="ai-hardware-page" className="-m-5 min-h-full bg-background p-5 text-foreground sm:-m-6 sm:p-6">
<header className="mb-6 flex flex-wrap items-start justify-between gap-4">
@@ -416,13 +668,13 @@ export function AiHardware() {
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>智能体</CardTitle><CardDescription>{overview.agents.length} 个</CardDescription></div><Button size="icon" aria-label="创建智能体" onClick={() => { resetDialog(); setCreateOpen(true); }}><Plus className="h-4 w-4" /></Button></CardHeader><CardContent className="space-y-2">{overview.agents.map((agent) => <button key={agent.id} type="button" aria-pressed={agent.id === selectedAgentId} onClick={() => setSelectedAgentId(agent.id)} className={`motion-press flex min-h-12 w-full items-center gap-3 rounded-xl px-3 py-2 text-left ${agent.id === selectedAgentId ? 'bg-brand-soft' : 'bg-surface-subtle hover:bg-surface-tertiary'}`}><Bot className="h-4 w-4 shrink-0" /><span className="min-w-0 flex-1"><span className="block truncate text-sm font-semibold">{agent.name}</span><span title={agent.id} className="block text-xs tabular-nums text-muted-foreground">{shortId(agent.id)} · r{agent.config_revision}</span></span></button>)}</CardContent></Card>
<div className="space-y-5">
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>{selectedAgent?.name ?? '智能体配置'}</CardTitle><CardDescription>基础对话和语音设置</CardDescription></div><Button variant="outline" disabled={configLoading || !config} onClick={openConfigurationEditor}>{configLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Settings2 className="mr-2 h-4 w-4" />}编辑配置</Button></CardHeader><CardContent>{config ? <dl className="grid gap-3 text-sm sm:grid-cols-2"><div><dt className="text-muted-foreground">语言</dt><dd>{config.language || config.lang_code || '未设置'}</dd></div><div><dt className="text-muted-foreground">语音</dt><dd>{config.tts_voice_id || '未设置'}</dd></div><div className="sm:col-span-2"><dt className="text-muted-foreground">系统提示</dt><dd className="mt-1 whitespace-pre-wrap">{config.system_prompt || '未设置'}</dd></div></dl> : configLoading ? <FeedbackState state="loading" title="正在读取配置" /> : configLoadFailed ? <FeedbackState state="error" title="无法读取智能体配置" description="请检查服务连接后重试。" action={<Button variant="outline" onClick={() => setConfigReloadKey((value) => value + 1)}>重试读取配置</Button>} /> : null}</CardContent></Card>
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>设备</CardTitle><CardDescription>{devices.length} 台已绑定设备</CardDescription></div><Button onClick={() => { resetDialog(); setDialogAgentId(selectedAgentId ?? overview.agents[0].id); setBindOpen(true); }}><Link2 className="mr-2 h-4 w-4" />绑定设备</Button></CardHeader><CardContent>{devices.length ? <div className="space-y-2">{devices.map((device) => <div key={device.id} className="flex min-h-12 items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2"><span className="min-w-0"><span className="block text-sm font-semibold">设备 {shortId(device.id)}</span><span title={device.id} className="text-xs tabular-nums text-muted-foreground">指派 r{device.assignment_revision}</span></span><Button variant="outline" size="sm" onClick={() => void openAssignment(device)}>重新指派</Button></div>)}</div> : <FeedbackState state="empty" title="还没有绑定设备" description="使用设备上的 6 位激活码完成绑定。" />}</CardContent></Card>
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>设备</CardTitle><CardDescription>{devices.length} 台已绑定设备</CardDescription></div><Button onClick={openBindingDialog}><Link2 className="mr-2 h-4 w-4" />绑定设备</Button></CardHeader><CardContent>{devices.length ? <div className="space-y-2">{devices.map((device) => <div key={device.id} className="flex min-h-12 items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2"><span className="min-w-0"><span className="block text-sm font-semibold">设备 {shortId(device.id)}</span><span title={device.id} className="text-xs tabular-nums text-muted-foreground">指派 r{device.assignment_revision}</span></span><Button variant="outline" size="sm" onClick={() => void openAssignment(device)}>重新指派</Button></div>)}</div> : <FeedbackState state="empty" title="还没有绑定设备" description="使用设备上的 6 位激活码完成绑定。" />}</CardContent></Card>
</div>
</div>
)}
<Dialog open={createOpen} onOpenChange={(open) => { if (!busy) { setCreateOpen(open); if (!open) { setAgentName(''); resetDialog(); } } }}><DialogContent><DialogHeader><DialogTitle>创建智能体</DialogTitle><DialogDescription>输入一个容易识别的名称,稍后仍可修改。</DialogDescription></DialogHeader><div><Label htmlFor="hardware-agent-name">名称</Label><Input id="hardware-agent-name" autoFocus maxLength={64} value={agentName} onChange={(e) => { setAgentName(e.target.value); clearCreateOperation(); }} /></div>{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setCreateOpen(false); resetDialog(); }}>取消</Button><Button aria-busy={busy} disabled={busy} onClick={() => void createAgent()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}创建</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={bindOpen} onOpenChange={(open) => { if (!busy) { setBindOpen(open); if (!open) resetDialog(); } }}><DialogContent><DialogHeader><DialogTitle>绑定设备</DialogTitle><DialogDescription>激活码只用于本次绑定,关闭窗口后会立即清除。</DialogDescription></DialogHeader><div><Label htmlFor="hardware-activation-code">6 位激活码</Label><Input ref={activationCodeInputRef} id="hardware-activation-code" autoFocus type="text" inputMode="numeric" autoComplete="off" maxLength={6} onInput={(e) => { e.currentTarget.value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6); }} /></div><AgentSelect agents={overview.agents} value={dialogAgentId} onChange={(value) => { setDialogAgentId(value); clearBindOperation(); }} />{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setBindOpen(false); resetDialog(); }}>取消</Button><Button aria-busy={busy} disabled={busy} onClick={() => void bindDevice()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}绑定</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={bindOpen} onOpenChange={(open) => { if (!open) closeBindingDialog(); }}><DialogContent><DialogHeader><DialogTitle>{bindingTitles[bindStep]}</DialogTitle><DialogDescription>{bindPath === 'guided' ? '按步骤完成机器人联网与账号绑定。' : '使用机器人播报的激活码绑定到智能体。'}</DialogDescription></DialogHeader>{bindingContent}{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter>{canGoBack ? <Button variant="outline" onClick={backBindingStep}><ArrowLeft className="mr-2 h-4 w-4" />上一步</Button> : null}{!['binding', 'bound'].includes(bindStep) ? <Button variant="outline" disabled={busy || portalOpening} onClick={closeBindingDialog}>取消</Button> : null}{bindingPrimaryAction}</DialogFooter></DialogContent></Dialog>
<Dialog open={configOpen} onOpenChange={(open) => { if (!busy) { setConfigOpen(open); if (!open) { resetDialog(); setCatalog(null); setCatalogError(null); } } }}><DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto"><DialogHeader><DialogTitle>编辑智能体配置</DialogTitle><DialogDescription>模型、语言和音色来自小智当前可用目录;选择“使用服务默认值”可清除可选字段。</DialogDescription></DialogHeader>{draft ? <ConfigFields draft={draft} catalog={catalog} catalogLoading={catalogLoading} catalogError={catalogError} setDraft={(next) => { setDraft(next); clearConfigOperation(); }} onTtsModelChange={(value, next) => { setDraft(next); clearConfigOperation(); void loadConfigurationCatalog(value || undefined); }} onRetryCatalog={() => void loadConfigurationCatalog(draft.tts_model_id || undefined)} /> : null}{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setConfigOpen(false); resetDialog(); }}>取消</Button><Button aria-busy={busy} disabled={busy} onClick={() => void saveConfiguration()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}保存</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={Boolean(assignmentDevice)} onOpenChange={(open) => { if (!open && !busy) { setAssignmentDevice(null); resetDialog(); } }}><DialogContent><DialogHeader><DialogTitle>重新指派设备</DialogTitle><DialogDescription>选择接收这台设备的智能体。系统会检查最新修订,避免覆盖其他更改。</DialogDescription></DialogHeader><AgentSelect agents={overview.agents} value={dialogAgentId} onChange={(value) => { setDialogAgentId(value); clearAssignmentOperation(); }} />{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setAssignmentDevice(null); resetDialog(); }}>取消</Button><Button aria-busy={busy} disabled={busy} onClick={() => void saveAssignment()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}确认指派</Button></DialogFooter></DialogContent></Dialog>
</main>

View File

@@ -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,

View File

@@ -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<void> {
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(<AiHardware />);
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<typeof device>();
api.bindAiHardwareDevice.mockReturnValueOnce(binding.promise);
render(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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 },

View File

@@ -39,7 +39,13 @@ function jsonResponse(value: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(value), { ...init, headers });
}
function setup(fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(overview))) {
function setup(
fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(overview)),
options: {
guidedHotspotBinding?: boolean;
openExternal?: (url: string) => Promise<void>;
} = {},
) {
const getAccessToken = vi.fn().mockResolvedValue('secret-token');
const handler = createAiHardwareRouteHandler({
fetchImpl,
@@ -47,6 +53,7 @@ function setup(fetchImpl = vi.fn<typeof fetch>().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<typeof createAiHardwareRouteHandler>,
}
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<typeof fetch>().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<typeof fetch>().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,