feat: add dynamic Robot configuration choices
This commit is contained in:
@@ -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.
|
||||
@@ -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']));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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', '尚未配置'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user