feat: add Makelore Robot hardware module

This commit is contained in:
2026-08-13 23:17:22 +08:00
parent 22add3f01f
commit aba5cae286
25 changed files with 3169 additions and 72 deletions

View File

@@ -18,6 +18,7 @@ import { PreviewScene } from './pages/Makelore';
import { ProjectConfiguration } from './pages/ProjectConfiguration';
import { Workbench } from './pages/Workbench';
import { ImageCanvas } from './pages/ImageCanvas';
import { AiHardware } from './pages/AiHardware';
import { Settings } from './pages/Settings';
import { Setup } from './pages/Setup';
import { Login } from './pages/Login';
@@ -114,6 +115,27 @@ function getReturnPath(location: ReturnType<typeof useLocation>): string {
return `${location.pathname}${location.search}`;
}
const PROGRAMMING_ROUTE_PREFIXES = [
'/project-config',
'/makelore-home',
'/kangaroo',
'/subagents',
'/chat',
'/deliverables',
'/workbench',
'/opencode-chat',
'/projects',
'/sessions',
'/models',
'/settings',
] as const;
function isProgrammingRoute(pathname: string): boolean {
return PROGRAMMING_ROUTE_PREFIXES.some(
(route) => pathname === route || pathname.startsWith(`${route}/`),
);
}
function ProtectedLayout({
authReady,
authRequired,
@@ -299,8 +321,9 @@ function App() {
useEffect(() => {
if (rendererOnlyPreview) return;
if (!setupReady) return;
if (!isProgrammingRoute(location.pathname)) return;
initProviders();
}, [initProviders, rendererOnlyPreview, setupReady]);
}, [initProviders, location.pathname, rendererOnlyPreview, setupReady]);
useEffect(() => {
if (rendererOnlyPreview) return;
@@ -388,6 +411,7 @@ function App() {
<Route path="/chat" element={<Navigate to="/opencode-chat" replace />} />
<Route path="/deliverables" element={<PreviewScene />} />
<Route path="/image-canvas" element={<ImageCanvas />} />
<Route path="/ai-hardware" element={<AiHardware />} />
<Route path="/workbench/:projectId" element={<Workbench />} />
<Route path="/opencode-chat" element={<ProjectChatRoute />} />
<Route path="/projects" element={<Projects />} />

View File

@@ -32,9 +32,11 @@ export function MainLayout() {
sidebar: false,
titlebar: false,
});
const isPaintingModule = getAiModuleForPath(location.pathname) === 'painting';
const activeModule = getAiModuleForPath(location.pathname);
const isProgrammingModule = activeModule === 'programming';
const isPaintingModule = activeModule === 'painting';
const isChatWorkspace = location.pathname === '/opencode-chat';
const isInitializationSafeRoute = location.pathname === '/project-config' || isPaintingModule;
const isInitializationSafeRoute = location.pathname === '/project-config' || !isProgrammingModule;
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
if (!sidebarCollapsed) return;
@@ -80,8 +82,8 @@ export function MainLayout() {
}, []);
useEffect(() => {
if (activeProject && !isPaintingModule) void load(activeProject.id).catch(() => undefined);
}, [activeProject, isPaintingModule, load]);
if (activeProject && isProgrammingModule) void load(activeProject.id).catch(() => undefined);
}, [activeProject, isProgrammingModule, load]);
const initializationBlocked = Boolean(activeProject && (!config || !config.initialized) && !isInitializationSafeRoute);
return (

View File

@@ -26,17 +26,13 @@ export function ModuleSwitcher({ sidebarCollapsed, compact = false }: { sidebarC
useEffect(() => {
if (!open) return undefined;
const handleMouseDown = (event: MouseEvent) => {
const target = event.target;
if (target instanceof Node && !switcherRef.current?.contains(target)) {
setOpen(false);
}
if (target instanceof Node && !switcherRef.current?.contains(target)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
@@ -78,39 +74,37 @@ export function ModuleSwitcher({ sidebarCollapsed, compact = false }: { sidebarC
)}
innerClassName="grid gap-1.5"
>
<div className="px-2 py-1 text-[11px] font-semibold text-muted-foreground">选择模块</div>
{aiModules.map((module) => {
const active = module.id === activeModuleId;
const tone = moduleToneClasses[module.id];
const moduleLabel = getModuleLabel(module);
return (
<button
key={module.id}
type="button"
role="menuitem"
data-testid={`sidebar-module-${module.id}`}
aria-label={`${moduleLabel}${module.enabled ? '' : ',即将上线'}`}
aria-current={active ? 'page' : undefined}
disabled={!module.enabled}
onClick={() => {
setOpen(false);
if (module.route) navigate(module.route);
}}
className={cn(
'flex min-h-9 w-full items-center rounded-md border px-2 py-1.5 text-left text-xs font-semibold text-foreground transition-[background-color,color,transform,opacity] hover:bg-background active:scale-[0.985]',
active
? `border-brand/20 ${tone.active} shadow-soft`
: 'border-transparent',
!module.enabled && 'cursor-not-allowed border-foreground/15 bg-surface-subtle text-muted-foreground/70 opacity-70 hover:bg-surface-subtle',
)}
>
<span className="min-w-0 flex-1 whitespace-nowrap">
<span className="block whitespace-nowrap">{moduleLabel}</span>
{!module.enabled ? <span className="mt-0.5 block text-[10px] font-medium opacity-80">{module.subtitle}</span> : null}
</span>
</button>
);
})}
<div className="px-2 py-1 text-[11px] font-semibold text-muted-foreground">选择模块</div>
{aiModules.map((module) => {
const active = module.id === activeModuleId;
const tone = moduleToneClasses[module.id];
const moduleLabel = getModuleLabel(module);
return (
<button
key={module.id}
type="button"
role="menuitem"
data-testid={`sidebar-module-${module.id}`}
aria-label={`${moduleLabel}${module.enabled ? '' : ',即将上线'}`}
aria-current={active ? 'page' : undefined}
disabled={!module.enabled}
onClick={() => {
setOpen(false);
if (module.route) navigate(module.route);
}}
className={cn(
'flex min-h-9 w-full items-center rounded-md border px-2 py-1.5 text-left text-xs font-semibold text-foreground transition-[background-color,color,transform,opacity] hover:bg-background active:scale-[0.985]',
active ? `border-brand/20 ${tone.active} shadow-soft` : 'border-transparent',
!module.enabled && 'cursor-not-allowed border-foreground/15 bg-surface-subtle text-muted-foreground/70 opacity-70 hover:bg-surface-subtle',
)}
>
<span className="min-w-0 flex-1 whitespace-nowrap">
<span className="block whitespace-nowrap">{moduleLabel}</span>
{!module.enabled ? <span className="mt-0.5 block text-[10px] font-medium opacity-80">{module.subtitle}</span> : null}
</span>
</button>
);
})}
</DisclosureContent>
</div>
);

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import {
BarChart3,
Cpu,
ChevronRight,
Crown,
ExternalLink,
@@ -208,7 +209,9 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
const tokenUsageRequestIdRef = useRef(0);
const navigate = useNavigate();
const location = useLocation();
const isPaintingModule = getAiModuleForPath(location.pathname) === 'painting';
const activeModule = getAiModuleForPath(location.pathname);
const isProgrammingModule = activeModule === 'programming';
const isPaintingModule = activeModule === 'painting';
const projectConfigPath = '/project-config';
const visibleProjects = opencodeProjects;
const selectedProjectFolderName = getFolderName(newProjectSelectedPath);
@@ -237,21 +240,21 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
const weeklyRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.weekly_refresh_at);
useEffect(() => {
if (isPaintingModule) return;
if (!isProgrammingModule) return;
void loadProjectList().catch(() => undefined);
}, [isPaintingModule, loadProjectList]);
}, [isProgrammingModule, loadProjectList]);
useEffect(() => {
if (isPaintingModule) return;
if (!isProgrammingModule) return;
for (const project of visibleProjects) {
void loadProjectConfig(project.id).catch(() => undefined);
}
}, [isPaintingModule, loadProjectConfig, visibleProjects]);
}, [isProgrammingModule, loadProjectConfig, visibleProjects]);
useEffect(() => {
if (isPaintingModule) return;
if (!isProgrammingModule) return;
void refreshProviderSnapshot().catch(() => undefined);
}, [isPaintingModule, refreshProviderSnapshot]);
}, [isProgrammingModule, refreshProviderSnapshot]);
useEffect(() => {
if (!authUser || !profileAccountKey) return;
@@ -648,7 +651,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<div className="min-h-0 flex-1 overflow-auto px-2 py-2">
{isPaintingModule ? (
<ImageWorkspaceSidebar sidebarCollapsed={sidebarCollapsed} />
) : (
) : isProgrammingModule ? (
<>
<button
type="button"
@@ -732,10 +735,31 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
) : null}
</div>
</>
) : (
<div data-testid="sidebar-robot-navigation" className="px-2 py-3">
<button
type="button"
aria-label="机器人工作台"
aria-current="page"
onClick={() => navigate('/ai-hardware')}
className={cn(
'flex min-h-10 w-full items-center gap-2 rounded-lg bg-brand-soft px-2 py-2 text-left text-sm font-semibold text-foreground',
sidebarCollapsed && 'justify-center px-0',
)}
>
<Cpu className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed ? <span></span> : null}
</button>
{!sidebarCollapsed ? (
<p className="mt-3 px-2 text-xs font-medium leading-5 text-muted-foreground">
</p>
) : null}
</div>
)}
</div>
{!isPaintingModule && createDialogOpen ? (
{isProgrammingModule && createDialogOpen ? (
<Dialog
open={createDialogOpen}
onOpenChange={(open) => {
@@ -934,7 +958,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
</Dialog>
) : null}
{!isPaintingModule && projectEntryError && typeof document !== 'undefined'
{isProgrammingModule && projectEntryError && typeof document !== 'undefined'
? createPortal(
<div
data-testid="project-config-error-overlay"
@@ -976,7 +1000,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
)
: null}
{!isPaintingModule ? (
{isProgrammingModule ? (
<ConfirmDialog
open={Boolean(removeProjectCandidate)}
title={`移除项目“${removeProjectCandidate?.name ?? ''}”?`}

493
src/lib/ai-hardware.ts Normal file
View 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>>;
}

View File

@@ -49,8 +49,8 @@ export const aiModules: readonly AiModuleDefinition[] = [
title: 'Makelore Robot',
subtitle: 'AI 机器',
description: '制作你的第一个机器人小伙伴',
route: null,
enabled: false,
route: '/ai-hardware',
enabled: true,
Icon: Bot,
switcherLabel: 'Robot 机器',
},
@@ -60,5 +60,8 @@ export function getAiModuleForPath(pathname: string): AiModuleId {
if (pathname === '/image-canvas' || pathname.startsWith('/image-canvas/')) {
return 'painting';
}
if (pathname === '/ai-hardware' || pathname.startsWith('/ai-hardware/')) {
return 'robot';
}
return 'programming';
}

View File

@@ -0,0 +1,391 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Bot, Link2, Loader2, Plus, RefreshCw, Settings2 } from 'lucide-react';
import { FeedbackState } from '@/components/common/FeedbackState';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
AiHardwareApiError,
bindAiHardwareDevice,
createAiHardwareAgent,
getAiHardwareAgentConfiguration,
getAiHardwareAssignment,
getAiHardwareOverview,
recoverAiHardwareCredential,
updateAiHardwareAgentConfiguration,
updateAiHardwareAssignment,
type AiHardwareAgent,
type AiHardwareAgentConfiguration,
type AiHardwareAgentConfigurationUpdate,
type AiHardwareClearableField,
type AiHardwareDevice,
type AiHardwareOverview,
} from '@/lib/ai-hardware';
type PageState = 'loading' | 'ready' | 'disabled' | 'auth' | 'error';
type BindRetryIntent = { agentId: string; fingerprint: ArrayBuffer; operationId: string };
type ConfigDraft = {
agent_name: string; system_prompt: string; language: string; lang_code: 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_volume: string; tts_rate: string; tts_pitch: string;
tts_language: string; mem_model_id: string; intent_model_id: string;
chat_history_conf: string;
};
const nullableTextFields = [
'system_prompt', 'language', 'lang_code', '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;
const nullableNumberFields = ['tts_volume', 'tts_rate', 'tts_pitch'] as const;
const shortId = (value: string) => value.length > 12 ? `${value.slice(0, 6)}${value.slice(-4)}` : value;
const isRevisionConflict = (error: unknown) => error instanceof AiHardwareApiError
&& ['ai_hardware_revision_conflict', 'REVISION_MISMATCH', 'AI_HARDWARE_REVISION_CONFLICT'].includes(error.code);
function safeMessage(error: unknown): string {
if (!(error instanceof AiHardwareApiError)) return '操作没有完成,请稍后重试。';
if (isRevisionConflict(error)) return '内容已在其他位置更新,请重新核对后再保存。';
const messages: Record<string, string> = {
AI_HARDWARE_AUTH_REQUIRED: '请先登录 Works Square然后重试。',
AI_HARDWARE_FORBIDDEN: '当前账号无权执行此操作。',
AI_HARDWARE_DISABLED: 'AI 机器模块尚未启用,请联系管理员。',
ai_hardware_unconfigured: 'AI 机器服务尚未配置,请联系管理员。',
ai_hardware_credential_recovery_required: '需要管理员恢复设备凭据后才能继续。',
ai_hardware_credential_unavailable: '设备凭据暂不可用,请联系管理员恢复。',
ai_hardware_credential_recovery_unavailable: '当前状态无需或不能恢复凭据,请刷新查看最新状态。',
ai_hardware_activation_code_invalid: '激活码无效或已过期,请从设备上获取新激活码。',
ai_hardware_device_already_bound: '设备已被绑定,无法重复绑定。',
ai_hardware_idempotency_conflict: '本次输入与待重试操作不一致,请再次提交以启动新操作。',
AI_HARDWARE_RATE_LIMITED: '请求过于频繁,请稍后重试。',
};
if (error.code === 'ai_hardware_operation_in_progress') {
return error.retryAfterSeconds === null
? '操作仍在处理中,请稍后重试。'
: `操作仍在处理中,请在 ${error.retryAfterSeconds} 秒后重试。`;
}
return messages[error.code] ?? (error.retryable ? '服务暂时不可用,请稍后重试。' : '操作没有完成,请稍后重试。');
}
const retryOperationId = (error: unknown): string | null => error instanceof AiHardwareApiError
&& (error.retryable || error.code === 'ai_hardware_operation_in_progress') ? error.operationId : null;
function draftFrom(config: AiHardwareAgentConfiguration): ConfigDraft {
return {
agent_name: config.name,
system_prompt: config.system_prompt ?? '', language: config.language ?? '', lang_code: config.lang_code ?? '',
asr_model_id: config.asr_model_id ?? '', vad_model_id: config.vad_model_id ?? '',
llm_model_id: config.llm_model_id ?? '', slm_model_id: config.slm_model_id ?? '',
vllm_model_id: config.vllm_model_id ?? '', tts_model_id: config.tts_model_id ?? '',
tts_voice_id: config.tts_voice_id ?? '', tts_volume: config.tts_volume?.toString() ?? '',
tts_rate: config.tts_rate?.toString() ?? '', tts_pitch: config.tts_pitch?.toString() ?? '',
tts_language: config.tts_language ?? '', mem_model_id: config.mem_model_id ?? '',
intent_model_id: config.intent_model_id ?? '',
chat_history_conf: config.chat_history_conf?.toString() ?? '',
};
}
function configurationUpdate(config: AiHardwareAgentConfiguration, draft: ConfigDraft): AiHardwareAgentConfigurationUpdate {
const update: AiHardwareAgentConfigurationUpdate = {};
const clearFields: AiHardwareClearableField[] = [];
if (draft.agent_name.trim() !== config.name) update.agent_name = draft.agent_name.trim();
for (const field of nullableTextFields) {
const value = draft[field].trim();
if (!value && config[field] !== null) clearFields.push(field);
else if (value && value !== config[field]) update[field] = value;
}
for (const field of nullableNumberFields) {
const raw = draft[field].trim();
if (!raw && config[field] !== null) clearFields.push(field);
else if (raw && Number(raw) !== config[field]) update[field] = Number(raw);
}
if (draft.chat_history_conf !== '') {
const history = Number(draft.chat_history_conf);
if (history !== config.chat_history_conf) update.chat_history_conf = history;
}
if (clearFields.length) update.clear_fields = clearFields;
return update;
}
function replayConfigurationUpdate(
config: AiHardwareAgentConfiguration,
update: AiHardwareAgentConfigurationUpdate,
): ConfigDraft {
const next = draftFrom(config);
if (update.agent_name !== undefined) next.agent_name = update.agent_name;
for (const field of nullableTextFields) {
if (update[field] !== undefined) next[field] = update[field];
if (update.clear_fields?.includes(field)) next[field] = '';
}
for (const field of nullableNumberFields) {
if (update[field] !== undefined) next[field] = update[field].toString();
if (update.clear_fields?.includes(field)) next[field] = '';
}
if (update.chat_history_conf !== undefined) next.chat_history_conf = update.chat_history_conf.toString();
return next;
}
async function createBindFingerprintKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
}
async function bindFingerprint(key: CryptoKey, activationCode: string): Promise<ArrayBuffer> {
return crypto.subtle.sign('HMAC', key, new TextEncoder().encode(activationCode));
}
function equalFingerprint(left: ArrayBuffer, right: ArrayBuffer): boolean {
const a = new Uint8Array(left);
const b = new Uint8Array(right);
if (a.length !== b.length) return false;
let difference = 0;
for (let index = 0; index < a.length; index += 1) difference |= a[index] ^ b[index];
return difference === 0;
}
export function AiHardware() {
const [pageState, setPageState] = useState<PageState>('loading');
const [overview, setOverview] = useState<AiHardwareOverview | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [config, setConfig] = useState<AiHardwareAgentConfiguration | null>(null);
const [configRevision, setConfigRevision] = useState<number | null>(null);
const [configLoading, setConfigLoading] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [bindOpen, setBindOpen] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const [assignmentDevice, setAssignmentDevice] = useState<AiHardwareDevice | null>(null);
const [agentName, setAgentName] = useState('');
const [dialogAgentId, setDialogAgentId] = useState('');
const [draft, setDraft] = useState<ConfigDraft | null>(null);
const [dialogError, setDialogError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [createOperationId, setCreateOperationId] = useState<string | null>(null);
const [configOperationId, setConfigOperationId] = useState<string | null>(null);
const [assignmentOperationId, setAssignmentOperationId] = useState<string | null>(null);
const [createOperationBody, setCreateOperationBody] = useState<string | null>(null);
const [configOperationBody, setConfigOperationBody] = useState<string | null>(null);
const [assignmentOperationBody, setAssignmentOperationBody] = useState<string | null>(null);
const [recoveryOperationId, setRecoveryOperationId] = useState<string | null>(null);
const [recoveryError, setRecoveryError] = useState<string | null>(null);
const [recoveryBusy, setRecoveryBusy] = useState(false);
const activationCodeInputRef = useRef<HTMLInputElement | null>(null);
const bindFingerprintKeyRef = useRef<CryptoKey | null>(null);
const bindRetryIntentRef = useRef<BindRetryIntent | null>(null);
const clearCreateOperation = () => { setCreateOperationId(null); setCreateOperationBody(null); };
const clearBindOperation = () => {
bindRetryIntentRef.current = null;
bindFingerprintKeyRef.current = null;
if (activationCodeInputRef.current) activationCodeInputRef.current.value = '';
};
const clearConfigOperation = () => { setConfigOperationId(null); setConfigOperationBody(null); };
const clearAssignmentOperation = () => { setAssignmentOperationId(null); setAssignmentOperationBody(null); };
const recoverCredential = async () => {
if (recoveryBusy) return;
setRecoveryBusy(true); setRecoveryError(null);
try {
const value = recoveryOperationId
? await recoverAiHardwareCredential({ operationId: recoveryOperationId })
: await recoverAiHardwareCredential();
setRecoveryOperationId(null); setOverview(value); setPageState('ready');
setSelectedAgentId(value.agents[0]?.id ?? null);
} catch (error) {
if (error instanceof AiHardwareApiError && error.code === 'ai_hardware_credential_recovery_unavailable') {
setRecoveryOperationId(null);
await loadOverview();
return;
}
setRecoveryOperationId(retryOperationId(error));
setRecoveryError(safeMessage(error));
} finally { setRecoveryBusy(false); }
};
const loadOverview = useCallback(async () => {
setPageState('loading'); setNotice(null);
try {
const value = await getAiHardwareOverview();
setOverview(value); setPageState('ready');
setSelectedAgentId((current) => value.agents.some((item) => item.id === current) ? current : value.agents[0]?.id ?? null);
} catch (error) {
setOverview(null);
if (error instanceof AiHardwareApiError && error.code === 'AI_HARDWARE_AUTH_REQUIRED') setPageState('auth');
else if (error instanceof AiHardwareApiError && ['AI_HARDWARE_DISABLED', 'ai_hardware_unconfigured'].includes(error.code)) setPageState('disabled');
else setPageState('error');
}
}, []);
useEffect(() => { void loadOverview(); }, [loadOverview]);
useEffect(() => {
if (!selectedAgentId) { setConfig(null); setConfigRevision(null); return; }
let active = true;
setConfig(null);
setConfigRevision(null);
setNotice(null);
setConfigLoading(true);
void getAiHardwareAgentConfiguration(selectedAgentId).then((result) => {
if (!active) return;
setConfig(result.data); setConfigRevision(result.revision);
}).catch(() => { if (active) setNotice('无法读取智能体配置,请刷新后重试。'); })
.finally(() => { if (active) setConfigLoading(false); });
return () => { active = false; };
}, [selectedAgentId]);
const selectedAgent = overview?.agents.find((item) => item.id === selectedAgentId) ?? null;
const devices = useMemo(() => overview?.devices ?? [], [overview]);
const resetDialog = () => {
setDialogError(null); setBusy(false);
clearCreateOperation(); clearBindOperation(); clearConfigOperation(); clearAssignmentOperation();
};
const createAgent = async () => {
const name = agentName.trim();
if (!name || name.length > 64) { setDialogError('名称需要包含 164 个字符。'); return; }
setBusy(true); setDialogError(null);
try {
const canRetry = createOperationId && createOperationBody === name;
const agent = canRetry ? await createAiHardwareAgent(name, { operationId: createOperationId }) : await createAiHardwareAgent(name);
setCreateOperationId(null);
setCreateOperationBody(null);
setCreateOpen(false); setAgentName(''); await loadOverview(); setSelectedAgentId(agent.id);
} catch (error) { setCreateOperationId(retryOperationId(error)); setCreateOperationBody(name); setDialogError(safeMessage(error)); } finally { setBusy(false); }
};
const bindDevice = async () => {
const activationCode = activationCodeInputRef.current?.value ?? '';
if (!/^[0-9]{6}$/.test(activationCode)) { setDialogError('请输入 6 位数字激活码。'); return; }
if (!dialogAgentId) { setDialogError('请选择要绑定的智能体。'); return; }
setBusy(true); setDialogError(null);
try {
const key = bindFingerprintKeyRef.current ?? await createBindFingerprintKey();
bindFingerprintKeyRef.current = key;
const fingerprint = await bindFingerprint(key, activationCode);
const retry = bindRetryIntentRef.current;
if (retry?.agentId === dialogAgentId && equalFingerprint(retry.fingerprint, fingerprint)) {
await bindAiHardwareDevice(activationCode, dialogAgentId, { operationId: retry.operationId });
}
else await bindAiHardwareDevice(activationCode, dialogAgentId);
clearBindOperation();
setBindOpen(false); await loadOverview();
} catch (error) {
const operationId = retryOperationId(error);
if (operationId && bindFingerprintKeyRef.current) {
bindRetryIntentRef.current = {
agentId: dialogAgentId,
fingerprint: await bindFingerprint(bindFingerprintKeyRef.current, activationCode),
operationId,
};
} else bindRetryIntentRef.current = null;
setDialogError(safeMessage(error));
if (activationCodeInputRef.current) activationCodeInputRef.current.value = '';
} finally { setBusy(false); }
};
const saveConfiguration = async () => {
if (!config || !draft || configRevision === null) return;
if (!draft.agent_name.trim() || draft.agent_name.trim().length > 64) { setDialogError('名称需要包含 164 个字符。'); return; }
for (const field of nullableNumberFields) {
if (draft[field] && (!Number.isInteger(Number(draft[field])) || Number(draft[field]) < -100 || Number(draft[field]) > 100)) {
setDialogError('音量、语速和音调必须是 -100 到 100 的整数。'); return;
}
}
const update = configurationUpdate(config, draft);
if (!Object.keys(update).length) { setConfigOpen(false); return; }
setBusy(true); setDialogError(null);
try {
const body = JSON.stringify(update);
const result = configOperationId && configOperationBody === body
? await updateAiHardwareAgentConfiguration(config.id, configRevision, update, { operationId: configOperationId })
: await updateAiHardwareAgentConfiguration(config.id, configRevision, update);
setConfigOperationId(null);
setConfigOperationBody(null);
setConfig(result.data); setConfigRevision(result.revision); setConfigOpen(false); await loadOverview();
} catch (error) {
if (isRevisionConflict(error)) {
setConfigOperationId(null);
const fresh = await getAiHardwareAgentConfiguration(config.id).catch(() => null);
if (fresh) {
setConfig(fresh.data);
setConfigRevision(fresh.revision);
setDraft(replayConfigurationUpdate(fresh.data, update));
}
}
else { setConfigOperationId(retryOperationId(error)); setConfigOperationBody(JSON.stringify(update)); }
setDialogError(safeMessage(error));
} finally { setBusy(false); }
};
const openAssignment = async (device: AiHardwareDevice) => {
setDialogError(null); setBusy(true); setDialogAgentId(device.agent_id); setAssignmentOperationId(null);
try { const result = await getAiHardwareAssignment(device.id); setAssignmentDevice({ ...result.data, assignment_revision: result.revision }); }
catch { setNotice('无法读取设备指派,请刷新后重试。'); }
finally { setBusy(false); }
};
const saveAssignment = async () => {
if (!assignmentDevice || !dialogAgentId) return;
setBusy(true); setDialogError(null);
try {
const body = dialogAgentId;
if (assignmentOperationId && assignmentOperationBody === body) await updateAiHardwareAssignment(assignmentDevice.id, assignmentDevice.assignment_revision, dialogAgentId, { operationId: assignmentOperationId });
else await updateAiHardwareAssignment(assignmentDevice.id, assignmentDevice.assignment_revision, dialogAgentId);
setAssignmentOperationId(null);
setAssignmentOperationBody(null);
setAssignmentDevice(null); await loadOverview();
} catch (error) {
if (isRevisionConflict(error)) {
setAssignmentOperationId(null);
const fresh = await getAiHardwareAssignment(assignmentDevice.id).catch(() => null);
if (fresh) setAssignmentDevice({ ...fresh.data, assignment_revision: fresh.revision });
}
else { setAssignmentOperationId(retryOperationId(error)); setAssignmentOperationBody(dialogAgentId); }
setDialogError(safeMessage(error));
} finally { setBusy(false); }
};
if (pageState === 'loading') return <main data-testid="ai-hardware-page" role="status" aria-live="polite" className="flex min-h-full items-center justify-center"><FeedbackState state="loading" title="正在读取 AI 机器" description="正在同步智能体与机器人设备状态。" /></main>;
if (pageState === 'auth') return <main data-testid="ai-hardware-page" role="alert" className="flex min-h-full items-center justify-center"><FeedbackState state="error" title="请先登录 Works Square" description="登录后即可管理机器人智能体和设备。" action={<Button variant="outline" onClick={() => void loadOverview()}></Button>} /></main>;
if (pageState === 'disabled') return <main data-testid="ai-hardware-page" className="flex min-h-full items-center justify-center"><Card className="max-w-md"><CardHeader><CardTitle>AI </CardTitle><CardDescription></CardDescription></CardHeader><CardContent><Button variant="outline" onClick={() => void loadOverview()}><RefreshCw className="mr-2 h-4 w-4" /></Button></CardContent></Card></main>;
if (pageState === 'error' || !overview) return <main data-testid="ai-hardware-page" role="alert" className="flex min-h-full items-center justify-center"><FeedbackState state="error" title="暂时无法读取 AI 机器" description="连接没有成功,请稍后重试。" action={<Button variant="outline" onClick={() => void loadOverview()}></Button>} /></main>;
if (overview.status === 'credential_recovery_required' || overview.status === 'invalid') return <main data-testid="ai-hardware-page" role="alert" className="flex min-h-full items-center justify-center"><div className="space-y-3 text-center"><FeedbackState state="error" title="需要恢复设备凭据" description="可在此安全地重新签发设备凭据;凭据内容不会显示在客户端。" action={<Button variant="outline" aria-busy={recoveryBusy} disabled={recoveryBusy} onClick={() => void recoverCredential()}>{recoveryBusy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button>} />{recoveryError ? <p className="text-sm text-destructive">{recoveryError}</p> : null}</div></main>;
if (overview.status === 'provisioning') return <main data-testid="ai-hardware-page" className="flex min-h-full items-center justify-center"><FeedbackState state="empty" title="正在准备机器人工作台" description="完成开通后即可创建智能体并绑定机器人设备。" action={<Button variant="outline" onClick={() => void loadOverview()}></Button>} /></main>;
return (
<main data-testid="ai-hardware-page" className="-m-5 min-h-full bg-background p-5 text-foreground sm:-m-6 sm:p-6">
<header className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div><h1 className="text-balance text-2xl font-semibold"></h1><p className="mt-1 text-pretty text-sm text-muted-foreground"></p></div>
<Button aria-label="刷新 AI 机器" variant="outline" onClick={() => void loadOverview()}><RefreshCw className="mr-2 h-4 w-4" /></Button>
</header>
{notice ? <p role="alert" className="mb-4 rounded-xl bg-amber-500/10 px-4 py-3 text-sm font-medium text-amber-800">{notice}</p> : null}
{overview.agents.length === 0 ? (
<Card className="mx-auto max-w-md text-center"><CardHeader><CardTitle></CardTitle><CardDescription>{overview.status === 'unprovisioned' ? '创建智能体将同时开通你的机器人工作台。' : '智能体创建后,才能把机器人设备绑定给它。'}</CardDescription></CardHeader><CardContent><Button onClick={() => { resetDialog(); setCreateOpen(true); }}><Plus className="mr-2 h-4 w-4" /></Button></CardContent></Card>
) : (
<div className="grid gap-5 lg:grid-cols-[minmax(240px,0.7fr)_minmax(0,1.3fr)]">
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle></CardTitle><CardDescription>{overview.agents.length} </CardDescription></div><Button size="icon" aria-label="创建智能体" onClick={() => { resetDialog(); setCreateOpen(true); }}><Plus className="h-4 w-4" /></Button></CardHeader><CardContent className="space-y-2">{overview.agents.map((agent) => <button key={agent.id} type="button" aria-pressed={agent.id === selectedAgentId} onClick={() => setSelectedAgentId(agent.id)} className={`motion-press flex min-h-12 w-full items-center gap-3 rounded-xl px-3 py-2 text-left ${agent.id === selectedAgentId ? 'bg-brand-soft' : 'bg-surface-subtle hover:bg-surface-tertiary'}`}><Bot className="h-4 w-4 shrink-0" /><span className="min-w-0 flex-1"><span className="block truncate text-sm font-semibold">{agent.name}</span><span title={agent.id} className="block text-xs tabular-nums text-muted-foreground">{shortId(agent.id)} · r{agent.config_revision}</span></span></button>)}</CardContent></Card>
<div className="space-y-5">
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>{selectedAgent?.name ?? '智能体配置'}</CardTitle><CardDescription></CardDescription></div><Button variant="outline" disabled={configLoading || !config} onClick={() => { if (!config) return; resetDialog(); setDraft(draftFrom(config)); setConfigOpen(true); }}>{configLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Settings2 className="mr-2 h-4 w-4" />}</Button></CardHeader><CardContent>{config ? <dl className="grid gap-3 text-sm sm:grid-cols-2"><div><dt className="text-muted-foreground"></dt><dd>{config.language || config.lang_code || '未设置'}</dd></div><div><dt className="text-muted-foreground"></dt><dd>{config.tts_voice_id || '未设置'}</dd></div><div className="sm:col-span-2"><dt className="text-muted-foreground"></dt><dd className="mt-1 whitespace-pre-wrap">{config.system_prompt || '未设置'}</dd></div></dl> : <FeedbackState state="loading" title="正在读取配置" />}</CardContent></Card>
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle></CardTitle><CardDescription>{devices.length} </CardDescription></div><Button onClick={() => { resetDialog(); setDialogAgentId(selectedAgentId ?? overview.agents[0].id); setBindOpen(true); }}><Link2 className="mr-2 h-4 w-4" /></Button></CardHeader><CardContent>{devices.length ? <div className="space-y-2">{devices.map((device) => <div key={device.id} className="flex min-h-12 items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2"><span className="min-w-0"><span className="block text-sm font-semibold"> {shortId(device.id)}</span><span title={device.id} className="text-xs tabular-nums text-muted-foreground"> r{device.assignment_revision}</span></span><Button variant="outline" size="sm" onClick={() => void openAssignment(device)}></Button></div>)}</div> : <FeedbackState state="empty" title="还没有绑定设备" description="使用设备上的 6 位激活码完成绑定。" />}</CardContent></Card>
</div>
</div>
)}
<Dialog open={createOpen} onOpenChange={(open) => { if (!busy) { setCreateOpen(open); if (!open) { setAgentName(''); resetDialog(); } } }}><DialogContent><DialogHeader><DialogTitle></DialogTitle><DialogDescription></DialogDescription></DialogHeader><div><Label htmlFor="hardware-agent-name"></Label><Input id="hardware-agent-name" autoFocus maxLength={64} value={agentName} onChange={(e) => { setAgentName(e.target.value); clearCreateOperation(); }} /></div>{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setCreateOpen(false); resetDialog(); }}></Button><Button aria-busy={busy} disabled={busy} onClick={() => void createAgent()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={bindOpen} onOpenChange={(open) => { if (!busy) { setBindOpen(open); if (!open) resetDialog(); } }}><DialogContent><DialogHeader><DialogTitle></DialogTitle><DialogDescription></DialogDescription></DialogHeader><div><Label htmlFor="hardware-activation-code">6 </Label><Input ref={activationCodeInputRef} id="hardware-activation-code" autoFocus type="text" inputMode="numeric" autoComplete="off" maxLength={6} onInput={(e) => { e.currentTarget.value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6); }} /></div><AgentSelect agents={overview.agents} value={dialogAgentId} onChange={(value) => { setDialogAgentId(value); clearBindOperation(); }} />{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setBindOpen(false); resetDialog(); }}></Button><Button aria-busy={busy} disabled={busy} onClick={() => void bindDevice()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={configOpen} onOpenChange={(open) => { if (!busy) { setConfigOpen(open); if (!open) resetDialog(); } }}><DialogContent className="max-h-[90vh] overflow-y-auto"><DialogHeader><DialogTitle></DialogTitle><DialogDescription></DialogDescription></DialogHeader>{draft ? <ConfigFields draft={draft} setDraft={(next) => { setDraft(next); clearConfigOperation(); }} /> : null}{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setConfigOpen(false); resetDialog(); }}></Button><Button aria-busy={busy} disabled={busy} onClick={() => void saveConfiguration()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={Boolean(assignmentDevice)} onOpenChange={(open) => { if (!open && !busy) { setAssignmentDevice(null); resetDialog(); } }}><DialogContent><DialogHeader><DialogTitle></DialogTitle><DialogDescription></DialogDescription></DialogHeader><AgentSelect agents={overview.agents} value={dialogAgentId} onChange={(value) => { setDialogAgentId(value); clearAssignmentOperation(); }} />{dialogError ? <p role="alert" className="text-sm text-destructive">{dialogError}</p> : null}<DialogFooter><Button variant="outline" disabled={busy} onClick={() => { setAssignmentDevice(null); resetDialog(); }}></Button><Button aria-busy={busy} disabled={busy} onClick={() => void saveAssignment()}>{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}</Button></DialogFooter></DialogContent></Dialog>
</main>
);
}
function AgentSelect({ agents, value, onChange }: { agents: AiHardwareAgent[]; value: string; onChange: (value: string) => void }) {
return <div><Label htmlFor="hardware-agent-select"></Label><select id="hardware-agent-select" className="mt-1 h-10 w-full rounded-xl border border-border/80 bg-surface-input px-3 text-sm" value={value} onChange={(e) => onChange(e.target.value)}>{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</select></div>;
}
function ConfigFields({ draft, setDraft }: { draft: ConfigDraft; setDraft: (draft: ConfigDraft) => void }) {
const field = (key: keyof ConfigDraft, label: string, type = 'text') => <div><Label htmlFor={`hardware-${key}`}>{label}</Label><Input id={`hardware-${key}`} type={type} value={draft[key]} onChange={(e) => setDraft({ ...draft, [key]: e.target.value })} /></div>;
return <div className="space-y-4"><div className="grid gap-4 sm:grid-cols-2">{field('agent_name', '名称')}<div className="sm:col-span-2"><Label htmlFor="hardware-system_prompt"></Label><Textarea id="hardware-system_prompt" value={draft.system_prompt} onChange={(e) => setDraft({ ...draft, system_prompt: e.target.value })} /></div>{field('language', '语言')}{field('lang_code', '语言代码')}{field('tts_voice_id', 'TTS 语音 ID')}{field('tts_volume', '音量 (-100100)', 'number')}{field('tts_rate', '语速 (-100100)', 'number')}{field('tts_pitch', '音调 (-100100)', 'number')}<div><Label htmlFor="hardware-chat_history_conf"></Label><select id="hardware-chat_history_conf" className="mt-1 h-10 w-full rounded-xl border border-border/80 bg-surface-input px-3 text-sm" value={draft.chat_history_conf} onChange={(e) => setDraft({ ...draft, chat_history_conf: e.target.value })}><option value=""></option><option value="0"></option><option value="1"></option><option value="2"></option></select></div></div><details className="rounded-xl border border-border/80 bg-surface-subtle px-4 py-3"><summary className="cursor-pointer text-sm font-semibold"></summary><p className="mt-2 text-xs text-muted-foreground"> ID </p><div className="mt-4 grid gap-4 sm:grid-cols-2">{field('asr_model_id', 'ASR 模型 ID')}{field('vad_model_id', 'VAD 模型 ID')}{field('llm_model_id', 'LLM 模型 ID')}{field('slm_model_id', 'SLM 模型 ID')}{field('vllm_model_id', 'VLLM 模型 ID')}{field('tts_model_id', 'TTS 模型 ID')}{field('tts_language', 'TTS 语言')}{field('mem_model_id', '记忆模型 ID')}{field('intent_model_id', '意图模型 ID')}</div></details></div>;
}
export default AiHardware;