feat: improve agent workbench and digital human flows
This commit is contained in:
+19
-1
@@ -2048,6 +2048,24 @@ export const api = {
|
||||
},
|
||||
);
|
||||
},
|
||||
uploadWorkflow: (file: File, data?: { source?: string; mediaType?: string }) => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
formData.set("source", data?.source || "runninghub");
|
||||
formData.set("media_type", data?.mediaType || "image");
|
||||
return request<{
|
||||
success: boolean;
|
||||
workflow: { name: string; display_name?: string; source?: string; path?: string; key?: string; media_type?: string };
|
||||
}>(
|
||||
"/api/digital-human/pixelle/workflows/upload",
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
preserveAuthErrors: true,
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
getTemplateParams: (template: string) =>
|
||||
request<{ template: string; media_width?: number; media_height?: number; params?: any }>(
|
||||
`/api/digital-human/pixelle/template-params?template=${encodeURIComponent(template)}`,
|
||||
@@ -2071,7 +2089,7 @@ export const api = {
|
||||
timeoutMs: 180_000,
|
||||
},
|
||||
),
|
||||
previewTts: (data: { text: string; workflow?: string; ref_audio?: string }) =>
|
||||
previewTts: (data: { text: string; inference_mode?: string; workflow?: string; voice?: string; speed?: number; ref_audio?: string }) =>
|
||||
request<any>(
|
||||
"/api/digital-human/pixelle/preview-tts",
|
||||
{
|
||||
|
||||
+1278
File diff suppressed because it is too large
Load Diff
+1260
-298
File diff suppressed because it is too large
Load Diff
@@ -595,8 +595,9 @@ export default function Dashboard({
|
||||
const siteAvailability = showInactiveSites
|
||||
? [...activeSites, ...inactiveSites]
|
||||
: activeSites;
|
||||
const proxyFailed24h = Math.max(0, proxy24hTotal - proxy24hSuccess);
|
||||
const proxySuccessRate =
|
||||
proxy24hTotal > 0 ? (proxy24hSuccess / proxy24hTotal) * 100 : 0;
|
||||
proxy24hTotal > 0 ? (proxy24hSuccess / proxy24hTotal) * 100 : 100;
|
||||
const activeAccountRate =
|
||||
totalAccounts > 0 ? (activeAccounts / totalAccounts) * 100 : 0;
|
||||
const siteRiskCount = activeSites.filter((site) => {
|
||||
@@ -643,15 +644,16 @@ export default function Dashboard({
|
||||
(item) => item.health.tone === "danger" || item.health.tone === "warning",
|
||||
);
|
||||
const routeRiskCount = routeRiskItems.length;
|
||||
const hasDangerRoute = routeHealthCounts.danger > 0;
|
||||
const overallHealthTone =
|
||||
proxySuccessRate >= 99 && siteRiskCount === 0 && routeRiskCount === 0
|
||||
? "success"
|
||||
: proxySuccessRate >= 95 && siteRiskCount <= 2 && routeHealthCounts.danger === 0
|
||||
proxyFailed24h > 0 || hasDangerRoute
|
||||
? "danger"
|
||||
: proxySuccessRate < 99 || siteRiskCount > 0 || routeRiskCount > 0
|
||||
? "warning"
|
||||
: "danger";
|
||||
: "success";
|
||||
const overallHealthLabel =
|
||||
overallHealthTone === "success"
|
||||
? t("稳定")
|
||||
? t("健康")
|
||||
: overallHealthTone === "warning"
|
||||
? t("需关注")
|
||||
: t("异常");
|
||||
@@ -665,7 +667,6 @@ export default function Dashboard({
|
||||
requestsPerMinute > 0
|
||||
? `${Math.round(requestsPerMinute).toLocaleString()} RPM`
|
||||
: t("暂无流量");
|
||||
const proxyFailed24h = Math.max(0, proxy24hTotal - proxy24hSuccess);
|
||||
const avgActiveSiteLatency = activeSites.length > 0
|
||||
? Math.round(
|
||||
activeSites.reduce(
|
||||
|
||||
+460
-96
@@ -29,11 +29,16 @@ type DigitalHumanDraft = {
|
||||
nScenes?: number;
|
||||
bgmPath?: string;
|
||||
bgmVolume?: number;
|
||||
ttsInferenceMode?: TtsInferenceMode;
|
||||
ttsVoice?: string;
|
||||
ttsSpeed?: number;
|
||||
ttsWorkflow?: string;
|
||||
refAudioPath?: string;
|
||||
previewTtsText?: string;
|
||||
imageWorkflow?: string;
|
||||
apiVideoWorkflow?: string;
|
||||
referenceImagePath?: string;
|
||||
referenceVideoPath?: string;
|
||||
llmModel?: string;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
@@ -46,6 +51,19 @@ type DigitalHumanDraft = {
|
||||
const DEFAULT_PROMPT_PREFIX =
|
||||
'Minimalist black-and-white matchstick figure style illustration, clean lines, simple sketch style';
|
||||
const DIGITAL_HUMAN_DRAFT_KEY = 'boosapi:digital-human:draft:v1';
|
||||
type TtsInferenceMode = 'local' | 'comfyui';
|
||||
|
||||
const EDGE_TTS_VOICES = [
|
||||
{ id: 'zh-CN-YunjianNeural', label: '男声-专业(云健)' },
|
||||
{ id: 'zh-CN-XiaoxiaoNeural', label: '女声-自然(晓晓)' },
|
||||
{ id: 'zh-CN-YunxiNeural', label: '男声-活力(云希)' },
|
||||
{ id: 'zh-CN-XiaoyiNeural', label: '女声-明亮(晓伊)' },
|
||||
{ id: 'zh-CN-YunyangNeural', label: '男声-新闻(云扬)' },
|
||||
{ id: 'zh-CN-liaoning-XiaobeiNeural', label: '东北女声(晓北)' },
|
||||
{ id: 'zh-CN-shaanxi-XiaoniNeural', label: '陕西女声(晓妮)' },
|
||||
{ id: 'en-US-JennyNeural', label: 'English - Jenny' },
|
||||
{ id: 'en-US-GuyNeural', label: 'English - Guy' },
|
||||
];
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
@@ -62,6 +80,50 @@ function workflowValue(item: WorkflowItem): string {
|
||||
return item.key?.trim() || item.name;
|
||||
}
|
||||
|
||||
function isVideoWorkflow(item: WorkflowItem): boolean {
|
||||
const key = workflowValue(item).toLowerCase();
|
||||
const name = item.name.toLowerCase();
|
||||
return key.includes('video_') || name.includes('video_') || basename(key).startsWith('af_');
|
||||
}
|
||||
|
||||
function workflowSource(value: string): string {
|
||||
return value.includes('/') ? value.split('/')[0].toLowerCase() : '';
|
||||
}
|
||||
|
||||
function isSameWorkflowValue(value: string, item: WorkflowItem): boolean {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return [workflowValue(item), item.name, item.display_name]
|
||||
.filter(Boolean)
|
||||
.some((candidate) => String(candidate).trim().toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
function resolveAvailableWorkflow(
|
||||
value: string,
|
||||
workflows: WorkflowItem[],
|
||||
predicate?: (item: WorkflowItem) => boolean,
|
||||
): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
const candidates = predicate ? workflows.filter(predicate) : workflows;
|
||||
const exact = candidates.find((item) => isSameWorkflowValue(trimmed, item));
|
||||
if (exact) return workflowValue(exact);
|
||||
|
||||
const source = workflowSource(trimmed);
|
||||
const lowerBase = basename(trimmed).toLowerCase();
|
||||
const sourceCandidates = source
|
||||
? candidates.filter((item) => workflowSource(workflowValue(item)) === source || item.source?.toLowerCase() === source)
|
||||
: candidates;
|
||||
const pool = sourceCandidates.length > 0 ? sourceCandidates : candidates;
|
||||
|
||||
if (lowerBase.includes('scail')) {
|
||||
const scail = pool.find((item) => workflowValue(item).toLowerCase().includes('scail') || item.name.toLowerCase().includes('scail'));
|
||||
if (scail) return workflowValue(scail);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function templateValue(item: TemplateItem): string {
|
||||
return item.key?.trim() || item.name;
|
||||
}
|
||||
@@ -96,6 +158,10 @@ function writeDigitalHumanDraft(draft: DigitalHumanDraft) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function normalizeNumber(value: unknown, fallback: number, min?: number, max?: number): number {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
@@ -397,11 +463,16 @@ export default function DigitalHuman() {
|
||||
const [bgmVolume, setBgmVolume] = useState(() => normalizeNumber(draftRef.current.bgmVolume, 0.3, 0, 1));
|
||||
|
||||
// Middle column — voice & visual
|
||||
const [ttsInferenceMode, setTtsInferenceMode] = useState<TtsInferenceMode>(draftRef.current.ttsInferenceMode === 'comfyui' ? 'comfyui' : 'local');
|
||||
const [ttsVoice, setTtsVoice] = useState(draftRef.current.ttsVoice || 'zh-CN-YunjianNeural');
|
||||
const [ttsSpeed, setTtsSpeed] = useState(() => normalizeNumber(draftRef.current.ttsSpeed, 1.2, 0.5, 2));
|
||||
const [ttsWorkflow, setTtsWorkflow] = useState(draftRef.current.ttsWorkflow || '');
|
||||
const [refAudioPath, setRefAudioPath] = useState(isPrivateWorkspaceMediaUrl(draftRef.current.refAudioPath) ? '' : draftRef.current.refAudioPath || '');
|
||||
const [previewTtsText, setPreviewTtsText] = useState(draftRef.current.previewTtsText || '你好,欢迎使用 BoosAPI 数字人。');
|
||||
const [imageWorkflow, setImageWorkflow] = useState(draftRef.current.imageWorkflow || '');
|
||||
const [apiVideoWorkflow, setApiVideoWorkflow] = useState(draftRef.current.apiVideoWorkflow || '');
|
||||
const [referenceImagePath, setReferenceImagePath] = useState(isPrivateWorkspaceMediaUrl(draftRef.current.referenceImagePath) ? '' : draftRef.current.referenceImagePath || '');
|
||||
const [referenceVideoPath, setReferenceVideoPath] = useState(isPrivateWorkspaceMediaUrl(draftRef.current.referenceVideoPath) ? '' : draftRef.current.referenceVideoPath || '');
|
||||
const [llmModel, setLlmModel] = useState(draftRef.current.llmModel || '');
|
||||
const [llmModels, setLlmModels] = useState<MarketplaceModel[]>([]);
|
||||
const [imageWidth, setImageWidth] = useState(() => normalizeNumber(draftRef.current.imageWidth, 1024, 64));
|
||||
@@ -418,8 +489,15 @@ export default function DigitalHuman() {
|
||||
const [previewError, setPreviewError] = useState('');
|
||||
const [uploadingAudio, setUploadingAudio] = useState(false);
|
||||
const [uploadingBgm, setUploadingBgm] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [uploadingVideo, setUploadingVideo] = useState(false);
|
||||
const [uploadingWorkflow, setUploadingWorkflow] = useState(false);
|
||||
const [workflowUploadSource, setWorkflowUploadSource] = useState<'runninghub' | 'selfhost'>('runninghub');
|
||||
const [uploadMessage, setUploadMessage] = useState('');
|
||||
const [bgmUploadMessage, setBgmUploadMessage] = useState('');
|
||||
const [imageUploadMessage, setImageUploadMessage] = useState('');
|
||||
const [videoUploadMessage, setVideoUploadMessage] = useState('');
|
||||
const [workflowUploadMessage, setWorkflowUploadMessage] = useState('');
|
||||
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generateError, setGenerateError] = useState('');
|
||||
@@ -479,6 +557,22 @@ export default function DigitalHuman() {
|
||||
setImageWorkflow(workflowValue(imageOne || mediaWorkflows[0]));
|
||||
}
|
||||
}, [imageWorkflow, mediaWorkflows]);
|
||||
useEffect(() => {
|
||||
if (!mediaWorkflows.length) return;
|
||||
if (imageWorkflow && !resolveAvailableWorkflow(imageWorkflow, mediaWorkflows)) {
|
||||
const imageOne = mediaWorkflows.find((w) => !isVideoWorkflow(w) && w.name.toLowerCase().includes('image'));
|
||||
if (imageOne) setImageWorkflow(workflowValue(imageOne));
|
||||
}
|
||||
if (apiVideoWorkflow) {
|
||||
const resolved = resolveAvailableWorkflow(apiVideoWorkflow, mediaWorkflows, isVideoWorkflow);
|
||||
if (resolved && resolved !== apiVideoWorkflow) {
|
||||
setApiVideoWorkflow(resolved);
|
||||
} else if (!resolved) {
|
||||
setApiVideoWorkflow('');
|
||||
setWorkflowUploadMessage(`已清除不存在的视频工作流:${apiVideoWorkflow}`);
|
||||
}
|
||||
}
|
||||
}, [imageWorkflow, apiVideoWorkflow, mediaWorkflows]);
|
||||
useEffect(() => {
|
||||
if (!frameTemplate && templates.length > 0) setFrameTemplate(templateValue(templates[0]));
|
||||
}, [frameTemplate, templates]);
|
||||
@@ -502,11 +596,16 @@ export default function DigitalHuman() {
|
||||
nScenes,
|
||||
bgmPath,
|
||||
bgmVolume,
|
||||
ttsInferenceMode,
|
||||
ttsVoice,
|
||||
ttsSpeed,
|
||||
ttsWorkflow,
|
||||
refAudioPath,
|
||||
previewTtsText,
|
||||
imageWorkflow,
|
||||
apiVideoWorkflow,
|
||||
referenceImagePath,
|
||||
referenceVideoPath,
|
||||
llmModel,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
@@ -522,11 +621,16 @@ export default function DigitalHuman() {
|
||||
nScenes,
|
||||
bgmPath,
|
||||
bgmVolume,
|
||||
ttsInferenceMode,
|
||||
ttsVoice,
|
||||
ttsSpeed,
|
||||
ttsWorkflow,
|
||||
refAudioPath,
|
||||
previewTtsText,
|
||||
imageWorkflow,
|
||||
apiVideoWorkflow,
|
||||
referenceImagePath,
|
||||
referenceVideoPath,
|
||||
llmModel,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
@@ -617,17 +721,32 @@ export default function DigitalHuman() {
|
||||
stopPolling();
|
||||
setTask(null);
|
||||
try {
|
||||
const resolvedImageWorkflow = resolveAvailableWorkflow(imageWorkflow, mediaWorkflows);
|
||||
const resolvedVideoWorkflow = resolveAvailableWorkflow(apiVideoWorkflow, mediaWorkflows, isVideoWorkflow);
|
||||
const payload: Record<string, unknown> = {
|
||||
text: text.trim(),
|
||||
prompt: text.trim(),
|
||||
mode,
|
||||
title: title.trim() || undefined,
|
||||
n_scenes: nScenes,
|
||||
media_workflow: imageWorkflow || undefined,
|
||||
api_video_workflow: apiVideoWorkflow || undefined,
|
||||
media_workflow: resolvedImageWorkflow || undefined,
|
||||
api_video_workflow: resolvedVideoWorkflow || undefined,
|
||||
llm_model: llmModel || undefined,
|
||||
tts_workflow: ttsWorkflow || undefined,
|
||||
tts_inference_mode: ttsInferenceMode,
|
||||
inference_mode: ttsInferenceMode,
|
||||
tts_voice: ttsInferenceMode === 'local' ? ttsVoice : undefined,
|
||||
voice: ttsInferenceMode === 'local' ? ttsVoice : undefined,
|
||||
tts_speed: ttsInferenceMode === 'local' ? ttsSpeed : undefined,
|
||||
speed: ttsInferenceMode === 'local' ? ttsSpeed : undefined,
|
||||
tts_workflow: ttsInferenceMode === 'comfyui' ? (ttsWorkflow || undefined) : undefined,
|
||||
frame_template: frameTemplate,
|
||||
avatar_url: referenceVideoPath.trim() || referenceImagePath.trim() || undefined,
|
||||
image_url: referenceImagePath.trim() || undefined,
|
||||
image_path: referenceImagePath.trim() || undefined,
|
||||
reference_image_url: referenceImagePath.trim() || undefined,
|
||||
video_url: referenceVideoPath.trim() || undefined,
|
||||
video_path: referenceVideoPath.trim() || undefined,
|
||||
reference_video_url: referenceVideoPath.trim() || undefined,
|
||||
ratio: selectedTemplateSize.ratio,
|
||||
resolution: selectedTemplateSize.resolution,
|
||||
prompt_prefix: promptPrefix.trim() || undefined,
|
||||
@@ -661,8 +780,15 @@ export default function DigitalHuman() {
|
||||
title,
|
||||
nScenes,
|
||||
imageWorkflow,
|
||||
apiVideoWorkflow,
|
||||
mediaWorkflows,
|
||||
llmModel,
|
||||
ttsInferenceMode,
|
||||
ttsVoice,
|
||||
ttsSpeed,
|
||||
ttsWorkflow,
|
||||
referenceImagePath,
|
||||
referenceVideoPath,
|
||||
promptPrefix,
|
||||
videoFps,
|
||||
bgmPath,
|
||||
@@ -681,8 +807,14 @@ export default function DigitalHuman() {
|
||||
setTitle(firstString(payload.title) || '');
|
||||
setNScenes(normalizeNumber(payload.n_scenes ?? payload.nScenes, 5, 1, 20));
|
||||
setImageWorkflow(firstString(payload.media_workflow, payload.mediaWorkflow, payload.workflow, payload.workflow_key) || '');
|
||||
setApiVideoWorkflow(firstString(payload.api_video_workflow, payload.apiVideoWorkflow) || '');
|
||||
setTtsInferenceMode(firstString(payload.tts_inference_mode, payload.inference_mode, payload.ttsInferenceMode) === 'comfyui' ? 'comfyui' : 'local');
|
||||
setTtsVoice(firstString(payload.tts_voice, payload.voice, payload.ttsVoice) || 'zh-CN-YunjianNeural');
|
||||
setTtsSpeed(normalizeNumber(payload.tts_speed ?? payload.speed ?? payload.ttsSpeed, 1.2, 0.5, 2));
|
||||
setTtsWorkflow(firstString(payload.tts_workflow, payload.ttsWorkflow) || '');
|
||||
setFrameTemplate(firstString(payload.frame_template, payload.frameTemplate) || '');
|
||||
setReferenceImagePath(firstString(payload.reference_image_url, payload.image_url, payload.image_path, payload.imagePath, payload.referenceImagePath) || '');
|
||||
setReferenceVideoPath(firstString(payload.reference_video_url, payload.video_url, payload.video_path, payload.videoPath, payload.referenceVideoPath) || '');
|
||||
setPromptPrefix(firstString(payload.prompt_prefix, payload.promptPrefix) || DEFAULT_PROMPT_PREFIX);
|
||||
setVideoFps(normalizeNumber(payload.video_fps ?? payload.videoFps, 30, 12, 60));
|
||||
setBgmPath(firstString(payload.bgm_path, payload.bgmPath) || '');
|
||||
@@ -787,8 +919,11 @@ export default function DigitalHuman() {
|
||||
try {
|
||||
const data = await api.pixelle.previewTts({
|
||||
text: previewTtsText.trim(),
|
||||
workflow: ttsWorkflow || undefined,
|
||||
ref_audio: refAudioPath.trim() || undefined,
|
||||
inference_mode: ttsInferenceMode,
|
||||
workflow: ttsInferenceMode === 'comfyui' ? (ttsWorkflow || undefined) : undefined,
|
||||
voice: ttsInferenceMode === 'local' ? ttsVoice : undefined,
|
||||
speed: ttsInferenceMode === 'local' ? ttsSpeed : undefined,
|
||||
ref_audio: ttsInferenceMode === 'comfyui' ? (refAudioPath.trim() || undefined) : undefined,
|
||||
});
|
||||
const audioPath = firstMediaPath(data, ['audio_path', 'audioPath', 'audio_url', 'audioUrl', 'url', 'file_url', 'fileUrl', 'path']);
|
||||
if (audioPath) {
|
||||
@@ -801,7 +936,7 @@ export default function DigitalHuman() {
|
||||
} finally {
|
||||
setPreviewing(null);
|
||||
}
|
||||
}, [previewTtsText, ttsWorkflow, refAudioPath]);
|
||||
}, [previewTtsText, ttsInferenceMode, ttsWorkflow, ttsVoice, ttsSpeed, refAudioPath]);
|
||||
|
||||
const previewBgm = useMemo(() => {
|
||||
if (!bgmPath) return '';
|
||||
@@ -853,7 +988,79 @@ export default function DigitalHuman() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadReferenceImage = useCallback(async (file?: File | null) => {
|
||||
if (!file) return;
|
||||
setPreviewError('');
|
||||
setImageUploadMessage('');
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setPreviewError('请上传图片文件作为图片工作流输入。');
|
||||
return;
|
||||
}
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const result = await api.pixelle.uploadAsset(file, 'reference_image');
|
||||
const fileUrl = result?.asset?.pixellePath || result?.asset?.fileUrl;
|
||||
if (!fileUrl) throw new Error('上传成功但未返回文件地址');
|
||||
setReferenceImagePath(fileUrl);
|
||||
setPreviewImageUrl(resolvePixelleMediaUrl(fileUrl));
|
||||
setImageUploadMessage(`已上传 ${file.name}`);
|
||||
} catch (error) {
|
||||
setPreviewError(getErrorMessage(error, '参考图片上传失败'));
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadReferenceVideo = useCallback(async (file?: File | null) => {
|
||||
if (!file) return;
|
||||
setPreviewError('');
|
||||
setVideoUploadMessage('');
|
||||
if (!file.type.startsWith('video/')) {
|
||||
setPreviewError('请上传视频文件作为视频工作流输入。');
|
||||
return;
|
||||
}
|
||||
setUploadingVideo(true);
|
||||
try {
|
||||
const result = await api.pixelle.uploadAsset(file, 'reference_video');
|
||||
const fileUrl = result?.asset?.pixellePath || result?.asset?.fileUrl;
|
||||
if (!fileUrl) throw new Error('上传成功但未返回文件地址');
|
||||
setReferenceVideoPath(fileUrl);
|
||||
setVideoUploadMessage(`已上传 ${file.name}`);
|
||||
} catch (error) {
|
||||
setPreviewError(getErrorMessage(error, '参考视频上传失败'));
|
||||
} finally {
|
||||
setUploadingVideo(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadWorkflow = useCallback(async (mediaType: 'image' | 'video', file?: File | null) => {
|
||||
if (!file) return;
|
||||
setPreviewError('');
|
||||
setWorkflowUploadMessage('');
|
||||
if (!file.name.toLowerCase().endsWith('.json')) {
|
||||
setWorkflowUploadMessage('请选择 JSON 工作流文件');
|
||||
return;
|
||||
}
|
||||
setUploadingWorkflow(true);
|
||||
try {
|
||||
const result = await api.pixelle.uploadWorkflow(file, {
|
||||
source: workflowUploadSource,
|
||||
mediaType,
|
||||
});
|
||||
const workflow = result.workflow;
|
||||
const key = workflowValue(workflow);
|
||||
setWorkflowUploadMessage(`已上传 ${key}`);
|
||||
await wait(1500);
|
||||
const refreshed = await api.pixelle.listMediaWorkflows();
|
||||
setMediaWorkflows(Array.isArray(refreshed?.workflows) ? refreshed.workflows : []);
|
||||
if (mediaType === 'image') setImageWorkflow(key);
|
||||
else setApiVideoWorkflow(key);
|
||||
} catch (error) {
|
||||
setWorkflowUploadMessage(getErrorMessage(error, '工作流上传失败'));
|
||||
} finally {
|
||||
setUploadingWorkflow(false);
|
||||
}
|
||||
}, [workflowUploadSource]);
|
||||
|
||||
const taskBadgeTone = task ? statusTone(task.status) : 'neutral';
|
||||
|
||||
@@ -1017,109 +1224,266 @@ export default function DigitalHuman() {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="digital-human-panel">
|
||||
<section className="digital-human-panel digital-human-pipeline-panel">
|
||||
<div className="digital-human-section-head">
|
||||
<div>
|
||||
<h2>语音与画面</h2>
|
||||
<p>选择 TTS、图像工作流、模板,并试听试看样片。</p>
|
||||
<h2>配置合成</h2>
|
||||
<p>参考 Pixelle 原版,将配音、分镜、插图和素材输入拆开配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="digital-human-field">
|
||||
<span>TTS 工作流</span>
|
||||
<select value={ttsWorkflow} onChange={(event) => setTtsWorkflow(event.target.value)}>
|
||||
<option value="">默认</option>
|
||||
{ttsWorkflows.map((wf) => (
|
||||
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>参考音频路径(可选,用于音色克隆)</span>
|
||||
<input value={refAudioPath} onChange={(event) => setRefAudioPath(event.target.value)} placeholder="例如 resources/voice/sample.wav" />
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>上传参考音频</span>
|
||||
<div className="digital-human-file-picker">
|
||||
<input
|
||||
id="digital-human-reference-upload"
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
disabled={uploadingAudio}
|
||||
onChange={(event) => void uploadReferenceAudio(event.target.files?.[0])}
|
||||
/>
|
||||
<label htmlFor="digital-human-reference-upload">
|
||||
{uploadingAudio ? '上传中...' : '选择音频文件'}
|
||||
</label>
|
||||
<span>{uploadMessage ? basename(uploadMessage.replace(/^已上传\s*/, '')) : '用于音色克隆,可选'}</span>
|
||||
<div className="digital-human-subpanel">
|
||||
<div className="digital-human-subpanel-head">
|
||||
<h3>配音合成</h3>
|
||||
<p>{ttsInferenceMode === 'local' ? '本地 Edge TTS 开箱即用,可调音色和语速。' : 'ComfyUI/RunningHub TTS 支持工作流和音色克隆。'}</p>
|
||||
</div>
|
||||
</label>
|
||||
{uploadMessage && <div className="digital-human-upload-note">{uploadMessage}</div>}
|
||||
<label className="digital-human-field">
|
||||
<span>语音试听文本</span>
|
||||
<input value={previewTtsText} onChange={(event) => setPreviewTtsText(event.target.value)} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void previewTts()} disabled={previewing === 'tts'}>
|
||||
{previewing === 'tts' ? '合成中...' : '试听语音'}
|
||||
</button>
|
||||
</div>
|
||||
{previewAudioUrl && <PreviewAudio src={previewAudioUrl} />}
|
||||
|
||||
<label className="digital-human-field">
|
||||
<span>图片工作流</span>
|
||||
<select value={imageWorkflow} onChange={(event) => setImageWorkflow(event.target.value)}>
|
||||
<option value="">默认</option>
|
||||
{mediaWorkflows.map((wf) => (
|
||||
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>视频工作流(可选,生成说话视频)</span>
|
||||
<select value={apiVideoWorkflow} onChange={(event) => setApiVideoWorkflow(event.target.value)}>
|
||||
<option value="">不生成视频(仅图片合成)</option>
|
||||
{mediaWorkflows
|
||||
.filter((wf) => wf.name.toLowerCase().includes('video'))
|
||||
.map((wf) => (
|
||||
<div className="digital-human-mode-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={`digital-human-mode${ttsInferenceMode === 'local' ? ' is-active' : ''}`}
|
||||
onClick={() => setTtsInferenceMode('local')}
|
||||
>
|
||||
<strong>本地合成</strong>
|
||||
<span>Edge TTS,无需工作流;适合快速生成旁白。</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`digital-human-mode${ttsInferenceMode === 'comfyui' ? ' is-active' : ''}`}
|
||||
onClick={() => setTtsInferenceMode('comfyui')}
|
||||
>
|
||||
<strong>ComfyUI 合成</strong>
|
||||
<span>使用 TTS 工作流;可上传参考音频做音色克隆。</span>
|
||||
</button>
|
||||
</div>
|
||||
{ttsInferenceMode === 'local' ? (
|
||||
<>
|
||||
<div className="digital-human-form-grid">
|
||||
<label className="digital-human-field">
|
||||
<span>音色选择</span>
|
||||
<select value={ttsVoice} onChange={(event) => setTtsVoice(event.target.value)}>
|
||||
{EDGE_TTS_VOICES.map((voice) => (
|
||||
<option key={voice.id} value={voice.id}>{voice.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>语速 {ttsSpeed.toFixed(1)}x</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={ttsSpeed}
|
||||
onChange={(event) => setTtsSpeed(normalizeNumber(event.target.value, 1.2, 0.5, 2))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="digital-human-field">
|
||||
<span>TTS 工作流</span>
|
||||
<select value={ttsWorkflow} onChange={(event) => setTtsWorkflow(event.target.value)}>
|
||||
<option value="">默认</option>
|
||||
{ttsWorkflows.map((wf) => (
|
||||
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="digital-human-form-grid">
|
||||
<label className="digital-human-field">
|
||||
<span>宽度</span>
|
||||
<input type="number" min={64} step={64} value={imageWidth} onChange={(event) => setImageWidth(Math.max(64, Number(event.target.value) || 1024))} />
|
||||
</select>
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>高度</span>
|
||||
<input type="number" min={64} step={64} value={imageHeight} onChange={(event) => setImageHeight(Math.max(64, Number(event.target.value) || 1024))} />
|
||||
<span>参考音频路径(可选,用于音色克隆)</span>
|
||||
<input value={refAudioPath} onChange={(event) => setRefAudioPath(event.target.value)} placeholder="例如 resources/voice/sample.wav" />
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>上传参考音频</span>
|
||||
<div className="digital-human-file-picker">
|
||||
<input
|
||||
id="digital-human-reference-upload"
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
disabled={uploadingAudio}
|
||||
onChange={(event) => void uploadReferenceAudio(event.target.files?.[0])}
|
||||
/>
|
||||
<label htmlFor="digital-human-reference-upload">
|
||||
{uploadingAudio ? '上传中...' : '选择音频文件'}
|
||||
</label>
|
||||
<span>{uploadMessage ? basename(uploadMessage.replace(/^已上传\s*/, '')) : '用于音色克隆,可选'}</span>
|
||||
</div>
|
||||
</label>
|
||||
{uploadMessage && <div className="digital-human-upload-note">{uploadMessage}</div>}
|
||||
</>
|
||||
)}
|
||||
<label className="digital-human-field">
|
||||
<span>语音试听文本</span>
|
||||
<input value={previewTtsText} onChange={(event) => setPreviewTtsText(event.target.value)} />
|
||||
</label>
|
||||
<div className="digital-human-inline-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void previewTts()} disabled={previewing === 'tts'}>
|
||||
{previewing === 'tts' ? '合成中...' : '试听语音'}
|
||||
</button>
|
||||
</div>
|
||||
{previewAudioUrl && <PreviewAudio src={previewAudioUrl} />}
|
||||
</div>
|
||||
<label className="digital-human-field">
|
||||
<span>风格 prompt prefix</span>
|
||||
<textarea rows={2} value={promptPrefix} onChange={(event) => setPromptPrefix(event.target.value)} />
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>预览镜头描述</span>
|
||||
<input value={previewImagePrompt} onChange={(event) => setPreviewImagePrompt(event.target.value)} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void previewImage()} disabled={previewing === 'image'}>
|
||||
{previewing === 'image' ? '生成中...' : '预览风格'}
|
||||
</button>
|
||||
</div>
|
||||
{previewImageUrl && <PreviewImage src={previewImageUrl} />}
|
||||
|
||||
<label className="digital-human-field">
|
||||
<span>视频模板</span>
|
||||
<select value={frameTemplate} onChange={(event) => setFrameTemplate(event.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{templates.map((tpl) => (
|
||||
<option key={templateValue(tpl)} value={templateValue(tpl)}>{pickItemLabel(tpl)}{tpl.size ? ` (${tpl.size})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="digital-human-subpanel">
|
||||
<div className="digital-human-subpanel-head">
|
||||
<h3>分镜模板</h3>
|
||||
<p>模板决定最终画面排版,尺寸会传给 Pixelle 合成链路。</p>
|
||||
</div>
|
||||
<label className="digital-human-field">
|
||||
<span>视频模板</span>
|
||||
<select value={frameTemplate} onChange={(event) => setFrameTemplate(event.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{templates.map((tpl) => (
|
||||
<option key={templateValue(tpl)} value={templateValue(tpl)}>{pickItemLabel(tpl)}{tpl.size ? ` (${tpl.size})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="digital-human-dimension-strip">
|
||||
<span>最终视频尺寸</span>
|
||||
<strong>{imageWidth} x {imageHeight}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="digital-human-subpanel">
|
||||
<div className="digital-human-subpanel-head">
|
||||
<h3>插图生成</h3>
|
||||
<p>选择图片工作流,也可以上传人物或参考图给工作流使用。</p>
|
||||
</div>
|
||||
<label className="digital-human-field">
|
||||
<span>图片工作流</span>
|
||||
<select value={imageWorkflow} onChange={(event) => setImageWorkflow(event.target.value)}>
|
||||
<option value="">默认</option>
|
||||
{mediaWorkflows.map((wf) => (
|
||||
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="digital-human-form-grid">
|
||||
<label className="digital-human-field">
|
||||
<span>宽度</span>
|
||||
<input type="number" min={64} step={64} value={imageWidth} onChange={(event) => setImageWidth(Math.max(64, Number(event.target.value) || 1024))} />
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>高度</span>
|
||||
<input type="number" min={64} step={64} value={imageHeight} onChange={(event) => setImageHeight(Math.max(64, Number(event.target.value) || 1024))} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="digital-human-field">
|
||||
<span>图片工作流输入图(可选)</span>
|
||||
<input
|
||||
value={referenceImagePath}
|
||||
onChange={(event) => setReferenceImagePath(event.target.value)}
|
||||
placeholder="例如 resources/boosapi-uploads/avatar.png"
|
||||
/>
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>上传图片素材</span>
|
||||
<div className="digital-human-file-picker">
|
||||
<input
|
||||
id="digital-human-image-asset-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={uploadingImage}
|
||||
onChange={(event) => void uploadReferenceImage(event.target.files?.[0])}
|
||||
/>
|
||||
<label htmlFor="digital-human-image-asset-upload">
|
||||
{uploadingImage ? '上传中...' : '选择图片文件'}
|
||||
</label>
|
||||
<span>{imageUploadMessage ? basename(imageUploadMessage.replace(/^已上传\s*/, '')) : '用于图片/人物参考,可选'}</span>
|
||||
</div>
|
||||
</label>
|
||||
{imageUploadMessage && <div className="digital-human-upload-note">{imageUploadMessage}</div>}
|
||||
<label className="digital-human-field">
|
||||
<span>风格 prompt prefix</span>
|
||||
<textarea rows={2} value={promptPrefix} onChange={(event) => setPromptPrefix(event.target.value)} />
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>预览镜头描述</span>
|
||||
<input value={previewImagePrompt} onChange={(event) => setPreviewImagePrompt(event.target.value)} />
|
||||
</label>
|
||||
<div className="digital-human-inline-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void previewImage()} disabled={previewing === 'image'}>
|
||||
{previewing === 'image' ? '生成中...' : '预览风格'}
|
||||
</button>
|
||||
</div>
|
||||
{previewImageUrl && <PreviewImage src={previewImageUrl} />}
|
||||
</div>
|
||||
|
||||
<div className="digital-human-subpanel">
|
||||
<div className="digital-human-subpanel-head">
|
||||
<h3>视频素材与工作流</h3>
|
||||
<p>用于说话视频、动作迁移或图生视频,RunningHub 与 Selfhost 都可以上传。</p>
|
||||
</div>
|
||||
<label className="digital-human-field">
|
||||
<span>视频工作流(可选,生成说话视频)</span>
|
||||
<select value={apiVideoWorkflow} onChange={(event) => setApiVideoWorkflow(event.target.value)}>
|
||||
<option value="">不生成视频(仅图片合成)</option>
|
||||
{mediaWorkflows
|
||||
.filter(isVideoWorkflow)
|
||||
.map((wf) => (
|
||||
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>视频工作流输入视频(可选)</span>
|
||||
<input
|
||||
value={referenceVideoPath}
|
||||
onChange={(event) => setReferenceVideoPath(event.target.value)}
|
||||
placeholder="例如 resources/boosapi-uploads/action.mp4"
|
||||
/>
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>上传视频素材</span>
|
||||
<div className="digital-human-file-picker">
|
||||
<input
|
||||
id="digital-human-video-asset-upload"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
disabled={uploadingVideo}
|
||||
onChange={(event) => void uploadReferenceVideo(event.target.files?.[0])}
|
||||
/>
|
||||
<label htmlFor="digital-human-video-asset-upload">
|
||||
{uploadingVideo ? '上传中...' : '选择视频文件'}
|
||||
</label>
|
||||
<span>{videoUploadMessage ? basename(videoUploadMessage.replace(/^已上传\s*/, '')) : '用于动作迁移/视频参考,可选'}</span>
|
||||
</div>
|
||||
</label>
|
||||
{videoUploadMessage && <div className="digital-human-upload-note">{videoUploadMessage}</div>}
|
||||
<div className="digital-human-field">
|
||||
<span>上传自定义图片/视频工作流</span>
|
||||
<div className="digital-human-workflow-upload-grid">
|
||||
<select value={workflowUploadSource} onChange={(event) => setWorkflowUploadSource(event.target.value as 'runninghub' | 'selfhost')}>
|
||||
<option value="runninghub">RunningHub</option>
|
||||
<option value="selfhost">Selfhost</option>
|
||||
</select>
|
||||
<div className="digital-human-file-picker">
|
||||
<input
|
||||
id="digital-human-image-workflow-upload"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
disabled={uploadingWorkflow}
|
||||
onChange={(event) => void uploadWorkflow('image', event.target.files?.[0])}
|
||||
/>
|
||||
<label htmlFor="digital-human-image-workflow-upload">
|
||||
{uploadingWorkflow ? '上传中...' : '上传图片工作流'}
|
||||
</label>
|
||||
<input
|
||||
id="digital-human-video-workflow-upload"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
disabled={uploadingWorkflow}
|
||||
onChange={(event) => void uploadWorkflow('video', event.target.files?.[0])}
|
||||
/>
|
||||
<label htmlFor="digital-human-video-workflow-upload">
|
||||
{uploadingWorkflow ? '上传中...' : '上传视频工作流'}
|
||||
</label>
|
||||
<span>{workflowUploadMessage || '自动保存到 Pixelle 工作流目录并刷新列表'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="digital-human-panel">
|
||||
|
||||
@@ -16,6 +16,11 @@ describe('Dashboard mobile layout', () => {
|
||||
expect(source).toMatch(/className="dashboard-hero-topline"[\s\S]*?className="dashboard-hero-status"[\s\S]*?className="dashboard-hero-maincopy"/);
|
||||
expect(css).toContain('.dashboard-hero-topline {');
|
||||
expect(css).toContain('.dashboard-hero-maincopy {');
|
||||
expect(css).toMatch(/@media \(max-width: 1200px\) \{[\s\S]*?\.dashboard-hero\.console-hero \{[\s\S]*?grid-template-columns: 1fr;/);
|
||||
expect(css).toMatch(/@media \(max-width: 1200px\) \{[\s\S]*?\.dashboard-hero \.console-hero-rail \{[\s\S]*?width: 100%;[\s\S]*?grid-template-columns: repeat\(3, minmax\(0, 1fr\)\);/);
|
||||
expect(css).toMatch(/@media \(max-width: 1100px\) \{[\s\S]*?\.dashboard-hero-topline \{[\s\S]*?flex-direction: column;/);
|
||||
expect(css).toMatch(/@media \(max-width: 1100px\) \{[\s\S]*?\.dashboard-hero-status \{[\s\S]*?width: 100%;/);
|
||||
expect(css).toMatch(/@media \(max-width: 1100px\) \{[\s\S]*?\.dashboard-hero-signals \{[\s\S]*?grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
|
||||
expect(css).toMatch(/@media \(max-width: 860px\) \{[\s\S]*?\.dashboard-hero-topline \{[\s\S]*?flex-direction: column;/);
|
||||
expect(css).toMatch(/@media \(max-width: 860px\) \{[\s\S]*?\.dashboard-hero-status \{[\s\S]*?width: 100%;/);
|
||||
expect(css).toMatch(/@media \(max-width: 860px\) \{[\s\S]*?\.dashboard-hero-signals \{[\s\S]*?grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
|
||||
|
||||
@@ -209,4 +209,108 @@ describe('Dashboard and ops log placement', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an idle healthy platform as healthy on Dashboard', async () => {
|
||||
apiMock.getDashboard.mockResolvedValue({
|
||||
totalBalance: 0,
|
||||
totalUsed: 0,
|
||||
todaySpend: 0,
|
||||
todayReward: 0,
|
||||
activeAccounts: 1,
|
||||
totalAccounts: 1,
|
||||
todayCheckin: { success: 0, total: 0 },
|
||||
proxy24h: { success: 0, total: 0, totalTokens: 0 },
|
||||
performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 },
|
||||
siteAvailability: [],
|
||||
modelAnalysis: null,
|
||||
});
|
||||
apiMock.getRoutesSummary.mockResolvedValue([]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<ToastProvider>
|
||||
<Dashboard />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const dashboardText = collectText(root.root);
|
||||
expect(dashboardText).toContain('健康');
|
||||
expect(dashboardText).toContain('100.0% 成功率');
|
||||
expect(dashboardText).toContain('平台运行平稳,可以继续观察吞吐和成本变化。');
|
||||
expect(dashboardText).not.toContain('平台存在明显异常');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not keep Ops Monitor abnormal after logs are cleared when routes only lack snapshots', async () => {
|
||||
apiMock.getDashboard.mockResolvedValue({
|
||||
totalBalance: 0,
|
||||
totalUsed: 0,
|
||||
todaySpend: 0,
|
||||
todayReward: 0,
|
||||
activeAccounts: 5,
|
||||
totalAccounts: 5,
|
||||
todayCheckin: { success: 0, total: 0 },
|
||||
proxy24h: { success: 0, total: 0, totalTokens: 0 },
|
||||
performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 },
|
||||
siteAvailability: [],
|
||||
modelAnalysis: null,
|
||||
});
|
||||
apiMock.getDashboardInsights.mockResolvedValue({ siteAvailability: [] });
|
||||
apiMock.getRoutesSummary.mockResolvedValue([{
|
||||
id: 101,
|
||||
modelPattern: 'gpt-5.2-codex',
|
||||
displayName: 'Codex Route',
|
||||
displayIcon: null,
|
||||
modelMapping: null,
|
||||
enabled: true,
|
||||
channelCount: 3,
|
||||
enabledChannelCount: 3,
|
||||
siteNames: ['Main Site'],
|
||||
decisionSnapshot: null,
|
||||
decisionRefreshedAt: null,
|
||||
}]);
|
||||
apiMock.getEvents.mockResolvedValue({ data: [] });
|
||||
apiMock.getTasks.mockResolvedValue({ tasks: [] });
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/ops']}>
|
||||
<ToastProvider>
|
||||
<OpsMonitor />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const statusCard = root.root.find((node) => (
|
||||
node.type === 'article'
|
||||
&& node.props.className === 'ops-monitor-kpi'
|
||||
&& collectText(node).includes('运行状态')
|
||||
));
|
||||
expect(statusCard.props['data-tone']).toBe('success');
|
||||
expect(collectText(statusCard)).toContain('健康');
|
||||
expect(collectText(statusCard)).toContain('成功率 100.0%');
|
||||
|
||||
const routeRiskCard = root.root.find((node) => (
|
||||
node.type === 'article'
|
||||
&& node.props.className === 'ops-monitor-kpi'
|
||||
&& collectText(node).includes('路由风险')
|
||||
));
|
||||
expect(routeRiskCard.props['data-tone']).toBe('success');
|
||||
expect(collectText(routeRiskCard)).toContain('路由风险0');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('RouteCard', () => {
|
||||
);
|
||||
|
||||
expect(collectText(root.root)).toContain('m.');
|
||||
expect(collectText(root.root)).toContain('注意 76');
|
||||
expect(collectText(root.root)).toContain('健康 90');
|
||||
|
||||
const regexBadge = root.root.find((node) => (
|
||||
node.type === 'span'
|
||||
|
||||
@@ -88,6 +88,33 @@ describe('routeHealth', () => {
|
||||
expect(empty.reasons).toContain('没有通道');
|
||||
});
|
||||
|
||||
it('does not mark fully routed routes without decision snapshots as risky', () => {
|
||||
const health = buildRouteHealthScore(buildRoute({
|
||||
channelCount: 3,
|
||||
enabledChannelCount: 3,
|
||||
decisionSnapshot: null,
|
||||
}));
|
||||
|
||||
expect(health.label).toBe('健康');
|
||||
expect(health.tone).toBe('good');
|
||||
expect(health.score).toBeGreaterThanOrEqual(80);
|
||||
expect(health.reasons).toContain('3/3 通道可用');
|
||||
expect(health.reasons).toContain('暂无选择概率快照');
|
||||
});
|
||||
|
||||
it('keeps partial channel coverage without decision snapshots in attention state', () => {
|
||||
const health = buildRouteHealthScore(buildRoute({
|
||||
channelCount: 4,
|
||||
enabledChannelCount: 2,
|
||||
decisionSnapshot: null,
|
||||
}));
|
||||
|
||||
expect(health.label).toBe('注意');
|
||||
expect(health.tone).toBe('warning');
|
||||
expect(health.reasons).toContain('2/4 通道可用');
|
||||
expect(health.reasons).toContain('暂无选择概率快照');
|
||||
});
|
||||
|
||||
it('penalizes candidate snapshots with recent failures and low eligibility', () => {
|
||||
const health = buildRouteHealthScore(buildRoute({
|
||||
channelCount: 3,
|
||||
|
||||
@@ -103,6 +103,9 @@ export function buildRouteHealthScore(
|
||||
reasons.push(`最高选择概率 ${Math.round(topProbability)}%`);
|
||||
}
|
||||
} else {
|
||||
if (enabledChannelCount > 0 && channelRatio === 1) {
|
||||
score += 14;
|
||||
}
|
||||
reasons.push('暂无选择概率快照');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user