fix: recover Robot configuration loading
This commit is contained in:
@@ -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.
|
||||||
@@ -172,6 +172,42 @@ async function readBoundedResponse(response: Response): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TimedResponse = {
|
||||||
|
response: Response;
|
||||||
|
signal: AbortSignal;
|
||||||
|
finish: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readTimedResponse(call: TimedResponse): Promise<unknown> {
|
||||||
|
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<unknown> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
await call.response.body?.cancel().catch(() => undefined);
|
||||||
|
} finally {
|
||||||
|
call.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureExactKeys(body: Record<string, unknown>, allowed: Set<string>): void {
|
function ensureExactKeys(body: Record<string, unknown>, allowed: Set<string>): void {
|
||||||
if (Object.keys(body).some((key) => !allowed.has(key))) {
|
if (Object.keys(body).some((key) => !allowed.has(key))) {
|
||||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||||
@@ -431,11 +467,17 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const idempotencyKey = operationId ? `makelore-${operationId}` : undefined;
|
const idempotencyKey = operationId ? `makelore-${operationId}` : undefined;
|
||||||
const call = async (accessToken: string): Promise<Response> => {
|
const call = async (accessToken: string): Promise<TimedResponse> => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
let finished = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
return await fetchImpl(`${apiBaseUrl}${upstreamPath}`, {
|
const response = await fetchImpl(`${apiBaseUrl}${upstreamPath}`, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
@@ -448,24 +490,26 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
});
|
});
|
||||||
|
return { response, signal: controller.signal, finish };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
finish();
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
throw new SafeRouteError(504, 'AI_HARDWARE_TIMEOUT', 'AI hardware service timed out', true);
|
throw new SafeRouteError(504, 'AI_HARDWARE_TIMEOUT', 'AI hardware service timed out', true);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentToken = token;
|
let currentToken = token;
|
||||||
let response = await call(currentToken);
|
let activeCall = await call(currentToken);
|
||||||
|
let response = activeCall.response;
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
await response.body?.cancel().catch(() => undefined);
|
await cancelTimedResponse(activeCall);
|
||||||
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
currentToken = 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) {
|
if (response.status === 409 && operationId) {
|
||||||
const retryDelay = retryAfterSeconds(response, MAX_RETRY_AFTER_SECONDS);
|
const retryDelay = retryAfterSeconds(response, MAX_RETRY_AFTER_SECONDS);
|
||||||
if (retryDelay !== undefined) {
|
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);
|
const safe = safeUpstreamError(cachedErrorPayload, response.status);
|
||||||
if (safe.code === 'ai_hardware_operation_in_progress') {
|
if (safe.code === 'ai_hardware_operation_in_progress') {
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, retryDelay * 1000));
|
await new Promise<void>((resolve) => setTimeout(resolve, retryDelay * 1000));
|
||||||
response = await call(currentToken);
|
activeCall = await call(currentToken);
|
||||||
|
response = activeCall.response;
|
||||||
cachedErrorPayload = undefined;
|
cachedErrorPayload = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,14 +531,14 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const status = response.status;
|
const status = response.status;
|
||||||
if (status === 404 && upstreamPath === UPSTREAM_ROOT) {
|
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);
|
sendFailure(res, 200, 404, 'AI_HARDWARE_DISABLED', 'AI hardware module is not enabled', false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const retryAfter = retryAfterSeconds(response);
|
const retryAfter = retryAfterSeconds(response);
|
||||||
const payload = cachedErrorPayload ?? (isJsonContentType(response)
|
const payload = cachedErrorPayload ?? (isJsonContentType(response)
|
||||||
? await readBoundedResponse(response).catch(() => null)
|
? await readSafeErrorPayload(activeCall)
|
||||||
: (await response.body?.cancel().catch(() => undefined), null));
|
: (await cancelTimedResponse(activeCall), null));
|
||||||
const safe = safeUpstreamError(payload, status);
|
const safe = safeUpstreamError(payload, status);
|
||||||
sendFailure(
|
sendFailure(
|
||||||
res,
|
res,
|
||||||
@@ -509,11 +554,17 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.status !== expectedStatus || !isJsonContentType(response)) {
|
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);
|
throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true);
|
||||||
}
|
}
|
||||||
const revision = requireEtag ? strongRevisionFrom(response) : undefined;
|
let revision: number | undefined;
|
||||||
const projected = project(await readBoundedResponse(response));
|
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 (!projected) throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true);
|
||||||
if (revision !== undefined) {
|
if (revision !== undefined) {
|
||||||
const dtoRevision = 'config_revision' in projected
|
const dtoRevision = 'config_revision' in projected
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ export function AiHardware() {
|
|||||||
const [config, setConfig] = useState<AiHardwareAgentConfiguration | null>(null);
|
const [config, setConfig] = useState<AiHardwareAgentConfiguration | null>(null);
|
||||||
const [configRevision, setConfigRevision] = useState<number | null>(null);
|
const [configRevision, setConfigRevision] = useState<number | null>(null);
|
||||||
const [configLoading, setConfigLoading] = useState(false);
|
const [configLoading, setConfigLoading] = useState(false);
|
||||||
|
const [configLoadFailed, setConfigLoadFailed] = useState(false);
|
||||||
|
const [configReloadKey, setConfigReloadKey] = useState(0);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [bindOpen, setBindOpen] = useState(false);
|
const [bindOpen, setBindOpen] = useState(false);
|
||||||
@@ -220,19 +222,30 @@ export function AiHardware() {
|
|||||||
|
|
||||||
useEffect(() => { void loadOverview(); }, [loadOverview]);
|
useEffect(() => { void loadOverview(); }, [loadOverview]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedAgentId) { setConfig(null); setConfigRevision(null); return; }
|
if (!selectedAgentId) {
|
||||||
|
setConfig(null);
|
||||||
|
setConfigRevision(null);
|
||||||
|
setConfigLoading(false);
|
||||||
|
setConfigLoadFailed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
setConfig(null);
|
setConfig(null);
|
||||||
setConfigRevision(null);
|
setConfigRevision(null);
|
||||||
|
setConfigLoadFailed(false);
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
setConfigLoading(true);
|
setConfigLoading(true);
|
||||||
void getAiHardwareAgentConfiguration(selectedAgentId).then((result) => {
|
void getAiHardwareAgentConfiguration(selectedAgentId).then((result) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setConfig(result.data); setConfigRevision(result.revision);
|
setConfig(result.data); setConfigRevision(result.revision);
|
||||||
}).catch(() => { if (active) setNotice('无法读取智能体配置,请刷新后重试。'); })
|
}).catch(() => {
|
||||||
|
if (!active) return;
|
||||||
|
setConfigLoadFailed(true);
|
||||||
|
setNotice('无法读取智能体配置,请刷新后重试。');
|
||||||
|
})
|
||||||
.finally(() => { if (active) setConfigLoading(false); });
|
.finally(() => { if (active) setConfigLoading(false); });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [selectedAgentId]);
|
}, [configReloadKey, selectedAgentId]);
|
||||||
|
|
||||||
const selectedAgent = overview?.agents.find((item) => item.id === selectedAgentId) ?? null;
|
const selectedAgent = overview?.agents.find((item) => item.id === selectedAgentId) ?? null;
|
||||||
const devices = useMemo(() => overview?.devices ?? [], [overview]);
|
const devices = useMemo(() => overview?.devices ?? [], [overview]);
|
||||||
@@ -365,7 +378,7 @@ export function AiHardware() {
|
|||||||
<div className="grid gap-5 lg:grid-cols-[minmax(240px,0.7fr)_minmax(0,1.3fr)]">
|
<div className="grid gap-5 lg:grid-cols-[minmax(240px,0.7fr)_minmax(0,1.3fr)]">
|
||||||
<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>
|
<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">
|
<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={() => { if (!config) return; resetDialog(); setDraft(draftFrom(config)); setConfigOpen(true); }}>{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> : <FeedbackState state="loading" title="正在读取配置" />}</CardContent></Card>
|
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>{selectedAgent?.name ?? '智能体配置'}</CardTitle><CardDescription>基础对话和语音设置</CardDescription></div><Button variant="outline" disabled={configLoading || !config} onClick={() => { if (!config) return; resetDialog(); setDraft(draftFrom(config)); setConfigOpen(true); }}>{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={() => { 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -366,6 +366,32 @@ describe('AI hardware page', () => {
|
|||||||
expect(screen.getByText('正在读取配置')).toBeInTheDocument();
|
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(<AiHardware />);
|
||||||
|
|
||||||
|
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 () => {
|
it('loads the current assignment revision before reassigning a device', async () => {
|
||||||
render(<AiHardware />);
|
render(<AiHardware />);
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '重新指派' }));
|
fireEvent.click(await screen.findByRole('button', { name: '重新指派' }));
|
||||||
|
|||||||
@@ -326,6 +326,41 @@ describe('AI hardware Host API route', () => {
|
|||||||
expect(hugeResult.payload).toMatchObject({ success: false, status: 502, code: 'AI_HARDWARE_RESPONSE_TOO_LARGE' });
|
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<Uint8Array> | undefined;
|
||||||
|
const fetchImpl = vi.fn<typeof fetch>((_input, init) => {
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
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<null>((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 () => {
|
it('bounds request bodies and distinguishes unrelated and unknown hardware routes', async () => {
|
||||||
const { handler, fetchImpl } = setup();
|
const { handler, fetchImpl } = setup();
|
||||||
const unrelated = response();
|
const unrelated = response();
|
||||||
|
|||||||
Reference in New Issue
Block a user