merge: integrate Robot configuration catalog editor
This commit is contained in:
@@ -22,13 +22,14 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
|
||||
| AI Design Image Source Picker | 当前 Workspace 已完成图片或本地上传 | 图片 Brief 绑定图生图参考图;视频 Brief 绑定首帧;提交一个真实 Asset ID |
|
||||
| AI Design Gateway Routing | Main 云端适配器 ↔ Conversation WebSocket;Main → Host API/SSE → Renderer store | 命令、Run 与设计事件共用双向 WebSocket;Conversation 更新按 Workspace + Conversation 路由,任务更新按 Workspace 归并 |
|
||||
|
||||
| Robot Workspace | Account-scoped agent configuration, device activation/binding, assignment, and credential-recovery UI | Renderer receives only safe Works Square projections; credentials, activation-code history, and upstream error bodies remain outside durable UI state. |
|
||||
| AI Hardware Main Route | Fixed `/api/works/ai-hardware` Host API to Works Square proxy | Main owns Bearer auth, stable operation IDs, bounded retry, ETag/If-Match, request/response limits, and error redaction. Versioned responses accept only canonical strong or weak numeric ETags that equal the DTO revision; mutations always emit strong `If-Match`. It never forwards Renderer authorization headers. |
|
||||
| Robot Workspace | Account-scoped agent configuration, device activation/binding, assignment, and credential-recovery UI | Renderer receives only safe Works Square projections. Configuration choices come from the USER-scoped safe catalog; unavailable current values remain editable without exposing provider credentials or configuration internals. |
|
||||
| AI Hardware Main Route | Fixed `/api/works/ai-hardware` Host API to Works Square proxy | Main owns Bearer auth, stable operation IDs, bounded retry, ETag/If-Match, request/response limits, error redaction, and the fixed no-store configuration-catalog proxy. Versioned responses accept only canonical strong or weak numeric ETags that equal the DTO revision; mutations always emit strong `If-Match`. It never forwards Renderer authorization headers. |
|
||||
|
||||
## Important Boundaries
|
||||
|
||||
- Robot is the single enabled hardware product module; there is no separate Hardware card. Learning remains disabled.
|
||||
- AI hardware network access is Main-owned. Renderer cannot hold Works Square or Xiaozhi credentials and cannot select arbitrary upstream paths or headers.
|
||||
- Robot model, language, and voice choices are dynamically projected from the Xiaozhi USER catalog through Works Square and Electron Main; the catalog is bounded, account-scoped, and `private, no-store` at each public hop.
|
||||
- One local account maps to one server-side Xiaozhi account binding. Agents and devices are resources beneath that account binding, not separate Xiaozhi users.
|
||||
- Robot/Canvas/module-selection routes must not initialize AI Programming projects or providers.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
|
||||
## Integrated Through
|
||||
|
||||
- `ea75b06`: Robot configuration reads accept canonical weak numeric response ETags introduced by public response compression only when the numeric revision exactly matches the strictly projected DTO; configuration and assignment writes continue to emit strong `If-Match`.
|
||||
- `fe55dee`: Robot configuration editing uses the safe Xiaozhi/Works catalog for model, language, and voice selections, with bounded sliders for TTS numeric controls and no-store catalog responses.
|
||||
- `fd9b5b46a913c515e94e4e26f185d43866c2581f` / `7a811590c4943b7b1b7ea5f3b4d3ce3ce05622a5`: Codex-style persistent AI Programming context-compaction timeline, run-lifecycle separation, polling-idle completion, and cold-hydration hardening.
|
||||
- `22378efcee07e7fb80b651e65e3202f1a1dfea1d`: AI Canvas bidirectional Agent WebSocket commands, idempotent transport fallback, and Quote-based generation-task recovery.
|
||||
- `aba5cae286807093cf4ef643fe9f498050985c31`: Robot / AI hardware module, Main-owned Works Square proxy, and cross-repository wire contract.
|
||||
@@ -33,6 +34,7 @@ Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失
|
||||
## Recently Completed
|
||||
|
||||
- 2026-08-15: Corrected the deployed Robot configuration-read contract after the compressed public Works response was observed with `ETag: W/\"0\"` and matching numeric `config_revision: 0`. Electron Main now accepts only canonical strong or weak numeric response tags, still requires exact DTO revision equality, and always sends strong `If-Match` for mutations. No production client rollout is claimed.
|
||||
- 2026-08-16: Integrated selection-oriented Robot configuration editing. Enabled model and caller-safe voice metadata now flows from Xiaozhi through Works Square and Electron Main without exposing provider secrets; unavailable current values and `clear_fields` semantics remain intact. Production deployment of the matching service endpoints is still required.
|
||||
- 2026-08-15:AI 编程上下文压缩改为 Codex 风格的会话内时间线事件;手动与自动压缩原位展示并持久保留,历史回放去重且状态只允许从 running 单调进入 completed,压缩完成不再冒充整个 run idle。
|
||||
- 2026-08-14: AI Canvas Agent 命令与流式事件改为共用双向 Conversation WebSocket,并保留仅面向传输故障的幂等 REST 回退;结构化 Gateway 错误不重试且未知文本脱敏;确认生成按 Quote 恢复已落库任务,切换 Conversation 后仍同步 Workspace 任务且不覆盖新会话状态或错误。
|
||||
- 2026-08-13: Integrated the enabled `Makelore Robot` top-level module at `/ai-hardware`. Renderer uses typed safe DTOs while Electron Main owns Works Square authentication, idempotency identities, ETag/If-Match forwarding, response projection, limits, timeouts, and credential recovery. Robot and Canvas routes no longer initialize AI Programming projects/providers. No production deployment or real activation-code smoke is claimed.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
## Scope
|
||||
|
||||
- On 2026-08-16, resume the existing Integration owner to merge reviewed Robot configuration-catalog/editor source `fe55dee` into local `main`, promote the accepted dynamic catalog boundary, and keep remote push outside this resumption.
|
||||
- On 2026-08-15, resume the existing Integration owner to merge reviewed Robot configuration-schema fix `ea75b06` into local `main`, accepting canonical weak numeric response ETags produced by the deployed compression layer while keeping strong `If-Match` writes.
|
||||
- On 2026-08-15, resume the existing Integration owner to merge reviewed Robot configuration-loading fix `1bcd519` into local `main`, preserving the page error/retry state and Main response-body deadline.
|
||||
- On 2026-08-15, resume the existing Integration owner to merge reviewed source commit `fd9b5b46a913c515e94e4e26f185d43866c2581f` for the Codex-style AI Programming context-compaction timeline into local `main` and promote its accepted lifecycle facts.
|
||||
@@ -124,6 +125,13 @@ Gate result:
|
||||
|
||||
## Plan
|
||||
|
||||
### 2026-08-16 Robot Configuration Catalog Integration Plan
|
||||
|
||||
1. Merge reviewed source commit `fe55dee` into local `main` with a normal no-ff merge while preserving source history.
|
||||
2. Exclude the source-owned task record from the final `main` tree and promote the accepted safe catalog boundary into canonical architecture/current-state memory.
|
||||
3. Re-run the Robot catalog/page/Main regression suite, typecheck, lint, production build, project-document gates, and whitespace checks.
|
||||
4. Obtain an independent read-only Sol PASS/FAIL review; do not push remotely as part of this resumption.
|
||||
|
||||
1. Fetch `origin/main` and verify local `main`, the remote tip, and reviewed AI Canvas source commit topology.
|
||||
2. Merge `22378ef` normally into local `main` without rebase/reset, and retain the source commit as a dedicated second parent.
|
||||
3. Promote the bidirectional Gateway and Quote-reconciliation facts into canonical project memory.
|
||||
@@ -154,6 +162,9 @@ Gate result:
|
||||
|
||||
## Outcome
|
||||
|
||||
- On 2026-08-16, started a normal `--no-ff --no-commit` merge of reviewed Robot catalog/editor source `fe55dee`; Git reported no textual conflicts. The source task record remains reachable on the source commit and feature branch and is excluded from the local `main` result.
|
||||
- Promoted the USER-scoped dynamic model/voice catalog boundary: Renderer receives only bounded display metadata through Electron Main and Works Square, all public catalog outcomes are no-store, and current unavailable values remain editable.
|
||||
- Replaced avoidable configuration text/number inputs with catalog selects and bounded TTS sliders while preserving `clear_fields`, revision conflict, operation identity, and Main-owned credential boundaries.
|
||||
- On 2026-08-15, merged reviewed Robot configuration-schema source `ea75b06` into local `main` without textual conflict and excluded its source-owned task record from the final tree.
|
||||
- Created no-ff merge commit `9738e1c5aebad6237f436c8c94abaf877eb3d175` with local baseline `a26a53a7f4b1326be3084955029a65ef1f79929a` as first parent and reviewed source `ea75b0618a793928878edad736f0837ac9e4adc1` as second parent.
|
||||
- Confirmed the failure was transport-contract drift, not a Xiaozhi/Works DTO failure: response compression weakened the origin strong revision ETag to canonical `W/\"0\"`; Main now accepts only canonical strong or weak numeric response tags whose revision equals the strictly projected body.
|
||||
@@ -234,6 +245,10 @@ Gate result:
|
||||
|
||||
## Verification
|
||||
|
||||
- 2026-08-16 source and staged-merge Robot catalog regression selection — 5 files / 84 tests passed.
|
||||
- 2026-08-16 Works Square catalog adapter/API suite — 131 tests passed; one existing Starlette/httpx deprecation warning.
|
||||
- 2026-08-16 Makelore typecheck, focused ESLint, and production `build:vite` — passed; only existing chunk-size and mixed-import warnings remain.
|
||||
- 2026-08-16 independent source review — `PASS`, with no P0-P3 findings after all-outcome no-store and 40px target fixes.
|
||||
- Robot configuration-schema source TDD recorded `2 failed, 23 passed` before the fix with `AI_HARDWARE_INVALID_ETAG` for the deployed weak-tag shape.
|
||||
- Merged-tree AI hardware route suite passed `25/25`; the seven-file Robot/Main regression selection passed `78/78`.
|
||||
- Merged-tree `pnpm run typecheck`, scoped ESLint, and `pnpm run build:vite` passed; build retains only existing chunk-size and mixed-import warnings.
|
||||
@@ -296,6 +311,7 @@ Gate result:
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Deploy matching Xiaozhi and Works Square catalog endpoints before releasing this client; otherwise the editor preserves current values but cannot populate dynamic choices.
|
||||
- The Robot integration is complete on local `main`. Production still requires matching Works Square/Xiaozhi deployment, feature configuration, credentials, and a real one-time activation-code smoke.
|
||||
- Stable mutation operation IDs are retained across ambiguous retries in the running app but are not persisted across an application restart.
|
||||
- Signed packaged updater smoke and live Works Square/Bailian image-to-image
|
||||
|
||||
@@ -13,6 +13,7 @@ const LOCAL_ROOT = '/api/works/ai-hardware';
|
||||
const UPSTREAM_ROOT = '/api/ai-hardware';
|
||||
const REVISION_ETAG = /^(?:W\/)?"(0|[1-9]\d*)"$/;
|
||||
const LOCAL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/;
|
||||
const CATALOG_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,254}$/;
|
||||
const OPERATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const MAX_RETRY_AFTER_SECONDS = 2;
|
||||
|
||||
@@ -119,6 +120,52 @@ function projectOverview(value: unknown): Record<string, unknown> | null {
|
||||
return { status: value.status, agents, devices };
|
||||
}
|
||||
|
||||
const CATALOG_MODEL_TYPES = new Set([
|
||||
'VAD', 'ASR', 'LLM', 'VLLM', 'Intent', 'Memory', 'TTS',
|
||||
]);
|
||||
|
||||
function projectCatalog(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)
|
||||
|| value.schema_version !== 1
|
||||
|| !Array.isArray(value.models)
|
||||
|| value.models.length > 256
|
||||
|| !Array.isArray(value.voices)
|
||||
|| value.voices.length > 512) return null;
|
||||
const models: Record<string, unknown>[] = [];
|
||||
for (const item of value.models) {
|
||||
if (!isRecord(item)
|
||||
|| typeof item.model_type !== 'string'
|
||||
|| !CATALOG_MODEL_TYPES.has(item.model_type)
|
||||
|| typeof item.model_id !== 'string' || item.model_id.length < 1 || item.model_id.length > 255
|
||||
|| typeof item.model_name !== 'string' || item.model_name.length < 1 || item.model_name.length > 128
|
||||
|| (item.supports_function_call !== null && typeof item.supports_function_call !== 'boolean')) return null;
|
||||
models.push({
|
||||
model_type: item.model_type,
|
||||
model_id: item.model_id,
|
||||
model_name: item.model_name,
|
||||
supports_function_call: item.supports_function_call,
|
||||
});
|
||||
}
|
||||
const voices: Record<string, unknown>[] = [];
|
||||
for (const item of value.voices) {
|
||||
if (!isRecord(item)
|
||||
|| typeof item.tts_model_id !== 'string' || item.tts_model_id.length < 1 || item.tts_model_id.length > 255
|
||||
|| typeof item.voice_id !== 'string' || item.voice_id.length < 1 || item.voice_id.length > 255
|
||||
|| typeof item.voice_name !== 'string' || item.voice_name.length < 1 || item.voice_name.length > 128
|
||||
|| !Array.isArray(item.languages) || item.languages.length > 16
|
||||
|| item.languages.some((language) => typeof language !== 'string' || language.length < 1 || language.length > 50)
|
||||
|| typeof item.is_clone !== 'boolean') return null;
|
||||
voices.push({
|
||||
tts_model_id: item.tts_model_id,
|
||||
voice_id: item.voice_id,
|
||||
voice_name: item.voice_name,
|
||||
languages: item.languages,
|
||||
is_clone: item.is_clone,
|
||||
});
|
||||
}
|
||||
return { schema_version: 1, models, voices };
|
||||
}
|
||||
|
||||
async function readBoundedJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const declared = Number(req.headers['content-length']);
|
||||
if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) {
|
||||
@@ -323,6 +370,7 @@ const SAFE_UPSTREAM_ERRORS: 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',
|
||||
ai_hardware_unconfigured: 'AI hardware integration is not configured',
|
||||
ai_hardware_disabled: 'AI hardware module is not enabled',
|
||||
ai_hardware_idempotency_key_invalid: 'AI hardware operation identity is invalid',
|
||||
@@ -384,6 +432,10 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname !== LOCAL_ROOT && !url.pathname.startsWith(`${LOCAL_ROOT}/`)) return false;
|
||||
if (url.pathname === `${LOCAL_ROOT}/catalog`) {
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
}
|
||||
|
||||
let operationId: string | undefined;
|
||||
try {
|
||||
@@ -399,6 +451,21 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
|
||||
upstreamPath = UPSTREAM_ROOT;
|
||||
project = projectOverview;
|
||||
expectedStatus = 200;
|
||||
} else if (method === 'GET' && url.pathname === `${LOCAL_ROOT}/catalog`) {
|
||||
const keys = [...url.searchParams.keys()];
|
||||
const values = url.searchParams.getAll('tts_model_id');
|
||||
if (keys.some((key) => key !== 'tts_model_id') || values.length > 1) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
const ttsModelId = values[0];
|
||||
if (ttsModelId !== undefined && !CATALOG_ID.test(ttsModelId)) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
upstreamPath = `${UPSTREAM_ROOT}/catalog${
|
||||
ttsModelId === undefined ? '' : `?tts_model_id=${encodeURIComponent(ttsModelId)}`
|
||||
}`;
|
||||
project = projectCatalog;
|
||||
expectedStatus = 200;
|
||||
} else if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/credential-recovery`) {
|
||||
const input = await readBoundedJson(req);
|
||||
ensureExactKeys(input, new Set(['client_operation_id']));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getAiHardwareAgentConfiguration,
|
||||
getAiHardwareAssignment,
|
||||
getAiHardwareOverview,
|
||||
getAiHardwareConfigurationCatalog,
|
||||
updateAiHardwareAgentConfiguration,
|
||||
updateAiHardwareAssignment,
|
||||
createAiHardwareOperationId,
|
||||
@@ -56,6 +57,44 @@ describe('AI hardware renderer API', () => {
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/ai-hardware', undefined);
|
||||
});
|
||||
|
||||
it('strictly reads the safe configuration catalog and encodes the TTS dependency', async () => {
|
||||
const catalog = {
|
||||
schema_version: 1,
|
||||
models: [{
|
||||
model_type: 'TTS', model_id: 'TTS:model', model_name: '云端语音',
|
||||
supports_function_call: null,
|
||||
}],
|
||||
voices: [{
|
||||
tts_model_id: 'TTS:model', voice_id: 'voice-1', voice_name: '小夏',
|
||||
languages: ['zh-CN'], is_clone: false,
|
||||
}],
|
||||
};
|
||||
hostApiFetchMock.mockResolvedValue({ success: true, data: catalog });
|
||||
|
||||
await expect(getAiHardwareConfigurationCatalog('TTS:model')).resolves.toEqual(catalog);
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/ai-hardware/catalog?tts_model_id=TTS%3Amodel',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsafe or expanded catalog DTOs', async () => {
|
||||
hostApiFetchMock.mockResolvedValue({ success: true, data: {
|
||||
schema_version: 1,
|
||||
models: [{
|
||||
model_type: 'TTS', model_id: 'TTS_model', model_name: '语音',
|
||||
supports_function_call: null, config_json: '{"api_key":"secret"}',
|
||||
}],
|
||||
voices: [],
|
||||
} });
|
||||
|
||||
await expect(getAiHardwareConfigurationCatalog()).rejects.toMatchObject({
|
||||
code: 'AI_HARDWARE_INVALID_RESPONSE',
|
||||
});
|
||||
expect(JSON.stringify(await getAiHardwareConfigurationCatalog().catch((error) => error)))
|
||||
.not.toContain('secret');
|
||||
});
|
||||
|
||||
it('sends create and bind business bodies without sensitive headers', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({ success: true, data: agent })
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AiHardware } from '@/pages/AiHardware';
|
||||
import {
|
||||
AiHardwareApiError,
|
||||
type AiHardwareAgentConfiguration,
|
||||
type AiHardwareConfigurationCatalog,
|
||||
type AiHardwareOverview,
|
||||
} from '@/lib/ai-hardware';
|
||||
|
||||
@@ -13,6 +14,7 @@ const api = vi.hoisted(() => ({
|
||||
createAiHardwareAgent: vi.fn(),
|
||||
bindAiHardwareDevice: vi.fn(),
|
||||
getAiHardwareAgentConfiguration: vi.fn(),
|
||||
getAiHardwareConfigurationCatalog: vi.fn(),
|
||||
updateAiHardwareAgentConfiguration: vi.fn(),
|
||||
getAiHardwareAssignment: vi.fn(),
|
||||
updateAiHardwareAssignment: vi.fn(),
|
||||
@@ -51,6 +53,24 @@ const configuration: AiHardwareAgentConfiguration = {
|
||||
tts_volume: 10, tts_rate: 5, tts_pitch: 0, mem_model_id: null, intent_model_id: null,
|
||||
chat_history_conf: 1,
|
||||
};
|
||||
const catalog: AiHardwareConfigurationCatalog = {
|
||||
schema_version: 1,
|
||||
models: [
|
||||
['ASR', 'asr-old', '旧语音识别'], ['ASR', 'asr-new', '新语音识别'],
|
||||
['VAD', 'vad-old', '旧活动检测'], ['VAD', 'vad-new', '新活动检测'],
|
||||
['LLM', 'llm-old', '旧语言模型'], ['LLM', 'llm-new', '新语言模型'],
|
||||
['LLM', 'slm-new', '轻量语言模型'], ['VLLM', 'vllm-old', '旧视觉模型'],
|
||||
['TTS', 'tts-old', '旧语音合成'], ['TTS', 'tts-new', '新语音合成'],
|
||||
['Memory', 'mem-old', '旧记忆'], ['Memory', 'mem-new', '新记忆'],
|
||||
['Intent', 'intent-old', '旧意图'], ['Intent', 'intent-new', '新意图'],
|
||||
].map(([model_type, model_id, model_name]) => ({
|
||||
model_type, model_id, model_name, supports_function_call: null,
|
||||
})) as AiHardwareConfigurationCatalog['models'],
|
||||
voices: [{
|
||||
tts_model_id: 'tts-new', voice_id: 'voice-new', voice_name: '龙小夏',
|
||||
languages: ['zh-CN', 'en-US'], is_clone: false,
|
||||
}],
|
||||
};
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
@@ -70,6 +90,7 @@ describe('AI hardware page', () => {
|
||||
api.getAiHardwareOverview.mockResolvedValue(activeOverview);
|
||||
api.recoverAiHardwareCredential.mockResolvedValue({ status: 'active', agents: [], devices: [] });
|
||||
api.getAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 4 });
|
||||
api.getAiHardwareConfigurationCatalog.mockResolvedValue(catalog);
|
||||
api.createAiHardwareAgent.mockResolvedValue(agentOne);
|
||||
api.bindAiHardwareDevice.mockResolvedValue(device);
|
||||
api.updateAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 5 });
|
||||
@@ -149,8 +170,8 @@ describe('AI hardware page', () => {
|
||||
await openConfigurationEditor();
|
||||
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的助手' } });
|
||||
fireEvent.change(screen.getByLabelText('系统提示'), { target: { value: '' } });
|
||||
fireEvent.change(screen.getByLabelText('TTS 语音 ID'), { target: { value: '' } });
|
||||
fireEvent.change(screen.getByLabelText('音量 (-100–100)'), { target: { value: '20' } });
|
||||
fireEvent.change(screen.getByLabelText('声音音色'), { target: { value: '' } });
|
||||
fireEvent.change(screen.getByLabelText('音量'), { target: { value: '20' } });
|
||||
fireEvent.change(screen.getByLabelText('聊天记录'), { target: { value: '2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
@@ -237,7 +258,6 @@ describe('AI hardware page', () => {
|
||||
});
|
||||
render(<AiHardware />);
|
||||
await openConfigurationEditor();
|
||||
fireEvent.click(screen.getByText('高级设置'));
|
||||
|
||||
const values: Record<string, string> = {
|
||||
asr_model_id: '', vad_model_id: 'vad-new', llm_model_id: '', slm_model_id: 'slm-new',
|
||||
@@ -260,6 +280,61 @@ describe('AI hardware page', () => {
|
||||
expect(Object.values(update)).not.toContain(null);
|
||||
});
|
||||
|
||||
it('uses dynamic model, language, and voice choices instead of free-form IDs', async () => {
|
||||
api.getAiHardwareAgentConfiguration.mockResolvedValueOnce({
|
||||
data: { ...configuration, tts_model_id: 'tts-old', tts_voice_id: 'legacy-voice' },
|
||||
revision: 4,
|
||||
});
|
||||
render(<AiHardware />);
|
||||
await openConfigurationEditor();
|
||||
|
||||
expect(api.getAiHardwareConfigurationCatalog).toHaveBeenCalledWith('tts-old');
|
||||
expect(screen.getByLabelText('语音合成 (TTS)')).toHaveValue('tts-old');
|
||||
expect(screen.getByRole('option', { name: '旧语音合成' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '当前值(目录中不可用):legacy-voice' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('语音合成 (TTS)'), { target: { value: 'tts-new' } });
|
||||
await waitFor(() => expect(api.getAiHardwareConfigurationCatalog).toHaveBeenLastCalledWith('tts-new'));
|
||||
expect(screen.getByRole('option', { name: '龙小夏' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('option', { name: 'zh-CN' }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses bounded sliders while preserving nullable provider defaults', async () => {
|
||||
render(<AiHardware />);
|
||||
await openConfigurationEditor();
|
||||
|
||||
const volume = screen.getByLabelText('音量');
|
||||
expect(volume).toHaveAttribute('type', 'range');
|
||||
expect(volume).toHaveAttribute('min', '-100');
|
||||
expect(volume).toHaveAttribute('max', '100');
|
||||
expect(screen.getByRole('button', { name: '音调使用默认值' })).toHaveClass('min-h-10');
|
||||
fireEvent.change(volume, { target: { value: '24' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '音调使用默认值' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => expect(api.updateAiHardwareAgentConfiguration).toHaveBeenCalled());
|
||||
expect(api.updateAiHardwareAgentConfiguration.mock.calls[0][2]).toMatchObject({
|
||||
tts_volume: 24,
|
||||
clear_fields: expect.arrayContaining(['tts_pitch']),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps current values when the catalog is unavailable and offers a safe retry', async () => {
|
||||
api.getAiHardwareConfigurationCatalog
|
||||
.mockRejectedValueOnce(new Error('provider token=secret'))
|
||||
.mockResolvedValueOnce(catalog);
|
||||
render(<AiHardware />);
|
||||
await openConfigurationEditor();
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('当前配置会保持不变');
|
||||
expect(screen.getByRole('option', { name: '当前值(目录中不可用):voice-a' })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/provider token|secret/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '重新加载选项' })).toHaveClass('min-h-10');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新加载选项' }));
|
||||
await waitFor(() => expect(api.getAiHardwareConfigurationCatalog).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByRole('option', { name: '旧语音合成' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['AI_HARDWARE_AUTH_REQUIRED', '请先登录'],
|
||||
['ai_hardware_unconfigured', '尚未配置'],
|
||||
|
||||
@@ -16,15 +16,17 @@ function request(method: string, body?: unknown, headers: Record<string, string>
|
||||
|
||||
function response() {
|
||||
const chunks: string[] = [];
|
||||
const headers = new Map<string, string>();
|
||||
const res = new EventEmitter();
|
||||
Object.assign(res, {
|
||||
statusCode: 0,
|
||||
setHeader: vi.fn(),
|
||||
setHeader: vi.fn((name: string, value: string) => headers.set(name.toLowerCase(), value)),
|
||||
end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }),
|
||||
});
|
||||
return {
|
||||
res: res as unknown as ServerResponse,
|
||||
get status() { return (res as { statusCode: number }).statusCode; },
|
||||
header: (name: string) => headers.get(name.toLowerCase()),
|
||||
json: () => JSON.parse(chunks.join('')) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
@@ -56,6 +58,63 @@ async function invoke(handler: ReturnType<typeof createAiHardwareRouteHandler>,
|
||||
}
|
||||
|
||||
describe('AI hardware Host API route', () => {
|
||||
it('proxies only the fixed catalog query and projects its safe DTO', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({
|
||||
schema_version: 1,
|
||||
models: [{
|
||||
model_type: 'TTS', model_id: 'TTS_model', model_name: '云端语音',
|
||||
supports_function_call: null, config_json: 'must-not-pass',
|
||||
}],
|
||||
voices: [{
|
||||
tts_model_id: 'TTS_model', voice_id: 'voice-1', voice_name: '小夏',
|
||||
languages: ['zh-CN'], is_clone: false, voice_demo: 'internal-url',
|
||||
}],
|
||||
credential: 'secret-token',
|
||||
}));
|
||||
const { handler } = setup(fetchImpl);
|
||||
const result = await invoke(
|
||||
handler,
|
||||
'GET',
|
||||
'/api/works/ai-hardware/catalog?tts_model_id=TTS_model',
|
||||
);
|
||||
|
||||
expect(fetchImpl.mock.calls[0][0]).toBe(
|
||||
'https://square.example/api/ai-hardware/catalog?tts_model_id=TTS_model',
|
||||
);
|
||||
expect((fetchImpl.mock.calls[0][1] as RequestInit).headers).toMatchObject({
|
||||
Authorization: 'Bearer secret-token',
|
||||
});
|
||||
expect(result.payload).toEqual({ success: true, data: {
|
||||
schema_version: 1,
|
||||
models: [{
|
||||
model_type: 'TTS', model_id: 'TTS_model', model_name: '云端语音',
|
||||
supports_function_call: null,
|
||||
}],
|
||||
voices: [{
|
||||
tts_model_id: 'TTS_model', voice_id: 'voice-1', voice_name: '小夏',
|
||||
languages: ['zh-CN'], is_clone: false,
|
||||
}],
|
||||
} });
|
||||
expect(result.header('cache-control')).toBe('private, no-store');
|
||||
expect(result.header('pragma')).toBe('no-cache');
|
||||
expect(JSON.stringify(result.payload)).not.toMatch(/config_json|voice_demo|credential|secret-token/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/api/works/ai-hardware/catalog?unknown=value',
|
||||
'/api/works/ai-hardware/catalog?tts_model_id=one&tts_model_id=two',
|
||||
'/api/works/ai-hardware/catalog?tts_model_id=%2Funsafe',
|
||||
])('rejects unsupported catalog query %s before fetching', async (path) => {
|
||||
const { handler, fetchImpl } = setup();
|
||||
const result = await invoke(handler, 'GET', path);
|
||||
expect(result.payload).toMatchObject({
|
||||
success: false, status: 400, code: 'AI_HARDWARE_INVALID_REQUEST',
|
||||
});
|
||||
expect(result.header('cache-control')).toBe('private, no-store');
|
||||
expect(result.header('pragma')).toBe('no-cache');
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('projects overview DTOs and drops unexpected sensitive fields', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({
|
||||
...overview,
|
||||
|
||||
Reference in New Issue
Block a user