feat: improve agent workbench and digital human flows

This commit is contained in:
boos
2026-06-25 16:32:21 +08:00
parent 22e6c162b6
commit bc99c678f9
24 changed files with 4105 additions and 488 deletions
+19 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -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
View File
@@ -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('暂无选择概率快照');
}