fix(*): 视频计费与结果解析 - 按 Token 预估展示并解析数组媒体结果

This commit is contained in:
ddCat
2026-09-19 07:48:08 +08:00
parent 2e5092285a
commit 0a06783f44
8 changed files with 92 additions and 14 deletions

View File

@@ -441,9 +441,11 @@ func TestNewAPIVideoGenerationsParsesNestedVideoResults(t *testing.T) {
tests := []struct {
name string
payload string
wantURL string
}{
{name: "channel result URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","result_url":"https://cdn.example/channel-result.mp4"}}`},
{name: "provider nested video URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","data":{"status":"completed","video_url":"https://cdn.example/provider-result.mp4"}}}`},
{name: "channel result URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","result_url":"https://cdn.example/channel-result.mp4"}}`, wantURL: "https://cdn.example/channel-result.mp4"},
{name: "provider nested video URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","data":{"status":"completed","video_url":"https://cdn.example/provider-result.mp4"}}}`, wantURL: "https://cdn.example/provider-result.mp4"},
{name: "provider data array URL", payload: `{"created":1789773326,"data":[{"url":"https://cdn.example/seedance-result.mp4?preview=1"}],"id":"task-upstream","object":"video.generation","status":"completed","usage":{"completion_tokens":108872,"total_tokens":108872}}`, wantURL: "https://cdn.example/seedance-result.mp4?preview=1"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -454,8 +456,8 @@ func TestNewAPIVideoGenerationsParsesNestedVideoResults(t *testing.T) {
if state.Status != StatusSucceeded || state.Result == nil || len(state.Result.Videos) != 1 {
t.Fatalf("state = %#v, want one completed video", state)
}
if state.Result.Videos[0].URL == "" {
t.Fatalf("video = %#v, want a result URL", state.Result.Videos[0])
if state.Result.Videos[0].URL != test.wantURL {
t.Fatalf("video = %#v, want URL %q", state.Result.Videos[0], test.wantURL)
}
})
}

View File

@@ -67,7 +67,7 @@
| `response.taskId` | `{"$coalesce":[{"$ref":"response.data.task_id"},{"$ref":"response.data.taskId"},{"$ref":"response.task_id"},{"$ref":"response.taskId"},{"$ref":"response.data.id"},{"$ref":"response.id"},{"$ref":"taskId"}]}` |
| `response.status` | `{"$coalesce":[{"$ref":"response.status"},{"$ref":"response.state"},{"$ref":"response.data.status"},"pending"]}` |
| `response.message` | `{"$coalesce":[{"$ref":"response.error.message"},{"$ref":"response.message"},{"$ref":"response.fail_reason"}]}` |
| `response.videos` | `{"$coalesce":[{"$ref":"response.data.result_url"},{"$ref":"response.data.video_url"},{"$ref":"response.data.output_url"},{"$ref":"response.data.url"},{"$ref":"response.data.metadata.url"},{"$ref":"response.data.data.video_url"},{"$ref":"response.data.data.output_url"},{"$ref":"response.data.data.result_url"},{"$ref":"response.data.data.url"},{"$ref":"response.data.data.metadata.url"},{"$ref":"response.video_url"},{"$ref":"response.videoUrl"},{"$ref":"response.result_url"},{"$ref":"response.output_url"},{"$ref":"response.url"},{"$ref":"response.metadata.url"},{"$ref":"response.output.url"}]}` |
| `response.videos` | `{"$coalesce":[{"$map":{"from":{"$ref":"response.data"},"as":"item","in":{"$coalesce":[{"$ref":"item.url"},{"$ref":"item.video_url"},{"$ref":"item.videoUrl"},{"$ref":"item.output_url"},{"$ref":"item.outputUrl"}]}}},{"$ref":"response.data.result_url"},{"$ref":"response.data.video_url"},{"$ref":"response.data.output_url"},{"$ref":"response.data.url"},{"$ref":"response.data.metadata.url"},{"$ref":"response.data.data.video_url"},{"$ref":"response.data.data.output_url"},{"$ref":"response.data.data.result_url"},{"$ref":"response.data.data.url"},{"$ref":"response.data.data.metadata.url"},{"$ref":"response.video_url"},{"$ref":"response.videoUrl"},{"$ref":"response.result_url"},{"$ref":"response.output_url"},{"$ref":"response.url"},{"$ref":"response.metadata.url"},{"$ref":"response.output.url"}]}` |
| `response.errorPaths[0]` | `"error.code"` |
| `response.resultEphemeral` | `true` |
@@ -345,6 +345,33 @@
},
"videos": {
"$coalesce": [
{
"$map": {
"from": {
"$ref": "response.data"
},
"as": "item",
"in": {
"$coalesce": [
{
"$ref": "item.url"
},
{
"$ref": "item.video_url"
},
{
"$ref": "item.videoUrl"
},
{
"$ref": "item.output_url"
},
{
"$ref": "item.outputUrl"
}
]
}
}
},
{
"$ref": "response.data.result_url"
},

File diff suppressed because one or more lines are too long

View File

@@ -337,7 +337,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const renderSubmitButton = (expanded: boolean) => {
const showCost = creditsEnabled && credits !== null;
const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
const actionLabel = isRunning ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成";
const actionLabel = isRunning ? "生成中" : showCost ? `${routeQuote?.estimated ? "预估" : "消耗"} ${formattedCredits} 积分,生成` : "生成";
return (
<Button
type="text"
@@ -357,7 +357,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
{showCost ? (
<span className="canvas-node-composer-submit-cost">
<CreditSymbol />
<span>{routeQuote?.estimated ? "预计 " : ""}{formattedCredits}</span>
<span>{routeQuote?.estimated ? `预估:${formattedCredits}` : formattedCredits}</span>
</span>
) : null}
<span className="canvas-node-composer-submit-action" aria-hidden>

View File

@@ -506,7 +506,7 @@ function tierSpecificationLabel(tier: NonNullable<NonNullable<AiConfig["channels
function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | null | undefined; quote?: LogicalModelQuote; compact?: boolean }) {
if (quote) {
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
const label = quote.estimated ? `预计 ${amount}` : `${amount}`;
const label = quote.estimated ? `预估:${amount}` : `${amount}`;
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={modelQuoteDescription(quote)}>
<Coins className="size-3" />

View File

@@ -33,7 +33,9 @@ import { useCopyText } from "@/hooks/use-copy-text";
import { buildImageResolutionOptions, formatImageResolutionSize, supportsImageResolutionPresets } from "@/lib/image-resolution-tiers";
import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
import type { Skill } from "@/services/api/skills";
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
import { resolveResourceUrl } from "@/services/api/resources";
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
import { useAppearanceStore } from "@/stores/use-appearance-store";
@@ -367,6 +369,8 @@ export function CreationComposer(props: ComposerProps) {
const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy;
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const priceChannel = resolveModelChannel(props.config, props.model);
const quoteRequest = useMemo(() => modelQuoteRequest(props.config, props.model, props.mode, props.modelRequirements), [props.config, props.mode, props.model, props.modelRequirements]);
const [routeQuote, setRouteQuote] = useState<LogicalModelQuote | null>(null);
const canOptimizePrompt = Boolean(props.promptOptimizerProvider) && (props.mode === "image" || props.mode === "video");
const optimizerReferences = props.references.filter((reference) => reference.active && reference.kind !== "skill");
const credits = requestCreditCost({
@@ -375,10 +379,28 @@ export function CreationComposer(props: ComposerProps) {
model: modelOptionName(props.model),
count: props.mode === "image" ? props.count : 1,
seconds: props.mode === "video" ? props.seconds : 1,
capability: props.mode,
config: props.config,
requirements: props.modelRequirements,
});
const showCost = creditsEnabled && credits !== null;
const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
const actionLabel = props.referenceReplacementBusy ? "正在替换参考图" : interactionBusy || (props.generationActive && !canSubmit) ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送";
useEffect(() => {
if (!creditsEnabled || !quoteRequest) {
setRouteQuote(null);
return;
}
const controller = new AbortController();
setRouteQuote(null);
quoteModel(quoteRequest, controller.signal)
.then(({ quote }) => setRouteQuote(quote))
.catch(() => {
if (!controller.signal.aborted) setRouteQuote(null);
});
return () => controller.abort();
}, [creditsEnabled, quoteRequest]);
const generationCredits = routeQuote ? routeQuote.amountMicrocredits / 1_000_000 : credits;
const showCost = creditsEnabled && generationCredits !== null && generationCredits !== undefined;
const formattedCredits = generationCredits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
const actionLabel = props.referenceReplacementBusy ? "正在替换参考图" : interactionBusy || (props.generationActive && !canSubmit) ? "生成中" : showCost ? `${routeQuote?.estimated ? "预估" : "消耗"} ${formattedCredits} 积分,发送` : "发送";
// Send-button working state must span the WHOLE generation (not just the
// submit-lock window): spinner + glow stay while a message is pending and
// the composer is empty; typing a next prompt returns the arrow so the
@@ -610,7 +632,7 @@ export function CreationComposer(props: ComposerProps) {
title={!canSubmit && !interactionBusy ? "输入创作想法后即可生成" : actionLabel}
>
{showWorkingGlow ? <WorkingGlow active color="var(--creation-text)" radius="999px" /> : null}
{showCost ? <span className="creation-submit-cost"><CreditSymbol /><span>{formattedCredits}</span></span> : null}
{showCost ? <span className="creation-submit-cost" title={routeQuote ? modelQuoteDescription(routeQuote) : undefined}><CreditSymbol /><span>{routeQuote?.estimated ? `预估:${formattedCredits}` : formattedCredits}</span></span> : null}
<span className="creation-submit-action" aria-hidden>{showWorkingSpinner ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}<span>{showWorkingSpinner ? "生成中" : "开始创作"}</span></span>
</Button>
</footer>

View File

@@ -545,7 +545,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
</div>
<footer className="workflow-editor-actions">
<div className="workflow-generation-cost" aria-live="polite">
{creditsEnabled && formattedGenerationCredits ? <><CreditSymbol /><span title={routeQuote ? modelQuoteDescription(routeQuote) : undefined}>本次预计 {formattedGenerationCredits} 积分</span></> : creditsEnabled && routedModel ? <span>本次费用将在提交时按实际规格计算</span> : null}
{creditsEnabled && formattedGenerationCredits ? <><CreditSymbol /><span title={routeQuote ? modelQuoteDescription(routeQuote) : undefined}>本次{routeQuote?.estimated ? "预估" : "费用"} {formattedGenerationCredits} 积分</span></> : creditsEnabled && routedModel ? <span>本次费用将在提交时按实际规格计算</span> : null}
</div>
<div className="flex items-center gap-2"><Button danger icon={<Trash2 className="size-4" />} loading={deleteShot.isPending} disabled={saveShot.isPending || selectedShotSubmitting || changeAssetBinding.isPending} onClick={requestDeleteShot}>删除镜头</Button><Button htmlType="submit" icon={<Save className="size-4" />} loading={saveShot.isPending} disabled={!editorDirty || deleteShot.isPending}>保存脚本</Button><Button type="primary" icon={<Play className="size-4" />} loading={selectedShotSubmitting || shotTask?.status === "queued" || shotTask?.status === "running"} disabled={deleteShot.isPending} onClick={() => void generateArtifact()}>{selectedShotSubmitting ? `${stageCopy.action}(正在提交)` : shotTask?.status === "queued" || shotTask?.status === "running" ? `${stageCopy.action}(已运行${shotTaskElapsed})` : shotTask?.status === "failed" ? `${stageCopy.action}(上次失败,可重试)` : shotTask?.status === "succeeded" && !newestArtifact ? `${stageCopy.action}(已完成,正在同步)` : newestArtifact ? `${stageCopy.action}(已生成)` : stageCopy.action}</Button></div>
</footer>