Files
makelore/src/lib/ai-hardware.ts

591 lines
21 KiB
TypeScript

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<string, string> = {
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<T> = {
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<string, unknown> {
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<string, unknown>, 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<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',
])) 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<T>(
path: string,
init: RequestInit | undefined,
read: (value: unknown) => T,
versioned = false,
): Promise<T | VersionedAiHardwareResult<T>> {
const envelope = readEnvelope(await hostApiFetch<unknown>(`${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<string, unknown>): 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<T>(
path: string,
method: string,
body: Record<string, unknown>,
operationIdValue: string,
read: (value: unknown) => T,
versioned = false,
): Promise<T | VersionedAiHardwareResult<T>> {
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<string, number> = {
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<string, unknown>;
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<string>(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<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> {
const operationIdValue = operationId(options);
return mutationRequest(
'/credential-recovery', 'POST', {}, operationIdValue, readOverview,
) as Promise<AiHardwareOverview>;
}
export async function createAiHardwareAgent(agentName: string, options?: AiHardwareMutationOptions): Promise<AiHardwareAgent> {
const normalizedName = agentName.trim();
assertString(normalizedName, 'agent_name', 64);
const operationIdValue = operationId(options);
return mutationRequest('/agents', 'POST', { agent_name: normalizedName }, operationIdValue, readAgent) as Promise<AiHardwareAgent>;
}
export async function bindAiHardwareDevice(
activationCode: string,
agentId: string,
options?: AiHardwareMutationOptions,
): Promise<AiHardwareDevice> {
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<AiHardwareDevice>;
}
export async function getAiHardwareAgentConfiguration(
agentId: string,
): Promise<VersionedAiHardwareResult<AiHardwareAgentConfiguration>> {
assertAgentId(agentId);
return request(`/agents/${encodeURIComponent(agentId)}`, undefined, readConfiguration, true) as Promise<VersionedAiHardwareResult<AiHardwareAgentConfiguration>>;
}
export async function updateAiHardwareAgentConfiguration(
agentId: string,
revision: number,
update: AiHardwareAgentConfigurationUpdate,
options?: AiHardwareMutationOptions,
): Promise<VersionedAiHardwareResult<AiHardwareAgentConfiguration>> {
assertAgentId(agentId);
assertRevision(revision);
validateUpdate(update);
const operationIdValue = operationId(options);
return mutationRequest(`/agents/${encodeURIComponent(agentId)}`, 'PATCH', {
revision,
...update,
}, operationIdValue, readConfiguration, true) as Promise<VersionedAiHardwareResult<AiHardwareAgentConfiguration>>;
}
export async function getAiHardwareAssignment(
deviceId: string,
): Promise<VersionedAiHardwareResult<AiHardwareDevice>> {
assertString(deviceId, 'device_id', 36);
return request(`/devices/${encodeURIComponent(deviceId)}/agent-assignment`, undefined, readDevice, true) as Promise<VersionedAiHardwareResult<AiHardwareDevice>>;
}
export async function updateAiHardwareAssignment(
deviceId: string,
revision: number,
agentId: string,
options?: AiHardwareMutationOptions,
): Promise<VersionedAiHardwareResult<AiHardwareDevice>> {
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<VersionedAiHardwareResult<AiHardwareDevice>>;
}