diff --git a/.project-docs/30-worklog/tasks/20260813-ai-hardware-client-9c7e.md b/.project-docs/30-worklog/tasks/20260813-ai-hardware-client-9c7e.md new file mode 100644 index 0000000..a617e6e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260813-ai-hardware-client-9c7e.md @@ -0,0 +1,89 @@ +# Task: Add top-level AI hardware module to Makelore client + +## Identity + +- Task ID: 20260813-ai-hardware-client-9c7e +- Mode: Feature +- Branch: codex/20260813-ai-hardware-client-9c7e-ai-hardware-client +- Worktree: D:\Datas\OthersProjects\makelore-ai-hardware-client-9c7e +- Base commit: 0148b91a15b238ced26782b9a1e70e438f4b61bc +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Enable the existing fourth top-level `Makelore Robot | AI 机器` module beside AI programming, AI design, and AI learning, and connect it to the AI hardware service. The user explicitly clarified that Hardware and Robot are the same product module, so no separate Hardware card remains. +- Add a dedicated Robot Renderer page for overview, agent creation/configuration, device activation binding, and device reassignment. +- Add a Main-owned Works Square proxy for `/api/ai-hardware` that owns authentication, idempotency, revision headers, response validation, and safe error projection. +- Keep the Robot workspace isolated from AI programming project state and the project-initialization gate. +- Add focused navigation, route, API, page, and Electron navigation verification while reusing the existing Robot cover asset. + +## Intent And Constraints + +- Renderer must not receive Works Square credentials, Xiaozhi credentials, raw upstream headers, raw problem bodies, or activation-code history. +- Renderer uses public local resource IDs and numeric revisions only; Electron Main converts revisions to strong `If-Match` values and derives provider idempotency keys from an explicit per-intent client operation ID. +- Reuse the existing single light Makelore visual system, module chooser, module switcher, UI primitives, Host API, and Works Square session. +- Treat feature-disabled, first-use empty, recoverable service failure, credential recovery, and revision conflict as distinct user states. +- Preserve unrelated work, do not change authentication behavior, and keep all work in the isolated owned worktree. + +## Outcome + +- Enabled the remote branch's existing `Makelore Robot | AI 机器` fourth top-level card and connected it to the protected `/ai-hardware` technical route; there is no separate Hardware product card or module ID. +- Isolated Robot and Canvas routes from AI programming project loads, provider refreshes, project dialogs, and the project-initialization gate. Provider initialization now uses an explicit Programming-route allowlist rather than treating every non-Canvas path as Programming. +- Added a dedicated Robot page with explicit loading, disabled, unavailable, provisioning, credential-recovery, invalid, empty, and active states. First use can create an agent and implicitly provision the account; the active workspace supports further agent creation, six-digit activation binding, selected-agent configuration editing, field-level optimistic-revision conflict recovery, and device reassignment. +- Added a strict Renderer API that validates local envelopes and public DTOs and never sends Works credentials, idempotency headers, or ETags from Renderer code. +- Added a dedicated Electron Main route that maps only the fixed `/api/works/ai-hardware` surface to Works Square `/api/ai-hardware`, obtains and refreshes the Main-owned Works token, generates idempotency keys, converts numeric revisions to strong `If-Match` values, validates bounded JSON responses and strong ETags, and projects stable redacted errors. +- Added stable mutation operation identities. A transport failure, token refresh, + or explicit `operation_in_progress` result retains the same operation ID and + provider idempotency key; editing the intent creates a new ID. The Main route + performs one bounded in-progress retry with the exact same request. +- For six-digit device activation, the code remains only in an uncontrolled DOM + input and function-local variables. Ambiguous retries are matched using a + dialog-lifetime, non-extractable random HMAC key and an in-memory fingerprint; + the same code can reuse its operation ID, while a changed code starts a new + operation on its first submission without retaining a reversible code value. +- Completed the Robot configuration UI for every public server field, including + advanced ASR/VAD/LLM/SLM/VLLM/TTS/memory/intent settings and explicit + `clear_fields` semantics, with revision-conflict rebase that preserves only + the user's attempted changes. +- Added the credential-recovery UI and typed API. Recovery never exposes a + provider token or private key to Renderer state, reuses an operation ID after + uncertain failure, and refreshes the current overview when recovery is no + longer needed. +- Added a real loopback HTTP contract against a uvicorn-hosted Works Square + FastAPI app, covering paths, Bearer ownership, response DTOs, status codes, + ETags/If-Match, idempotency replay/conflict, in-progress retry, and recovery. +- Hardened the generic Host API IPC proxy so case variants of Renderer-supplied authorization and capability headers cannot coexist with Main-owned values. +- Updated README and repository guidance to describe three enabled modules (Code, Canvas, Robot) plus the disabled Learning entry. +- Fetched the latest remote `origin/main` and semantically merged its Robot entry, horizontal module-card layout, Canvas overlay/full-height behavior, profile/avatar work, and related tests with this task. After the user's product clarification, the resulting chooser retains four entries: Code, Canvas, Learning, and the now-enabled Robot. + +## Verification + +- Focused Robot route/API/page suites: 62 tests passed; the credential-recovery, + advanced-configuration, and activation-intent page suite alone passes 24 tests. +- Provider/module isolation regression: 4 files and 28 tests passed, including + direct Robot, Canvas, module chooser, and Programming route behavior. +- Cross-repository Makelore-to-Works-Square loopback contract: 8 tests passed. +- Final full unit suite after the remote fast-forward, semantic conflict resolution, Robot/Hardware unification, activation-intent hardening, and provider-route gate: `156 files passed`, `1681 tests passed` via `corepack pnpm test`. An earlier pre-final run had one unrelated OpenCode queue timing failure; the isolated test and the subsequent full runs passed. +- TypeScript: `corepack pnpm run typecheck` passed. +- ESLint: `corepack pnpm run lint:check` passed with six pre-existing warnings and no errors; warnings are in unrelated Chat, Home, and Makelore files. +- Production build: `corepack pnpm run build:vite` passed; existing chunk-size and mixed dynamic/static import warnings remain. +- Packaged Electron navigation smoke after the Robot/Hardware unification: `2 tests passed` via `corepack pnpm exec playwright test tests/e2e/main-navigation.spec.ts`. +- `git diff --check` passed; Git emitted only line-ending conversion warnings. +- Final source-task closeout rechecked all 25 dirty paths and confirmed that each belongs to the Robot/AI Hardware implementation, its Programming-only provider route gate, security hardening, focused verification, or this task record; no unrelated work was adopted. +- Final closeout focused verification: `8 files passed`, `72 tests passed` via `corepack pnpm exec vitest run` across the Robot API/page/routes/registration, provider route gate, Host API header hardening, initialization gate, and module navigation suites. +- Final closeout TypeScript verification: `corepack pnpm run typecheck` passed. A fresh `git diff --check` also passed with only Git's existing LF-to-CRLF conversion warnings. + +## Follow-ups + +- The Works Square and Xiaozhi source contracts were verified in their current worktrees, but this client task did not verify that either service snapshot is deployed, that the backend feature flag is enabled, or that production credentials are provisioned. Run a signed-in staging smoke with a real activation code before release. +- Operation IDs survive the current interactive retry flow but are not persisted + across application restarts. A future offline queue must durably persist the + operation ID together with the exact method, path, body, and revision. +- Existing application-wide build chunk-size warnings and six unrelated lint warnings were not expanded into this feature scope. + +## Promotion Candidates + +- Target canonical document: `.project-docs/20-architecture/system-overview.md` and module map. Proposal: record Robot as the enabled fourth top-level client module backed by the AI hardware service, and document that only Programming consumes project/provider state. Evidence: `src/lib/ai-modules.ts`, `src/components/layout/MainLayout.tsx`, `src/components/layout/Sidebar.tsx`, and navigation tests. Future impact: Robot and Hardware must not be modeled as separate product modules; new modules must opt into Programming state explicitly rather than inherit it as a fallback. Semantic conflicts: current canonical documents predate the enabled Robot module. Human confirmation required: no; the user explicitly resolved the naming conflict in this task. +- Target canonical document: `.project-docs/20-architecture/data-flow.md` and trust-boundary decision material. Proposal: record the Hardware request path `Renderer typed API -> authenticated loopback Host API -> Main-owned Works session -> Works Square AI Hardware API`, with numeric revisions converted to strong ETags and idempotency keys generated in Main. Evidence: `src/lib/ai-hardware.ts`, `electron/api/routes/ai-hardware.ts`, and route/API tests. Future impact: credentials, raw upstream problems, activation-code history, and response headers remain outside Renderer state. Semantic conflicts: none identified. Human confirmation required: no, once accepted. +- Target canonical document: security/Host API decision material. Proposal: record case-insensitive stripping of Renderer-supplied authorization and renderer-capability headers before Main injects trusted values. Evidence: `electron/main/ipc/host-api-proxy.ts` and `tests/unit/host-api-proxy.test.ts`. Future impact: all future protected headers must be normalized case-insensitively. Semantic conflicts: none identified. Human confirmation required: no. diff --git a/AGENTS.md b/AGENTS.md index 4434738..abcb5ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Product boundary -Makelore is a cross-platform Electron application with two visible modules: `Makelore Code|AI 编程` and `Makelore Canvas|AI 绘画`. The repository root is the complete product source tree. It does not depend on a sibling source checkout. +Makelore is a cross-platform Electron application with three enabled modules: `Makelore Code|AI 编程`, `Makelore Canvas|AI 绘画`, and `Makelore Robot|AI 机器`; `Makelore Learning|AI 学习` remains a disabled entry. The repository root is the complete product source tree. It does not depend on a sibling source checkout. Do not restore Works gallery, asset gallery, publish/upload, or cloud-deploy workbench pages. `/deliverables` is a project output preview and remains supported. diff --git a/README.md b/README.md index 8ae8015..9fe4ba0 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ **一念成光,万物可创。** -Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版本为 `2.0.0`,包含两个已开通产品模块,并保留一个学习模块入口: +Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含三个已开通产品模块,并保留一个学习模块入口: - `Makelore Code|AI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时。 - `Makelore Canvas|AI 绘画`:以设计项目(Workspace)组织 Agent 对话、方向确认、文生图、单参考图生图、视频生成任务和私有结果;参考图可从当前项目作品选择或从本地上传。 +- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配,让 AI 能力走进真实世界。 - `Makelore Learning|AI 学习`:数学与知识宇宙主题的学习入口,当前暂未开通;开屏页和模块切换菜单保留置灰入口,不能进入。 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入浏览器授权,授权成功后回到入口选择页,已登录用户可直接进入对应工作区。 diff --git a/electron/api/routes/ai-hardware.ts b/electron/api/routes/ai-hardware.ts new file mode 100644 index 0000000..f011e5b --- /dev/null +++ b/electron/api/routes/ai-hardware.ts @@ -0,0 +1,541 @@ +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { HostApiContext } from '../context'; +import { sendJson } from '../route-utils'; +import { WORKS_SQUARE_CONFIG } from '../works-config'; +import { getValidWorksSquareAccessToken } from '../../services/works-square-session'; +import { proxyAwareFetch } from '../../utils/proxy-fetch'; + +const MAX_REQUEST_BYTES = 64 * 1024; +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 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; + +type TokenGetter = typeof getValidWorksSquareAccessToken; + +export type AiHardwareRouteDependencies = { + fetchImpl?: typeof fetch; + getAccessToken?: TokenGetter; + apiBaseUrl?: string; + randomUuid?: () => string; + timeoutMs?: number; +}; + +class SafeRouteError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly retryable = false, + ) { + super(message); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asRequiredString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function asRevision(value: unknown): number | null { + return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null; +} + +function projectAgent(value: unknown): Record | null { + if (!isRecord(value)) return null; + const id = asRequiredString(value.id); + const name = asRequiredString(value.name); + const configRevision = asRevision(value.config_revision); + return id && name && configRevision !== null + ? { id, name, config_revision: configRevision } + : null; +} + +function projectDevice(value: unknown): Record | null { + if (!isRecord(value)) return null; + const id = asRequiredString(value.id); + const agentId = asRequiredString(value.agent_id); + const assignmentRevision = asRevision(value.assignment_revision); + return id && agentId && assignmentRevision !== null + ? { id, agent_id: agentId, assignment_revision: assignmentRevision } + : null; +} + +const CONFIG_STRING_FIELDS = [ + 'system_prompt', 'lang_code', 'language', 'asr_model_id', 'vad_model_id', 'llm_model_id', + 'slm_model_id', 'vllm_model_id', 'tts_model_id', 'tts_voice_id', 'tts_language', + 'mem_model_id', 'intent_model_id', +] as const; +const CONFIG_NUMBER_FIELDS = ['tts_volume', 'tts_rate', 'tts_pitch'] as const; +const CONFIG_STRING_LIMITS: Record = { + system_prompt: 16000, lang_code: 10, language: 10, asr_model_id: 32, vad_model_id: 64, + llm_model_id: 32, slm_model_id: 255, vllm_model_id: 32, tts_model_id: 32, + tts_voice_id: 32, tts_language: 50, mem_model_id: 32, intent_model_id: 32, +}; +const CLEARABLE_CONFIG_FIELDS = new Set([ + ...CONFIG_STRING_FIELDS, ...CONFIG_NUMBER_FIELDS, +]); +const CONFIG_UPDATE_FIELDS = new Set([ + 'agent_name', ...CONFIG_STRING_FIELDS, ...CONFIG_NUMBER_FIELDS, 'chat_history_conf', 'clear_fields', +]); + +function projectAgentConfig(value: unknown): Record | null { + const agent = projectAgent(value); + if (!agent || !isRecord(value)) return null; + const result: Record = { ...agent }; + for (const field of CONFIG_STRING_FIELDS) { + const fieldValue = value[field]; + if (fieldValue !== null && typeof fieldValue !== 'string') return null; + result[field] = fieldValue; + } + for (const field of CONFIG_NUMBER_FIELDS) { + const fieldValue = value[field]; + if (fieldValue !== null && (!Number.isInteger(fieldValue) || (fieldValue as number) < -100 || (fieldValue as number) > 100)) return null; + result[field] = fieldValue; + } + const chatHistory = value.chat_history_conf; + if (chatHistory !== null && (!Number.isInteger(chatHistory) || (chatHistory as number) < 0 || (chatHistory as number) > 2)) return null; + result.chat_history_conf = chatHistory; + return result; +} + +function projectOverview(value: unknown): Record | null { + if (!isRecord(value) || !Array.isArray(value.agents) || !Array.isArray(value.devices)) return null; + const allowedStatuses = new Set([ + 'unprovisioned', 'provisioning', 'active', 'credential_recovery_required', 'invalid', + ]); + if (typeof value.status !== 'string' || !allowedStatuses.has(value.status)) return null; + const agents = value.agents.map(projectAgent); + const devices = value.devices.map(projectDevice); + if (agents.some((item) => item === null) || devices.some((item) => item === null)) return null; + return { status: value.status, agents, devices }; +} + +async function readBoundedJson(req: IncomingMessage): Promise> { + const declared = Number(req.headers['content-length']); + if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) { + throw new SafeRouteError(413, 'AI_HARDWARE_REQUEST_TOO_LARGE', 'AI hardware request is too large'); + } + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.byteLength; + if (size > MAX_REQUEST_BYTES) { + throw new SafeRouteError(413, 'AI_HARDWARE_REQUEST_TOO_LARGE', 'AI hardware request is too large'); + } + chunks.push(buffer); + } + try { + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown; + if (!isRecord(value)) throw new Error('not an object'); + return value; + } catch { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } +} + +async function readBoundedResponse(response: Response): Promise { + const declared = Number(response.headers.get('content-length')); + if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) { + await response.body?.cancel().catch(() => undefined); + throw new SafeRouteError(502, 'AI_HARDWARE_RESPONSE_TOO_LARGE', 'AI hardware service returned too much data', true); + } + if (!response.body) return null; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined); + throw new SafeRouteError(502, 'AI_HARDWARE_RESPONSE_TOO_LARGE', 'AI hardware service returned too much data', true); + } + chunks.push(value); + } + const text = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8').trim(); + if (!text) return null; + try { + return JSON.parse(text) as unknown; + } catch { + throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true); + } +} + +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'); + } +} + +function requireLocalId(value: unknown): string { + const id = asRequiredString(value); + if (!id || !LOCAL_ID.test(id)) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + return id; +} + +function decodeLocalId(value: string): string { + try { + return requireLocalId(decodeURIComponent(value)); + } catch (error) { + if (error instanceof SafeRouteError) throw error; + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } +} + +function requireRevision(body: Record): number { + const revision = asRevision(body.revision); + if (revision === null) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + return revision; +} + +function takeOperationId(body: Record, createUuid: () => string): string { + const candidate = body.client_operation_id ?? createUuid(); + if (typeof candidate !== 'string' || !OPERATION_ID.test(candidate)) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + delete body.client_operation_id; + return candidate.toLowerCase(); +} + +function validateConfigUpdate(body: Record): Record { + ensureExactKeys(body, new Set(['revision', ...CONFIG_UPDATE_FIELDS])); + requireRevision(body); + const output: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (key === 'revision') continue; + if (key === 'agent_name') { + const name = asRequiredString(value); + if (!name || name.length > 64) throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + output[key] = name; + } else if (key === 'clear_fields') { + if (!Array.isArray(value) || value.length > 16 || new Set(value).size !== value.length || value.some((item) => typeof item !== 'string' || !CLEARABLE_CONFIG_FIELDS.has(item))) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + output[key] = value; + } else if (CONFIG_NUMBER_FIELDS.includes(key as typeof CONFIG_NUMBER_FIELDS[number])) { + if (!Number.isInteger(value) || (value as number) < -100 || (value as number) > 100) throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + output[key] = value; + } else if (key === 'chat_history_conf') { + if (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 2) throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + output[key] = value; + } else { + const limit = CONFIG_STRING_LIMITS[key]; + if (typeof value !== 'string' || !value.trim() || limit === undefined || value.length > limit) throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + output[key] = value; + } + } + const clears = Array.isArray(output.clear_fields) ? new Set(output.clear_fields) : new Set(); + if (Object.keys(output).some((key) => key !== 'clear_fields' && clears.has(key))) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + if (!Object.keys(output).length || (Object.keys(output).length === 1 && Array.isArray(output.clear_fields) && output.clear_fields.length === 0)) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + return output; +} + +function strongRevisionFrom(response: Response): number { + const etag = response.headers.get('etag'); + const match = etag?.match(STRONG_REVISION); + 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); + return revision; +} + +function isJsonContentType(response: Response): boolean { + const contentType = response.headers.get('content-type')?.toLowerCase().split(';', 1)[0].trim(); + return contentType === 'application/json' || contentType === 'application/problem+json'; +} + +function safeErrorForStatus(status: number): { code: string; error: string; retryable: boolean } { + if (status === 401) return { code: 'AI_HARDWARE_AUTH_REQUIRED', error: 'Works Square sign-in is required', retryable: false }; + if (status === 403) return { code: 'AI_HARDWARE_FORBIDDEN', error: 'AI hardware operation is not allowed', retryable: false }; + if (status === 404) return { code: 'AI_HARDWARE_RESOURCE_NOT_FOUND', error: 'AI hardware resource was not found', retryable: false }; + if (status === 409 || status === 412) return { code: 'AI_HARDWARE_REVISION_CONFLICT', error: 'AI hardware data changed; refresh and retry', retryable: false }; + if (status === 429) return { code: 'AI_HARDWARE_RATE_LIMITED', error: 'AI hardware service is busy; retry later', retryable: true }; + if (status >= 500) return { code: 'AI_HARDWARE_UNAVAILABLE', error: 'AI hardware service is temporarily unavailable', retryable: true }; + return { code: 'AI_HARDWARE_REQUEST_FAILED', error: 'AI hardware request was rejected', retryable: false }; +} + +const SAFE_UPSTREAM_ERRORS: Record = { + ai_hardware_idempotency_conflict: 'AI hardware request conflicts with an earlier operation', + ai_hardware_operation_in_progress: 'AI hardware operation is still in progress', + ai_hardware_device_already_bound: 'AI hardware device is already bound', + ai_hardware_revision_conflict: 'AI hardware data changed; refresh and retry', + ai_hardware_state_conflict: 'AI hardware operation conflicts with the current state', + ai_hardware_revision_required: 'A current AI hardware revision is required', + ai_hardware_activation_code_invalid: 'AI hardware activation code is invalid', + ai_hardware_request_rejected: 'AI hardware request was rejected', + ai_hardware_resource_not_found: 'AI hardware resource was not found', + ai_hardware_provider_state_conflict: 'AI hardware provider state is inconsistent', + ai_hardware_credential_recovery_required: 'AI hardware credential recovery is required', + ai_hardware_credential_unavailable: 'AI hardware credential is unavailable', + ai_hardware_credential_recovery_unavailable: 'AI hardware credential recovery is not currently available', + ai_hardware_unconfigured: 'AI hardware integration is not configured', + ai_hardware_disabled: 'AI hardware module is not enabled', + ai_hardware_idempotency_key_invalid: 'AI hardware operation identity is invalid', + ai_hardware_revision_invalid: 'AI hardware revision is invalid', + xiaozhi_hardware_protocol_error: 'AI hardware service returned an invalid response', + xiaozhi_hardware_timeout: 'AI hardware service timed out', + xiaozhi_hardware_unavailable: 'AI hardware service is temporarily unavailable', +}; + +function safeUpstreamError(payload: unknown, status: number): { code: string; error: string; retryable: boolean } { + const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null; + const code = detail && typeof detail.error_code === 'string' && SAFE_UPSTREAM_ERRORS[detail.error_code] + ? detail.error_code + : null; + if (!code) return safeErrorForStatus(status); + return { code, error: SAFE_UPSTREAM_ERRORS[code], retryable: detail?.retryable === true }; +} + +function sendFailure( + res: ServerResponse, + localStatus: number, + status: number, + code: string, + error: string, + retryable: boolean, + retryAfterSeconds?: number, + operationId?: string, +): void { + sendJson(res, localStatus, { + success: false, + status, + code, + error, + retryable, + ...(retryAfterSeconds === undefined ? {} : { retry_after_seconds: retryAfterSeconds }), + ...(operationId === undefined ? {} : { operation_id: operationId }), + }); +} + +function retryAfterSeconds(response: Response, cap = 86_400): number | undefined { + const raw = response.headers.get('retry-after')?.trim(); + if (!raw) return undefined; + const delta = /^\d+$/.test(raw) ? Number(raw) : Math.ceil((Date.parse(raw) - Date.now()) / 1000); + if (!Number.isFinite(delta) || delta < 0) return undefined; + return Math.min(delta, cap); +} + +export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDependencies = {}) { + const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; + const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; + const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); + const createUuid = dependencies.randomUuid ?? randomUUID; + const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + return async function handleAiHardwareRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + _ctx: HostApiContext, + ): Promise { + if (url.pathname !== LOCAL_ROOT && !url.pathname.startsWith(`${LOCAL_ROOT}/`)) return false; + + let operationId: string | undefined; + try { + let upstreamPath: string; + let body: Record | undefined; + let project: (value: unknown) => Record | null; + let requireEtag = false; + let ifMatch: number | undefined; + let expectedStatus: number; + const method = req.method ?? 'GET'; + + if (method === 'GET' && url.pathname === LOCAL_ROOT) { + upstreamPath = UPSTREAM_ROOT; + project = projectOverview; + expectedStatus = 200; + } else if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/credential-recovery`) { + const input = await readBoundedJson(req); + ensureExactKeys(input, new Set(['client_operation_id'])); + operationId = takeOperationId(input, createUuid); + upstreamPath = `${UPSTREAM_ROOT}/credential-recovery`; + project = projectOverview; + expectedStatus = 200; + } else if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/agents`) { + const input = await readBoundedJson(req); + ensureExactKeys(input, new Set(['agent_name', 'client_operation_id'])); + operationId = takeOperationId(input, createUuid); + const agentName = asRequiredString(input.agent_name); + if (!agentName || agentName.length > 64) throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + upstreamPath = `${UPSTREAM_ROOT}/agents`; + body = { agent_name: agentName }; + project = projectAgent; + expectedStatus = 201; + } else if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/device-bindings`) { + const input = await readBoundedJson(req); + ensureExactKeys(input, new Set(['activation_code', 'agent_id', 'client_operation_id'])); + operationId = takeOperationId(input, createUuid); + if (typeof input.activation_code !== 'string' || !/^\d{6}$/.test(input.activation_code)) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + upstreamPath = `${UPSTREAM_ROOT}/device-bindings`; + body = { activation_code: input.activation_code, agent_id: requireLocalId(input.agent_id) }; + project = projectDevice; + expectedStatus = 201; + } else { + const agentMatch = url.pathname.match(/^\/api\/works\/ai-hardware\/agents\/([^/]+)$/); + const assignmentMatch = url.pathname.match(/^\/api\/works\/ai-hardware\/devices\/([^/]+)\/agent-assignment$/); + if (agentMatch && (method === 'GET' || method === 'PATCH')) { + const id = decodeLocalId(agentMatch[1]); + upstreamPath = `/api/ai-hardware/agents/${encodeURIComponent(id)}`; + project = projectAgentConfig; + requireEtag = true; + expectedStatus = 200; + if (method === 'PATCH') { + const input = await readBoundedJson(req); + operationId = takeOperationId(input, createUuid); + ifMatch = requireRevision(input); + body = validateConfigUpdate(input); + } + } else if (assignmentMatch && (method === 'GET' || method === 'PUT')) { + const id = decodeLocalId(assignmentMatch[1]); + upstreamPath = `/api/ai-hardware/devices/${encodeURIComponent(id)}/agent-assignment`; + project = projectDevice; + requireEtag = true; + expectedStatus = 200; + if (method === 'PUT') { + const input = await readBoundedJson(req); + ensureExactKeys(input, new Set(['revision', 'agent_id', 'client_operation_id'])); + operationId = takeOperationId(input, createUuid); + ifMatch = requireRevision(input); + body = { agent_id: requireLocalId(input.agent_id) }; + } + } else { + sendFailure(res, 404, 404, 'AI_HARDWARE_ROUTE_NOT_FOUND', 'AI hardware route was not found', false); + return true; + } + } + + const token = await getAccessToken({ fetchImpl }); + if (!token) { + sendFailure(res, 200, 401, 'AI_HARDWARE_AUTH_REQUIRED', 'Works Square sign-in is required', false); + return true; + } + const idempotencyKey = operationId ? `makelore-${operationId}` : undefined; + const call = async (accessToken: string): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchImpl(`${apiBaseUrl}${upstreamPath}`, { + method, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), + ...(ifMatch === undefined ? {} : { 'If-Match': `"${ifMatch}"` }), + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + redirect: 'manual', + }); + } catch (error) { + 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); + if (response.status === 401) { + await response.body?.cancel().catch(() => undefined); + const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); + if (refreshed) { + currentToken = refreshed; + response = await call(currentToken); + } + } + + let cachedErrorPayload: unknown; + 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; + 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); + cachedErrorPayload = undefined; + } + } + } + + if (!response.ok) { + const status = response.status; + if (status === 404 && upstreamPath === UPSTREAM_ROOT) { + await response.body?.cancel().catch(() => undefined); + 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)); + const safe = safeUpstreamError(payload, status); + sendFailure( + res, + 200, + status, + safe.code, + safe.error, + safe.retryable, + retryAfter, + operationId, + ); + return true; + } + + if (response.status !== expectedStatus || !isJsonContentType(response)) { + await response.body?.cancel().catch(() => undefined); + 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)); + 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 + ? projected.config_revision + : projected.assignment_revision; + if (dtoRevision !== revision) { + throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true); + } + } + sendJson(res, 200, { success: true, data: projected, ...(revision === undefined ? {} : { revision }) }); + return true; + } catch (error) { + if (error instanceof SafeRouteError) { + sendFailure(res, 200, error.status, error.code, error.message, error.retryable, undefined, operationId); + } else if (error instanceof Error && error.name === 'AbortError') { + sendFailure(res, 200, 504, 'AI_HARDWARE_TIMEOUT', 'AI hardware service timed out', true, undefined, operationId); + } else { + sendFailure(res, 200, 502, 'AI_HARDWARE_UNAVAILABLE', 'AI hardware service is temporarily unavailable', true, undefined, operationId); + } + return true; + } + }; +} + +export const handleAiHardwareRoutes = createAiHardwareRouteHandler(); diff --git a/electron/api/server.ts b/electron/api/server.ts index c3cab78..6e4acae 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -4,6 +4,7 @@ import { getPort } from '../utils/config'; import { logger } from '../utils/logger'; import type { HostApiContext } from './context'; import { handleAiProxyRoutes } from './routes/ai-proxy'; +import { handleAiHardwareRoutes } from './routes/ai-hardware'; import { handleAppRoutes } from './routes/app'; import { handleAuthRoutes } from './routes/auth'; import { handleOpencodeRoutes } from './routes/opencode'; @@ -29,6 +30,7 @@ type RouteHandler = ( const coreRouteHandlers: RouteHandler[] = [ handleAiProxyRoutes, + handleAiHardwareRoutes, handleAppRoutes, handleAuthRoutes, handleImageWorkspaceRoutes, diff --git a/electron/main/ipc/host-api-proxy.ts b/electron/main/ipc/host-api-proxy.ts index 40e364e..d2f1d2e 100644 --- a/electron/main/ipc/host-api-proxy.ts +++ b/electron/main/ipc/host-api-proxy.ts @@ -29,7 +29,15 @@ export function registerHostApiProxyHandlers(): void { } const method = (request.method || 'GET').toUpperCase(); - const headers: Record = { ...(request.headers || {}) }; + const protectedHeaders = new Set([ + 'authorization', + RENDERER_CAPABILITY_HEADER.toLowerCase(), + ]); + const headers = Object.fromEntries( + Object.entries(request.headers || {}).filter( + ([name]) => !protectedHeaders.has(name.toLowerCase()), + ), + ); // Inject the per-session auth token so the Host API server accepts this request. headers['Authorization'] = `Bearer ${getHostApiToken()}`; headers[RENDERER_CAPABILITY_HEADER] = getRendererCapability(); diff --git a/package.json b/package.json index a8c866b..f32230a 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "lint:check": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:contract:ai-hardware": "vitest run --config vitest.contract.config.ts", "test:electron:windows": "node scripts/run-electron-vitest.mjs", "verify:electron:release": "node scripts/run-electron-vitest.mjs --verify-release-runtime", "verify:artifact:win": "node scripts/verify-packaged-electron-runtime.mjs", diff --git a/src/App.tsx b/src/App.tsx index 384c5e3..568b940 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { PreviewScene } from './pages/Makelore'; import { ProjectConfiguration } from './pages/ProjectConfiguration'; import { Workbench } from './pages/Workbench'; import { ImageCanvas } from './pages/ImageCanvas'; +import { AiHardware } from './pages/AiHardware'; import { Settings } from './pages/Settings'; import { Setup } from './pages/Setup'; import { Login } from './pages/Login'; @@ -114,6 +115,27 @@ function getReturnPath(location: ReturnType): string { return `${location.pathname}${location.search}`; } +const PROGRAMMING_ROUTE_PREFIXES = [ + '/project-config', + '/makelore-home', + '/kangaroo', + '/subagents', + '/chat', + '/deliverables', + '/workbench', + '/opencode-chat', + '/projects', + '/sessions', + '/models', + '/settings', +] as const; + +function isProgrammingRoute(pathname: string): boolean { + return PROGRAMMING_ROUTE_PREFIXES.some( + (route) => pathname === route || pathname.startsWith(`${route}/`), + ); +} + function ProtectedLayout({ authReady, authRequired, @@ -299,8 +321,9 @@ function App() { useEffect(() => { if (rendererOnlyPreview) return; if (!setupReady) return; + if (!isProgrammingRoute(location.pathname)) return; initProviders(); - }, [initProviders, rendererOnlyPreview, setupReady]); + }, [initProviders, location.pathname, rendererOnlyPreview, setupReady]); useEffect(() => { if (rendererOnlyPreview) return; @@ -388,6 +411,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 3212d1d..37b9136 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -32,9 +32,11 @@ export function MainLayout() { sidebar: false, titlebar: false, }); - const isPaintingModule = getAiModuleForPath(location.pathname) === 'painting'; + const activeModule = getAiModuleForPath(location.pathname); + const isProgrammingModule = activeModule === 'programming'; + const isPaintingModule = activeModule === 'painting'; const isChatWorkspace = location.pathname === '/opencode-chat'; - const isInitializationSafeRoute = location.pathname === '/project-config' || isPaintingModule; + const isInitializationSafeRoute = location.pathname === '/project-config' || !isProgrammingModule; const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => { if (!sidebarCollapsed) return; @@ -80,8 +82,8 @@ export function MainLayout() { }, []); useEffect(() => { - if (activeProject && !isPaintingModule) void load(activeProject.id).catch(() => undefined); - }, [activeProject, isPaintingModule, load]); + if (activeProject && isProgrammingModule) void load(activeProject.id).catch(() => undefined); + }, [activeProject, isProgrammingModule, load]); const initializationBlocked = Boolean(activeProject && (!config || !config.initialized) && !isInitializationSafeRoute); return ( diff --git a/src/components/layout/ModuleSwitcher.tsx b/src/components/layout/ModuleSwitcher.tsx index 5a9942a..361f2e5 100644 --- a/src/components/layout/ModuleSwitcher.tsx +++ b/src/components/layout/ModuleSwitcher.tsx @@ -26,17 +26,13 @@ export function ModuleSwitcher({ sidebarCollapsed, compact = false }: { sidebarC useEffect(() => { if (!open) return undefined; - const handleMouseDown = (event: MouseEvent) => { const target = event.target; - if (target instanceof Node && !switcherRef.current?.contains(target)) { - setOpen(false); - } + if (target instanceof Node && !switcherRef.current?.contains(target)) setOpen(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false); }; - document.addEventListener('mousedown', handleMouseDown); document.addEventListener('keydown', handleKeyDown); return () => { @@ -78,39 +74,37 @@ export function ModuleSwitcher({ sidebarCollapsed, compact = false }: { sidebarC )} innerClassName="grid gap-1.5" > -
选择模块
- {aiModules.map((module) => { - const active = module.id === activeModuleId; - const tone = moduleToneClasses[module.id]; - const moduleLabel = getModuleLabel(module); - return ( - - ); - })} +
选择模块
+ {aiModules.map((module) => { + const active = module.id === activeModuleId; + const tone = moduleToneClasses[module.id]; + const moduleLabel = getModuleLabel(module); + return ( + + ); + })} ); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f2d6eeb..112c351 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { BarChart3, + Cpu, ChevronRight, Crown, ExternalLink, @@ -208,7 +209,9 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const tokenUsageRequestIdRef = useRef(0); const navigate = useNavigate(); const location = useLocation(); - const isPaintingModule = getAiModuleForPath(location.pathname) === 'painting'; + const activeModule = getAiModuleForPath(location.pathname); + const isProgrammingModule = activeModule === 'programming'; + const isPaintingModule = activeModule === 'painting'; const projectConfigPath = '/project-config'; const visibleProjects = opencodeProjects; const selectedProjectFolderName = getFolderName(newProjectSelectedPath); @@ -237,21 +240,21 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const weeklyRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.weekly_refresh_at); useEffect(() => { - if (isPaintingModule) return; + if (!isProgrammingModule) return; void loadProjectList().catch(() => undefined); - }, [isPaintingModule, loadProjectList]); + }, [isProgrammingModule, loadProjectList]); useEffect(() => { - if (isPaintingModule) return; + if (!isProgrammingModule) return; for (const project of visibleProjects) { void loadProjectConfig(project.id).catch(() => undefined); } - }, [isPaintingModule, loadProjectConfig, visibleProjects]); + }, [isProgrammingModule, loadProjectConfig, visibleProjects]); useEffect(() => { - if (isPaintingModule) return; + if (!isProgrammingModule) return; void refreshProviderSnapshot().catch(() => undefined); - }, [isPaintingModule, refreshProviderSnapshot]); + }, [isProgrammingModule, refreshProviderSnapshot]); useEffect(() => { if (!authUser || !profileAccountKey) return; @@ -648,7 +651,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
{isPaintingModule ? ( - ) : ( + ) : isProgrammingModule ? ( <> + {!sidebarCollapsed ? ( +

+ 管理机器人智能体、设备绑定与配置。 +

+ ) : null} +
)} - {!isPaintingModule && createDialogOpen ? ( + {isProgrammingModule && createDialogOpen ? ( { @@ -934,7 +958,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi ) : null} - {!isPaintingModule && projectEntryError && typeof document !== 'undefined' + {isProgrammingModule && projectEntryError && typeof document !== 'undefined' ? createPortal(
= { + ai_hardware_resource_not_found: 'AI hardware resource was not found', + ai_hardware_idempotency_conflict: 'This operation conflicts with an earlier request', + ai_hardware_operation_in_progress: 'AI hardware operation is still in progress', + ai_hardware_device_already_bound: 'AI hardware device is already bound', + ai_hardware_revision_conflict: 'AI hardware state changed; refresh and retry', + ai_hardware_state_conflict: 'AI hardware operation conflicts with the current state', + ai_hardware_activation_code_invalid: 'AI hardware activation code is invalid', + ai_hardware_request_rejected: 'AI hardware request was rejected', + ai_hardware_revision_required: 'A current AI hardware revision is required', + ai_hardware_provider_state_conflict: 'AI hardware provider state is inconsistent', + xiaozhi_hardware_protocol_error: 'AI hardware service returned an invalid response', + ai_hardware_unconfigured: 'AI hardware integration is not configured', + ai_hardware_credential_recovery_required: 'AI hardware credential recovery is required', + ai_hardware_credential_unavailable: 'AI hardware credential is unavailable', + ai_hardware_credential_recovery_unavailable: 'AI hardware credential recovery is not currently available', + xiaozhi_hardware_unavailable: 'AI hardware service is unavailable', + xiaozhi_hardware_timeout: 'AI hardware service timed out', + ai_hardware_disabled: 'AI hardware module is not enabled', + AI_HARDWARE_DISABLED: 'AI hardware module is not enabled', + AI_HARDWARE_TIMEOUT: 'AI hardware service timed out', + AI_HARDWARE_UNAVAILABLE: 'AI hardware service is unavailable', + AI_HARDWARE_TRANSPORT_ERROR: 'AI hardware request could not reach the local service', + AI_HARDWARE_REVISION_CONFLICT: 'AI hardware state changed; refresh and retry', + AI_HARDWARE_AUTH_REQUIRED: 'Works Square sign-in is required', + AI_HARDWARE_FORBIDDEN: 'AI hardware operation is not allowed', + AI_HARDWARE_RESOURCE_NOT_FOUND: 'AI hardware resource was not found', + AI_HARDWARE_RATE_LIMITED: 'AI hardware service is busy; retry later', + AI_HARDWARE_INVALID_REQUEST: 'Invalid AI hardware request', + AI_HARDWARE_INVALID_RESPONSE: 'AI hardware service returned an invalid response', +}; +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; + +export type AiHardwareStatus = + | 'unprovisioned' + | 'provisioning' + | 'active' + | 'credential_recovery_required' + | 'invalid'; + +export type AiHardwareAgent = { + id: string; + name: string; + config_revision: number; +}; + +export type AiHardwareDevice = { + id: string; + agent_id: string; + assignment_revision: number; +}; + +export type AiHardwareOverview = { + status: AiHardwareStatus; + agents: AiHardwareAgent[]; + devices: AiHardwareDevice[]; +}; + +export type AiHardwareAgentConfiguration = AiHardwareAgent & { + system_prompt: string | null; + lang_code: string | null; + language: string | null; + asr_model_id: string | null; + vad_model_id: string | null; + llm_model_id: string | null; + slm_model_id: string | null; + vllm_model_id: string | null; + tts_model_id: string | null; + tts_voice_id: string | null; + tts_language: string | null; + tts_volume: number | null; + tts_rate: number | null; + tts_pitch: number | null; + mem_model_id: string | null; + intent_model_id: string | null; + chat_history_conf: number | null; +}; + +export const AI_HARDWARE_CLEARABLE_FIELDS = [ + 'system_prompt', + 'lang_code', + 'language', + 'asr_model_id', + 'vad_model_id', + 'llm_model_id', + 'slm_model_id', + 'vllm_model_id', + 'tts_model_id', + 'tts_voice_id', + 'tts_language', + 'tts_volume', + 'tts_rate', + 'tts_pitch', + 'mem_model_id', + 'intent_model_id', +] as const; + +export type AiHardwareClearableField = typeof AI_HARDWARE_CLEARABLE_FIELDS[number]; + +export type AiHardwareAgentConfigurationUpdate = Partial<{ + agent_name: string; + system_prompt: string; + lang_code: string; + language: string; + asr_model_id: string; + vad_model_id: string; + llm_model_id: string; + slm_model_id: string; + vllm_model_id: string; + tts_model_id: string; + tts_voice_id: string; + tts_language: string; + tts_volume: number; + tts_rate: number; + tts_pitch: number; + mem_model_id: string; + intent_model_id: string; + chat_history_conf: number; +}> & { clear_fields?: AiHardwareClearableField[] }; + +export type VersionedAiHardwareResult = { + data: T; + revision: number; +}; + +type MainEnvelope = { + success?: unknown; + status?: unknown; + code?: unknown; + error?: unknown; + retryable?: unknown; + retry_after_seconds?: unknown; + operation_id?: unknown; + data?: unknown; + revision?: unknown; +}; + +export class AiHardwareApiError extends Error { + readonly status: number; + readonly code: string; + readonly retryable: boolean; + readonly retryAfterSeconds: number | null; + readonly operationId: string | null; + + constructor(options: { + status: number; + code: string; + message: string; + retryable?: boolean; + retryAfterSeconds?: number | null; + operationId?: string | null; + }) { + super(options.message); + this.name = 'AiHardwareApiError'; + this.status = options.status; + this.code = options.code; + this.retryable = options.retryable ?? false; + this.retryAfterSeconds = options.retryAfterSeconds ?? null; + this.operationId = options.operationId ?? null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isInteger(value: unknown, min = 0, max = Number.MAX_SAFE_INTEGER): value is number { + return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max; +} + +function hasOnlyKeys(value: Record, keys: readonly string[]): boolean { + const allowed = new Set(keys); + return Object.keys(value).every((key) => allowed.has(key)); +} + +function invalidPayload(): never { + throw new AiHardwareApiError({ + status: 502, + code: 'AI_HARDWARE_INVALID_RESPONSE', + message: 'AI hardware service returned an invalid response', + }); +} + +function readAgent(value: unknown): AiHardwareAgent { + if (!isRecord(value) || !hasOnlyKeys(value, ['id', 'name', 'config_revision']) + || typeof value.id !== 'string' || value.id.length < 1 || value.id.length > 36 + || typeof value.name !== 'string' || value.name.length < 1 || value.name.length > 64 + || !isInteger(value.config_revision)) invalidPayload(); + return value as AiHardwareAgent; +} + +function readDevice(value: unknown): AiHardwareDevice { + if (!isRecord(value) || !hasOnlyKeys(value, ['id', 'agent_id', 'assignment_revision']) + || typeof value.id !== 'string' || value.id.length < 1 || value.id.length > 36 + || typeof value.agent_id !== 'string' || value.agent_id.length < 1 || value.agent_id.length > 36 + || !isInteger(value.assignment_revision)) invalidPayload(); + return value as AiHardwareDevice; +} + +function nullableString(value: unknown): boolean { + return value === null || typeof value === 'string'; +} + +function nullableBoundedInteger(value: unknown, min: number, max: number): boolean { + return value === null || isInteger(value, min, max); +} + +const CONFIG_KEYS = [ + 'id', 'name', 'config_revision', 'system_prompt', 'lang_code', 'language', + 'asr_model_id', 'vad_model_id', 'llm_model_id', 'slm_model_id', 'vllm_model_id', + 'tts_model_id', 'tts_voice_id', 'tts_language', 'tts_volume', 'tts_rate', + 'tts_pitch', 'mem_model_id', 'intent_model_id', 'chat_history_conf', +] as const; + +function readConfiguration(value: unknown): AiHardwareAgentConfiguration { + if (!isRecord(value) || !hasOnlyKeys(value, CONFIG_KEYS) + || Object.keys(value).length !== CONFIG_KEYS.length) invalidPayload(); + readAgent({ id: value.id, name: value.name, config_revision: value.config_revision }); + for (const key of [ + 'system_prompt', 'lang_code', 'language', 'asr_model_id', 'vad_model_id', + 'llm_model_id', 'slm_model_id', 'vllm_model_id', 'tts_model_id', 'tts_voice_id', + 'tts_language', 'mem_model_id', 'intent_model_id', + ] as const) { + if (!nullableString(value[key])) invalidPayload(); + } + for (const key of ['tts_volume', 'tts_rate', 'tts_pitch'] as const) { + if (!nullableBoundedInteger(value[key], -100, 100)) invalidPayload(); + } + if (!nullableBoundedInteger(value.chat_history_conf, 0, 2)) invalidPayload(); + return value as AiHardwareAgentConfiguration; +} + +function readOverview(value: unknown): AiHardwareOverview { + const statuses: AiHardwareStatus[] = [ + 'unprovisioned', 'provisioning', 'active', 'credential_recovery_required', 'invalid', + ]; + if (!isRecord(value) || !hasOnlyKeys(value, ['status', 'agents', 'devices']) + || typeof value.status !== 'string' + || !statuses.includes(value.status as AiHardwareStatus) + || !Array.isArray(value.agents) || !Array.isArray(value.devices)) invalidPayload(); + return { + status: value.status as AiHardwareStatus, + agents: value.agents.map(readAgent), + devices: value.devices.map(readDevice), + }; +} + +function readEnvelope(value: unknown): MainEnvelope { + if (!isRecord(value) || !hasOnlyKeys(value, [ + 'success', 'status', 'code', 'error', 'retryable', 'retry_after_seconds', 'operation_id', 'data', 'revision', + ])) invalidPayload(); + return value; +} + +function throwEnvelopeError(envelope: MainEnvelope): never { + const status = isInteger(envelope.status, 400, 599) ? envelope.status : 502; + const code = typeof envelope.code === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(envelope.code) + ? envelope.code + : DEFAULT_ERROR_CODE; + const retryAfterSeconds = isInteger(envelope.retry_after_seconds, 0, 86_400) + ? envelope.retry_after_seconds + : null; + const operationId = typeof envelope.operation_id === 'string' && OPERATION_ID.test(envelope.operation_id) + ? envelope.operation_id.toLowerCase() + : null; + throw new AiHardwareApiError({ + status, + code, + message: SAFE_ERROR_MESSAGES[code] ?? `AI hardware request failed (${status})`, + retryable: envelope.retryable === true, + retryAfterSeconds, + operationId, + }); +} + +async function request( + path: string, + init: RequestInit | undefined, + read: (value: unknown) => T, + versioned = false, +): Promise> { + const envelope = readEnvelope(await hostApiFetch(`${API_ROOT}${path}`, init)); + if (envelope.success !== true) throwEnvelopeError(envelope); + const data = read(envelope.data); + if (!versioned) return data; + if (!isInteger(envelope.revision)) invalidPayload(); + return { data, revision: envelope.revision }; +} + +function jsonInit(method: string, body: unknown): RequestInit { + return { method, body: JSON.stringify(body) }; +} + +function assertRevision(revision: number): void { + if (!isInteger(revision)) throw new TypeError('revision must be a non-negative integer'); +} + +function assertNoNull(value: Record): void { + if (Object.values(value).some((item) => item === null)) { + throw new TypeError('AI hardware updates must use clear_fields instead of null'); + } +} + +function assertString(value: unknown, field: string, maxLength: number): asserts value is string { + if (typeof value !== 'string' || value.length < 1 || value.length > maxLength) { + throw new TypeError(`${field} is invalid`); + } +} + +function assertAgentId(value: string): void { + assertString(value, 'agent_id', 36); +} + +async function mutationRequest( + path: string, + method: string, + body: Record, + operationIdValue: string, + read: (value: unknown) => T, + versioned = false, +): Promise> { + try { + return await request(path, jsonInit(method, { + ...body, + client_operation_id: operationIdValue, + }), read, versioned); + } catch (error) { + if (isRecord(error) && error.name === 'AiHardwareApiError') { + if (error.operationId) throw error; + const structured = error as unknown as AiHardwareApiError; + throw new AiHardwareApiError({ + status: structured.status, + code: structured.code, + message: structured.message, + retryable: structured.retryable, + retryAfterSeconds: structured.retryAfterSeconds, + operationId: operationIdValue, + }); + } + throw new AiHardwareApiError({ + status: 502, + code: 'AI_HARDWARE_TRANSPORT_ERROR', + message: 'AI hardware request could not reach the local service', + retryable: true, + operationId: operationIdValue, + }); + } +} + +export type AiHardwareMutationOptions = { operationId?: string }; + +export function createAiHardwareOperationId(): string { + return globalThis.crypto.randomUUID(); +} + +function operationId(options?: AiHardwareMutationOptions): string { + const value = options?.operationId ?? createAiHardwareOperationId(); + if (!OPERATION_ID.test(value)) throw new TypeError('operationId is invalid'); + return value.toLowerCase(); +} + +const UPDATE_STRING_LIMITS: Record = { + agent_name: 64, + system_prompt: 16_000, + lang_code: 10, + language: 10, + asr_model_id: 32, + vad_model_id: 64, + llm_model_id: 32, + slm_model_id: 255, + vllm_model_id: 32, + tts_model_id: 32, + tts_voice_id: 32, + tts_language: 50, + mem_model_id: 32, + intent_model_id: 32, +}; + +function validateUpdate(update: AiHardwareAgentConfigurationUpdate): void { + const record = update as Record; + assertNoNull(record); + const allowed = new Set([...CONFIG_KEYS.slice(1), 'agent_name', 'clear_fields']); + allowed.delete('name'); allowed.delete('config_revision'); + if (!hasOnlyKeys(record, [...allowed])) throw new TypeError('Unsupported AI hardware field'); + const clears = update.clear_fields ?? []; + const clearable = new Set(AI_HARDWARE_CLEARABLE_FIELDS); + if (clears.length > 16 || new Set(clears).size !== clears.length + || clears.some((field) => !clearable.has(field))) { + throw new TypeError('Invalid clear_fields'); + } + if (Object.keys(record).filter((key) => key !== 'clear_fields').some((key) => clears.includes(key as AiHardwareClearableField))) { + throw new TypeError('A field cannot be set and cleared together'); + } + if (Object.keys(record).length === 0 + || (Object.keys(record).length === 1 && 'clear_fields' in record && clears.length === 0)) { + throw new TypeError('At least one configuration change is required'); + } + for (const [field, maxLength] of Object.entries(UPDATE_STRING_LIMITS)) { + if (field in record) assertString(record[field], field, maxLength); + } + for (const field of ['tts_volume', 'tts_rate', 'tts_pitch']) { + if (field in record && !isInteger(record[field], -100, 100)) { + throw new TypeError(`${field} is invalid`); + } + } + if ('chat_history_conf' in record && !isInteger(record.chat_history_conf, 0, 2)) { + throw new TypeError('chat_history_conf is invalid'); + } +} + +export async function getAiHardwareOverview(): Promise { + return request('', undefined, readOverview) as Promise; +} + +export async function recoverAiHardwareCredential( + options?: AiHardwareMutationOptions, +): Promise { + const operationIdValue = operationId(options); + return mutationRequest( + '/credential-recovery', 'POST', {}, operationIdValue, readOverview, + ) as Promise; +} + +export async function createAiHardwareAgent(agentName: string, options?: AiHardwareMutationOptions): Promise { + const normalizedName = agentName.trim(); + assertString(normalizedName, 'agent_name', 64); + const operationIdValue = operationId(options); + return mutationRequest('/agents', 'POST', { agent_name: normalizedName }, operationIdValue, readAgent) as Promise; +} + +export async function bindAiHardwareDevice( + activationCode: string, + agentId: string, + options?: AiHardwareMutationOptions, +): Promise { + if (!/^[0-9]{6}$/.test(activationCode)) throw new TypeError('activation_code is invalid'); + assertAgentId(agentId); + const operationIdValue = operationId(options); + return mutationRequest('/device-bindings', 'POST', { + activation_code: activationCode, + agent_id: agentId, + }, operationIdValue, readDevice) as Promise; +} + +export async function getAiHardwareAgentConfiguration( + agentId: string, +): Promise> { + assertAgentId(agentId); + return request(`/agents/${encodeURIComponent(agentId)}`, undefined, readConfiguration, true) as Promise>; +} + +export async function updateAiHardwareAgentConfiguration( + agentId: string, + revision: number, + update: AiHardwareAgentConfigurationUpdate, + options?: AiHardwareMutationOptions, +): Promise> { + assertAgentId(agentId); + assertRevision(revision); + validateUpdate(update); + const operationIdValue = operationId(options); + return mutationRequest(`/agents/${encodeURIComponent(agentId)}`, 'PATCH', { + revision, + ...update, + }, operationIdValue, readConfiguration, true) as Promise>; +} + +export async function getAiHardwareAssignment( + deviceId: string, +): Promise> { + assertString(deviceId, 'device_id', 36); + return request(`/devices/${encodeURIComponent(deviceId)}/agent-assignment`, undefined, readDevice, true) as Promise>; +} + +export async function updateAiHardwareAssignment( + deviceId: string, + revision: number, + agentId: string, + options?: AiHardwareMutationOptions, +): Promise> { + assertString(deviceId, 'device_id', 36); + assertRevision(revision); + assertAgentId(agentId); + const operationIdValue = operationId(options); + return mutationRequest(`/devices/${encodeURIComponent(deviceId)}/agent-assignment`, 'PUT', { + revision, + agent_id: agentId, + }, operationIdValue, readDevice, true) as Promise>; +} diff --git a/src/lib/ai-modules.ts b/src/lib/ai-modules.ts index 2bd6eb2..50c271c 100644 --- a/src/lib/ai-modules.ts +++ b/src/lib/ai-modules.ts @@ -49,8 +49,8 @@ export const aiModules: readonly AiModuleDefinition[] = [ title: 'Makelore Robot', subtitle: 'AI 机器', description: '制作你的第一个机器人小伙伴', - route: null, - enabled: false, + route: '/ai-hardware', + enabled: true, Icon: Bot, switcherLabel: 'Robot 机器', }, @@ -60,5 +60,8 @@ export function getAiModuleForPath(pathname: string): AiModuleId { if (pathname === '/image-canvas' || pathname.startsWith('/image-canvas/')) { return 'painting'; } + if (pathname === '/ai-hardware' || pathname.startsWith('/ai-hardware/')) { + return 'robot'; + } return 'programming'; } diff --git a/src/pages/AiHardware/index.tsx b/src/pages/AiHardware/index.tsx new file mode 100644 index 0000000..fbfc525 --- /dev/null +++ b/src/pages/AiHardware/index.tsx @@ -0,0 +1,391 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Bot, Link2, Loader2, Plus, RefreshCw, Settings2 } from 'lucide-react'; +import { FeedbackState } from '@/components/common/FeedbackState'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + AiHardwareApiError, + bindAiHardwareDevice, + createAiHardwareAgent, + getAiHardwareAgentConfiguration, + getAiHardwareAssignment, + getAiHardwareOverview, + recoverAiHardwareCredential, + updateAiHardwareAgentConfiguration, + updateAiHardwareAssignment, + type AiHardwareAgent, + type AiHardwareAgentConfiguration, + type AiHardwareAgentConfigurationUpdate, + type AiHardwareClearableField, + type AiHardwareDevice, + type AiHardwareOverview, +} from '@/lib/ai-hardware'; + +type PageState = 'loading' | 'ready' | 'disabled' | 'auth' | 'error'; +type BindRetryIntent = { agentId: string; fingerprint: ArrayBuffer; operationId: string }; +type ConfigDraft = { + agent_name: string; system_prompt: string; language: string; lang_code: string; + asr_model_id: string; vad_model_id: string; llm_model_id: string; slm_model_id: string; + vllm_model_id: string; tts_model_id: string; + tts_voice_id: string; tts_volume: string; tts_rate: string; tts_pitch: string; + tts_language: string; mem_model_id: string; intent_model_id: string; + chat_history_conf: string; +}; + +const nullableTextFields = [ + 'system_prompt', 'language', 'lang_code', 'asr_model_id', 'vad_model_id', 'llm_model_id', + 'slm_model_id', 'vllm_model_id', 'tts_model_id', 'tts_voice_id', 'tts_language', + 'mem_model_id', 'intent_model_id', +] as const; +const nullableNumberFields = ['tts_volume', 'tts_rate', 'tts_pitch'] as const; +const shortId = (value: string) => value.length > 12 ? `${value.slice(0, 6)}…${value.slice(-4)}` : value; +const isRevisionConflict = (error: unknown) => error instanceof AiHardwareApiError + && ['ai_hardware_revision_conflict', 'REVISION_MISMATCH', 'AI_HARDWARE_REVISION_CONFLICT'].includes(error.code); +function safeMessage(error: unknown): string { + if (!(error instanceof AiHardwareApiError)) return '操作没有完成,请稍后重试。'; + if (isRevisionConflict(error)) return '内容已在其他位置更新,请重新核对后再保存。'; + const messages: Record = { + AI_HARDWARE_AUTH_REQUIRED: '请先登录 Works Square,然后重试。', + AI_HARDWARE_FORBIDDEN: '当前账号无权执行此操作。', + AI_HARDWARE_DISABLED: 'AI 机器模块尚未启用,请联系管理员。', + ai_hardware_unconfigured: 'AI 机器服务尚未配置,请联系管理员。', + ai_hardware_credential_recovery_required: '需要管理员恢复设备凭据后才能继续。', + ai_hardware_credential_unavailable: '设备凭据暂不可用,请联系管理员恢复。', + ai_hardware_credential_recovery_unavailable: '当前状态无需或不能恢复凭据,请刷新查看最新状态。', + ai_hardware_activation_code_invalid: '激活码无效或已过期,请从设备上获取新激活码。', + ai_hardware_device_already_bound: '设备已被绑定,无法重复绑定。', + ai_hardware_idempotency_conflict: '本次输入与待重试操作不一致,请再次提交以启动新操作。', + AI_HARDWARE_RATE_LIMITED: '请求过于频繁,请稍后重试。', + }; + if (error.code === 'ai_hardware_operation_in_progress') { + return error.retryAfterSeconds === null + ? '操作仍在处理中,请稍后重试。' + : `操作仍在处理中,请在 ${error.retryAfterSeconds} 秒后重试。`; + } + return messages[error.code] ?? (error.retryable ? '服务暂时不可用,请稍后重试。' : '操作没有完成,请稍后重试。'); +} +const retryOperationId = (error: unknown): string | null => error instanceof AiHardwareApiError + && (error.retryable || error.code === 'ai_hardware_operation_in_progress') ? error.operationId : null; + +function draftFrom(config: AiHardwareAgentConfiguration): ConfigDraft { + return { + agent_name: config.name, + system_prompt: config.system_prompt ?? '', language: config.language ?? '', lang_code: config.lang_code ?? '', + asr_model_id: config.asr_model_id ?? '', vad_model_id: config.vad_model_id ?? '', + llm_model_id: config.llm_model_id ?? '', slm_model_id: config.slm_model_id ?? '', + vllm_model_id: config.vllm_model_id ?? '', tts_model_id: config.tts_model_id ?? '', + tts_voice_id: config.tts_voice_id ?? '', tts_volume: config.tts_volume?.toString() ?? '', + tts_rate: config.tts_rate?.toString() ?? '', tts_pitch: config.tts_pitch?.toString() ?? '', + tts_language: config.tts_language ?? '', mem_model_id: config.mem_model_id ?? '', + intent_model_id: config.intent_model_id ?? '', + chat_history_conf: config.chat_history_conf?.toString() ?? '', + }; +} + +function configurationUpdate(config: AiHardwareAgentConfiguration, draft: ConfigDraft): AiHardwareAgentConfigurationUpdate { + const update: AiHardwareAgentConfigurationUpdate = {}; + const clearFields: AiHardwareClearableField[] = []; + if (draft.agent_name.trim() !== config.name) update.agent_name = draft.agent_name.trim(); + for (const field of nullableTextFields) { + const value = draft[field].trim(); + if (!value && config[field] !== null) clearFields.push(field); + else if (value && value !== config[field]) update[field] = value; + } + for (const field of nullableNumberFields) { + const raw = draft[field].trim(); + if (!raw && config[field] !== null) clearFields.push(field); + else if (raw && Number(raw) !== config[field]) update[field] = Number(raw); + } + if (draft.chat_history_conf !== '') { + const history = Number(draft.chat_history_conf); + if (history !== config.chat_history_conf) update.chat_history_conf = history; + } + if (clearFields.length) update.clear_fields = clearFields; + return update; +} + +function replayConfigurationUpdate( + config: AiHardwareAgentConfiguration, + update: AiHardwareAgentConfigurationUpdate, +): ConfigDraft { + const next = draftFrom(config); + if (update.agent_name !== undefined) next.agent_name = update.agent_name; + for (const field of nullableTextFields) { + if (update[field] !== undefined) next[field] = update[field]; + if (update.clear_fields?.includes(field)) next[field] = ''; + } + for (const field of nullableNumberFields) { + if (update[field] !== undefined) next[field] = update[field].toString(); + if (update.clear_fields?.includes(field)) next[field] = ''; + } + if (update.chat_history_conf !== undefined) next.chat_history_conf = update.chat_history_conf.toString(); + return next; +} + +async function createBindFingerprintKey(): Promise { + return crypto.subtle.generateKey({ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); +} + +async function bindFingerprint(key: CryptoKey, activationCode: string): Promise { + return crypto.subtle.sign('HMAC', key, new TextEncoder().encode(activationCode)); +} + +function equalFingerprint(left: ArrayBuffer, right: ArrayBuffer): boolean { + const a = new Uint8Array(left); + const b = new Uint8Array(right); + if (a.length !== b.length) return false; + let difference = 0; + for (let index = 0; index < a.length; index += 1) difference |= a[index] ^ b[index]; + return difference === 0; +} + +export function AiHardware() { + const [pageState, setPageState] = useState('loading'); + const [overview, setOverview] = useState(null); + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [config, setConfig] = useState(null); + const [configRevision, setConfigRevision] = useState(null); + const [configLoading, setConfigLoading] = useState(false); + const [notice, setNotice] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [bindOpen, setBindOpen] = useState(false); + const [configOpen, setConfigOpen] = useState(false); + const [assignmentDevice, setAssignmentDevice] = useState(null); + const [agentName, setAgentName] = useState(''); + const [dialogAgentId, setDialogAgentId] = useState(''); + const [draft, setDraft] = useState(null); + const [dialogError, setDialogError] = useState(null); + const [busy, setBusy] = useState(false); + const [createOperationId, setCreateOperationId] = useState(null); + const [configOperationId, setConfigOperationId] = useState(null); + const [assignmentOperationId, setAssignmentOperationId] = useState(null); + const [createOperationBody, setCreateOperationBody] = useState(null); + const [configOperationBody, setConfigOperationBody] = useState(null); + const [assignmentOperationBody, setAssignmentOperationBody] = useState(null); + const [recoveryOperationId, setRecoveryOperationId] = useState(null); + const [recoveryError, setRecoveryError] = useState(null); + const [recoveryBusy, setRecoveryBusy] = useState(false); + const activationCodeInputRef = useRef(null); + const bindFingerprintKeyRef = useRef(null); + const bindRetryIntentRef = useRef(null); + + const clearCreateOperation = () => { setCreateOperationId(null); setCreateOperationBody(null); }; + const clearBindOperation = () => { + bindRetryIntentRef.current = null; + bindFingerprintKeyRef.current = null; + if (activationCodeInputRef.current) activationCodeInputRef.current.value = ''; + }; + const clearConfigOperation = () => { setConfigOperationId(null); setConfigOperationBody(null); }; + const clearAssignmentOperation = () => { setAssignmentOperationId(null); setAssignmentOperationBody(null); }; + + const recoverCredential = async () => { + if (recoveryBusy) return; + setRecoveryBusy(true); setRecoveryError(null); + try { + const value = recoveryOperationId + ? await recoverAiHardwareCredential({ operationId: recoveryOperationId }) + : await recoverAiHardwareCredential(); + setRecoveryOperationId(null); setOverview(value); setPageState('ready'); + setSelectedAgentId(value.agents[0]?.id ?? null); + } catch (error) { + if (error instanceof AiHardwareApiError && error.code === 'ai_hardware_credential_recovery_unavailable') { + setRecoveryOperationId(null); + await loadOverview(); + return; + } + setRecoveryOperationId(retryOperationId(error)); + setRecoveryError(safeMessage(error)); + } finally { setRecoveryBusy(false); } + }; + + const loadOverview = useCallback(async () => { + setPageState('loading'); setNotice(null); + try { + const value = await getAiHardwareOverview(); + setOverview(value); setPageState('ready'); + setSelectedAgentId((current) => value.agents.some((item) => item.id === current) ? current : value.agents[0]?.id ?? null); + } catch (error) { + setOverview(null); + if (error instanceof AiHardwareApiError && error.code === 'AI_HARDWARE_AUTH_REQUIRED') setPageState('auth'); + else if (error instanceof AiHardwareApiError && ['AI_HARDWARE_DISABLED', 'ai_hardware_unconfigured'].includes(error.code)) setPageState('disabled'); + else setPageState('error'); + } + }, []); + + useEffect(() => { void loadOverview(); }, [loadOverview]); + useEffect(() => { + if (!selectedAgentId) { setConfig(null); setConfigRevision(null); return; } + let active = true; + setConfig(null); + setConfigRevision(null); + setNotice(null); + setConfigLoading(true); + void getAiHardwareAgentConfiguration(selectedAgentId).then((result) => { + if (!active) return; + setConfig(result.data); setConfigRevision(result.revision); + }).catch(() => { if (active) setNotice('无法读取智能体配置,请刷新后重试。'); }) + .finally(() => { if (active) setConfigLoading(false); }); + return () => { active = false; }; + }, [selectedAgentId]); + + const selectedAgent = overview?.agents.find((item) => item.id === selectedAgentId) ?? null; + const devices = useMemo(() => overview?.devices ?? [], [overview]); + const resetDialog = () => { + setDialogError(null); setBusy(false); + clearCreateOperation(); clearBindOperation(); clearConfigOperation(); clearAssignmentOperation(); + }; + + const createAgent = async () => { + const name = agentName.trim(); + if (!name || name.length > 64) { setDialogError('名称需要包含 1–64 个字符。'); return; } + setBusy(true); setDialogError(null); + try { + const canRetry = createOperationId && createOperationBody === name; + const agent = canRetry ? await createAiHardwareAgent(name, { operationId: createOperationId }) : await createAiHardwareAgent(name); + setCreateOperationId(null); + setCreateOperationBody(null); + setCreateOpen(false); setAgentName(''); await loadOverview(); setSelectedAgentId(agent.id); + } catch (error) { setCreateOperationId(retryOperationId(error)); setCreateOperationBody(name); setDialogError(safeMessage(error)); } finally { setBusy(false); } + }; + + const bindDevice = async () => { + const activationCode = activationCodeInputRef.current?.value ?? ''; + if (!/^[0-9]{6}$/.test(activationCode)) { setDialogError('请输入 6 位数字激活码。'); return; } + if (!dialogAgentId) { setDialogError('请选择要绑定的智能体。'); return; } + setBusy(true); setDialogError(null); + try { + const key = bindFingerprintKeyRef.current ?? await createBindFingerprintKey(); + bindFingerprintKeyRef.current = key; + const fingerprint = await bindFingerprint(key, activationCode); + const retry = bindRetryIntentRef.current; + if (retry?.agentId === dialogAgentId && equalFingerprint(retry.fingerprint, fingerprint)) { + await bindAiHardwareDevice(activationCode, dialogAgentId, { operationId: retry.operationId }); + } + else await bindAiHardwareDevice(activationCode, dialogAgentId); + clearBindOperation(); + setBindOpen(false); await loadOverview(); + } catch (error) { + const operationId = retryOperationId(error); + if (operationId && bindFingerprintKeyRef.current) { + bindRetryIntentRef.current = { + agentId: dialogAgentId, + fingerprint: await bindFingerprint(bindFingerprintKeyRef.current, activationCode), + operationId, + }; + } else bindRetryIntentRef.current = null; + setDialogError(safeMessage(error)); + if (activationCodeInputRef.current) activationCodeInputRef.current.value = ''; + } finally { setBusy(false); } + }; + + const saveConfiguration = async () => { + if (!config || !draft || configRevision === null) return; + if (!draft.agent_name.trim() || draft.agent_name.trim().length > 64) { setDialogError('名称需要包含 1–64 个字符。'); return; } + for (const field of nullableNumberFields) { + if (draft[field] && (!Number.isInteger(Number(draft[field])) || Number(draft[field]) < -100 || Number(draft[field]) > 100)) { + setDialogError('音量、语速和音调必须是 -100 到 100 的整数。'); return; + } + } + const update = configurationUpdate(config, draft); + if (!Object.keys(update).length) { setConfigOpen(false); return; } + setBusy(true); setDialogError(null); + try { + const body = JSON.stringify(update); + const result = configOperationId && configOperationBody === body + ? await updateAiHardwareAgentConfiguration(config.id, configRevision, update, { operationId: configOperationId }) + : await updateAiHardwareAgentConfiguration(config.id, configRevision, update); + setConfigOperationId(null); + setConfigOperationBody(null); + setConfig(result.data); setConfigRevision(result.revision); setConfigOpen(false); await loadOverview(); + } catch (error) { + if (isRevisionConflict(error)) { + setConfigOperationId(null); + const fresh = await getAiHardwareAgentConfiguration(config.id).catch(() => null); + if (fresh) { + setConfig(fresh.data); + setConfigRevision(fresh.revision); + setDraft(replayConfigurationUpdate(fresh.data, update)); + } + } + else { setConfigOperationId(retryOperationId(error)); setConfigOperationBody(JSON.stringify(update)); } + setDialogError(safeMessage(error)); + } finally { setBusy(false); } + }; + + const openAssignment = async (device: AiHardwareDevice) => { + setDialogError(null); setBusy(true); setDialogAgentId(device.agent_id); setAssignmentOperationId(null); + try { const result = await getAiHardwareAssignment(device.id); setAssignmentDevice({ ...result.data, assignment_revision: result.revision }); } + catch { setNotice('无法读取设备指派,请刷新后重试。'); } + finally { setBusy(false); } + }; + const saveAssignment = async () => { + if (!assignmentDevice || !dialogAgentId) return; + setBusy(true); setDialogError(null); + try { + const body = dialogAgentId; + if (assignmentOperationId && assignmentOperationBody === body) await updateAiHardwareAssignment(assignmentDevice.id, assignmentDevice.assignment_revision, dialogAgentId, { operationId: assignmentOperationId }); + else await updateAiHardwareAssignment(assignmentDevice.id, assignmentDevice.assignment_revision, dialogAgentId); + setAssignmentOperationId(null); + setAssignmentOperationBody(null); + setAssignmentDevice(null); await loadOverview(); + } catch (error) { + if (isRevisionConflict(error)) { + setAssignmentOperationId(null); + const fresh = await getAiHardwareAssignment(assignmentDevice.id).catch(() => null); + if (fresh) setAssignmentDevice({ ...fresh.data, assignment_revision: fresh.revision }); + } + else { setAssignmentOperationId(retryOperationId(error)); setAssignmentOperationBody(dialogAgentId); } + setDialogError(safeMessage(error)); + } finally { setBusy(false); } + }; + + if (pageState === 'loading') return
; + if (pageState === 'auth') return
void loadOverview()}>重新检查} />
; + if (pageState === 'disabled') return
AI 机器尚未启用管理员启用并完成连接配置后,这里会显示你的智能体和机器人设备。
; + if (pageState === 'error' || !overview) return
void loadOverview()}>重试} />
; + if (overview.status === 'credential_recovery_required' || overview.status === 'invalid') return
void recoverCredential()}>{recoveryBusy && }恢复设备凭据} />{recoveryError ?

{recoveryError}

: null}
; + if (overview.status === 'provisioning') return
void loadOverview()}>刷新状态} />
; + + return ( +
+
+

机器人工作台

管理智能体、机器人设备绑定与公开配置。

+ +
+ {notice ?

{notice}

: null} + {overview.agents.length === 0 ? ( + 创建第一个智能体{overview.status === 'unprovisioned' ? '创建智能体将同时开通你的机器人工作台。' : '智能体创建后,才能把机器人设备绑定给它。'} + ) : ( +
+
智能体{overview.agents.length} 个
{overview.agents.map((agent) => )}
+
+
{selectedAgent?.name ?? '智能体配置'}基础对话和语音设置
{config ?
语言
{config.language || config.lang_code || '未设置'}
语音
{config.tts_voice_id || '未设置'}
系统提示
{config.system_prompt || '未设置'}
: }
+
设备{devices.length} 台已绑定设备
{devices.length ?
{devices.map((device) =>
设备 {shortId(device.id)}指派 r{device.assignment_revision}
)}
: }
+
+
+ )} + + { if (!busy) { setCreateOpen(open); if (!open) { setAgentName(''); resetDialog(); } } }}>创建智能体输入一个容易识别的名称,稍后仍可修改。
{ setAgentName(e.target.value); clearCreateOperation(); }} />
{dialogError ?

{dialogError}

: null}
+ { if (!busy) { setBindOpen(open); if (!open) resetDialog(); } }}>绑定设备激活码只用于本次绑定,关闭窗口后会立即清除。
{ e.currentTarget.value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6); }} />
{ setDialogAgentId(value); clearBindOperation(); }} />{dialogError ?

{dialogError}

: null}
+ { if (!busy) { setConfigOpen(open); if (!open) resetDialog(); } }}>编辑智能体配置留空可清除可选字段;保存时不会发送空值。{draft ? { setDraft(next); clearConfigOperation(); }} /> : null}{dialogError ?

{dialogError}

: null}
+ { if (!open && !busy) { setAssignmentDevice(null); resetDialog(); } }}>重新指派设备选择接收这台设备的智能体。系统会检查最新修订,避免覆盖其他更改。 { setDialogAgentId(value); clearAssignmentOperation(); }} />{dialogError ?

{dialogError}

: null}
+
+ ); +} + +function AgentSelect({ agents, value, onChange }: { agents: AiHardwareAgent[]; value: string; onChange: (value: string) => void }) { + return
; +} + +function ConfigFields({ draft, setDraft }: { draft: ConfigDraft; setDraft: (draft: ConfigDraft) => void }) { + const field = (key: keyof ConfigDraft, label: string, type = 'text') =>
setDraft({ ...draft, [key]: e.target.value })} />
; + return
{field('agent_name', '名称')}