feat: add dynamic Robot configuration choices
This commit is contained in:
@@ -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