feat: add dynamic Robot configuration choices

This commit is contained in:
2026-08-16 00:55:06 +08:00
parent 7bef261fb8
commit fe55deed04
7 changed files with 524 additions and 10 deletions

View File

@@ -18,6 +18,7 @@ const SAFE_ERROR_MESSAGES: Record<string, string> = {
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',
ai_hardware_catalog_unavailable: 'AI hardware configuration options are unavailable',
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',
@@ -80,6 +81,36 @@ export type AiHardwareAgentConfiguration = AiHardwareAgent & {
chat_history_conf: number | null;
};
export type AiHardwareCatalogModelType =
| 'VAD'
| 'ASR'
| 'LLM'
| 'VLLM'
| 'Intent'
| 'Memory'
| 'TTS';
export type AiHardwareCatalogModel = {
model_type: AiHardwareCatalogModelType;
model_id: string;
model_name: string;
supports_function_call: boolean | null;
};
export type AiHardwareCatalogVoice = {
tts_model_id: string;
voice_id: string;
voice_name: string;
languages: string[];
is_clone: boolean;
};
export type AiHardwareConfigurationCatalog = {
schema_version: 1;
models: AiHardwareCatalogModel[];
voices: AiHardwareCatalogVoice[];
};
export const AI_HARDWARE_CLEARABLE_FIELDS = [
'system_prompt',
'lang_code',
@@ -249,6 +280,62 @@ function readOverview(value: unknown): AiHardwareOverview {
};
}
const CATALOG_MODEL_TYPES = new Set<AiHardwareCatalogModelType>([
'VAD', 'ASR', 'LLM', 'VLLM', 'Intent', 'Memory', 'TTS',
]);
function boundedString(value: unknown, maxLength: number): value is string {
return typeof value === 'string' && value.length > 0 && value.length <= maxLength;
}
function readCatalogModel(value: unknown): AiHardwareCatalogModel {
if (!isRecord(value)
|| !hasOnlyKeys(value, ['model_type', 'model_id', 'model_name', 'supports_function_call'])
|| Object.keys(value).length !== 4
|| typeof value.model_type !== 'string'
|| !CATALOG_MODEL_TYPES.has(value.model_type as AiHardwareCatalogModelType)
|| !boundedString(value.model_id, 255)
|| !boundedString(value.model_name, 128)
|| (value.supports_function_call !== null && typeof value.supports_function_call !== 'boolean')) {
invalidPayload();
}
return value as AiHardwareCatalogModel;
}
function readCatalogVoice(value: unknown): AiHardwareCatalogVoice {
if (!isRecord(value)
|| !hasOnlyKeys(value, ['tts_model_id', 'voice_id', 'voice_name', 'languages', 'is_clone'])
|| Object.keys(value).length !== 5
|| !boundedString(value.tts_model_id, 255)
|| !boundedString(value.voice_id, 255)
|| !boundedString(value.voice_name, 128)
|| !Array.isArray(value.languages)
|| value.languages.length > 16
|| value.languages.some((language) => !boundedString(language, 50))
|| typeof value.is_clone !== 'boolean') {
invalidPayload();
}
return value as AiHardwareCatalogVoice;
}
function readConfigurationCatalog(value: unknown): AiHardwareConfigurationCatalog {
if (!isRecord(value)
|| !hasOnlyKeys(value, ['schema_version', 'models', 'voices'])
|| Object.keys(value).length !== 3
|| value.schema_version !== 1
|| !Array.isArray(value.models)
|| value.models.length > 256
|| !Array.isArray(value.voices)
|| value.voices.length > 512) {
invalidPayload();
}
return {
schema_version: 1,
models: value.models.map(readCatalogModel),
voices: value.voices.map(readCatalogVoice),
};
}
function readEnvelope(value: unknown): MainEnvelope {
if (!isRecord(value) || !hasOnlyKeys(value, [
'success', 'status', 'code', 'error', 'retryable', 'retry_after_seconds', 'operation_id', 'data', 'revision',
@@ -416,6 +503,16 @@ export async function getAiHardwareOverview(): Promise<AiHardwareOverview> {
return request('', undefined, readOverview) as Promise<AiHardwareOverview>;
}
export async function getAiHardwareConfigurationCatalog(
ttsModelId?: string,
): Promise<AiHardwareConfigurationCatalog> {
if (ttsModelId !== undefined) assertString(ttsModelId, 'tts_model_id', 255);
const query = ttsModelId === undefined
? ''
: `?tts_model_id=${encodeURIComponent(ttsModelId)}`;
return request(`/catalog${query}`, undefined, readConfigurationCatalog) as Promise<AiHardwareConfigurationCatalog>;
}
export async function recoverAiHardwareCredential(
options?: AiHardwareMutationOptions,
): Promise<AiHardwareOverview> {

View File

@@ -8,12 +8,14 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
AiHardwareApiError,
bindAiHardwareDevice,
createAiHardwareAgent,
getAiHardwareAgentConfiguration,
getAiHardwareConfigurationCatalog,
getAiHardwareAssignment,
getAiHardwareOverview,
recoverAiHardwareCredential,
@@ -22,7 +24,9 @@ import {
type AiHardwareAgent,
type AiHardwareAgentConfiguration,
type AiHardwareAgentConfigurationUpdate,
type AiHardwareCatalogModelType,
type AiHardwareClearableField,
type AiHardwareConfigurationCatalog,
type AiHardwareDevice,
type AiHardwareOverview,
} from '@/lib/ai-hardware';
@@ -158,6 +162,9 @@ export function AiHardware() {
const [createOpen, setCreateOpen] = useState(false);
const [bindOpen, setBindOpen] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const [catalog, setCatalog] = useState<AiHardwareConfigurationCatalog | null>(null);
const [catalogLoading, setCatalogLoading] = useState(false);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [assignmentDevice, setAssignmentDevice] = useState<AiHardwareDevice | null>(null);
const [agentName, setAgentName] = useState('');
const [dialogAgentId, setDialogAgentId] = useState('');
@@ -176,6 +183,7 @@ export function AiHardware() {
const activationCodeInputRef = useRef<HTMLInputElement | null>(null);
const bindFingerprintKeyRef = useRef<CryptoKey | null>(null);
const bindRetryIntentRef = useRef<BindRetryIntent | null>(null);
const catalogRequestRef = useRef(0);
const clearCreateOperation = () => { setCreateOperationId(null); setCreateOperationBody(null); };
const clearBindOperation = () => {
@@ -186,6 +194,24 @@ export function AiHardware() {
const clearConfigOperation = () => { setConfigOperationId(null); setConfigOperationBody(null); };
const clearAssignmentOperation = () => { setAssignmentOperationId(null); setAssignmentOperationBody(null); };
const loadConfigurationCatalog = useCallback(async (ttsModelId?: string) => {
const requestId = catalogRequestRef.current + 1;
catalogRequestRef.current = requestId;
setCatalogLoading(true);
setCatalogError(null);
setCatalog((current) => current ? { ...current, voices: [] } : null);
try {
const next = await getAiHardwareConfigurationCatalog(ttsModelId);
if (catalogRequestRef.current !== requestId) return;
setCatalog(next);
} catch {
if (catalogRequestRef.current !== requestId) return;
setCatalogError('暂时无法读取可选配置,请重试。当前配置会保持不变。');
} finally {
if (catalogRequestRef.current === requestId) setCatalogLoading(false);
}
}, []);
const recoverCredential = async () => {
if (recoveryBusy) return;
setRecoveryBusy(true); setRecoveryError(null);
@@ -254,6 +280,17 @@ export function AiHardware() {
clearCreateOperation(); clearBindOperation(); clearConfigOperation(); clearAssignmentOperation();
};
const openConfigurationEditor = () => {
if (!config) return;
resetDialog();
const next = draftFrom(config);
setDraft(next);
setCatalog(null);
setCatalogError(null);
setConfigOpen(true);
void loadConfigurationCatalog(next.tts_model_id || undefined);
};
const createAgent = async () => {
const name = agentName.trim();
if (!name || name.length > 64) { setDialogError('名称需要包含 164 个字符。'); return; }
@@ -378,7 +415,7 @@ export function AiHardware() {
<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>{selectedAgent?.name ?? '智能体配置'}</CardTitle><CardDescription></CardDescription></div><Button variant="outline" disabled={configLoading || !config} onClick={openConfigurationEditor}>{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>
@@ -386,19 +423,101 @@ export function AiHardware() {
<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={configOpen} onOpenChange={(open) => { if (!busy) { setConfigOpen(open); if (!open) { resetDialog(); setCatalog(null); setCatalogError(null); } } }}><DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto"><DialogHeader><DialogTitle></DialogTitle><DialogDescription>使</DialogDescription></DialogHeader>{draft ? <ConfigFields draft={draft} catalog={catalog} catalogLoading={catalogLoading} catalogError={catalogError} setDraft={(next) => { setDraft(next); clearConfigOperation(); }} onTtsModelChange={(value, next) => { setDraft(next); clearConfigOperation(); void loadConfigurationCatalog(value || undefined); }} onRetryCatalog={() => void loadConfigurationCatalog(draft.tts_model_id || undefined)} /> : 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>;
return <div><Label htmlFor="hardware-agent-select"></Label><Select id="hardware-agent-select" className="mt-1" 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>;
type CatalogOption = { value: string; label: string };
function SelectField({
id, label, value, options, onChange,
}: {
id: string;
label: string;
value: string;
options: CatalogOption[];
onChange: (value: string) => void;
}) {
const currentAvailable = !value || options.some((option) => option.value === value);
return <div><Label htmlFor={id}>{label}</Label><Select id={id} className="mt-1" value={value} onChange={(event) => onChange(event.target.value)}><option value="">使</option>{!currentAvailable ? <option value={value}>{value}</option> : null}{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</Select></div>;
}
function RangeField({
field, label, value, setDraft,
}: {
field: typeof nullableNumberFields[number];
label: string;
value: string;
setDraft: (value: string) => void;
}) {
const unset = value === '';
return <div className="rounded-xl border border-border/70 bg-surface-subtle px-3 py-3"><div className="flex items-center justify-between gap-3"><Label htmlFor={`hardware-${field}`}>{label}</Label><output htmlFor={`hardware-${field}`} className="min-w-12 text-right text-sm font-semibold tabular-nums">{unset ? '默认' : value}</output></div><input id={`hardware-${field}`} aria-valuetext={unset ? '使用服务默认值' : value} type="range" min="-100" max="100" step="1" disabled={unset} value={unset ? '0' : value} onChange={(event) => setDraft(event.target.value)} className="mt-3 h-10 w-full cursor-pointer accent-brand disabled:cursor-not-allowed disabled:opacity-40" /><Button type="button" variant="outline" className="mt-2 min-h-10" aria-label={`${label}${unset ? '启用调节' : '使用默认值'}`} onClick={() => setDraft(unset ? '0' : '')}>{unset ? '启用调节' : '使用默认值'}</Button></div>;
}
function ConfigFields({
draft,
catalog,
catalogLoading,
catalogError,
setDraft,
onTtsModelChange,
onRetryCatalog,
}: {
draft: ConfigDraft;
catalog: AiHardwareConfigurationCatalog | null;
catalogLoading: boolean;
catalogError: string | null;
setDraft: (draft: ConfigDraft) => void;
onTtsModelChange: (value: string, draft: ConfigDraft) => void;
onRetryCatalog: () => void;
}) {
const modelOptions = (type: AiHardwareCatalogModelType): CatalogOption[] => (catalog?.models ?? [])
.filter((model) => model.model_type === type)
.map((model) => ({ value: model.model_id, label: model.model_name }));
const voices = (catalog?.voices ?? []).filter((voice) => voice.tts_model_id === draft.tts_model_id);
const voiceOptions = voices.map((voice) => ({
value: voice.voice_id,
label: `${voice.voice_name}${voice.is_clone ? '(我的复刻音色)' : ''}`,
}));
const languages = [...new Set(voices.flatMap((voice) => voice.languages))]
.sort((left, right) => left.localeCompare(right))
.map((language) => ({ value: language, label: language }));
const select = (
key: keyof ConfigDraft,
label: string,
options: CatalogOption[],
onChange?: (value: string) => void,
) => <SelectField id={`hardware-${key}`} label={label} value={draft[key]} options={options} onChange={(value) => onChange ? onChange(value) : setDraft({ ...draft, [key]: value })} />;
const changeTtsModel = (value: string) => onTtsModelChange(value, {
...draft,
tts_model_id: value,
tts_voice_id: '',
tts_language: '',
});
return <div className="space-y-5">
{catalogLoading ? <p role="status" aria-live="polite" className="flex items-center gap-2 rounded-xl bg-surface-subtle px-3 py-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /></p> : null}
{catalogError ? <div role="alert" className="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-amber-500/10 px-3 py-2 text-sm text-amber-800"><span>{catalogError}</span><Button type="button" variant="outline" className="min-h-10" onClick={onRetryCatalog}></Button></div> : null}
<section aria-labelledby="hardware-basic-heading" className="space-y-4 rounded-2xl border border-border/70 p-4">
<div><h3 id="hardware-basic-heading" className="font-semibold"></h3><p className="text-xs text-muted-foreground"></p></div>
<div className="grid gap-4 sm:grid-cols-2"><div><Label htmlFor="hardware-agent_name"></Label><Input id="hardware-agent_name" value={draft.agent_name} onChange={(event) => setDraft({ ...draft, agent_name: event.target.value })} /></div><div className="sm:col-span-2"><Label htmlFor="hardware-system_prompt"></Label><Textarea id="hardware-system_prompt" className="min-h-28" value={draft.system_prompt} onChange={(event) => setDraft({ ...draft, system_prompt: event.target.value })} /></div></div>
</section>
<section aria-labelledby="hardware-model-heading" className="space-y-4 rounded-2xl border border-border/70 p-4">
<div><h3 id="hardware-model-heading" className="font-semibold"></h3><p className="text-xs text-muted-foreground">SLM </p></div>
<div className="grid gap-4 sm:grid-cols-2">{select('vad_model_id', '语音活动检测 (VAD)', modelOptions('VAD'))}{select('asr_model_id', '语音识别 (ASR)', modelOptions('ASR'))}{select('llm_model_id', '主语言模型 (LLM)', modelOptions('LLM'))}{select('slm_model_id', '小参数模型 (SLM)', modelOptions('LLM'))}{select('vllm_model_id', '视觉大模型 (VLLM)', modelOptions('VLLM'))}{select('intent_model_id', '意图识别 (Intent)', modelOptions('Intent'))}{select('mem_model_id', '记忆模式 (Mem)', modelOptions('Memory'))}{select('tts_model_id', '语音合成 (TTS)', modelOptions('TTS'), changeTtsModel)}</div>
</section>
<section aria-labelledby="hardware-voice-heading" className="space-y-4 rounded-2xl border border-border/70 p-4">
<div><h3 id="hardware-voice-heading" className="font-semibold"></h3><p className="text-xs text-muted-foreground"> TTS </p></div>
<div className="grid gap-4 sm:grid-cols-2">{select('tts_language', '对话语言', languages)}{select('tts_voice_id', '声音音色', voiceOptions)}{select('language', '语言', languages)}{select('lang_code', '语言代码', languages)}<div><Label htmlFor="hardware-chat_history_conf"></Label><Select id="hardware-chat_history_conf" className="mt-1" value={draft.chat_history_conf} onChange={(event) => setDraft({ ...draft, chat_history_conf: event.target.value })}><option value=""></option><option value="0"></option><option value="1"></option><option value="2"></option></Select></div></div>
<div className="grid gap-3 sm:grid-cols-3"><RangeField field="tts_volume" label="音量" value={draft.tts_volume} setDraft={(value) => setDraft({ ...draft, tts_volume: value })} /><RangeField field="tts_rate" label="语速" value={draft.tts_rate} setDraft={(value) => setDraft({ ...draft, tts_rate: value })} /><RangeField field="tts_pitch" label="音调" value={draft.tts_pitch} setDraft={(value) => setDraft({ ...draft, tts_pitch: value })} /></div>
</section>
</div>;
}
export default AiHardware;