import { hostApiFetch } from '@/lib/host-api'; const API_ROOT = '/api/works/ai-hardware'; const DEFAULT_ERROR_CODE = 'AI_HARDWARE_REQUEST_FAILED'; const SAFE_ERROR_MESSAGES: Record = { ai_hardware_resource_not_found: 'AI hardware resource was not found', ai_hardware_idempotency_conflict: 'This operation conflicts with an earlier request', ai_hardware_operation_in_progress: 'AI hardware operation is still in progress', ai_hardware_device_already_bound: 'AI hardware device is already bound', ai_hardware_revision_conflict: 'AI hardware state changed; refresh and retry', ai_hardware_state_conflict: 'AI hardware operation conflicts with the current state', ai_hardware_activation_code_invalid: 'AI hardware activation code is invalid', ai_hardware_request_rejected: 'AI hardware request was rejected', ai_hardware_revision_required: 'A current AI hardware revision is required', ai_hardware_provider_state_conflict: 'AI hardware provider state is inconsistent', xiaozhi_hardware_protocol_error: 'AI hardware service returned an invalid response', ai_hardware_unconfigured: 'AI hardware integration is not configured', 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', AI_HARDWARE_DISABLED: 'AI hardware module is not enabled', AI_HARDWARE_TIMEOUT: 'AI hardware service timed out', AI_HARDWARE_UNAVAILABLE: 'AI hardware service is unavailable', AI_HARDWARE_TRANSPORT_ERROR: 'AI hardware request could not reach the local service', AI_HARDWARE_REVISION_CONFLICT: 'AI hardware state changed; refresh and retry', AI_HARDWARE_AUTH_REQUIRED: 'Works Square sign-in is required', AI_HARDWARE_FORBIDDEN: 'AI hardware operation is not allowed', AI_HARDWARE_RESOURCE_NOT_FOUND: 'AI hardware resource was not found', AI_HARDWARE_RATE_LIMITED: 'AI hardware service is busy; retry later', AI_HARDWARE_INVALID_REQUEST: 'Invalid AI hardware request', AI_HARDWARE_INVALID_RESPONSE: 'AI hardware service returned an invalid response', }; 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; export type AiHardwareStatus = | 'unprovisioned' | 'provisioning' | 'active' | 'credential_recovery_required' | 'invalid'; export type AiHardwareAgent = { id: string; name: string; config_revision: number; }; export type AiHardwareDevice = { id: string; agent_id: string; assignment_revision: number; }; export type AiHardwareOverview = { status: AiHardwareStatus; agents: AiHardwareAgent[]; devices: AiHardwareDevice[]; }; export type AiHardwareAgentConfiguration = AiHardwareAgent & { system_prompt: string | null; lang_code: string | null; language: string | null; asr_model_id: string | null; vad_model_id: string | null; llm_model_id: string | null; slm_model_id: string | null; vllm_model_id: string | null; tts_model_id: string | null; tts_voice_id: string | null; tts_language: string | null; tts_volume: number | null; tts_rate: number | null; tts_pitch: number | null; mem_model_id: string | null; intent_model_id: string | null; 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', 'language', 'asr_model_id', 'vad_model_id', 'llm_model_id', 'slm_model_id', 'vllm_model_id', 'tts_model_id', 'tts_voice_id', 'tts_language', 'tts_volume', 'tts_rate', 'tts_pitch', 'mem_model_id', 'intent_model_id', ] as const; export type AiHardwareClearableField = typeof AI_HARDWARE_CLEARABLE_FIELDS[number]; export type AiHardwareAgentConfigurationUpdate = Partial<{ agent_name: string; system_prompt: string; lang_code: string; language: string; asr_model_id: string; vad_model_id: string; llm_model_id: string; slm_model_id: string; vllm_model_id: string; tts_model_id: string; tts_voice_id: string; tts_language: string; tts_volume: number; tts_rate: number; tts_pitch: number; mem_model_id: string; intent_model_id: string; chat_history_conf: number; }> & { clear_fields?: AiHardwareClearableField[] }; export type VersionedAiHardwareResult = { data: T; revision: number; }; type MainEnvelope = { success?: unknown; status?: unknown; code?: unknown; error?: unknown; retryable?: unknown; retry_after_seconds?: unknown; operation_id?: unknown; data?: unknown; revision?: unknown; }; export class AiHardwareApiError extends Error { readonly status: number; readonly code: string; readonly retryable: boolean; readonly retryAfterSeconds: number | null; readonly operationId: string | null; constructor(options: { status: number; code: string; message: string; retryable?: boolean; retryAfterSeconds?: number | null; operationId?: string | null; }) { super(options.message); this.name = 'AiHardwareApiError'; this.status = options.status; this.code = options.code; this.retryable = options.retryable ?? false; this.retryAfterSeconds = options.retryAfterSeconds ?? null; this.operationId = options.operationId ?? null; } } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function isInteger(value: unknown, min = 0, max = Number.MAX_SAFE_INTEGER): value is number { return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max; } function hasOnlyKeys(value: Record, keys: readonly string[]): boolean { const allowed = new Set(keys); return Object.keys(value).every((key) => allowed.has(key)); } function invalidPayload(): never { throw new AiHardwareApiError({ status: 502, code: 'AI_HARDWARE_INVALID_RESPONSE', message: 'AI hardware service returned an invalid response', }); } function readAgent(value: unknown): AiHardwareAgent { if (!isRecord(value) || !hasOnlyKeys(value, ['id', 'name', 'config_revision']) || typeof value.id !== 'string' || value.id.length < 1 || value.id.length > 36 || typeof value.name !== 'string' || value.name.length < 1 || value.name.length > 64 || !isInteger(value.config_revision)) invalidPayload(); return value as AiHardwareAgent; } function readDevice(value: unknown): AiHardwareDevice { if (!isRecord(value) || !hasOnlyKeys(value, ['id', 'agent_id', 'assignment_revision']) || typeof value.id !== 'string' || value.id.length < 1 || value.id.length > 36 || typeof value.agent_id !== 'string' || value.agent_id.length < 1 || value.agent_id.length > 36 || !isInteger(value.assignment_revision)) invalidPayload(); return value as AiHardwareDevice; } function nullableString(value: unknown): boolean { return value === null || typeof value === 'string'; } function nullableBoundedInteger(value: unknown, min: number, max: number): boolean { return value === null || isInteger(value, min, max); } const CONFIG_KEYS = [ 'id', 'name', 'config_revision', 'system_prompt', 'lang_code', 'language', 'asr_model_id', 'vad_model_id', 'llm_model_id', 'slm_model_id', 'vllm_model_id', 'tts_model_id', 'tts_voice_id', 'tts_language', 'tts_volume', 'tts_rate', 'tts_pitch', 'mem_model_id', 'intent_model_id', 'chat_history_conf', ] as const; function readConfiguration(value: unknown): AiHardwareAgentConfiguration { if (!isRecord(value) || !hasOnlyKeys(value, CONFIG_KEYS) || Object.keys(value).length !== CONFIG_KEYS.length) invalidPayload(); readAgent({ id: value.id, name: value.name, config_revision: value.config_revision }); for (const key of [ 'system_prompt', 'lang_code', 'language', 'asr_model_id', 'vad_model_id', 'llm_model_id', 'slm_model_id', 'vllm_model_id', 'tts_model_id', 'tts_voice_id', 'tts_language', 'mem_model_id', 'intent_model_id', ] as const) { if (!nullableString(value[key])) invalidPayload(); } for (const key of ['tts_volume', 'tts_rate', 'tts_pitch'] as const) { if (!nullableBoundedInteger(value[key], -100, 100)) invalidPayload(); } if (!nullableBoundedInteger(value.chat_history_conf, 0, 2)) invalidPayload(); return value as AiHardwareAgentConfiguration; } function readOverview(value: unknown): AiHardwareOverview { const statuses: AiHardwareStatus[] = [ 'unprovisioned', 'provisioning', 'active', 'credential_recovery_required', 'invalid', ]; if (!isRecord(value) || !hasOnlyKeys(value, ['status', 'agents', 'devices']) || typeof value.status !== 'string' || !statuses.includes(value.status as AiHardwareStatus) || !Array.isArray(value.agents) || !Array.isArray(value.devices)) invalidPayload(); return { status: value.status as AiHardwareStatus, agents: value.agents.map(readAgent), devices: value.devices.map(readDevice), }; } const CATALOG_MODEL_TYPES = new Set([ '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', ])) invalidPayload(); return value; } function throwEnvelopeError(envelope: MainEnvelope): never { const status = isInteger(envelope.status, 400, 599) ? envelope.status : 502; const code = typeof envelope.code === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(envelope.code) ? envelope.code : DEFAULT_ERROR_CODE; const retryAfterSeconds = isInteger(envelope.retry_after_seconds, 0, 86_400) ? envelope.retry_after_seconds : null; const operationId = typeof envelope.operation_id === 'string' && OPERATION_ID.test(envelope.operation_id) ? envelope.operation_id.toLowerCase() : null; throw new AiHardwareApiError({ status, code, message: SAFE_ERROR_MESSAGES[code] ?? `AI hardware request failed (${status})`, retryable: envelope.retryable === true, retryAfterSeconds, operationId, }); } async function request( path: string, init: RequestInit | undefined, read: (value: unknown) => T, versioned = false, ): Promise> { const envelope = readEnvelope(await hostApiFetch(`${API_ROOT}${path}`, init)); if (envelope.success !== true) throwEnvelopeError(envelope); const data = read(envelope.data); if (!versioned) return data; if (!isInteger(envelope.revision)) invalidPayload(); return { data, revision: envelope.revision }; } function jsonInit(method: string, body: unknown): RequestInit { return { method, body: JSON.stringify(body) }; } function assertRevision(revision: number): void { if (!isInteger(revision)) throw new TypeError('revision must be a non-negative integer'); } function assertNoNull(value: Record): void { if (Object.values(value).some((item) => item === null)) { throw new TypeError('AI hardware updates must use clear_fields instead of null'); } } function assertString(value: unknown, field: string, maxLength: number): asserts value is string { if (typeof value !== 'string' || value.length < 1 || value.length > maxLength) { throw new TypeError(`${field} is invalid`); } } function assertAgentId(value: string): void { assertString(value, 'agent_id', 36); } async function mutationRequest( path: string, method: string, body: Record, operationIdValue: string, read: (value: unknown) => T, versioned = false, ): Promise> { try { return await request(path, jsonInit(method, { ...body, client_operation_id: operationIdValue, }), read, versioned); } catch (error) { if (isRecord(error) && error.name === 'AiHardwareApiError') { if (error.operationId) throw error; const structured = error as unknown as AiHardwareApiError; throw new AiHardwareApiError({ status: structured.status, code: structured.code, message: structured.message, retryable: structured.retryable, retryAfterSeconds: structured.retryAfterSeconds, operationId: operationIdValue, }); } throw new AiHardwareApiError({ status: 502, code: 'AI_HARDWARE_TRANSPORT_ERROR', message: 'AI hardware request could not reach the local service', retryable: true, operationId: operationIdValue, }); } } export type AiHardwareMutationOptions = { operationId?: string }; export function createAiHardwareOperationId(): string { return globalThis.crypto.randomUUID(); } function operationId(options?: AiHardwareMutationOptions): string { const value = options?.operationId ?? createAiHardwareOperationId(); if (!OPERATION_ID.test(value)) throw new TypeError('operationId is invalid'); return value.toLowerCase(); } const UPDATE_STRING_LIMITS: Record = { agent_name: 64, system_prompt: 16_000, lang_code: 10, language: 10, asr_model_id: 32, vad_model_id: 64, llm_model_id: 32, slm_model_id: 255, vllm_model_id: 32, tts_model_id: 32, tts_voice_id: 32, tts_language: 50, mem_model_id: 32, intent_model_id: 32, }; function validateUpdate(update: AiHardwareAgentConfigurationUpdate): void { const record = update as Record; assertNoNull(record); const allowed = new Set([...CONFIG_KEYS.slice(1), 'agent_name', 'clear_fields']); allowed.delete('name'); allowed.delete('config_revision'); if (!hasOnlyKeys(record, [...allowed])) throw new TypeError('Unsupported AI hardware field'); const clears = update.clear_fields ?? []; const clearable = new Set(AI_HARDWARE_CLEARABLE_FIELDS); if (clears.length > 16 || new Set(clears).size !== clears.length || clears.some((field) => !clearable.has(field))) { throw new TypeError('Invalid clear_fields'); } if (Object.keys(record).filter((key) => key !== 'clear_fields').some((key) => clears.includes(key as AiHardwareClearableField))) { throw new TypeError('A field cannot be set and cleared together'); } if (Object.keys(record).length === 0 || (Object.keys(record).length === 1 && 'clear_fields' in record && clears.length === 0)) { throw new TypeError('At least one configuration change is required'); } for (const [field, maxLength] of Object.entries(UPDATE_STRING_LIMITS)) { if (field in record) assertString(record[field], field, maxLength); } for (const field of ['tts_volume', 'tts_rate', 'tts_pitch']) { if (field in record && !isInteger(record[field], -100, 100)) { throw new TypeError(`${field} is invalid`); } } if ('chat_history_conf' in record && !isInteger(record.chat_history_conf, 0, 2)) { throw new TypeError('chat_history_conf is invalid'); } } export async function getAiHardwareOverview(): Promise { return request('', undefined, readOverview) as Promise; } export async function getAiHardwareConfigurationCatalog( ttsModelId?: string, ): Promise { 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; } export async function recoverAiHardwareCredential( options?: AiHardwareMutationOptions, ): Promise { const operationIdValue = operationId(options); return mutationRequest( '/credential-recovery', 'POST', {}, operationIdValue, readOverview, ) as Promise; } export async function createAiHardwareAgent(agentName: string, options?: AiHardwareMutationOptions): Promise { const normalizedName = agentName.trim(); assertString(normalizedName, 'agent_name', 64); const operationIdValue = operationId(options); return mutationRequest('/agents', 'POST', { agent_name: normalizedName }, operationIdValue, readAgent) as Promise; } export async function bindAiHardwareDevice( activationCode: string, agentId: string, options?: AiHardwareMutationOptions, ): Promise { if (!/^[0-9]{6}$/.test(activationCode)) throw new TypeError('activation_code is invalid'); assertAgentId(agentId); const operationIdValue = operationId(options); return mutationRequest('/device-bindings', 'POST', { activation_code: activationCode, agent_id: agentId, }, operationIdValue, readDevice) as Promise; } export async function getAiHardwareAgentConfiguration( agentId: string, ): Promise> { assertAgentId(agentId); return request(`/agents/${encodeURIComponent(agentId)}`, undefined, readConfiguration, true) as Promise>; } export async function updateAiHardwareAgentConfiguration( agentId: string, revision: number, update: AiHardwareAgentConfigurationUpdate, options?: AiHardwareMutationOptions, ): Promise> { assertAgentId(agentId); assertRevision(revision); validateUpdate(update); const operationIdValue = operationId(options); return mutationRequest(`/agents/${encodeURIComponent(agentId)}`, 'PATCH', { revision, ...update, }, operationIdValue, readConfiguration, true) as Promise>; } export async function getAiHardwareAssignment( deviceId: string, ): Promise> { assertString(deviceId, 'device_id', 36); return request(`/devices/${encodeURIComponent(deviceId)}/agent-assignment`, undefined, readDevice, true) as Promise>; } export async function updateAiHardwareAssignment( deviceId: string, revision: number, agentId: string, options?: AiHardwareMutationOptions, ): Promise> { assertString(deviceId, 'device_id', 36); assertRevision(revision); assertAgentId(agentId); const operationIdValue = operationId(options); return mutationRequest(`/devices/${encodeURIComponent(deviceId)}/agent-assignment`, 'PUT', { revision, agent_id: agentId, }, operationIdValue, readDevice, true) as Promise>; }