diff --git a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md
index 1211425..4f0f19b 100644
--- a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md
+++ b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md
@@ -276,9 +276,16 @@ Gate result:
- Created no-ff merge commit `1d0878bb5b18e9b17b147a4d43ea0861913da785` with `953b0f4` as first parent and source `fd9b5b4` as second parent.
- The first independent integration review returned `FAIL` on two uncovered edges: HTTP polling-only idle could release the run while leaving its compaction running, and cold busy hydration could misclassify a historical compaction Part as current.
- Closed both findings in `7a811590c4943b7b1b7ea5f3b4d3ce3ce05622a5`: compact polling idle now completes only the matching `runID + generation` event before queue release, and cold hydration keeps a native Part running only when current transcript state provides a matching running identity. Historical compactions remain completed during a later ordinary busy run.
+- On 2026-08-16, staged a local no-ff merge of reviewed Robot device-selection source `87a95b4` onto `main` at `3b46697`; Git reported no textual conflicts. The source task record remains intact on the source commit and feature branch and is excluded from the `main` result to preserve task-document ownership boundaries.
+- The Robot workspace now derives device rows and per-agent counts from the selected agent, keeps activation-code binding fixed to that current agent, and retains the editable agent selector only for explicit reassignment. This is a Makelore-only correction and does not add device discovery, manual MAC entry, or new Works Square/Xiaozhi endpoints.
+- This resumption intentionally performs a local merge only. It does not fetch or push, and the existing remote-authentication follow-up for this long-running integration task remains unchanged.
## Verification
+- 2026-08-16 Robot device-selection merged-tree regression selection — 3 files / 92 tests passed.
+- Robot device-selection merged-tree `pnpm run typecheck` and scoped ESLint — passed.
+- Staged/unstaged whitespace checks and unmerged-entry checks — passed before independent review.
+- Independent Robot device-selection integration review — `PASS`, no P0-P3 findings; it confirmed selected-agent device isolation and counts, immutable bind target, retained reassignment semantics, source-record exclusion, and the Makelore-only API boundary.
- 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.
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}
6 位激活码 { 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' ? '创建智能体将同时开通你的机器人工作台。' : '智能体创建后,才能把机器人设备绑定给它。'} { resetDialog(); setCreateOpen(true); }}> 创建智能体
) : (
-
智能体 {overview.agents.length} 个
{ resetDialog(); setCreateOpen(true); }}> {overview.agents.map((agent) => 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'}`}>{agent.name} {shortId(agent.id)} · r{agent.config_revision} )}
+
智能体 {overview.agents.length} 个
{ resetDialog(); setCreateOpen(true); }}> {overview.agents.map((agent) => {
+ const deviceCount = overview.devices.filter((device) => device.agent_id === agent.id).length;
+ return 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'}`}>{agent.name} {shortId(agent.id)} · r{agent.config_revision} · {deviceCount} 台设备 ;
+ })}
{selectedAgent?.name ?? '智能体配置'} 基础对话和语音设置
{configLoading ? : }编辑配置 {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} void openAssignment(device)}>重新指派
)}
: }
+
当前智能体设备 {devices.length} 台设备绑定到当前智能体
为当前智能体绑定设备{devices.length ? {devices.map((device) =>
设备 {shortId(device.id)} 指派 r{device.assignment_revision} void openAssignment(device)}>重新指派
)}
: }
)}
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' } });