feat: add dynamic Robot configuration choices

This commit is contained in:
2026-08-16 00:55:06 +08:00
parent 7bef261fb8
commit fe55deed04
7 changed files with 524 additions and 10 deletions

View File

@@ -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.

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,