merge: integrate Robot configuration catalog editor

This commit is contained in:
2026-08-16 00:58:54 +08:00
9 changed files with 487 additions and 12 deletions

View File

@@ -22,13 +22,14 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
| AI Design Image Source Picker | 当前 Workspace 已完成图片或本地上传 | 图片 Brief 绑定图生图参考图;视频 Brief 绑定首帧;提交一个真实 Asset ID |
| AI Design Gateway Routing | Main 云端适配器 ↔ Conversation WebSocketMain → Host API/SSE → Renderer store | 命令、Run 与设计事件共用双向 WebSocketConversation 更新按 Workspace + Conversation 路由,任务更新按 Workspace 归并 |
| Robot Workspace | Account-scoped agent configuration, device activation/binding, assignment, and credential-recovery UI | Renderer receives only safe Works Square projections; credentials, activation-code history, and upstream error bodies remain outside durable UI state. |
| AI Hardware Main Route | Fixed `/api/works/ai-hardware` Host API to Works Square proxy | Main owns Bearer auth, stable operation IDs, bounded retry, ETag/If-Match, request/response limits, and error redaction. Versioned responses accept only canonical strong or weak numeric ETags that equal the DTO revision; mutations always emit strong `If-Match`. It never forwards Renderer authorization headers. |
| Robot Workspace | Account-scoped agent configuration, device activation/binding, assignment, and credential-recovery UI | Renderer receives only safe Works Square projections. Configuration choices come from the USER-scoped safe catalog; unavailable current values remain editable without exposing provider credentials or configuration internals. |
| AI Hardware Main Route | Fixed `/api/works/ai-hardware` Host API to Works Square proxy | Main owns Bearer auth, stable operation IDs, bounded retry, ETag/If-Match, request/response limits, error redaction, and the fixed no-store configuration-catalog proxy. Versioned responses accept only canonical strong or weak numeric ETags that equal the DTO revision; mutations always emit strong `If-Match`. It never forwards Renderer authorization headers. |
## Important Boundaries
- Robot is the single enabled hardware product module; there is no separate Hardware card. Learning remains disabled.
- AI hardware network access is Main-owned. Renderer cannot hold Works Square or Xiaozhi credentials and cannot select arbitrary upstream paths or headers.
- Robot model, language, and voice choices are dynamically projected from the Xiaozhi USER catalog through Works Square and Electron Main; the catalog is bounded, account-scoped, and `private, no-store` at each public hop.
- One local account maps to one server-side Xiaozhi account binding. Agents and devices are resources beneath that account binding, not separate Xiaozhi users.
- Robot/Canvas/module-selection routes must not initialize AI Programming projects or providers.

View File

@@ -5,6 +5,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
## Integrated Through
- `ea75b06`: Robot configuration reads accept canonical weak numeric response ETags introduced by public response compression only when the numeric revision exactly matches the strictly projected DTO; configuration and assignment writes continue to emit strong `If-Match`.
- `fe55dee`: Robot configuration editing uses the safe Xiaozhi/Works catalog for model, language, and voice selections, with bounded sliders for TTS numeric controls and no-store catalog responses.
- `fd9b5b46a913c515e94e4e26f185d43866c2581f` / `7a811590c4943b7b1b7ea5f3b4d3ce3ce05622a5`: Codex-style persistent AI Programming context-compaction timeline, run-lifecycle separation, polling-idle completion, and cold-hydration hardening.
- `22378efcee07e7fb80b651e65e3202f1a1dfea1d`: AI Canvas bidirectional Agent WebSocket commands, idempotent transport fallback, and Quote-based generation-task recovery.
- `aba5cae286807093cf4ef643fe9f498050985c31`: Robot / AI hardware module, Main-owned Works Square proxy, and cross-repository wire contract.
@@ -33,6 +34,7 @@ Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失
## Recently Completed
- 2026-08-15: Corrected the deployed Robot configuration-read contract after the compressed public Works response was observed with `ETag: W/\"0\"` and matching numeric `config_revision: 0`. Electron Main now accepts only canonical strong or weak numeric response tags, still requires exact DTO revision equality, and always sends strong `If-Match` for mutations. No production client rollout is claimed.
- 2026-08-16: Integrated selection-oriented Robot configuration editing. Enabled model and caller-safe voice metadata now flows from Xiaozhi through Works Square and Electron Main without exposing provider secrets; unavailable current values and `clear_fields` semantics remain intact. Production deployment of the matching service endpoints is still required.
- 2026-08-15AI 编程上下文压缩改为 Codex 风格的会话内时间线事件;手动与自动压缩原位展示并持久保留,历史回放去重且状态只允许从 running 单调进入 completed压缩完成不再冒充整个 run idle。
- 2026-08-14: AI Canvas Agent 命令与流式事件改为共用双向 Conversation WebSocket并保留仅面向传输故障的幂等 REST 回退;结构化 Gateway 错误不重试且未知文本脱敏;确认生成按 Quote 恢复已落库任务,切换 Conversation 后仍同步 Workspace 任务且不覆盖新会话状态或错误。
- 2026-08-13: Integrated the enabled `Makelore Robot` top-level module at `/ai-hardware`. Renderer uses typed safe DTOs while Electron Main owns Works Square authentication, idempotency identities, ETag/If-Match forwarding, response projection, limits, timeouts, and credential recovery. Robot and Canvas routes no longer initialize AI Programming projects/providers. No production deployment or real activation-code smoke is claimed.

View File

@@ -12,6 +12,7 @@
## Scope
- On 2026-08-16, resume the existing Integration owner to merge reviewed Robot configuration-catalog/editor source `fe55dee` into local `main`, promote the accepted dynamic catalog boundary, and keep remote push outside this resumption.
- On 2026-08-15, resume the existing Integration owner to merge reviewed Robot configuration-schema fix `ea75b06` into local `main`, accepting canonical weak numeric response ETags produced by the deployed compression layer while keeping strong `If-Match` writes.
- On 2026-08-15, resume the existing Integration owner to merge reviewed Robot configuration-loading fix `1bcd519` into local `main`, preserving the page error/retry state and Main response-body deadline.
- On 2026-08-15, resume the existing Integration owner to merge reviewed source commit `fd9b5b46a913c515e94e4e26f185d43866c2581f` for the Codex-style AI Programming context-compaction timeline into local `main` and promote its accepted lifecycle facts.
@@ -124,6 +125,13 @@ Gate result:
## Plan
### 2026-08-16 Robot Configuration Catalog Integration Plan
1. Merge reviewed source commit `fe55dee` into local `main` with a normal no-ff merge while preserving source history.
2. Exclude the source-owned task record from the final `main` tree and promote the accepted safe catalog boundary into canonical architecture/current-state memory.
3. Re-run the Robot catalog/page/Main regression suite, typecheck, lint, production build, project-document gates, and whitespace checks.
4. Obtain an independent read-only Sol PASS/FAIL review; do not push remotely as part of this resumption.
1. Fetch `origin/main` and verify local `main`, the remote tip, and reviewed AI Canvas source commit topology.
2. Merge `22378ef` normally into local `main` without rebase/reset, and retain the source commit as a dedicated second parent.
3. Promote the bidirectional Gateway and Quote-reconciliation facts into canonical project memory.
@@ -154,6 +162,9 @@ Gate result:
## Outcome
- On 2026-08-16, started a normal `--no-ff --no-commit` merge of reviewed Robot catalog/editor source `fe55dee`; Git reported no textual conflicts. The source task record remains reachable on the source commit and feature branch and is excluded from the local `main` result.
- Promoted the USER-scoped dynamic model/voice catalog boundary: Renderer receives only bounded display metadata through Electron Main and Works Square, all public catalog outcomes are no-store, and current unavailable values remain editable.
- Replaced avoidable configuration text/number inputs with catalog selects and bounded TTS sliders while preserving `clear_fields`, revision conflict, operation identity, and Main-owned credential boundaries.
- On 2026-08-15, merged reviewed Robot configuration-schema source `ea75b06` into local `main` without textual conflict and excluded its source-owned task record from the final tree.
- Created no-ff merge commit `9738e1c5aebad6237f436c8c94abaf877eb3d175` with local baseline `a26a53a7f4b1326be3084955029a65ef1f79929a` as first parent and reviewed source `ea75b0618a793928878edad736f0837ac9e4adc1` as second parent.
- Confirmed the failure was transport-contract drift, not a Xiaozhi/Works DTO failure: response compression weakened the origin strong revision ETag to canonical `W/\"0\"`; Main now accepts only canonical strong or weak numeric response tags whose revision equals the strictly projected body.
@@ -234,6 +245,10 @@ Gate result:
## Verification
- 2026-08-16 source and staged-merge Robot catalog regression selection — 5 files / 84 tests passed.
- 2026-08-16 Works Square catalog adapter/API suite — 131 tests passed; one existing Starlette/httpx deprecation warning.
- 2026-08-16 Makelore typecheck, focused ESLint, and production `build:vite` — passed; only existing chunk-size and mixed-import warnings remain.
- 2026-08-16 independent source review — `PASS`, with no P0-P3 findings after all-outcome no-store and 40px target fixes.
- Robot configuration-schema source TDD recorded `2 failed, 23 passed` before the fix with `AI_HARDWARE_INVALID_ETAG` for the deployed weak-tag shape.
- Merged-tree AI hardware route suite passed `25/25`; the seven-file Robot/Main regression selection passed `78/78`.
- Merged-tree `pnpm run typecheck`, scoped ESLint, and `pnpm run build:vite` passed; build retains only existing chunk-size and mixed-import warnings.
@@ -296,6 +311,7 @@ Gate result:
## Follow-ups
- Deploy matching Xiaozhi and Works Square catalog endpoints before releasing this client; otherwise the editor preserves current values but cannot populate dynamic choices.
- The Robot integration is complete on local `main`. Production still requires matching Works Square/Xiaozhi deployment, feature configuration, credentials, and a real one-time activation-code smoke.
- Stable mutation operation IDs are retained across ambiguous retries in the running app but are not persisted across an application restart.
- Signed packaged updater smoke and live Works Square/Bailian image-to-image

View File

@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown>[] = [];
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<string, unknown>[] = [];
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<Record<string, unknown>> {
const declared = Number(req.headers['content-length']);
if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) {
@@ -323,6 +370,7 @@ const SAFE_UPSTREAM_ERRORS: Record<string, string> = {
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<boolean> {
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']));

View File

@@ -18,6 +18,7 @@ const SAFE_ERROR_MESSAGES: Record<string, string> = {
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<AiHardwareCatalogModelType>([
'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<AiHardwareOverview> {
return request('', undefined, readOverview) as Promise<AiHardwareOverview>;
}
export async function getAiHardwareConfigurationCatalog(
ttsModelId?: string,
): Promise<AiHardwareConfigurationCatalog> {
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<AiHardwareConfigurationCatalog>;
}
export async function recoverAiHardwareCredential(
options?: AiHardwareMutationOptions,
): Promise<AiHardwareOverview> {

View File

@@ -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<AiHardwareConfigurationCatalog | null>(null);
const [catalogLoading, setCatalogLoading] = useState(false);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [assignmentDevice, setAssignmentDevice] = useState<AiHardwareDevice | null>(null);
const [agentName, setAgentName] = useState('');
const [dialogAgentId, setDialogAgentId] = useState('');
@@ -176,6 +183,7 @@ export function AiHardware() {
const activationCodeInputRef = useRef<HTMLInputElement | null>(null);
const bindFingerprintKeyRef = useRef<CryptoKey | null>(null);
const bindRetryIntentRef = useRef<BindRetryIntent | null>(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('名称需要包含 164 个字符。'); return; }
@@ -378,7 +415,7 @@ export function AiHardware() {
<div className="grid gap-5 lg:grid-cols-[minmax(240px,0.7fr)_minmax(0,1.3fr)]">
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle></CardTitle><CardDescription>{overview.agents.length} </CardDescription></div><Button size="icon" aria-label="创建智能体" onClick={() => { resetDialog(); setCreateOpen(true); }}><Plus className="h-4 w-4" /></Button></CardHeader><CardContent className="space-y-2">{overview.agents.map((agent) => <button key={agent.id} type="button" aria-pressed={agent.id === selectedAgentId} onClick={() => setSelectedAgentId(agent.id)} className={`motion-press flex min-h-12 w-full items-center gap-3 rounded-xl px-3 py-2 text-left ${agent.id === selectedAgentId ? 'bg-brand-soft' : 'bg-surface-subtle hover:bg-surface-tertiary'}`}><Bot className="h-4 w-4 shrink-0" /><span className="min-w-0 flex-1"><span className="block truncate text-sm font-semibold">{agent.name}</span><span title={agent.id} className="block text-xs tabular-nums text-muted-foreground">{shortId(agent.id)} · r{agent.config_revision}</span></span></button>)}</CardContent></Card>
<div className="space-y-5">
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>{selectedAgent?.name ?? '智能体配置'}</CardTitle><CardDescription></CardDescription></div><Button variant="outline" disabled={configLoading || !config} onClick={() => { if (!config) return; resetDialog(); setDraft(draftFrom(config)); setConfigOpen(true); }}>{configLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Settings2 className="mr-2 h-4 w-4" />}</Button></CardHeader><CardContent>{config ? <dl className="grid gap-3 text-sm sm:grid-cols-2"><div><dt className="text-muted-foreground"></dt><dd>{config.language || config.lang_code || '未设置'}</dd></div><div><dt className="text-muted-foreground"></dt><dd>{config.tts_voice_id || '未设置'}</dd></div><div className="sm:col-span-2"><dt className="text-muted-foreground"></dt><dd className="mt-1 whitespace-pre-wrap">{config.system_prompt || '未设置'}</dd></div></dl> : configLoading ? <FeedbackState state="loading" title="正在读取配置" /> : configLoadFailed ? <FeedbackState state="error" title="无法读取智能体配置" description="请检查服务连接后重试。" action={<Button variant="outline" onClick={() => setConfigReloadKey((value) => value + 1)}></Button>} /> : null}</CardContent></Card>
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>{selectedAgent?.name ?? '智能体配置'}</CardTitle><CardDescription></CardDescription></div><Button variant="outline" disabled={configLoading || !config} onClick={openConfigurationEditor}>{configLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Settings2 className="mr-2 h-4 w-4" />}</Button></CardHeader><CardContent>{config ? <dl className="grid gap-3 text-sm sm:grid-cols-2"><div><dt className="text-muted-foreground"></dt><dd>{config.language || config.lang_code || '未设置'}</dd></div><div><dt className="text-muted-foreground"></dt><dd>{config.tts_voice_id || '未设置'}</dd></div><div className="sm:col-span-2"><dt className="text-muted-foreground"></dt><dd className="mt-1 whitespace-pre-wrap">{config.system_prompt || '未设置'}</dd></div></dl> : configLoading ? <FeedbackState state="loading" title="正在读取配置" /> : configLoadFailed ? <FeedbackState state="error" title="无法读取智能体配置" description="请检查服务连接后重试。" action={<Button variant="outline" onClick={() => setConfigReloadKey((value) => value + 1)}></Button>} /> : null}</CardContent></Card>
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle></CardTitle><CardDescription>{devices.length} </CardDescription></div><Button onClick={() => { resetDialog(); setDialogAgentId(selectedAgentId ?? overview.agents[0].id); setBindOpen(true); }}><Link2 className="mr-2 h-4 w-4" /></Button></CardHeader><CardContent>{devices.length ? <div className="space-y-2">{devices.map((device) => <div key={device.id} className="flex min-h-12 items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2"><span className="min-w-0"><span className="block text-sm font-semibold"> {shortId(device.id)}</span><span title={device.id} className="text-xs tabular-nums text-muted-foreground"> r{device.assignment_revision}</span></span><Button variant="outline" size="sm" onClick={() => void openAssignment(device)}></Button></div>)}</div> : <FeedbackState state="empty" title="还没有绑定设备" description="使用设备上的 6 位激活码完成绑定。" />}</CardContent></Card>
</div>
</div>
@@ -386,19 +423,101 @@ export function AiHardware() {
<Dialog open={createOpen} onOpenChange={(open) => { if (!busy) { setCreateOpen(open); if (!open) { setAgentName(''); resetDialog(); } } }}><DialogContent><DialogHeader><DialogTitle></DialogTitle><DialogDescription></DialogDescription></DialogHeader><div><Label htmlFor="hardware-agent-name"></Label><Input id="hardware-agent-name" autoFocus maxLength={64} value={agentName} onChange={(e) => { setAgentName(e.target.value); clearCreateOperation(); }} /></div>{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setCreateOpen(false); resetDialog(); }}></Button><Button aria-busy={busy} disabled={busy} onClick={() => void createAgent()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={bindOpen} onOpenChange={(open) => { if (!busy) { setBindOpen(open); if (!open) resetDialog(); } }}><DialogContent><DialogHeader><DialogTitle></DialogTitle><DialogDescription></DialogDescription></DialogHeader><div><Label htmlFor="hardware-activation-code">6 </Label><Input ref={activationCodeInputRef} id="hardware-activation-code" autoFocus type="text" inputMode="numeric" autoComplete="off" maxLength={6} onInput={(e) => { e.currentTarget.value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6); }} /></div><AgentSelect agents={overview.agents} value={dialogAgentId} onChange={(value) => { setDialogAgentId(value); clearBindOperation(); }} />{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setBindOpen(false); resetDialog(); }}></Button><Button aria-busy={busy} disabled={busy} onClick={() => void bindDevice()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={configOpen} onOpenChange={(open) => { if (!busy) { setConfigOpen(open); if (!open) resetDialog(); } }}><DialogContent className="max-h-[90vh] overflow-y-auto"><DialogHeader><DialogTitle></DialogTitle><DialogDescription></DialogDescription></DialogHeader>{draft ? <ConfigFields draft={draft} setDraft={(next) => { setDraft(next); clearConfigOperation(); }} /> : null}{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setConfigOpen(false); resetDialog(); }}></Button><Button aria-busy={busy} disabled={busy} onClick={() => void saveConfiguration()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={configOpen} onOpenChange={(open) => { if (!busy) { setConfigOpen(open); if (!open) { resetDialog(); setCatalog(null); setCatalogError(null); } } }}><DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto"><DialogHeader><DialogTitle></DialogTitle><DialogDescription>使</DialogDescription></DialogHeader>{draft ? <ConfigFields draft={draft} catalog={catalog} catalogLoading={catalogLoading} catalogError={catalogError} setDraft={(next) => { setDraft(next); clearConfigOperation(); }} onTtsModelChange={(value, next) => { setDraft(next); clearConfigOperation(); void loadConfigurationCatalog(value || undefined); }} onRetryCatalog={() => void loadConfigurationCatalog(draft.tts_model_id || undefined)} /> : null}{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setConfigOpen(false); resetDialog(); }}></Button><Button aria-busy={busy} disabled={busy} onClick={() => void saveConfiguration()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={Boolean(assignmentDevice)} onOpenChange={(open) => { if (!open && !busy) { setAssignmentDevice(null); resetDialog(); } }}><DialogContent><DialogHeader><DialogTitle></DialogTitle><DialogDescription></DialogDescription></DialogHeader><AgentSelect agents={overview.agents} value={dialogAgentId} onChange={(value) => { setDialogAgentId(value); clearAssignmentOperation(); }} />{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setAssignmentDevice(null); resetDialog(); }}></Button><Button aria-busy={busy} disabled={busy} onClick={() => void saveAssignment()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button></DialogFooter></DialogContent></Dialog>
</main>
);
}
function AgentSelect({ agents, value, onChange }: { agents: AiHardwareAgent[]; value: string; onChange: (value: string) => void }) {
return <div><Label htmlFor="hardware-agent-select"></Label><select id="hardware-agent-select" className="mt-1 h-10 w-full rounded-xl border border-border/80 bg-surface-input px-3 text-sm" value={value} onChange={(e) => onChange(e.target.value)}>{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</select></div>;
return <div><Label htmlFor="hardware-agent-select"></Label><Select id="hardware-agent-select" className="mt-1" value={value} onChange={(e) => onChange(e.target.value)}>{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</Select></div>;
}
function ConfigFields({ draft, setDraft }: { draft: ConfigDraft; setDraft: (draft: ConfigDraft) => void }) {
const field = (key: keyof ConfigDraft, label: string, type = 'text') => <div><Label htmlFor={`hardware-${key}`}>{label}</Label><Input id={`hardware-${key}`} type={type} value={draft[key]} onChange={(e) => setDraft({ ...draft, [key]: e.target.value })} /></div>;
return <div className="space-y-4"><div className="grid gap-4 sm:grid-cols-2">{field('agent_name', '名称')}<div className="sm:col-span-2"><Label htmlFor="hardware-system_prompt"></Label><Textarea id="hardware-system_prompt" value={draft.system_prompt} onChange={(e) => setDraft({ ...draft, system_prompt: e.target.value })} /></div>{field('language', '语言')}{field('lang_code', '语言代码')}{field('tts_voice_id', 'TTS 语音 ID')}{field('tts_volume', '音量 (-100100)', 'number')}{field('tts_rate', '语速 (-100100)', 'number')}{field('tts_pitch', '音调 (-100100)', 'number')}<div><Label htmlFor="hardware-chat_history_conf"></Label><select id="hardware-chat_history_conf" className="mt-1 h-10 w-full rounded-xl border border-border/80 bg-surface-input px-3 text-sm" value={draft.chat_history_conf} onChange={(e) => setDraft({ ...draft, chat_history_conf: e.target.value })}><option value=""></option><option value="0"></option><option value="1"></option><option value="2"></option></select></div></div><details className="rounded-xl border border-border/80 bg-surface-subtle px-4 py-3"><summary className="cursor-pointer text-sm font-semibold"></summary><p className="mt-2 text-xs text-muted-foreground"> ID </p><div className="mt-4 grid gap-4 sm:grid-cols-2">{field('asr_model_id', 'ASR 模型 ID')}{field('vad_model_id', 'VAD 模型 ID')}{field('llm_model_id', 'LLM 模型 ID')}{field('slm_model_id', 'SLM 模型 ID')}{field('vllm_model_id', 'VLLM 模型 ID')}{field('tts_model_id', 'TTS 模型 ID')}{field('tts_language', 'TTS 语言')}{field('mem_model_id', '记忆模型 ID')}{field('intent_model_id', '意图模型 ID')}</div></details></div>;
type CatalogOption = { value: string; label: string };
function SelectField({
id, label, value, options, onChange,
}: {
id: string;
label: string;
value: string;
options: CatalogOption[];
onChange: (value: string) => void;
}) {
const currentAvailable = !value || options.some((option) => option.value === value);
return <div><Label htmlFor={id}>{label}</Label><Select id={id} className="mt-1" value={value} onChange={(event) => onChange(event.target.value)}><option value="">使</option>{!currentAvailable ? <option value={value}>{value}</option> : null}{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</Select></div>;
}
function RangeField({
field, label, value, setDraft,
}: {
field: typeof nullableNumberFields[number];
label: string;
value: string;
setDraft: (value: string) => void;
}) {
const unset = value === '';
return <div className="rounded-xl border border-border/70 bg-surface-subtle px-3 py-3"><div className="flex items-center justify-between gap-3"><Label htmlFor={`hardware-${field}`}>{label}</Label><output htmlFor={`hardware-${field}`} className="min-w-12 text-right text-sm font-semibold tabular-nums">{unset ? '默认' : value}</output></div><input id={`hardware-${field}`} aria-valuetext={unset ? '使用服务默认值' : value} type="range" min="-100" max="100" step="1" disabled={unset} value={unset ? '0' : value} onChange={(event) => setDraft(event.target.value)} className="mt-3 h-10 w-full cursor-pointer accent-brand disabled:cursor-not-allowed disabled:opacity-40" /><Button type="button" variant="outline" className="mt-2 min-h-10" aria-label={`${label}${unset ? '启用调节' : '使用默认值'}`} onClick={() => setDraft(unset ? '0' : '')}>{unset ? '启用调节' : '使用默认值'}</Button></div>;
}
function ConfigFields({
draft,
catalog,
catalogLoading,
catalogError,
setDraft,
onTtsModelChange,
onRetryCatalog,
}: {
draft: ConfigDraft;
catalog: AiHardwareConfigurationCatalog | null;
catalogLoading: boolean;
catalogError: string | null;
setDraft: (draft: ConfigDraft) => void;
onTtsModelChange: (value: string, draft: ConfigDraft) => void;
onRetryCatalog: () => void;
}) {
const modelOptions = (type: AiHardwareCatalogModelType): CatalogOption[] => (catalog?.models ?? [])
.filter((model) => model.model_type === type)
.map((model) => ({ value: model.model_id, label: model.model_name }));
const voices = (catalog?.voices ?? []).filter((voice) => voice.tts_model_id === draft.tts_model_id);
const voiceOptions = voices.map((voice) => ({
value: voice.voice_id,
label: `${voice.voice_name}${voice.is_clone ? '(我的复刻音色)' : ''}`,
}));
const languages = [...new Set(voices.flatMap((voice) => voice.languages))]
.sort((left, right) => left.localeCompare(right))
.map((language) => ({ value: language, label: language }));
const select = (
key: keyof ConfigDraft,
label: string,
options: CatalogOption[],
onChange?: (value: string) => void,
) => <SelectField id={`hardware-${key}`} label={label} value={draft[key]} options={options} onChange={(value) => onChange ? onChange(value) : setDraft({ ...draft, [key]: value })} />;
const changeTtsModel = (value: string) => onTtsModelChange(value, {
...draft,
tts_model_id: value,
tts_voice_id: '',
tts_language: '',
});
return <div className="space-y-5">
{catalogLoading ? <p role="status" aria-live="polite" className="flex items-center gap-2 rounded-xl bg-surface-subtle px-3 py-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /></p> : null}
{catalogError ? <div role="alert" className="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-amber-500/10 px-3 py-2 text-sm text-amber-800"><span>{catalogError}</span><Button type="button" variant="outline" className="min-h-10" onClick={onRetryCatalog}></Button></div> : null}
<section aria-labelledby="hardware-basic-heading" className="space-y-4 rounded-2xl border border-border/70 p-4">
<div><h3 id="hardware-basic-heading" className="font-semibold"></h3><p className="text-xs text-muted-foreground"></p></div>
<div className="grid gap-4 sm:grid-cols-2"><div><Label htmlFor="hardware-agent_name"></Label><Input id="hardware-agent_name" value={draft.agent_name} onChange={(event) => setDraft({ ...draft, agent_name: event.target.value })} /></div><div className="sm:col-span-2"><Label htmlFor="hardware-system_prompt"></Label><Textarea id="hardware-system_prompt" className="min-h-28" value={draft.system_prompt} onChange={(event) => setDraft({ ...draft, system_prompt: event.target.value })} /></div></div>
</section>
<section aria-labelledby="hardware-model-heading" className="space-y-4 rounded-2xl border border-border/70 p-4">
<div><h3 id="hardware-model-heading" className="font-semibold"></h3><p className="text-xs text-muted-foreground">SLM </p></div>
<div className="grid gap-4 sm:grid-cols-2">{select('vad_model_id', '语音活动检测 (VAD)', modelOptions('VAD'))}{select('asr_model_id', '语音识别 (ASR)', modelOptions('ASR'))}{select('llm_model_id', '主语言模型 (LLM)', modelOptions('LLM'))}{select('slm_model_id', '小参数模型 (SLM)', modelOptions('LLM'))}{select('vllm_model_id', '视觉大模型 (VLLM)', modelOptions('VLLM'))}{select('intent_model_id', '意图识别 (Intent)', modelOptions('Intent'))}{select('mem_model_id', '记忆模式 (Mem)', modelOptions('Memory'))}{select('tts_model_id', '语音合成 (TTS)', modelOptions('TTS'), changeTtsModel)}</div>
</section>
<section aria-labelledby="hardware-voice-heading" className="space-y-4 rounded-2xl border border-border/70 p-4">
<div><h3 id="hardware-voice-heading" className="font-semibold"></h3><p className="text-xs text-muted-foreground"> TTS </p></div>
<div className="grid gap-4 sm:grid-cols-2">{select('tts_language', '对话语言', languages)}{select('tts_voice_id', '声音音色', voiceOptions)}{select('language', '语言', languages)}{select('lang_code', '语言代码', languages)}<div><Label htmlFor="hardware-chat_history_conf"></Label><Select id="hardware-chat_history_conf" className="mt-1" value={draft.chat_history_conf} onChange={(event) => setDraft({ ...draft, chat_history_conf: event.target.value })}><option value=""></option><option value="0"></option><option value="1"></option><option value="2"></option></Select></div></div>
<div className="grid gap-3 sm:grid-cols-3"><RangeField field="tts_volume" label="音量" value={draft.tts_volume} setDraft={(value) => setDraft({ ...draft, tts_volume: value })} /><RangeField field="tts_rate" label="语速" value={draft.tts_rate} setDraft={(value) => setDraft({ ...draft, tts_rate: value })} /><RangeField field="tts_pitch" label="音调" value={draft.tts_pitch} setDraft={(value) => setDraft({ ...draft, tts_pitch: value })} /></div>
</section>
</div>;
}
export default AiHardware;

View File

@@ -6,6 +6,7 @@ import {
getAiHardwareAgentConfiguration,
getAiHardwareAssignment,
getAiHardwareOverview,
getAiHardwareConfigurationCatalog,
updateAiHardwareAgentConfiguration,
updateAiHardwareAssignment,
createAiHardwareOperationId,
@@ -56,6 +57,44 @@ describe('AI hardware renderer API', () => {
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/ai-hardware', undefined);
});
it('strictly reads the safe configuration catalog and encodes the TTS dependency', async () => {
const catalog = {
schema_version: 1,
models: [{
model_type: 'TTS', model_id: 'TTS:model', model_name: '云端语音',
supports_function_call: null,
}],
voices: [{
tts_model_id: 'TTS:model', voice_id: 'voice-1', voice_name: '小夏',
languages: ['zh-CN'], is_clone: false,
}],
};
hostApiFetchMock.mockResolvedValue({ success: true, data: catalog });
await expect(getAiHardwareConfigurationCatalog('TTS:model')).resolves.toEqual(catalog);
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/ai-hardware/catalog?tts_model_id=TTS%3Amodel',
undefined,
);
});
it('rejects unsafe or expanded catalog DTOs', async () => {
hostApiFetchMock.mockResolvedValue({ success: true, data: {
schema_version: 1,
models: [{
model_type: 'TTS', model_id: 'TTS_model', model_name: '语音',
supports_function_call: null, config_json: '{"api_key":"secret"}',
}],
voices: [],
} });
await expect(getAiHardwareConfigurationCatalog()).rejects.toMatchObject({
code: 'AI_HARDWARE_INVALID_RESPONSE',
});
expect(JSON.stringify(await getAiHardwareConfigurationCatalog().catch((error) => error)))
.not.toContain('secret');
});
it('sends create and bind business bodies without sensitive headers', async () => {
hostApiFetchMock
.mockResolvedValueOnce({ success: true, data: agent })

View File

@@ -4,6 +4,7 @@ import { AiHardware } from '@/pages/AiHardware';
import {
AiHardwareApiError,
type AiHardwareAgentConfiguration,
type AiHardwareConfigurationCatalog,
type AiHardwareOverview,
} from '@/lib/ai-hardware';
@@ -13,6 +14,7 @@ const api = vi.hoisted(() => ({
createAiHardwareAgent: vi.fn(),
bindAiHardwareDevice: vi.fn(),
getAiHardwareAgentConfiguration: vi.fn(),
getAiHardwareConfigurationCatalog: vi.fn(),
updateAiHardwareAgentConfiguration: vi.fn(),
getAiHardwareAssignment: vi.fn(),
updateAiHardwareAssignment: vi.fn(),
@@ -51,6 +53,24 @@ const configuration: AiHardwareAgentConfiguration = {
tts_volume: 10, tts_rate: 5, tts_pitch: 0, mem_model_id: null, intent_model_id: null,
chat_history_conf: 1,
};
const catalog: AiHardwareConfigurationCatalog = {
schema_version: 1,
models: [
['ASR', 'asr-old', '旧语音识别'], ['ASR', 'asr-new', '新语音识别'],
['VAD', 'vad-old', '旧活动检测'], ['VAD', 'vad-new', '新活动检测'],
['LLM', 'llm-old', '旧语言模型'], ['LLM', 'llm-new', '新语言模型'],
['LLM', 'slm-new', '轻量语言模型'], ['VLLM', 'vllm-old', '旧视觉模型'],
['TTS', 'tts-old', '旧语音合成'], ['TTS', 'tts-new', '新语音合成'],
['Memory', 'mem-old', '旧记忆'], ['Memory', 'mem-new', '新记忆'],
['Intent', 'intent-old', '旧意图'], ['Intent', 'intent-new', '新意图'],
].map(([model_type, model_id, model_name]) => ({
model_type, model_id, model_name, supports_function_call: null,
})) as AiHardwareConfigurationCatalog['models'],
voices: [{
tts_model_id: 'tts-new', voice_id: 'voice-new', voice_name: '龙小夏',
languages: ['zh-CN', 'en-US'], is_clone: false,
}],
};
function deferred<T>() {
let resolve!: (value: T) => void;
@@ -70,6 +90,7 @@ describe('AI hardware page', () => {
api.getAiHardwareOverview.mockResolvedValue(activeOverview);
api.recoverAiHardwareCredential.mockResolvedValue({ status: 'active', agents: [], devices: [] });
api.getAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 4 });
api.getAiHardwareConfigurationCatalog.mockResolvedValue(catalog);
api.createAiHardwareAgent.mockResolvedValue(agentOne);
api.bindAiHardwareDevice.mockResolvedValue(device);
api.updateAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 5 });
@@ -149,8 +170,8 @@ describe('AI hardware page', () => {
await openConfigurationEditor();
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的助手' } });
fireEvent.change(screen.getByLabelText('系统提示'), { target: { value: '' } });
fireEvent.change(screen.getByLabelText('TTS 语音 ID'), { target: { value: '' } });
fireEvent.change(screen.getByLabelText('音量 (-100100)'), { target: { value: '20' } });
fireEvent.change(screen.getByLabelText('声音音色'), { target: { value: '' } });
fireEvent.change(screen.getByLabelText('音量'), { target: { value: '20' } });
fireEvent.change(screen.getByLabelText('聊天记录'), { target: { value: '2' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
@@ -237,7 +258,6 @@ describe('AI hardware page', () => {
});
render(<AiHardware />);
await openConfigurationEditor();
fireEvent.click(screen.getByText('高级设置'));
const values: Record<string, string> = {
asr_model_id: '', vad_model_id: 'vad-new', llm_model_id: '', slm_model_id: 'slm-new',
@@ -260,6 +280,61 @@ describe('AI hardware page', () => {
expect(Object.values(update)).not.toContain(null);
});
it('uses dynamic model, language, and voice choices instead of free-form IDs', async () => {
api.getAiHardwareAgentConfiguration.mockResolvedValueOnce({
data: { ...configuration, tts_model_id: 'tts-old', tts_voice_id: 'legacy-voice' },
revision: 4,
});
render(<AiHardware />);
await openConfigurationEditor();
expect(api.getAiHardwareConfigurationCatalog).toHaveBeenCalledWith('tts-old');
expect(screen.getByLabelText('语音合成 (TTS)')).toHaveValue('tts-old');
expect(screen.getByRole('option', { name: '旧语音合成' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: '当前值目录中不可用legacy-voice' })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('语音合成 (TTS)'), { target: { value: 'tts-new' } });
await waitFor(() => expect(api.getAiHardwareConfigurationCatalog).toHaveBeenLastCalledWith('tts-new'));
expect(screen.getByRole('option', { name: '龙小夏' })).toBeInTheDocument();
expect(screen.getAllByRole('option', { name: 'zh-CN' }).length).toBeGreaterThan(0);
});
it('uses bounded sliders while preserving nullable provider defaults', async () => {
render(<AiHardware />);
await openConfigurationEditor();
const volume = screen.getByLabelText('音量');
expect(volume).toHaveAttribute('type', 'range');
expect(volume).toHaveAttribute('min', '-100');
expect(volume).toHaveAttribute('max', '100');
expect(screen.getByRole('button', { name: '音调使用默认值' })).toHaveClass('min-h-10');
fireEvent.change(volume, { target: { value: '24' } });
fireEvent.click(screen.getByRole('button', { name: '音调使用默认值' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(api.updateAiHardwareAgentConfiguration).toHaveBeenCalled());
expect(api.updateAiHardwareAgentConfiguration.mock.calls[0][2]).toMatchObject({
tts_volume: 24,
clear_fields: expect.arrayContaining(['tts_pitch']),
});
});
it('keeps current values when the catalog is unavailable and offers a safe retry', async () => {
api.getAiHardwareConfigurationCatalog
.mockRejectedValueOnce(new Error('provider token=secret'))
.mockResolvedValueOnce(catalog);
render(<AiHardware />);
await openConfigurationEditor();
expect(await screen.findByRole('alert')).toHaveTextContent('当前配置会保持不变');
expect(screen.getByRole('option', { name: '当前值目录中不可用voice-a' })).toBeInTheDocument();
expect(screen.queryByText(/provider token|secret/)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '重新加载选项' })).toHaveClass('min-h-10');
fireEvent.click(screen.getByRole('button', { name: '重新加载选项' }));
await waitFor(() => expect(api.getAiHardwareConfigurationCatalog).toHaveBeenCalledTimes(2));
expect(await screen.findByRole('option', { name: '旧语音合成' })).toBeInTheDocument();
});
it.each([
['AI_HARDWARE_AUTH_REQUIRED', '请先登录'],
['ai_hardware_unconfigured', '尚未配置'],

View File

@@ -16,15 +16,17 @@ function request(method: string, body?: unknown, headers: Record<string, string>
function response() {
const chunks: string[] = [];
const headers = new Map<string, string>();
const res = new EventEmitter();
Object.assign(res, {
statusCode: 0,
setHeader: vi.fn(),
setHeader: vi.fn((name: string, value: string) => headers.set(name.toLowerCase(), value)),
end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }),
});
return {
res: res as unknown as ServerResponse,
get status() { return (res as { statusCode: number }).statusCode; },
header: (name: string) => headers.get(name.toLowerCase()),
json: () => JSON.parse(chunks.join('')) as Record<string, unknown>,
};
}
@@ -56,6 +58,63 @@ async function invoke(handler: ReturnType<typeof createAiHardwareRouteHandler>,
}
describe('AI hardware Host API route', () => {
it('proxies only the fixed catalog query and projects its safe DTO', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({
schema_version: 1,
models: [{
model_type: 'TTS', model_id: 'TTS_model', model_name: '云端语音',
supports_function_call: null, config_json: 'must-not-pass',
}],
voices: [{
tts_model_id: 'TTS_model', voice_id: 'voice-1', voice_name: '小夏',
languages: ['zh-CN'], is_clone: false, voice_demo: 'internal-url',
}],
credential: 'secret-token',
}));
const { handler } = setup(fetchImpl);
const result = await invoke(
handler,
'GET',
'/api/works/ai-hardware/catalog?tts_model_id=TTS_model',
);
expect(fetchImpl.mock.calls[0][0]).toBe(
'https://square.example/api/ai-hardware/catalog?tts_model_id=TTS_model',
);
expect((fetchImpl.mock.calls[0][1] as RequestInit).headers).toMatchObject({
Authorization: 'Bearer secret-token',
});
expect(result.payload).toEqual({ success: true, data: {
schema_version: 1,
models: [{
model_type: 'TTS', model_id: 'TTS_model', model_name: '云端语音',
supports_function_call: null,
}],
voices: [{
tts_model_id: 'TTS_model', voice_id: 'voice-1', voice_name: '小夏',
languages: ['zh-CN'], is_clone: false,
}],
} });
expect(result.header('cache-control')).toBe('private, no-store');
expect(result.header('pragma')).toBe('no-cache');
expect(JSON.stringify(result.payload)).not.toMatch(/config_json|voice_demo|credential|secret-token/);
});
it.each([
'/api/works/ai-hardware/catalog?unknown=value',
'/api/works/ai-hardware/catalog?tts_model_id=one&tts_model_id=two',
'/api/works/ai-hardware/catalog?tts_model_id=%2Funsafe',
])('rejects unsupported catalog query %s before fetching', async (path) => {
const { handler, fetchImpl } = setup();
const result = await invoke(handler, 'GET', path);
expect(result.payload).toMatchObject({
success: false, status: 400, code: 'AI_HARDWARE_INVALID_REQUEST',
});
expect(result.header('cache-control')).toBe('private, no-store');
expect(result.header('pragma')).toBe('no-cache');
expect(fetchImpl).not.toHaveBeenCalled();
});
it('projects overview DTOs and drops unexpected sensitive fields', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({
...overview,