From fe55deed04046fc9086d96533eb3a38d9b1775ae Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 16 Aug 2026 00:55:06 +0800 Subject: [PATCH] feat: add dynamic Robot configuration choices --- .../20260816-robot-config-selects-5a7c.md | 58 ++++++++ electron/api/routes/ai-hardware.ts | 67 +++++++++ src/lib/ai-hardware.ts | 97 +++++++++++++ src/pages/AiHardware/index.tsx | 131 +++++++++++++++++- tests/unit/ai-hardware-api.test.ts | 39 ++++++ tests/unit/ai-hardware-page.test.tsx | 81 ++++++++++- tests/unit/ai-hardware-routes.test.ts | 61 +++++++- 7 files changed, 524 insertions(+), 10 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260816-robot-config-selects-5a7c.md diff --git a/.project-docs/30-worklog/tasks/20260816-robot-config-selects-5a7c.md b/.project-docs/30-worklog/tasks/20260816-robot-config-selects-5a7c.md new file mode 100644 index 0000000..df187ed --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260816-robot-config-selects-5a7c.md @@ -0,0 +1,58 @@ +# Task: Optimize Robot configuration editor controls + +## Identity + +- Task ID: 20260816-robot-config-selects-5a7c +- Mode: Feature +- Branch: codex/20260816-robot-config-selects-5a7c-robot-config-selects +- Worktree: D:\Datas\OthersProjects\makelore-robot-config-selects-5a7c +- Base commit: 7bef261fb87b1928e1c4c289a479657bf909e1d2 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Use the signed-in Xiaozhi role-configuration page as a read-only interaction reference for the Makelore Robot configuration editor. +- Replace avoidable free-text and numeric entry with selection-oriented controls while preserving every supported public configuration field and `clear_fields` behavior. +- Add the smallest safe option-catalog seam required for dynamic model, language, and voice choices; do not expose Xiaozhi credentials or arbitrary upstream paths to Renderer. +- Add focused Renderer/API/route tests and preserve the existing revision-conflict, idempotency, error-redaction, and credential boundaries. + +## Intent And Constraints + +- Nickname and system prompt remain free-form; VAD/ASR/LLM/SLM/VLLM/Intent/Mem/TTS/language/voice should follow Xiaozhi's selection pattern when an authoritative option source exists. +- TTS volume, rate, and pitch must be adjustable without keyboard entry and must retain the full supported integer range, including existing non-preset values. +- Dynamic choices must use stable machine values with human-readable labels, preserve the currently configured value, and fail safely when catalogs are unavailable. +- The page must remain usable at the existing desktop viewport, with accessible labels, keyboard interaction, busy/error states, and at least 40px interactive targets. +- This feature task owns only its isolated worktree and task-scoped record; canonical project memory remains integration-owned. + +## Plan + +1. Verify the Xiaozhi UI and option sources, then define the minimal safe catalog contract. +2. Implement selection controls and dependent option behavior without weakening update validation. +3. Add TDD coverage for catalogs, selected values, clearing, numeric controls, and unchanged conflict recovery. +4. Run focused/full checks, obtain independent review, then integrate through the existing serialized main task. + +## Outcome + +- Added a strict Renderer catalog API and fixed Electron Main proxy route for Xiaozhi model/voice choices without exposing provider credentials or arbitrary upstream paths. +- Reworked the Robot configuration dialog into grouped Basic, Model, and Language/Voice sections. Model IDs, language values, voices, and chat-history modes now use selects; SLM reuses the LLM directory. +- Replaced numeric TTS inputs with bounded `-100..100` sliders and explicit provider-default controls while preserving nullable `clear_fields` semantics. +- Preserved unavailable current values, added safe catalog retry/error states, refreshed dependent voices when TTS changes, and retained existing revision rebase and operation-identity behavior. +- Applied `private, no-store` and `no-cache` to every local catalog success/failure response, and kept all new retry/default controls at a minimum 40px interaction height. + +## Verification + +- `corepack pnpm exec vitest run tests/unit/ai-hardware-api.test.ts tests/unit/ai-hardware-routes.test.ts tests/unit/ai-hardware-page.test.tsx tests/unit/module-navigation.test.tsx tests/unit/main-layout-module-gate.test.tsx` -> 5 files, 84 tests passed. +- `corepack pnpm run typecheck` -> passed. +- Focused ESLint on the six changed TypeScript/TSX files -> passed. +- `corepack pnpm run build:vite` -> passed; only the existing chunk-size/dynamic-import warnings remained. +- `git diff --check` -> passed with Git line-ending warnings only. +- Independent Sol review -> PASS after cache-boundary and 40px target fixes; no P0-P3 findings remain. + +## Follow-ups + +- Deploy the matching Xiaozhi and Works Square catalog endpoints before releasing this client; catalog failure is safe and preserves the current configuration, but choices cannot populate until both services are updated. + +## Promotion Candidates + +- Target: `.project-docs/20-architecture/system-overview.md` and Robot integration contract. Proposal: record the USER-scoped safe configuration-catalog flow from Xiaozhi through Works Square Main-owned credentials to Makelore. Evidence: the three task records and focused cross-layer tests. Future impact: model/voice selections remain dynamic without exposing provider configuration. No semantic conflict identified; integration owner decides promotion. diff --git a/electron/api/routes/ai-hardware.ts b/electron/api/routes/ai-hardware.ts index c701de5..05aab02 100644 --- a/electron/api/routes/ai-hardware.ts +++ b/electron/api/routes/ai-hardware.ts @@ -13,6 +13,7 @@ const LOCAL_ROOT = '/api/works/ai-hardware'; const UPSTREAM_ROOT = '/api/ai-hardware'; const REVISION_ETAG = /^(?:W\/)?"(0|[1-9]\d*)"$/; const LOCAL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/; +const CATALOG_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,254}$/; 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; @@ -119,6 +120,52 @@ function projectOverview(value: unknown): Record | null { return { status: value.status, agents, devices }; } +const CATALOG_MODEL_TYPES = new Set([ + 'VAD', 'ASR', 'LLM', 'VLLM', 'Intent', 'Memory', 'TTS', +]); + +function projectCatalog(value: unknown): Record | null { + if (!isRecord(value) + || value.schema_version !== 1 + || !Array.isArray(value.models) + || value.models.length > 256 + || !Array.isArray(value.voices) + || value.voices.length > 512) return null; + const models: Record[] = []; + for (const item of value.models) { + if (!isRecord(item) + || typeof item.model_type !== 'string' + || !CATALOG_MODEL_TYPES.has(item.model_type) + || typeof item.model_id !== 'string' || item.model_id.length < 1 || item.model_id.length > 255 + || typeof item.model_name !== 'string' || item.model_name.length < 1 || item.model_name.length > 128 + || (item.supports_function_call !== null && typeof item.supports_function_call !== 'boolean')) return null; + models.push({ + model_type: item.model_type, + model_id: item.model_id, + model_name: item.model_name, + supports_function_call: item.supports_function_call, + }); + } + const voices: Record[] = []; + for (const item of value.voices) { + if (!isRecord(item) + || typeof item.tts_model_id !== 'string' || item.tts_model_id.length < 1 || item.tts_model_id.length > 255 + || typeof item.voice_id !== 'string' || item.voice_id.length < 1 || item.voice_id.length > 255 + || typeof item.voice_name !== 'string' || item.voice_name.length < 1 || item.voice_name.length > 128 + || !Array.isArray(item.languages) || item.languages.length > 16 + || item.languages.some((language) => typeof language !== 'string' || language.length < 1 || language.length > 50) + || typeof item.is_clone !== 'boolean') return null; + voices.push({ + tts_model_id: item.tts_model_id, + voice_id: item.voice_id, + voice_name: item.voice_name, + languages: item.languages, + is_clone: item.is_clone, + }); + } + return { schema_version: 1, models, voices }; +} + async function readBoundedJson(req: IncomingMessage): Promise> { const declared = Number(req.headers['content-length']); if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) { @@ -323,6 +370,7 @@ const SAFE_UPSTREAM_ERRORS: Record = { 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_catalog_unavailable: 'AI hardware configuration options are unavailable', 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', @@ -384,6 +432,10 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend _ctx: HostApiContext, ): Promise { if (url.pathname !== LOCAL_ROOT && !url.pathname.startsWith(`${LOCAL_ROOT}/`)) return false; + if (url.pathname === `${LOCAL_ROOT}/catalog`) { + res.setHeader('Cache-Control', 'private, no-store'); + res.setHeader('Pragma', 'no-cache'); + } let operationId: string | undefined; try { @@ -399,6 +451,21 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend upstreamPath = UPSTREAM_ROOT; project = projectOverview; expectedStatus = 200; + } else if (method === 'GET' && url.pathname === `${LOCAL_ROOT}/catalog`) { + const keys = [...url.searchParams.keys()]; + const values = url.searchParams.getAll('tts_model_id'); + if (keys.some((key) => key !== 'tts_model_id') || values.length > 1) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + const ttsModelId = values[0]; + if (ttsModelId !== undefined && !CATALOG_ID.test(ttsModelId)) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + upstreamPath = `${UPSTREAM_ROOT}/catalog${ + ttsModelId === undefined ? '' : `?tts_model_id=${encodeURIComponent(ttsModelId)}` + }`; + project = projectCatalog; + expectedStatus = 200; } else if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/credential-recovery`) { const input = await readBoundedJson(req); ensureExactKeys(input, new Set(['client_operation_id'])); diff --git a/src/lib/ai-hardware.ts b/src/lib/ai-hardware.ts index 1b8962f..f58c7d8 100644 --- a/src/lib/ai-hardware.ts +++ b/src/lib/ai-hardware.ts @@ -18,6 +18,7 @@ const SAFE_ERROR_MESSAGES: Record = { 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_catalog_unavailable: 'AI hardware configuration options are unavailable', 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', @@ -80,6 +81,36 @@ export type AiHardwareAgentConfiguration = AiHardwareAgent & { chat_history_conf: number | null; }; +export type AiHardwareCatalogModelType = + | 'VAD' + | 'ASR' + | 'LLM' + | 'VLLM' + | 'Intent' + | 'Memory' + | 'TTS'; + +export type AiHardwareCatalogModel = { + model_type: AiHardwareCatalogModelType; + model_id: string; + model_name: string; + supports_function_call: boolean | null; +}; + +export type AiHardwareCatalogVoice = { + tts_model_id: string; + voice_id: string; + voice_name: string; + languages: string[]; + is_clone: boolean; +}; + +export type AiHardwareConfigurationCatalog = { + schema_version: 1; + models: AiHardwareCatalogModel[]; + voices: AiHardwareCatalogVoice[]; +}; + export const AI_HARDWARE_CLEARABLE_FIELDS = [ 'system_prompt', 'lang_code', @@ -249,6 +280,62 @@ function readOverview(value: unknown): AiHardwareOverview { }; } +const CATALOG_MODEL_TYPES = new Set([ + 'VAD', 'ASR', 'LLM', 'VLLM', 'Intent', 'Memory', 'TTS', +]); + +function boundedString(value: unknown, maxLength: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= maxLength; +} + +function readCatalogModel(value: unknown): AiHardwareCatalogModel { + if (!isRecord(value) + || !hasOnlyKeys(value, ['model_type', 'model_id', 'model_name', 'supports_function_call']) + || Object.keys(value).length !== 4 + || typeof value.model_type !== 'string' + || !CATALOG_MODEL_TYPES.has(value.model_type as AiHardwareCatalogModelType) + || !boundedString(value.model_id, 255) + || !boundedString(value.model_name, 128) + || (value.supports_function_call !== null && typeof value.supports_function_call !== 'boolean')) { + invalidPayload(); + } + return value as AiHardwareCatalogModel; +} + +function readCatalogVoice(value: unknown): AiHardwareCatalogVoice { + if (!isRecord(value) + || !hasOnlyKeys(value, ['tts_model_id', 'voice_id', 'voice_name', 'languages', 'is_clone']) + || Object.keys(value).length !== 5 + || !boundedString(value.tts_model_id, 255) + || !boundedString(value.voice_id, 255) + || !boundedString(value.voice_name, 128) + || !Array.isArray(value.languages) + || value.languages.length > 16 + || value.languages.some((language) => !boundedString(language, 50)) + || typeof value.is_clone !== 'boolean') { + invalidPayload(); + } + return value as AiHardwareCatalogVoice; +} + +function readConfigurationCatalog(value: unknown): AiHardwareConfigurationCatalog { + if (!isRecord(value) + || !hasOnlyKeys(value, ['schema_version', 'models', 'voices']) + || Object.keys(value).length !== 3 + || value.schema_version !== 1 + || !Array.isArray(value.models) + || value.models.length > 256 + || !Array.isArray(value.voices) + || value.voices.length > 512) { + invalidPayload(); + } + return { + schema_version: 1, + models: value.models.map(readCatalogModel), + voices: value.voices.map(readCatalogVoice), + }; +} + function readEnvelope(value: unknown): MainEnvelope { if (!isRecord(value) || !hasOnlyKeys(value, [ 'success', 'status', 'code', 'error', 'retryable', 'retry_after_seconds', 'operation_id', 'data', 'revision', @@ -416,6 +503,16 @@ export async function getAiHardwareOverview(): Promise { return request('', undefined, readOverview) as Promise; } +export async function getAiHardwareConfigurationCatalog( + ttsModelId?: string, +): Promise { + if (ttsModelId !== undefined) assertString(ttsModelId, 'tts_model_id', 255); + const query = ttsModelId === undefined + ? '' + : `?tts_model_id=${encodeURIComponent(ttsModelId)}`; + return request(`/catalog${query}`, undefined, readConfigurationCatalog) as Promise; +} + export async function recoverAiHardwareCredential( options?: AiHardwareMutationOptions, ): Promise { diff --git a/src/pages/AiHardware/index.tsx b/src/pages/AiHardware/index.tsx index 7ea1d90..623f12f 100644 --- a/src/pages/AiHardware/index.tsx +++ b/src/pages/AiHardware/index.tsx @@ -8,12 +8,14 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Select } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { AiHardwareApiError, bindAiHardwareDevice, createAiHardwareAgent, getAiHardwareAgentConfiguration, + getAiHardwareConfigurationCatalog, getAiHardwareAssignment, getAiHardwareOverview, recoverAiHardwareCredential, @@ -22,7 +24,9 @@ import { type AiHardwareAgent, type AiHardwareAgentConfiguration, type AiHardwareAgentConfigurationUpdate, + type AiHardwareCatalogModelType, type AiHardwareClearableField, + type AiHardwareConfigurationCatalog, type AiHardwareDevice, type AiHardwareOverview, } from '@/lib/ai-hardware'; @@ -158,6 +162,9 @@ export function AiHardware() { const [createOpen, setCreateOpen] = useState(false); const [bindOpen, setBindOpen] = useState(false); const [configOpen, setConfigOpen] = useState(false); + const [catalog, setCatalog] = useState(null); + const [catalogLoading, setCatalogLoading] = useState(false); + const [catalogError, setCatalogError] = useState(null); const [assignmentDevice, setAssignmentDevice] = useState(null); const [agentName, setAgentName] = useState(''); const [dialogAgentId, setDialogAgentId] = useState(''); @@ -176,6 +183,7 @@ export function AiHardware() { const activationCodeInputRef = useRef(null); const bindFingerprintKeyRef = useRef(null); const bindRetryIntentRef = useRef(null); + const catalogRequestRef = useRef(0); const clearCreateOperation = () => { setCreateOperationId(null); setCreateOperationBody(null); }; const clearBindOperation = () => { @@ -186,6 +194,24 @@ export function AiHardware() { const clearConfigOperation = () => { setConfigOperationId(null); setConfigOperationBody(null); }; const clearAssignmentOperation = () => { setAssignmentOperationId(null); setAssignmentOperationBody(null); }; + const loadConfigurationCatalog = useCallback(async (ttsModelId?: string) => { + const requestId = catalogRequestRef.current + 1; + catalogRequestRef.current = requestId; + setCatalogLoading(true); + setCatalogError(null); + setCatalog((current) => current ? { ...current, voices: [] } : null); + try { + const next = await getAiHardwareConfigurationCatalog(ttsModelId); + if (catalogRequestRef.current !== requestId) return; + setCatalog(next); + } catch { + if (catalogRequestRef.current !== requestId) return; + setCatalogError('暂时无法读取可选配置,请重试。当前配置会保持不变。'); + } finally { + if (catalogRequestRef.current === requestId) setCatalogLoading(false); + } + }, []); + const recoverCredential = async () => { if (recoveryBusy) return; setRecoveryBusy(true); setRecoveryError(null); @@ -254,6 +280,17 @@ export function AiHardware() { clearCreateOperation(); clearBindOperation(); clearConfigOperation(); clearAssignmentOperation(); }; + const openConfigurationEditor = () => { + if (!config) return; + resetDialog(); + const next = draftFrom(config); + setDraft(next); + setCatalog(null); + setCatalogError(null); + setConfigOpen(true); + void loadConfigurationCatalog(next.tts_model_id || undefined); + }; + const createAgent = async () => { const name = agentName.trim(); if (!name || name.length > 64) { setDialogError('名称需要包含 1–64 个字符。'); return; } @@ -378,7 +415,7 @@ export function AiHardware() {
智能体{overview.agents.length} 个
{overview.agents.map((agent) => )}
-
{selectedAgent?.name ?? '智能体配置'}基础对话和语音设置
{config ?
语言
{config.language || config.lang_code || '未设置'}
语音
{config.tts_voice_id || '未设置'}
系统提示
{config.system_prompt || '未设置'}
: configLoading ? : configLoadFailed ? setConfigReloadKey((value) => value + 1)}>重试读取配置} /> : null}
+
{selectedAgent?.name ?? '智能体配置'}基础对话和语音设置
{config ?
语言
{config.language || config.lang_code || '未设置'}
语音
{config.tts_voice_id || '未设置'}
系统提示
{config.system_prompt || '未设置'}
: configLoading ? : configLoadFailed ? setConfigReloadKey((value) => value + 1)}>重试读取配置} /> : null}
设备{devices.length} 台已绑定设备
{devices.length ?
{devices.map((device) =>
设备 {shortId(device.id)}指派 r{device.assignment_revision}
)}
: }
@@ -386,19 +423,101 @@ export function AiHardware() { { 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 (!busy) { setConfigOpen(open); if (!open) { resetDialog(); setCatalog(null); setCatalogError(null); } } }}>编辑智能体配置模型、语言和音色来自小智当前可用目录;选择“使用服务默认值”可清除可选字段。{draft ? { setDraft(next); clearConfigOperation(); }} onTtsModelChange={(value, next) => { setDraft(next); clearConfigOperation(); void loadConfigurationCatalog(value || undefined); }} onRetryCatalog={() => void loadConfigurationCatalog(draft.tts_model_id || undefined)} /> : 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
; + 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', '名称')}