feat: add Makelore Robot hardware module
This commit is contained in:
493
src/lib/ai-hardware.ts
Normal file
493
src/lib/ai-hardware.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
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',
|
||||
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 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),
|
||||
};
|
||||
}
|
||||
|
||||
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 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>>;
|
||||
}
|
||||
Reference in New Issue
Block a user