405 lines
32 KiB
TypeScript
405 lines
32 KiB
TypeScript
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('名称需要包含 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> : 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', '音量 (-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;
|