From c5cede2359e3ab813caac543c3a087060061a668 Mon Sep 17 00:00:00 2001 From: wangbo Date: Tue, 12 May 2026 15:42:39 +0800 Subject: [PATCH] fix: polish parameter preprocessing details --- apps/api/internal/runner/param_processor.go | 39 ++++++++++- .../internal/runner/param_processor_test.go | 38 +++++++++++ apps/web/src/pages/WorkspacePage.tsx | 66 ++++++++++++++++--- apps/web/src/styles.css | 30 +++++++++ 4 files changed, 161 insertions(+), 12 deletions(-) diff --git a/apps/api/internal/runner/param_processor.go b/apps/api/internal/runner/param_processor.go index 845b65f..26cdfcd 100644 --- a/apps/api/internal/runner/param_processor.go +++ b/apps/api/internal/runner/param_processor.go @@ -359,15 +359,16 @@ func (contentFilterProcessor) Process(params map[string]any, modelType string, c next := make([]map[string]any, 0, len(content)) for index, item := range content { if isImageContent(item) { + reason, path, value := imageContentRemovalEvidence(item, modelType, context) context.recordChange( "ContentFilterProcessor", "remove", fmt.Sprintf("content[%d]", index), item, nil, - "当前候选模型没有图像参考输入模式,需移除 image_url。", - capabilityPath(modelType, ""), - capabilityForType(context.modelCapability, modelType), + reason, + path, + value, ) continue } @@ -408,6 +409,38 @@ func (contentFilterProcessor) Process(params map[string]any, modelType string, c return true } +func imageContentRemovalEvidence(item map[string]any, modelType string, context *paramProcessContext) (string, string, any) { + role := stringFromAny(item["role"]) + switch role { + case "first_frame": + return "模型能力未开启首帧输入,已移除 first_frame。", capabilityPath(modelType, "input_first_frame"), map[string]any{ + "input_first_frame": capabilityValue(context.modelCapability, modelType, "input_first_frame"), + "input_first_last_frame": capabilityValue(context.modelCapability, modelType, "input_first_last_frame"), + } + case "last_frame": + return "模型能力未开启尾帧或首尾帧输入,已移除 last_frame。", capabilityPath(modelType, "input_first_last_frame"), map[string]any{ + "input_last_frame": capabilityValue(context.modelCapability, modelType, "input_last_frame"), + "input_first_last_frame": capabilityValue(context.modelCapability, modelType, "input_first_last_frame"), + "max_images_for_last_frame": capabilityValue(context.modelCapability, modelType, "max_images_for_last_frame"), + "max_images_for_first_frame": capabilityValue(context.modelCapability, modelType, "max_images_for_first_frame"), + "max_images_for_middle_frame": capabilityValue(context.modelCapability, modelType, "max_images_for_middle_frame"), + } + case "reference_image": + return "模型能力未开启参考图输入,已移除 reference_image。", capabilityPath(modelType, "input_reference_generate_single"), map[string]any{ + "input_reference_generate_single": capabilityValue(context.modelCapability, modelType, "input_reference_generate_single"), + "input_reference_generate_multiple": capabilityValue(context.modelCapability, modelType, "input_reference_generate_multiple"), + "max_images": capabilityValue(context.modelCapability, modelType, "max_images"), + } + default: + return "当前模型能力未开启图像输入,已移除 image_url。", capabilityPath(modelType, "input_first_frame"), map[string]any{ + "input_first_frame": capabilityValue(context.modelCapability, modelType, "input_first_frame"), + "input_first_last_frame": capabilityValue(context.modelCapability, modelType, "input_first_last_frame"), + "input_reference_generate_single": capabilityValue(context.modelCapability, modelType, "input_reference_generate_single"), + "input_reference_generate_multiple": capabilityValue(context.modelCapability, modelType, "input_reference_generate_multiple"), + } + } +} + type inputAudioProcessor struct{} func (inputAudioProcessor) Name() string { return "InputAudioProcessor" } diff --git a/apps/api/internal/runner/param_processor_test.go b/apps/api/internal/runner/param_processor_test.go index 235fa20..1804ea3 100644 --- a/apps/api/internal/runner/param_processor_test.go +++ b/apps/api/internal/runner/param_processor_test.go @@ -180,6 +180,44 @@ func TestParamProcessorVideoCapabilitiesNormalizeAndFilter(t *testing.T) { } } +func TestParamProcessorVideoGenerateLogsFirstFrameRemoval(t *testing.T) { + body := map[string]any{ + "model": "Seedance T2V", + "prompt": "animate it", + "content": []any{ + map[string]any{"type": "text", "text": "animate it"}, + map[string]any{"type": "image_url", "role": "first_frame", "image_url": "https://example.com/first.png"}, + }, + } + candidate := store.RuntimeModelCandidate{ + ModelType: "video_generate", + Capabilities: map[string]any{ + "video_generate": map[string]any{ + "duration_range": []any{3, 12}, + }, + }, + } + + result := preprocessRequestWithLog("videos.generations", body, candidate) + for _, item := range contentItems(result.Body["content"]) { + if isImageContent(item) { + t.Fatalf("first frame image should be removed for video_generate: %+v", result.Body["content"]) + } + } + for _, change := range result.Log.Changes { + if change.Path == "content[1]" { + if change.Reason != "模型能力未开启首帧输入,已移除 first_frame。" { + t.Fatalf("unexpected first frame removal reason: %+v", change) + } + if change.CapabilityPath != "capabilities.video_generate.input_first_frame" { + t.Fatalf("unexpected first frame capability path: %+v", change) + } + return + } + } + t.Fatalf("expected first frame removal log, got %+v", result.Log.Changes) +} + func TestParamProcessorImageResolutionAndOutputCount(t *testing.T) { body := map[string]any{ "model": "即梦V4.0", diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index 58a7772..6da9ff5 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'; +import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react'; import { Popover as AntPopover } from 'antd'; import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, SlidersHorizontal, Trash2, UserRound } from 'lucide-react'; import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; @@ -838,27 +838,46 @@ function TaskParamConversionCell(props: { task: GatewayTask; token: string }) { const [logs, setLogs] = useState(null); const [loadState, setLoadState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle'); const [error, setError] = useState(''); + const logsRef = useRef(null); + const loadingRef = useRef(false); useEffect(() => { - if (!open || !summary.changed || logs || loadState === 'loading' || !props.token) return; + logsRef.current = null; + loadingRef.current = false; + setLogs(null); + setLoadState('idle'); + setError(''); + }, [props.task.id]); + + useEffect(() => { + if (!open || !summary.changed || logsRef.current || loadingRef.current || !props.token) return; let cancelled = false; + loadingRef.current = true; setLoadState('loading'); setError(''); listTaskParamPreprocessing(props.token, props.task.id) .then((response) => { if (cancelled) return; - setLogs(response.items ?? []); + const items = response.items ?? []; + logsRef.current = items; + setLogs(items); setLoadState('ready'); }) .catch((err) => { if (cancelled) return; setError(err instanceof Error ? err.message : '参数转换明细加载失败'); setLoadState('error'); + }) + .finally(() => { + if (!cancelled) { + loadingRef.current = false; + } }); return () => { cancelled = true; + loadingRef.current = false; }; - }, [loadState, logs, open, props.task.id, props.token, summary.changed]); + }, [open, props.task.id, props.token, summary.changed]); if (!summary.changed) { return 无转换; @@ -900,8 +919,11 @@ function TaskParamConversionPopover(props: { {logs.map((log) => ( - {taskParamLogTitle(log)} - {log.changeCount} 项 + + {taskParamLogTitle(log)} + {taskParamLogSubtitle(log)} + + {log.changeCount} 项 {(log.changes ?? []).slice(0, 8).map((change, index) => ( @@ -1116,9 +1138,16 @@ function taskParamConversionSummary(task: GatewayTask): TaskParamConversionSumma paths: [], capabilityPaths: [], }; - mergeTaskParamSummary(summary, metadataObject(task.metrics, 'parameterPreprocessingSummary')); + let mergedAttemptSummary = false; for (const attempt of task.attempts ?? []) { - mergeTaskParamSummary(summary, metadataObject(attempt.metrics, 'parameterPreprocessingSummary')); + const attemptSummary = metadataObject(attempt.metrics, 'parameterPreprocessingSummary'); + if (Object.keys(attemptSummary).length) { + mergedAttemptSummary = true; + mergeTaskParamSummary(summary, attemptSummary); + } + } + if (!mergedAttemptSummary) { + mergeTaskParamSummary(summary, metadataObject(task.metrics, 'parameterPreprocessingSummary')); } return summary; } @@ -1147,11 +1176,30 @@ function taskParamLogTitle(log: GatewayTaskParamPreprocessingLog) { const parts = [ log.attemptNo ? `#${log.attemptNo}` : '', log.modelType || '', - log.clientId || '', ].filter(Boolean); return parts.join(' · ') || '预处理记录'; } +function taskParamLogSubtitle(log: GatewayTaskParamPreprocessingLog) { + const model = log.model ?? {}; + const modelLabel = firstNonEmptyText( + objectString(model, 'modelAlias'), + objectString(model, 'providerModelName'), + objectString(model, 'modelName'), + ); + const provider = objectString(model, 'provider'); + const parts = [ + provider, + modelLabel, + ].filter(Boolean); + if (parts.length) return parts.join(' · '); + return log.clientId || '候选模型'; +} + +function firstNonEmptyText(...values: string[]) { + return values.find((value) => value.trim()) ?? ''; +} + function taskParamActionLabel(action: string) { if (action === 'remove') return '移除'; if (action === 'adjust') return '调整'; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index d29b493..7dda0e3 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -521,6 +521,36 @@ strong { border-top: 1px solid var(--border); } +.taskParamConversionLogHeader { + align-items: flex-start; +} + +.taskParamConversionLogTitleBlock { + display: grid; + min-width: 0; + gap: 0.15rem; +} + +.taskParamConversionLogTitleBlock strong { + line-height: 1.3; +} + +.taskParamConversionLogTitleBlock small { + max-width: 32rem; + overflow: hidden; + color: var(--text-soft); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.taskParamConversionCountBadge { + flex: 0 0 auto; + white-space: nowrap; +} + .taskParamConversionChange { display: grid; gap: 0.28rem;