feat: add dynamic Robot configuration choices

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

View File

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

View File

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

View File

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