feat: add Makelore Robot hardware module
This commit is contained in:
26
src/App.tsx
26
src/App.tsx
@@ -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 />} />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
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>>;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
391
src/pages/AiHardware/index.tsx
Normal file
391
src/pages/AiHardware/index.tsx
Normal 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('名称需要包含 1–64 个字符。'); 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('名称需要包含 1–64 个字符。'); 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', '音量 (-100–100)', 'number')}{field('tts_rate', '语速 (-100–100)', 'number')}{field('tts_pitch', '音调 (-100–100)', '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;
|
||||
Reference in New Issue
Block a user