feat: add dynamic Robot configuration choices

This commit is contained in:
brother7 committed 2026-08-16 00:55:06 +08:00
1 parent 7bef261fb8
commit fe55deed04
7 files changed
+524 -10

No files matched your search

@@ -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.
+67
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']));
+97
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> {
+125 -6
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('名称需要包含 1–64 个字符。'); 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', '音量 (-100–100)', 'number')}{field('tts_rate', '语速 (-100–100)', 'number')}{field('tts_pitch', '音调 (-100–100)', '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;
+39
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 })
+78 -3
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('音量 (-100–100)'), { 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', '尚未配置'],
+60 -1
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,