Compare commits
6
Commits
a4050f0a65
...
7bef261fb8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bef261fb8 | ||
|
|
9738e1c5ae | ||
|
|
ea75b0618a | ||
|
|
a26a53a7f4 | ||
|
|
f6f7f21941 | ||
|
|
1bcd51964d |
No files matched your search
@@ -23,7 +23,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
|
||||
| AI Design Gateway Routing | Main 云端适配器 ↔ Conversation WebSocket;Main → Host API/SSE → Renderer store | 命令、Run 与设计事件共用双向 WebSocket;Conversation 更新按 Workspace + Conversation 路由,任务更新按 Workspace 归并 |
|
||||
|
||||
| Robot Workspace | Account-scoped agent configuration, device activation/binding, assignment, and credential-recovery UI | Renderer receives only safe Works Square projections; credentials, activation-code history, and upstream error bodies remain outside durable UI state. |
|
||||
| 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, and error redaction. It never forwards Renderer authorization headers. |
|
||||
| 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, and error redaction. 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. |
|
||||
|
||||
## Important Boundaries
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
|
||||
|
||||
## Integrated Through
|
||||
|
||||
- `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`.
|
||||
- `fd9b5b46a913c515e94e4e26f185d43866c2581f` / `7a811590c4943b7b1b7ea5f3b4d3ce3ce05622a5`: Codex-style persistent AI Programming context-compaction timeline, run-lifecycle separation, polling-idle completion, and cold-hydration hardening.
|
||||
- `22378efcee07e7fb80b651e65e3202f1a1dfea1d`: AI Canvas bidirectional Agent WebSocket commands, idempotent transport fallback, and Quote-based generation-task recovery.
|
||||
- `aba5cae286807093cf4ef643fe9f498050985c31`: Robot / AI hardware module, Main-owned Works Square proxy, and cross-repository wire contract.
|
||||
@@ -31,6 +32,7 @@ Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失
|
||||
|
||||
## Recently Completed
|
||||
|
||||
- 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-15:AI 编程上下文压缩改为 Codex 风格的会话内时间线事件;手动与自动压缩原位展示并持久保留,历史回放去重且状态只允许从 running 单调进入 completed,压缩完成不再冒充整个 run idle。
|
||||
- 2026-08-14: AI Canvas Agent 命令与流式事件改为共用双向 Conversation WebSocket,并保留仅面向传输故障的幂等 REST 回退;结构化 Gateway 错误不重试且未知文本脱敏;确认生成按 Quote 恢复已落库任务,切换 Conversation 后仍同步 Workspace 任务且不覆盖新会话状态或错误。
|
||||
- 2026-08-13: Integrated the enabled `Makelore Robot` top-level module at `/ai-hardware`. Renderer uses typed safe DTOs while Electron Main owns Works Square authentication, idempotency identities, ETag/If-Match forwarding, response projection, limits, timeouts, and credential recovery. Robot and Canvas routes no longer initialize AI Programming projects/providers. No production deployment or real activation-code smoke is claimed.
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
## Scope
|
||||
|
||||
- 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.
|
||||
- On 2026-08-15, resume the existing Integration owner to merge reviewed Robot configuration-loading fix `1bcd519` into local `main`, preserving the page error/retry state and Main response-body deadline.
|
||||
- On 2026-08-15, resume the existing Integration owner to merge reviewed source commit `fd9b5b46a913c515e94e4e26f185d43866c2581f` for the Codex-style AI Programming context-compaction timeline into local `main` and promote its accepted lifecycle facts.
|
||||
- On 2026-08-14, resume the existing Integration owner to merge reviewed AI Canvas source commit `22378efcee07e7fb80b651e65e3202f1a1dfea1d` into local `main` and promote its accepted project-memory candidates.
|
||||
- Resume the existing integration owner to fast-forward the completed Robot / AI hardware source commit into local `main` after verifying the current remote `main` tip.
|
||||
@@ -104,6 +106,22 @@ Gate result:
|
||||
- The related threshold and UI-audit tasks agree with the source: automatic compaction remains OpenCode/model-limit-owned, while this source changes only the persistent Renderer interaction and run lifecycle. No semantic conflict requires human resolution.
|
||||
- Gate result: Passed for the local context-compaction merge. Remote push remains outside this resumption.
|
||||
|
||||
### 2026-08-15 Robot Configuration Loading Integration Resume
|
||||
|
||||
- Reused this Integration owner because it still exclusively owns `main` and the repository integration lock; `task_context.py touch` refreshed the reservation.
|
||||
- Verified source task `20260815-robot-config-loading-4f8c2d` is ready for integration, its final Sol review returned PASS, and source commit `1bcd519` is a direct descendant of current `main` at merge base `a4050f0`.
|
||||
- A fresh `git fetch origin main --prune` succeeded and confirmed local `main` and `origin/main` are both exactly `a4050f0`, with no remote-only commit or divergence.
|
||||
- The source changes only the Robot page, Main-owned AI hardware route, focused tests, and its own source task record. It preserves Main-owned auth/idempotency/ETag and introduces no semantic overlap with registered peer work.
|
||||
- Gate result: Passed for the local Robot configuration-loading merge. This request authorizes a local `main` merge, not an automatic remote push.
|
||||
|
||||
### 2026-08-15 Robot Configuration Schema Integration Resume
|
||||
|
||||
- Reused this Integration owner because it still exclusively owns `main` and the repository integration lock; `task_context.py touch` refreshed the existing reservation.
|
||||
- Verified source task `20260815-robot-config-schema-8e2c41` is ready for integration, source commit `ea75b06` is a direct descendant of current `main` at `a26a53a`, and its final read-only Sol review returned PASS.
|
||||
- Production evidence distinguishes the hops: Xiaozhi-to-Works returns a canonical strong numeric ETag, while the compressed public Works response was observed with matching canonical weak `W/\"0\"`, numeric `config_revision: 0`, and the exact expected key set.
|
||||
- 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.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Fetch `origin/main` and verify local `main`, the remote tip, and reviewed AI Canvas source commit topology.
|
||||
@@ -120,8 +138,32 @@ Gate result:
|
||||
4. Re-run focused/full unit checks, typecheck, lint, production build, Electron E2E, document drift, and whitespace checks on the merged tree.
|
||||
5. Obtain an independent read-only Sol PASS/FAIL review, record the result, and keep remote push outside this request.
|
||||
|
||||
### 2026-08-15 Robot Configuration Loading Integration Plan
|
||||
|
||||
1. Merge reviewed source commit `1bcd519` into local `main` with a normal no-ff merge while preserving source history.
|
||||
2. Exclude the source-owned task record from the final `main` tree so the integration task does not own or rewrite another task's project document.
|
||||
3. Run the 7-file Robot/Main regression suite, typecheck, scoped lint, build, project-document gates, and whitespace checks.
|
||||
4. Obtain an independent read-only Sol PASS/FAIL review, record exact topology and verification, and keep remote push outside this request.
|
||||
|
||||
### 2026-08-15 Robot Configuration Schema Integration Plan
|
||||
|
||||
1. Merge reviewed source commit `ea75b06` into local `main` with a normal no-ff merge while preserving source history.
|
||||
2. Exclude the source-owned task record from the final `main` tree, leaving it reachable on the source commit and feature branch.
|
||||
3. Re-run the AI hardware Main/Renderer/page regression selection, typecheck, scoped lint, build, project-document gates, and whitespace checks.
|
||||
4. Obtain an independent read-only Sol PASS/FAIL review and keep the pre-existing remote-push blocker separate.
|
||||
|
||||
## Outcome
|
||||
|
||||
- On 2026-08-15, merged reviewed Robot configuration-schema source `ea75b06` into local `main` without textual conflict and excluded its source-owned task record from the final tree.
|
||||
- Created no-ff merge commit `9738e1c5aebad6237f436c8c94abaf877eb3d175` with local baseline `a26a53a7f4b1326be3084955029a65ef1f79929a` as first parent and reviewed source `ea75b0618a793928878edad736f0837ac9e4adc1` as second parent.
|
||||
- Confirmed the failure was transport-contract drift, not a Xiaozhi/Works DTO failure: response compression weakened the origin strong revision ETag to canonical `W/\"0\"`; Main now accepts only canonical strong or weak numeric response tags whose revision equals the strictly projected body.
|
||||
- Preserved optimistic concurrency and security boundaries: PATCH/PUT still send strong `If-Match`, and authentication, idempotency, size/deadline limits, fixed paths, and error redaction are unchanged.
|
||||
- On 2026-08-15, formed the reviewed Robot configuration-loading fix as source commit `1bcd51964da04e5d80b461547d6bcccfafc0bec9` on its isolated feature branch.
|
||||
- Fetched `origin/main` and verified both local and remote-tracking `main` were exactly `a4050f0a6567e8203630bf5d16b57f00c45caab8` with zero ahead/behind before integration.
|
||||
- Started a normal `--no-ff --no-commit` merge of `1bcd519`; Git reported no textual conflicts. The source task record remains reachable on source commit `1bcd519` and its feature branch and is excluded from the `main` result to preserve project-document ownership boundaries.
|
||||
- The merged code gives Robot configuration reads an explicit terminal error/retry state and keeps the same upstream deadline active through bounded response-body read and parse. Strict configuration DTO, ETag/revision, Main-owned authentication/idempotency, size limits, and error redaction remain unchanged.
|
||||
- Independent final integration review returned `PASS` after validating merge topology, remote baseline, staged-tree ownership, behavior, tests, build, and project-document gates.
|
||||
- Created no-ff merge commit `f6f7f21941bc5f443eb5e7800066f5b870313680` with local `main` baseline `a4050f0a6567e8203630bf5d16b57f00c45caab8` as first parent and source `1bcd51964da04e5d80b461547d6bcccfafc0bec9` as second parent.
|
||||
- On 2026-08-13, resumed this integration task for the user's local-main merge request. `git fetch origin main --prune` succeeded and confirmed `origin/main=22add3f01f2b7cb6318495e8294db006f6f18abf`.
|
||||
- Verified source commit `aba5cae286807093cf4ef643fe9f498050985c31` is a direct descendant of that remote tip and that local `main` is its ancestor, then fast-forwarded local `main` to `aba5cae` without rebase, reset, stash use, or conflict.
|
||||
- The existing shared `stash@{0}` was not applied, popped, or dropped.
|
||||
@@ -192,6 +234,19 @@ Gate result:
|
||||
|
||||
## Verification
|
||||
|
||||
- Robot configuration-schema source TDD recorded `2 failed, 23 passed` before the fix with `AI_HARDWARE_INVALID_ETAG` for the deployed weak-tag shape.
|
||||
- Merged-tree AI hardware route suite passed `25/25`; the seven-file Robot/Main regression selection passed `78/78`.
|
||||
- Merged-tree `pnpm run typecheck`, scoped ESLint, and `pnpm run build:vite` passed; build retains only existing chunk-size and mixed-import warnings.
|
||||
- Merged-tree `pnpm test` passed `156` files / `1727` tests.
|
||||
- Task-aware project-document drift, task registry doctor, staged whitespace checks, and unmerged-entry checks passed.
|
||||
- Independent final Robot response-ETag integration review returned `PASS` with no P0-P2 findings; it confirmed weak ETags are accepted only on responses, write preconditions remain strong, and the parsed ETag must equal the projected DTO revision.
|
||||
- 2026-08-15 Robot configuration-loading merged-tree regression selection — 7 files / 74 tests passed.
|
||||
- Robot merged-tree `pnpm run typecheck` — passed.
|
||||
- ESLint on the four merged TypeScript/TSX files — passed.
|
||||
- `pnpm run build:vite` — Renderer, Electron Main, and Preload production builds passed; only the existing chunk-size and mixed-import warnings remain.
|
||||
- Staged/unstaged whitespace checks and task-aware project-document drift — passed before final review.
|
||||
- Independent Robot configuration-loading integration review — `PASS`, no blocking findings.
|
||||
- Final merge topology — first parent `a4050f0`, second parent `1bcd519`, merge commit `f6f7f21`.
|
||||
- Robot source closeout before integration: 8 focused files / 72 tests passed; TypeScript and task-aware document drift passed.
|
||||
- Post-fast-forward `pnpm test`: 156 files / 1681 tests passed.
|
||||
- `pnpm run typecheck` and `pnpm run build:vite`: passed; build retains only existing chunk/dynamic-import warnings.
|
||||
|
||||
@@ -11,7 +11,7 @@ const MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const LOCAL_ROOT = '/api/works/ai-hardware';
|
||||
const UPSTREAM_ROOT = '/api/ai-hardware';
|
||||
const STRONG_REVISION = /^"(0|[1-9]\d*)"$/;
|
||||
const REVISION_ETAG = /^(?:W\/)?"(0|[1-9]\d*)"$/;
|
||||
const LOCAL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/;
|
||||
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;
|
||||
@@ -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 {
|
||||
if (Object.keys(body).some((key) => !allowed.has(key))) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
@@ -249,9 +285,9 @@ function validateConfigUpdate(body: Record<string, unknown>): Record<string, unk
|
||||
return output;
|
||||
}
|
||||
|
||||
function strongRevisionFrom(response: Response): number {
|
||||
function revisionFromEtag(response: Response): number {
|
||||
const etag = response.headers.get('etag');
|
||||
const match = etag?.match(STRONG_REVISION);
|
||||
const match = etag?.match(REVISION_ETAG);
|
||||
if (!match) throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_ETAG', 'AI hardware service returned an invalid revision', true);
|
||||
const revision = Number(match[1]);
|
||||
if (!Number.isSafeInteger(revision)) throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_ETAG', 'AI hardware service returned an invalid revision', true);
|
||||
@@ -431,11 +467,17 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
|
||||
return true;
|
||||
}
|
||||
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 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<void>((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 ? revisionFromEtag(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
|
||||
|
||||
@@ -152,6 +152,8 @@ export function AiHardware() {
|
||||
const [config, setConfig] = useState<AiHardwareAgentConfiguration | null>(null);
|
||||
const [configRevision, setConfigRevision] = useState<number | null>(null);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configLoadFailed, setConfigLoadFailed] = useState(false);
|
||||
const [configReloadKey, setConfigReloadKey] = useState(0);
|
||||
const [notice, setNotice] = useState<string | null>(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() {
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(<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 () => {
|
||||
render(<AiHardware />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '重新指派' }));
|
||||
|
||||
@@ -98,10 +98,56 @@ describe('AI hardware Host API route', () => {
|
||||
expect(result.payload).toEqual({ success: true, data: config, revision: 0 });
|
||||
});
|
||||
|
||||
it('rejects weak upstream ETags without exposing upstream data', async () => {
|
||||
it('accepts canonical weak numeric ETags for versioned responses and keeps outbound If-Match strong', async () => {
|
||||
const config = {
|
||||
id: 'a-1', name: 'Desk', config_revision: 0,
|
||||
system_prompt: null, lang_code: null, language: null, asr_model_id: null,
|
||||
vad_model_id: null, llm_model_id: null, slm_model_id: null, vllm_model_id: null,
|
||||
tts_model_id: null, tts_voice_id: null, tts_language: null, tts_volume: null,
|
||||
tts_rate: null, tts_pitch: null, mem_model_id: null, intent_model_id: null,
|
||||
chat_history_conf: null,
|
||||
};
|
||||
const updatedConfig = { ...config, config_revision: 1, system_prompt: 'hello' };
|
||||
const assignment = { id: 'd-1', agent_id: 'a-1', assignment_revision: 0 };
|
||||
const updatedAssignment = { ...assignment, assignment_revision: 1 };
|
||||
const fetchImpl = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse(config, { headers: { etag: 'W/"0"' } }))
|
||||
.mockResolvedValueOnce(jsonResponse(updatedConfig, { headers: { etag: 'W/"1"' } }))
|
||||
.mockResolvedValueOnce(jsonResponse(assignment, { headers: { etag: 'W/"0"' } }))
|
||||
.mockResolvedValueOnce(jsonResponse(updatedAssignment, { headers: { etag: 'W/"1"' } }));
|
||||
const { handler } = setup(fetchImpl);
|
||||
|
||||
const getConfig = await invoke(handler, 'GET', '/api/works/ai-hardware/agents/a-1');
|
||||
expect(getConfig.payload).toEqual({ success: true, data: config, revision: 0 });
|
||||
|
||||
const patchConfig = await invoke(handler, 'PATCH', '/api/works/ai-hardware/agents/a-1', {
|
||||
revision: 0, system_prompt: 'hello',
|
||||
});
|
||||
expect(patchConfig.payload).toEqual({ success: true, data: updatedConfig, revision: 1 });
|
||||
expect((fetchImpl.mock.calls[1][1] as RequestInit).headers).toMatchObject({ 'If-Match': '"0"' });
|
||||
|
||||
const getAssignment = await invoke(handler, 'GET', '/api/works/ai-hardware/devices/d-1/agent-assignment');
|
||||
expect(getAssignment.payload).toEqual({ success: true, data: assignment, revision: 0 });
|
||||
|
||||
const putAssignment = await invoke(handler, 'PUT', '/api/works/ai-hardware/devices/d-1/agent-assignment', {
|
||||
revision: 0, agent_id: 'a-1',
|
||||
});
|
||||
expect(putAssignment.payload).toEqual({ success: true, data: updatedAssignment, revision: 1 });
|
||||
expect((fetchImpl.mock.calls[3][1] as RequestInit).headers).toMatchObject({ 'If-Match': '"0"' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'w/"0"',
|
||||
'W/ "0"',
|
||||
'W/"00"',
|
||||
'W/"-1"',
|
||||
'W/"1.0"',
|
||||
'W/"revision"',
|
||||
'W/"9007199254740992"',
|
||||
])('rejects non-canonical weak ETag %s without exposing upstream data', async (etag) => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({
|
||||
id: 'd-1', agent_id: 'a-1', assignment_revision: 0, token: 'secret',
|
||||
}, { headers: { etag: 'W/"0"' } }));
|
||||
}, { headers: { etag } }));
|
||||
const { handler } = setup(fetchImpl);
|
||||
const result = await invoke(handler, 'GET', '/api/works/ai-hardware/devices/d-1/agent-assignment');
|
||||
expect(result.status).toBe(200);
|
||||
@@ -109,7 +155,7 @@ describe('AI hardware Host API route', () => {
|
||||
expect(JSON.stringify(result.payload)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('rejects config and assignment DTO revisions that disagree with the strong ETag', async () => {
|
||||
it('rejects config and assignment DTO revisions that disagree with the canonical ETag', async () => {
|
||||
const config = {
|
||||
id: 'a-1', name: 'Desk', config_revision: 2,
|
||||
system_prompt: null, lang_code: null, language: null, asr_model_id: null,
|
||||
@@ -118,13 +164,13 @@ describe('AI hardware Host API route', () => {
|
||||
tts_rate: null, tts_pitch: null, mem_model_id: null, intent_model_id: null,
|
||||
chat_history_conf: null,
|
||||
};
|
||||
const configHandler = setup(vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(config, { headers: { etag: '"1"' } })));
|
||||
const configHandler = setup(vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(config, { headers: { etag: 'W/"1"' } })));
|
||||
const configResult = await invoke(configHandler.handler, 'GET', '/api/works/ai-hardware/agents/a-1');
|
||||
expect(configResult.payload).toMatchObject({ success: false, status: 502, code: 'AI_HARDWARE_INVALID_RESPONSE' });
|
||||
|
||||
const deviceHandler = setup(vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(
|
||||
{ id: 'd-1', agent_id: 'a-1', assignment_revision: 3 },
|
||||
{ headers: { etag: '"2"' } },
|
||||
{ headers: { etag: 'W/"2"' } },
|
||||
)));
|
||||
const deviceResult = await invoke(deviceHandler.handler, 'GET', '/api/works/ai-hardware/devices/d-1/agent-assignment');
|
||||
expect(deviceResult.payload).toMatchObject({ success: false, status: 502, code: 'AI_HARDWARE_INVALID_RESPONSE' });
|
||||
@@ -326,6 +372,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<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 () => {
|
||||
const { handler, fetchImpl } = setup();
|
||||
const unrelated = response();
|
||||
|
||||
Reference in new issue
Block a user