diff --git a/.project-docs/30-worklog/tasks/20260815-robot-config-loading-4f8c2d.md b/.project-docs/30-worklog/tasks/20260815-robot-config-loading-4f8c2d.md new file mode 100644 index 0000000..603918c --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260815-robot-config-loading-4f8c2d.md @@ -0,0 +1,94 @@ +# Task: Fix Robot agent configuration loading + +## Identity + +- Task ID: 20260815-robot-config-loading-4f8c2d +- Mode: Feature +- Branch: codex/20260815-robot-config-loading-4f8c2d-robot-config-loading +- Worktree: D:\Datas\OthersProjects\makelore-robot-config-loading-4f8c2d +- Base commit: a4050f0a6567e8203630bf5d16b57f00c45caab8 +- Owner: codex +- Status: Ready for review + +## Scope + +- Reproduce the Robot page state where a selected agent remains indefinitely in configuration loading and the edit action stays disabled after device binding. +- Trace the Renderer typed API, page effects, and Main-owned Works Square route without weakening authentication, ETag, idempotency, or response validation. +- Fix the smallest confirmed client-side cause and add a regression test at the user-visible page seam. + +## Intent And Constraints + +- The edit button may be enabled only after a real, versioned agent configuration has loaded; do not bypass the loading/error state. +- Preserve Main ownership of Works Square credentials and upstream headers, and retain strict numeric revision plus ETag equality checks. +- Keep activation codes and upstream response details out of Renderer state, logs, task records, and test output. +- Work only in the isolated linked worktree; do not alter or release the existing `main` integration owner. + +## Project Context Loaded + +Task context: +- Task ID: `20260815-robot-config-loading-4f8c2d` +- Mode: `feature` +- Branch: `codex/20260815-robot-config-loading-4f8c2d-robot-config-loading` +- Worktree: `D:\Datas\OthersProjects\makelore-robot-config-loading-4f8c2d` +- Base commit: `a4050f0a6567e8203630bf5d16b57f00c45caab8` +- Other active local tasks: the existing `main` integration owner, completed Robot source task, and isolated release, AI Design, AI Programming, updater, and packaging tasks returned by the registry. +- Overlap or semantic-conflict assessment: the completed Robot task establishes the relevant trust and state boundaries; no active peer owns the Robot page or typed hardware API. The same-base AI Design points task is confined to AI Design. Placeholder peer records have unrelated titles and isolated worktrees, so no semantic conflict blocks this diagnosis. + +Read: +- `.project-docs/05-agent-entry/read-before-planning.md` +- `.project-docs/05-agent-entry/memory-index.md` +- `.project-docs/05-agent-entry/planning-gate.md` +- this task record +- `.project-docs/00-brief/project-positioning.md` +- `.project-docs/00-brief/success-criteria.md` +- `.project-docs/30-worklog/current-state.md` +- `.project-docs/10-decisions/decision-index.md` +- `.project-docs/20-architecture/system-overview.md` +- `.project-docs/20-architecture/module-map.md` +- `.project-docs/20-architecture/data-flow.md` +- `.project-docs/40-domain/business-rules.md` +- evidence, reflection, commitment, and stale-item indexes +- every peer task record returned by `task_context.py status --json` + +Relevant understanding: +- Project goal: provide a non-technical desktop workflow while keeping credentials and sensitive network behavior in Electron Main. +- Current integrated focus: Robot is the single enabled hardware module and its typed Renderer → Host API → Main → Works Square chain is integrated. +- Active task scope: restore the configuration-loading terminal state so the user can edit the selected agent after binding a device. +- Active constraints: do not expose credentials/raw upstream errors, do not bypass revisions, and do not initialize Programming state on Robot routes. +- Decisions affecting this task: one local account maps to one Xiaozhi binding; agents/devices are resources beneath it; Main owns auth/idempotency/ETags. +- Evidence, reflections, or commitments affecting this task: the screenshot proves overview and device binding render successfully while the configuration pane remains loading; real production Robot smoke remains the decisive deployment check. +- Files or modules likely involved: `src/pages/AiHardware/index.tsx`, `src/lib/ai-hardware.ts`, `electron/api/routes/ai-hardware.ts`, and their focused tests. +- Unknowns, stale docs, or conflicts: the screenshot alone does not distinguish a never-settling promise from a rejected response followed by stale loading state; a red-capable page test is required before selecting a fix. + +Gate result: +- Passed. + +## Outcome + +- Reproduced the user-visible failure with a deterministic page test: after the configuration request rejected, the card still rendered `正在读取配置`, exposed no retry action, and kept editing disabled. +- Split configuration loading from its terminal failure state. A failed read now renders a safe local error state with `重试读取配置`; a successful retry restores the prompt and enables editing only after a real configuration and revision have loaded. +- Clear loading/error state when no agent is selected, preventing an abandoned request from leaving a stale spinner. +- Extended the Main-owned upstream deadline through bounded response-body reading and JSON parsing. A response that returns headers and an initial chunk but never completes now terminates as the safe retryable `504 / AI_HARDWARE_TIMEOUT` envelope instead of leaving Renderer pending forever. +- Preserved strict response schemas, ETag/revision equality, Main-owned authentication, idempotency, response-size limits, and upstream error redaction. +- Cross-repository inspection found no current source-contract mismatch for agent configuration fields, revision numbers, or ETags. Production still needs to be checked for the current Xiaozhi image and Liquibase migrations if a retry reports a server error. + +## Verification + +- Red page test on the pre-fix implementation: `pnpm exec vitest run tests/unit/ai-hardware-page.test.tsx -t "stops loading and lets the user retry after configuration loading fails"` — 1 failed, proving the stale loading state. +- Red Main route test on the pre-fix implementation: stalled configuration response body exceeded the 100 ms test deadline without returning an envelope. +- `corepack pnpm exec vitest run tests/unit/ai-hardware-page.test.tsx` — 25 passed. +- `corepack pnpm exec vitest run tests/unit/ai-hardware-routes.test.ts` — 18 passed. +- Focused Robot/Main regression suite across page, route, typed API, Host proxy, module navigation, layout gate, and provider gate — 7 files / 74 tests passed. +- `corepack pnpm run typecheck` — passed. +- ESLint on the four changed TypeScript/TSX files — passed. +- `git diff --check` — passed; only Git line-ending conversion warnings were emitted. +- Independent final Sol review — PASS; the reviewer reproduced the 7-file / 74-test result and found no code or documentation blocker. + +## Follow-ups + +- After deployment, verify the Xiaozhi manager image includes numeric External API revision serialization and that Liquibase changesets `202608131500` and `202608131501` are applied if configuration reads still fail. +- Perform a signed-in staging smoke: bind a device, load the selected agent configuration, edit `systemPrompt`, save with the returned revision, and confirm the updated prompt after refresh. + +## Promotion Candidates + +- None recorded. diff --git a/electron/api/routes/ai-hardware.ts b/electron/api/routes/ai-hardware.ts index f011e5b..76b9fda 100644 --- a/electron/api/routes/ai-hardware.ts +++ b/electron/api/routes/ai-hardware.ts @@ -172,6 +172,42 @@ async function readBoundedResponse(response: Response): Promise { } } +type TimedResponse = { + response: Response; + signal: AbortSignal; + finish: () => void; +}; + +async function readTimedResponse(call: TimedResponse): Promise { + try { + return await readBoundedResponse(call.response); + } catch (error) { + if (call.signal.aborted) { + throw new SafeRouteError(504, 'AI_HARDWARE_TIMEOUT', 'AI hardware service timed out', true); + } + throw error; + } finally { + call.finish(); + } +} + +async function readSafeErrorPayload(call: TimedResponse): Promise { + try { + return await readTimedResponse(call); + } catch (error) { + if (error instanceof SafeRouteError && error.code === 'AI_HARDWARE_TIMEOUT') throw error; + return null; + } +} + +async function cancelTimedResponse(call: TimedResponse): Promise { + try { + await call.response.body?.cancel().catch(() => undefined); + } finally { + call.finish(); + } +} + function ensureExactKeys(body: Record, allowed: Set): void { if (Object.keys(body).some((key) => !allowed.has(key))) { throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); @@ -431,11 +467,17 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend return true; } const idempotencyKey = operationId ? `makelore-${operationId}` : undefined; - const call = async (accessToken: string): Promise => { + const call = async (accessToken: string): Promise => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + }; try { - return await fetchImpl(`${apiBaseUrl}${upstreamPath}`, { + const response = await fetchImpl(`${apiBaseUrl}${upstreamPath}`, { method, headers: { Accept: 'application/json', @@ -448,24 +490,26 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend signal: controller.signal, redirect: 'manual', }); + return { response, signal: controller.signal, finish }; } catch (error) { + finish(); if (controller.signal.aborted) { throw new SafeRouteError(504, 'AI_HARDWARE_TIMEOUT', 'AI hardware service timed out', true); } throw error; - } finally { - clearTimeout(timer); } }; let currentToken = token; - let response = await call(currentToken); + let activeCall = await call(currentToken); + let response = activeCall.response; if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); + await cancelTimedResponse(activeCall); const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); if (refreshed) { currentToken = refreshed; - response = await call(currentToken); + activeCall = await call(currentToken); + response = activeCall.response; } } @@ -473,11 +517,12 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend if (response.status === 409 && operationId) { const retryDelay = retryAfterSeconds(response, MAX_RETRY_AFTER_SECONDS); if (retryDelay !== undefined) { - cachedErrorPayload = isJsonContentType(response) ? await readBoundedResponse(response).catch(() => null) : null; + cachedErrorPayload = isJsonContentType(response) ? await readSafeErrorPayload(activeCall) : null; const safe = safeUpstreamError(cachedErrorPayload, response.status); if (safe.code === 'ai_hardware_operation_in_progress') { await new Promise((resolve) => setTimeout(resolve, retryDelay * 1000)); - response = await call(currentToken); + activeCall = await call(currentToken); + response = activeCall.response; cachedErrorPayload = undefined; } } @@ -486,14 +531,14 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend if (!response.ok) { const status = response.status; if (status === 404 && upstreamPath === UPSTREAM_ROOT) { - await response.body?.cancel().catch(() => undefined); + await cancelTimedResponse(activeCall); sendFailure(res, 200, 404, 'AI_HARDWARE_DISABLED', 'AI hardware module is not enabled', false); return true; } const retryAfter = retryAfterSeconds(response); const payload = cachedErrorPayload ?? (isJsonContentType(response) - ? await readBoundedResponse(response).catch(() => null) - : (await response.body?.cancel().catch(() => undefined), null)); + ? await readSafeErrorPayload(activeCall) + : (await cancelTimedResponse(activeCall), null)); const safe = safeUpstreamError(payload, status); sendFailure( res, @@ -509,11 +554,17 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend } if (response.status !== expectedStatus || !isJsonContentType(response)) { - await response.body?.cancel().catch(() => undefined); + await cancelTimedResponse(activeCall); throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true); } - const revision = requireEtag ? strongRevisionFrom(response) : undefined; - const projected = project(await readBoundedResponse(response)); + let revision: number | undefined; + try { + revision = requireEtag ? strongRevisionFrom(response) : undefined; + } catch (error) { + await cancelTimedResponse(activeCall); + throw error; + } + const projected = project(await readTimedResponse(activeCall)); if (!projected) throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true); if (revision !== undefined) { const dtoRevision = 'config_revision' in projected diff --git a/src/pages/AiHardware/index.tsx b/src/pages/AiHardware/index.tsx index fbfc525..7ea1d90 100644 --- a/src/pages/AiHardware/index.tsx +++ b/src/pages/AiHardware/index.tsx @@ -152,6 +152,8 @@ export function AiHardware() { const [config, setConfig] = useState(null); const [configRevision, setConfigRevision] = useState(null); const [configLoading, setConfigLoading] = useState(false); + const [configLoadFailed, setConfigLoadFailed] = useState(false); + const [configReloadKey, setConfigReloadKey] = useState(0); const [notice, setNotice] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [bindOpen, setBindOpen] = useState(false); @@ -220,19 +222,30 @@ export function AiHardware() { useEffect(() => { void loadOverview(); }, [loadOverview]); useEffect(() => { - if (!selectedAgentId) { setConfig(null); setConfigRevision(null); return; } + if (!selectedAgentId) { + setConfig(null); + setConfigRevision(null); + setConfigLoading(false); + setConfigLoadFailed(false); + return; + } let active = true; setConfig(null); setConfigRevision(null); + setConfigLoadFailed(false); setNotice(null); setConfigLoading(true); void getAiHardwareAgentConfiguration(selectedAgentId).then((result) => { if (!active) return; setConfig(result.data); setConfigRevision(result.revision); - }).catch(() => { if (active) setNotice('无法读取智能体配置,请刷新后重试。'); }) + }).catch(() => { + if (!active) return; + setConfigLoadFailed(true); + setNotice('无法读取智能体配置,请刷新后重试。'); + }) .finally(() => { if (active) setConfigLoading(false); }); return () => { active = false; }; - }, [selectedAgentId]); + }, [configReloadKey, selectedAgentId]); const selectedAgent = overview?.agents.find((item) => item.id === selectedAgentId) ?? null; const devices = useMemo(() => overview?.devices ?? [], [overview]); @@ -365,7 +378,7 @@ export function AiHardware() {
智能体{overview.agents.length} 个
{overview.agents.map((agent) => )}
-
{selectedAgent?.name ?? '智能体配置'}基础对话和语音设置
{config ?
语言
{config.language || config.lang_code || '未设置'}
语音
{config.tts_voice_id || '未设置'}
系统提示
{config.system_prompt || '未设置'}
: }
+
{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}
)}
: }
diff --git a/tests/unit/ai-hardware-page.test.tsx b/tests/unit/ai-hardware-page.test.tsx index 72a8249..d94f4a3 100644 --- a/tests/unit/ai-hardware-page.test.tsx +++ b/tests/unit/ai-hardware-page.test.tsx @@ -366,6 +366,32 @@ describe('AI hardware page', () => { expect(screen.getByText('正在读取配置')).toBeInTheDocument(); }); + it('stops loading and lets the user retry after configuration loading fails', async () => { + api.getAiHardwareOverview.mockResolvedValueOnce({ + status: 'active', + agents: [agentOne], + devices: [device], + }); + api.getAiHardwareAgentConfiguration + .mockRejectedValueOnce(new AiHardwareApiError({ + status: 502, + code: 'xiaozhi_hardware_unavailable', + message: 'private provider details', + retryable: true, + })) + .mockResolvedValueOnce({ data: configuration, revision: 4 }); + + render(); + + expect(await screen.findByRole('alert')).toHaveTextContent('无法读取智能体配置'); + expect(screen.queryByText('正在读取配置')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '重试读取配置' })); + await waitFor(() => expect(api.getAiHardwareAgentConfiguration).toHaveBeenCalledTimes(2)); + expect(await screen.findByText('保持简洁')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '编辑配置' })).toBeEnabled(); + }); + it('loads the current assignment revision before reassigning a device', async () => { render(); fireEvent.click(await screen.findByRole('button', { name: '重新指派' })); diff --git a/tests/unit/ai-hardware-routes.test.ts b/tests/unit/ai-hardware-routes.test.ts index bd76fb2..9e36692 100644 --- a/tests/unit/ai-hardware-routes.test.ts +++ b/tests/unit/ai-hardware-routes.test.ts @@ -326,6 +326,41 @@ describe('AI hardware Host API route', () => { expect(hugeResult.payload).toMatchObject({ success: false, status: 502, code: 'AI_HARDWARE_RESPONSE_TOO_LARGE' }); }); + it('keeps the upstream deadline active while reading a stalled configuration body', async () => { + let bodyController: ReadableStreamDefaultController | undefined; + const fetchImpl = vi.fn((_input, init) => { + const body = new ReadableStream({ + start(controller) { + bodyController = controller; + controller.enqueue(new TextEncoder().encode('{"id":"a-1"')); + init?.signal?.addEventListener('abort', () => { + controller.error(new DOMException('secret stalled body', 'AbortError')); + }, { once: true }); + }, + }); + return Promise.resolve(new Response(body, { + headers: { 'content-type': 'application/json', etag: '"0"' }, + })); + }); + const { handler } = setup(fetchImpl); + const pending = invoke(handler, 'GET', '/api/works/ai-hardware/agents/a-1'); + const result = await Promise.race([ + pending, + new Promise((resolve) => setTimeout(() => resolve(null), 100)), + ]); + + if (result === null) { + bodyController?.close(); + await pending; + } + expect(result?.payload).toMatchObject({ + success: false, + status: 504, + code: 'AI_HARDWARE_TIMEOUT', + retryable: true, + }); + }); + it('bounds request bodies and distinguishes unrelated and unknown hardware routes', async () => { const { handler, fetchImpl } = setup(); const unrelated = response();