diff --git a/.project-docs/30-worklog/tasks/20260816-robot-device-selection-f6a2.md b/.project-docs/30-worklog/tasks/20260816-robot-device-selection-f6a2.md new file mode 100644 index 0000000..6d4b3e3 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260816-robot-device-selection-f6a2.md @@ -0,0 +1,68 @@ +# Task: Fix Robot device selection and binding + +## Identity + +- Task ID: 20260816-robot-device-selection-f6a2 +- Mode: Feature +- Branch: codex/20260816-robot-device-selection-f6a2-robot-device-selection +- Worktree: D:\Datas\OthersProjects\makelore-robot-device-selection-f6a2 +- Base commit: 3b4669722e0ea671909beb039da1e07c2483341a +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Fix the Robot page so the device panel follows the currently selected agent. +- Prove that binding a new activation code targets the selected agent while preserving the existing guided/manual binding flow. +- Clarify that the current product adds devices through a six-digit activation code; nearby-device discovery remains out of scope. + +## Intent And Constraints + +- Treat devices as agent-scoped child resources in the UI while retaining the account-wide overview DTO. +- Do not add a new backend route, automatic discovery, firmware behavior, or a second hardware module. +- Preserve Main-owned provisioning, activation-code secrecy, idempotency, reassignment revisions, and the default-off guided Hotspot flow. +- Use a deterministic page regression test that reproduces the exact stale-device symptom before changing production code. + +## Project Context Loaded + +- Task: `20260816-robot-device-selection-f6a2`, Feature mode, isolated worktree and branch, base `3b466972`. +- Read: active task record, planning entry, project memory index, positioning, current state, decision index, system overview, business rules, success criteria, and the completed provisioning/hotspot-binding peer records. +- Peer assessment: Guided Hotspot Binding overlaps the same page but is already merged into this base; its state machine and security constraints must be preserved. No active task owns this worktree or is implementing the same selected-agent device seam. +- Gate result: Concurrent Task Gate and Planning Gate passed. + +## Plan + +1. Add a red page test with two agents and two devices that asserts the device panel follows the selected agent. +2. Add a red binding test proving a new activation code is submitted with the selected agent ID. +3. Filter the overview device projection by selected agent and tighten agent-scoped device copy without changing API contracts. +4. Run focused tests, typecheck, scoped lint, diff checks, project-document gates, and an independent Sol review. + +## Outcome + +- Reproduced the stale-device bug: the page projected every account device regardless of the selected agent, so changing agents refreshed configuration but not the device panel. +- Filtered the device projection by `device.agent_id === selectedAgentId`; switching agents now changes both configuration and visible devices. +- Added an agent-scoped device count to each agent row, matching Xiaozhi's verified `deviceCount` semantics without adding a new API. +- Clarified the device panel, empty state, and binding action as belonging to the current agent. +- Made the binding target immutable for the dialog lifetime: it shows the current agent read-only, while the editable agent selector remains exclusive to existing-device reassignment. +- Proved that opening the binding flow after selecting the second agent submits the new six-digit activation code with that agent's ID. +- Kept existing atomic reassignment, guided Hotspot provisioning, activation-code secrecy, and account-wide overview contracts unchanged. No backend or firmware code was changed. + +## Verification + +- RED: the new two-agent/two-device page regression failed against the previous implementation because the first agent panel also rendered the second agent's device. +- PASS: `pnpm exec vitest run tests/unit/ai-hardware-page.test.tsx` — 1 file, 39 tests. +- PASS: `pnpm exec vitest run tests/unit/ai-hardware-page.test.tsx tests/unit/ai-hardware-api.test.ts tests/unit/ai-hardware-routes.test.ts` — 3 files, 92 tests. +- PASS: `pnpm run typecheck`. +- PASS: scoped ESLint for the changed page and test. +- PASS: `git diff --check` (line-ending warnings only). +- Source comparison confirmed Xiaozhi agent cards expose per-agent device totals and device management filters by agent; adding a normal device uses a six-digit activation code. Makelore derives the same count from its safe overview DTO. +- Independent Sol review initially returned FAIL with one P1 and one P2: the binding target remained editable and the short `aria-label` hid the scoped visible action. Both were corrected, the complete focused suite was rerun, and re-review returned PASS with no P0-P3 findings. + +## Follow-ups + +- Automatic nearby-device discovery and management-side manual MAC insertion remain intentionally unsupported by the public Makelore/Works contract. +- A physical multi-device smoke should bind a second real device with its own fresh activation code, switch between two agents, and verify atomic reassignment before the next packaged release. + +## Promotion Candidates + +- None recorded. diff --git a/src/pages/AiHardware/index.tsx b/src/pages/AiHardware/index.tsx index a909f69..b15f8be 100644 --- a/src/pages/AiHardware/index.tsx +++ b/src/pages/AiHardware/index.tsx @@ -326,7 +326,10 @@ export function AiHardware() { }, [configReloadKey, selectedAgentId]); const selectedAgent = overview?.agents.find((item) => item.id === selectedAgentId) ?? null; - const devices = useMemo(() => overview?.devices ?? [], [overview]); + const devices = useMemo( + () => overview?.devices.filter((device) => device.agent_id === selectedAgentId) ?? [], + [overview, selectedAgentId], + ); const resetDialog = () => { setDialogError(null); setBusy(false); clearCreateOperation(); clearBindOperation(); clearConfigOperation(); clearAssignmentOperation(); @@ -625,7 +628,10 @@ export function AiHardware() {

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

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

绑定到当前智能体

+

{overview.agents.find((agent) => agent.id === dialogAgentId)?.name ?? '未选择'}

+

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

); @@ -665,10 +671,13 @@ export function AiHardware() { 创建第一个智能体{overview.status === 'unprovisioned' ? '创建智能体将同时开通你的机器人工作台。' : '智能体创建后,才能把机器人设备绑定给它。'} ) : (
-
智能体{overview.agents.length} 个
{overview.agents.map((agent) => )}
+
智能体{overview.agents.length} 个
{overview.agents.map((agent) => { + const deviceCount = overview.devices.filter((device) => device.agent_id === agent.id).length; + return ; + })}
{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}
)}
: }
)} diff --git a/tests/unit/ai-hardware-page.test.tsx b/tests/unit/ai-hardware-page.test.tsx index 5053eb8..1552484 100644 --- a/tests/unit/ai-hardware-page.test.tsx +++ b/tests/unit/ai-hardware-page.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AiHardware } from '@/pages/AiHardware'; import { @@ -154,7 +154,7 @@ describe('AI hardware page', () => { status: 422, code: 'ai_hardware_activation_code_invalid', message: `invalid ${secret}`, })); render(); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' })); const input = screen.getByLabelText('6 位激活码'); fireEvent.change(input, { target: { value: '12x' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); @@ -171,13 +171,45 @@ describe('AI hardware page', () => { it('keeps the existing direct-code binding experience when guided provisioning is disabled', async () => { render(); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + 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('shows and binds devices for the currently selected agent', async () => { + const agentOneDevice = { id: 'device-one', agent_id: agentOne.id, assignment_revision: 3 }; + const agentTwoDevice = { id: 'device-two', agent_id: agentTwo.id, assignment_revision: 1 }; + api.getAiHardwareOverview.mockResolvedValueOnce({ + status: 'active', + agents: [agentOne, agentTwo], + devices: [agentOneDevice, agentTwoDevice], + }); + api.bindAiHardwareDevice.mockResolvedValueOnce(agentTwoDevice); + + render(); + + expect(await screen.findByRole('button', { name: /客厅助手.*1 台设备/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /书房助手.*1 台设备/ })).toBeInTheDocument(); + expect(await screen.findByText('设备 device-one')).toBeInTheDocument(); + expect(screen.queryByText('设备 device-two')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /书房助手/ })); + expect(await screen.findByText('设备 device-two')).toBeInTheDocument(); + expect(screen.queryByText('设备 device-one')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '为当前智能体绑定设备' })); + const bindingDialog = screen.getByRole('dialog'); + expect(within(bindingDialog).queryByLabelText('智能体')).not.toBeInTheDocument(); + expect(within(bindingDialog).getByText('绑定到当前智能体')).toBeInTheDocument(); + expect(within(bindingDialog).getByText('书房助手')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '654321' } }); + fireEvent.click(screen.getByRole('button', { name: '绑定' })); + + await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledWith('654321', agentTwo.id)); + }); + it('guides the existing hotspot flow, locks navigation while binding, and avoids online claims', async () => { api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); const binding = deferred(); @@ -185,7 +217,7 @@ describe('AI hardware page', () => { render(); await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' })); fireEvent.click(await screen.findByRole('button', { name: '开始引导配网' })); expect(screen.getByText(/设备热点没有加密保护/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' })); @@ -219,7 +251,7 @@ describe('AI hardware page', () => { api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); render(); await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' })); fireEvent.click(screen.getByRole('button', { name: '我已有 6 位激活码' })); expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument(); @@ -255,12 +287,12 @@ describe('AI hardware page', () => { api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); render(); await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + 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.click(screen.getByRole('button', { name: '为当前智能体绑定设备' })); expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument(); expect(api.openAiHardwareProvisioningPortal).not.toHaveBeenCalled(); }); @@ -271,7 +303,7 @@ describe('AI hardware page', () => { api.openAiHardwareProvisioningPortal.mockReturnValueOnce(opening.promise); render(); await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + 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: '电脑已连接设备热点' })); @@ -284,7 +316,7 @@ describe('AI hardware page', () => { opening.resolve({ opened: true }); 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: '为当前智能体绑定设备' })); expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument(); }); @@ -304,7 +336,7 @@ describe('AI hardware page', () => { .mockResolvedValueOnce({ opened: true }); render(); await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + 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: '电脑已连接设备热点' })); @@ -320,7 +352,7 @@ describe('AI hardware page', () => { 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: '绑定设备' })); + fireEvent.click(screen.getByRole('button', { name: '为当前智能体绑定设备' })); expect(screen.getByRole('button', { name: '开始引导配网' })).toBeInTheDocument(); expect(screen.queryByText(/不会撤销机器人已经保存的 Wi-Fi 设置/)).not.toBeInTheDocument(); }); @@ -508,7 +540,7 @@ describe('AI hardware page', () => { status: 409, code, message: 'secret detail activation=031425', retryAfterSeconds: 7, })); render(); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + 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(expected); @@ -520,7 +552,7 @@ describe('AI hardware page', () => { status: 409, code: 'ai_hardware_operation_in_progress', message: 'private', retryAfterSeconds: 7, })); render(); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + 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('7 秒后重试'); @@ -536,7 +568,7 @@ describe('AI hardware page', () => { message: 'private upstream state', })); render(); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' })); fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); @@ -554,7 +586,7 @@ describe('AI hardware page', () => { })) .mockResolvedValueOnce(device); render(); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' })); const input = screen.getByLabelText('6 位激活码'); fireEvent.change(input, { target: { value: '031425' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); @@ -580,14 +612,14 @@ describe('AI hardware page', () => { })) .mockResolvedValueOnce(device); const first = render(); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + 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.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' })); fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '654321' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); @@ -605,7 +637,7 @@ describe('AI hardware page', () => { })) .mockResolvedValueOnce(device); render(); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' })); const input = screen.getByLabelText('6 位激活码'); fireEvent.change(input, { target: { value: '031425' } }); @@ -634,7 +666,7 @@ describe('AI hardware page', () => { })) .mockResolvedValueOnce(device); render(); - fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); + fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' })); const input = screen.getByLabelText('6 位激活码'); fireEvent.change(input, { target: { value: '031425' } });