feat: add dynamic Robot configuration choices
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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('名称需要包含 1–64 个字符。'); 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', '音量 (-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>;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user