Files
makelore/src/pages/AiHardware/index.tsx

405 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 [configLoadFailed, setConfigLoadFailed] = useState(false);
const [configReloadKey, setConfigReloadKey] = useState(0);
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);
setConfigLoading(false);
setConfigLoadFailed(false);
return;
}
let active = true;
setConfig(null);
setConfigRevision(null);
setConfigLoadFailed(false);
setNotice(null);
setConfigLoading(true);
void getAiHardwareAgentConfiguration(selectedAgentId).then((result) => {
if (!active) return;
setConfig(result.data); setConfigRevision(result.revision);
}).catch(() => {
if (!active) return;
setConfigLoadFailed(true);
setNotice('无法读取智能体配置,请刷新后重试。');
})
.finally(() => { if (active) setConfigLoading(false); });
return () => { active = false; };
}, [configReloadKey, 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> : configLoading ? <FeedbackState state="loading" title="正在读取配置" /> : configLoadFailed ? <FeedbackState state="error" title="无法读取智能体配置" description="请检查服务连接后重试。" action={<Button variant="outline" onClick={() => setConfigReloadKey((value) => value + 1)}></Button>} /> : null}</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;