Compare commits

...

11 Commits

Author SHA1 Message Date
stringadmin 45fe601e17 Merge pull request 'Codex/time driven progress' (#34) from codex/time-driven-progress into master
Web Quality / verify (push) Has been cancelled
Reviewed-on: #34
2026-06-10 10:05:08 +00:00
stringadmin 9d9c3ce186 Merge branch 'master' into codex/time-driven-progress
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 10:04:59 +00:00
stringadmin 228e89cfb6 Merge pull request 'feat: 多页面UI打磨 — 设置面板、状态反馈与样式升级' (#35) from feat/ui-polish-and-skills into master
Web Quality / verify (push) Has been cancelled
Reviewed-on: #35
2026-06-10 09:57:06 +00:00
stringadmin 0fbb5372d5 Merge branch 'master' into feat/ui-polish-and-skills
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 09:56:47 +00:00
stringadmin aa5ba96764 Merge branch 'master' into codex/time-driven-progress
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 09:51:55 +00:00
stringadmin ba2e7cfda2 Consolidate generation task stores
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 17:37:18 +08:00
stringadmin e9601a651c Use time-driven generation progress
Web Quality / verify (push) Has been cancelled
2026-06-10 16:00:26 +08:00
stringadmin 82bd939e26 Merge pull request 'Codex/canvas pricing cleanup' (#33) from codex/canvas-pricing-cleanup into master
Web Quality / verify (push) Has been cancelled
Reviewed-on: #33
2026-06-10 07:35:45 +00:00
stringadmin 9e080bbb8f Use server enterprise video pricing
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
2026-06-10 14:27:42 +08:00
stringadmin d28889fd0c Use server prices for text billing estimates
Web Quality / verify (push) Has been cancelled
2026-06-10 14:12:55 +08:00
stringadmin bfb70bab26 Fix canvas generation cleanup 2026-06-10 14:12:14 +08:00
24 changed files with 1475 additions and 129 deletions
+9
View File
@@ -0,0 +1,9 @@
# Optimization Backlog
## Progress Contract Frontend Consumption
- Status: pending
- Priority: medium
- Context: The backend now returns `progressSource`, `stage`, `startedAt`, and `expectedDurationMs` on generation task status payloads. The frontend progress UI currently still derives these values locally from message state and static defaults.
- Follow-up: Wire the backend task progress contract through `aiGenerationClient`, task/message view models, and the progress card components so model-aware `expectedDurationMs` and real provider progress can be consumed end to end.
- Boundary: Keep this separate from the task store consolidation. The store consolidation is complete without requiring these fields because `WebGenerationPreviewTask` is not the source for Workbench progress cards.
+18 -1
View File
@@ -150,6 +150,10 @@ export interface AiTaskStatus {
type: "image" | "video";
status: "pending" | "running" | "completed" | "failed" | "cancelled";
progress: number;
progressSource?: "real" | "estimated" | string | null;
stage?: string | null;
startedAt?: string | null;
expectedDurationMs?: number | null;
resultUrl: string | null;
error: string | null;
params?: Record<string, unknown>;
@@ -514,7 +518,20 @@ export const aiGenerationClient = {
subscribeTaskStatus(
taskId: string,
onUpdate: (task: Pick<AiTaskStatus, "taskId" | "status" | "progress" | "resultUrl" | "error">) => void,
onUpdate: (
task: Pick<
AiTaskStatus,
| "taskId"
| "status"
| "progress"
| "progressSource"
| "stage"
| "startedAt"
| "expectedDurationMs"
| "resultUrl"
| "error"
>,
) => void,
): () => void {
const url = buildApiUrl(`ai/tasks/${taskId}/stream`);
const controller = new AbortController();
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from "../test/testHarness";
import {
normalizeEnterpriseVideoPricingConfig,
normalizePublicModelPrice,
normalizePublicModelPrices,
normalizePublicPricingPayload,
} from "./publicPricingClient";
describe("publicPricingClient", () => {
it("normalizes camelCase public model price payloads", () => {
expect(
normalizePublicModelPrice({
id: 1,
modelKey: "gpt-4o",
displayName: "GPT-4o",
category: "text",
pricingType: "token",
inputPriceMills: 27,
outputPriceMills: 108,
flatPriceMills: null,
currency: "CNY",
enabled: true,
}),
).toEqual({
id: 1,
modelKey: "gpt-4o",
displayName: "GPT-4o",
category: "text",
pricingType: "token",
inputPriceMills: 27,
outputPriceMills: 108,
flatPriceMills: null,
currency: "CNY",
enabled: true,
createdAt: undefined,
updatedAt: undefined,
});
});
it("normalizes snake_case public model price payloads inside containers", () => {
expect(
normalizePublicModelPrices({
prices: [
{
model_key: "deepseek-chat",
display_name: "DeepSeek Chat",
pricing_type: "token",
input_price_mills: "2",
output_price_mills: "8",
flat_price_mills: "0",
enabled: 1,
},
{ display_name: "missing key" },
],
}),
).toEqual([
{
id: undefined,
modelKey: "deepseek-chat",
displayName: "DeepSeek Chat",
category: undefined,
pricingType: "token",
inputPriceMills: 2,
outputPriceMills: 8,
flatPriceMills: 0,
currency: "CNY",
enabled: true,
createdAt: undefined,
updatedAt: undefined,
},
]);
});
it("normalizes public pricing payloads with model prices and enterprise video pricing", () => {
expect(
normalizePublicPricingPayload({
modelPrices: [
{
modelKey: "qwen-turbo",
pricingType: "token",
inputPriceMills: 2,
outputPriceMills: 6,
},
],
enterpriseVideoPricing: {
currency: "CNY",
creditsPerCny: 100,
billingUnit: "per_second",
defaultResolution: "1080P",
resolutions: ["720P", "1080P"],
rules: [
{
id: "happyhorse",
modelIncludes: ["happyhorse"],
rates: { "720P": 0.72, "1080P": 1.28 },
},
],
},
}),
).toEqual({
modelPrices: [
{
id: undefined,
modelKey: "qwen-turbo",
displayName: undefined,
category: undefined,
pricingType: "token",
inputPriceMills: 2,
outputPriceMills: 6,
flatPriceMills: null,
currency: "CNY",
enabled: true,
createdAt: undefined,
updatedAt: undefined,
},
],
enterpriseVideoPricing: {
currency: "CNY",
creditsPerCny: 100,
billingUnit: "per_second",
defaultResolution: "1080P",
resolutions: ["720P", "1080P"],
rules: [
{
id: "happyhorse",
modelIncludes: ["happyhorse"],
rates: { "720P": 0.72, "1080P": 1.28 },
},
],
},
});
});
it("rejects malformed enterprise video pricing configs", () => {
expect(
normalizeEnterpriseVideoPricingConfig({
rules: [{ id: "broken", modelIncludes: [], rates: {} }],
}),
).toEqual(null);
});
});
+236
View File
@@ -0,0 +1,236 @@
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
import { isRecord, serverRequest } from "./serverConnection";
import type { EnterpriseVideoPricingConfig, EnterpriseVideoPricingRule } from "../utils/enterpriseVideoPolicy";
export interface PublicModelPrice {
id?: number | string;
modelKey: string;
displayName?: string;
category?: string;
pricingType?: string;
inputPriceMills: number | null;
outputPriceMills: number | null;
flatPriceMills: number | null;
currency: string;
enabled: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface PublicPricingPayload {
modelPrices: PublicModelPrice[];
enterpriseVideoPricing: EnterpriseVideoPricingConfig | null;
}
function readString(
record: Record<string, unknown>,
keys: string[],
): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function readNumber(
record: Record<string, unknown>,
keys: string[],
): number | null {
for (const key of keys) {
const value = record[key];
const parsed =
typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function readBoolean(
record: Record<string, unknown>,
keys: string[],
fallback: boolean,
): boolean {
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "enabled"].includes(normalized)) return true;
if (["0", "false", "no", "disabled"].includes(normalized)) return false;
}
}
return fallback;
}
function readStringArray(record: Record<string, unknown>, keys: string[]): string[] {
for (const key of keys) {
const value = record[key];
if (!Array.isArray(value)) continue;
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
}
return [];
}
function normalizeRateMap(raw: unknown): Record<string, number> | null {
if (!isRecord(raw)) return null;
const result: Record<string, number> = {};
for (const [key, value] of Object.entries(raw)) {
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (Number.isFinite(parsed) && parsed >= 0) result[key] = parsed;
}
return Object.keys(result).length ? result : null;
}
function normalizeEnterpriseVideoPricingRule(raw: unknown): EnterpriseVideoPricingRule | null {
if (!isRecord(raw)) return null;
const id = readString(raw, ["id", "key", "name"]);
const modelIncludes = readStringArray(raw, ["modelIncludes", "model_includes", "modelPatterns", "model_patterns"]);
const rates = normalizeRateMap(raw.rates);
if (!id || modelIncludes.length === 0 || !rates) return null;
const when = isRecord(raw.when)
? {
...(typeof raw.when.muted === "boolean" ? { muted: raw.when.muted } : {}),
...(typeof raw.when.hasReferenceVideo === "boolean"
? { hasReferenceVideo: raw.when.hasReferenceVideo }
: {}),
}
: undefined;
return {
id,
modelIncludes,
...(when && Object.keys(when).length ? { when } : {}),
rates,
};
}
export function normalizePublicModelPrice(
raw: unknown,
): PublicModelPrice | null {
if (!isRecord(raw)) return null;
const modelKey = readString(raw, ["modelKey", "model_key", "key", "model"]);
if (!modelKey) return null;
const displayName = readString(raw, ["displayName", "display_name", "name"]);
const category = readString(raw, ["category", "type"]);
const pricingType = readString(raw, ["pricingType", "pricing_type"]);
const currency = readString(raw, ["currency"]) || "CNY";
const createdAt = readString(raw, ["createdAt", "created_at"]);
const updatedAt = readString(raw, ["updatedAt", "updated_at"]);
const idValue = raw.id;
return {
id:
typeof idValue === "number" || typeof idValue === "string"
? idValue
: undefined,
modelKey,
displayName,
category,
pricingType,
inputPriceMills: readNumber(raw, ["inputPriceMills", "input_price_mills"]),
outputPriceMills: readNumber(raw, [
"outputPriceMills",
"output_price_mills",
]),
flatPriceMills: readNumber(raw, ["flatPriceMills", "flat_price_mills"]),
currency,
enabled: readBoolean(raw, ["enabled", "is_enabled"], true),
createdAt,
updatedAt,
};
}
export function normalizePublicModelPrices(
payload: unknown,
): PublicModelPrice[] {
const rawPrices = Array.isArray(payload)
? payload
: isRecord(payload) && Array.isArray(payload.prices)
? payload.prices
: isRecord(payload) && Array.isArray(payload.modelPrices)
? payload.modelPrices
: isRecord(payload) && Array.isArray(payload.model_prices)
? payload.model_prices
: isRecord(payload) && Array.isArray(payload.models)
? payload.models
: [];
return rawPrices
.map((item) => normalizePublicModelPrice(item))
.filter((item): item is PublicModelPrice => Boolean(item));
}
export function normalizeEnterpriseVideoPricingConfig(raw: unknown): EnterpriseVideoPricingConfig | null {
if (!isRecord(raw)) return null;
const rules = Array.isArray(raw.rules)
? raw.rules
.map((item) => normalizeEnterpriseVideoPricingRule(item))
.filter((item): item is EnterpriseVideoPricingRule => Boolean(item))
: [];
if (rules.length === 0) return null;
const creditsPerCny = readNumber(raw, ["creditsPerCny", "credits_per_cny"]);
const defaultResolution = readString(raw, ["defaultResolution", "default_resolution"]);
const billingUnit = readString(raw, ["billingUnit", "billing_unit"]);
const currency = readString(raw, ["currency"]);
const resolutions = readStringArray(raw, ["resolutions", "supportedResolutions", "supported_resolutions"]);
return {
...(currency ? { currency } : {}),
...(creditsPerCny !== null ? { creditsPerCny } : {}),
...(billingUnit ? { billingUnit } : {}),
...(defaultResolution ? { defaultResolution } : {}),
...(resolutions.length ? { resolutions } : {}),
rules,
};
}
export function normalizePublicPricingPayload(payload: unknown): PublicPricingPayload {
const enterpriseVideoPricingRaw =
isRecord(payload) && (payload.enterpriseVideoPricing ?? payload.enterprise_video_pricing);
return {
modelPrices: normalizePublicModelPrices(payload),
enterpriseVideoPricing: normalizeEnterpriseVideoPricingConfig(enterpriseVideoPricingRaw),
};
}
let cachedPricing: PublicPricingPayload | null = null;
let pricesRouteMissing = false;
export const publicPricingClient = {
async getPricing(): Promise<PublicPricingPayload> {
if (cachedPricing) return cachedPricing;
if (pricesRouteMissing) return { modelPrices: [], enterpriseVideoPricing: null };
try {
const payload = await serverRequest<unknown>("prices", {
fallbackMessage: "Model prices request failed",
});
cachedPricing = normalizePublicPricingPayload(payload);
return cachedPricing;
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
pricesRouteMissing = true;
return { modelPrices: [], enterpriseVideoPricing: null };
}
throw error;
}
},
async getPrices(): Promise<PublicModelPrice[]> {
const pricing = await publicPricingClient.getPricing();
return pricing.modelPrices;
},
};
+19 -3
View File
@@ -9,6 +9,10 @@ export interface TaskProgressEvent {
taskId: string;
status: string;
progress: number;
progressSource?: "real" | "estimated" | string | null;
stage?: string | null;
startedAt?: string | null;
expectedDurationMs?: number | null;
resultUrl?: string | null;
error?: string | null;
}
@@ -37,7 +41,8 @@ export function waitForTask(
operation: options.operation,
});
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
const noProgressTimeoutMs =
options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
const startedAt = options.startedAt ?? Date.now();
return new Promise((resolve, reject) => {
@@ -58,7 +63,10 @@ export function waitForTask(
};
timeoutId = setTimeout(
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
() =>
settle(() =>
reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))),
),
timeoutMs,
);
@@ -105,7 +113,11 @@ export function waitForTask(
policy: { ...timeoutPolicy, noProgressTimeoutMs },
});
if (timeoutReason) {
settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))));
settle(() =>
reject(
new Error(buildLocalTimeoutMessage(options.kind || "video")),
),
);
return;
}
try {
@@ -114,6 +126,10 @@ export function waitForTask(
taskId,
status: task.status,
progress: task.progress || 0,
progressSource: task.progressSource,
stage: task.stage,
startedAt: task.startedAt,
expectedDurationMs: task.expectedDurationMs,
resultUrl: task.resultUrl,
error: task.error,
});
+36 -8
View File
@@ -33,6 +33,10 @@ import { communityClient } from "../../api/communityClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import {
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS,
} from "../../hooks/useSmoothedProgress";
import type { WebCanvasWorkflow } from "../../types";
import type { AssetLibraryCategory } from "../assets/localAssetStore";
import {
@@ -371,12 +375,19 @@ function CanvasPage({
const textNodeIdRef = useRef(9);
const imageNodeIdRef = useRef(1);
const videoNodeIdRef = useRef(1);
const objectUrlsRef = useRef(new Set<string>());
const trackObjectUrl = (file: Blob) => {
const url = URL.createObjectURL(file);
objectUrlsRef.current.add(url);
return url;
};
const { pushSnapshot, undo, redo } = useCanvasHistory();
const {
textGenerationState, imageGenerationState, videoGenerationState,
generationToast, setGenerationToast,
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
imageGenerationAbortRef, videoGenerationAbortRef,
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState,
@@ -527,6 +538,7 @@ function CanvasPage({
const autoSaveStatusTimerRef = useRef<number | null>(null);
useEffect(() => {
const objectUrls = objectUrlsRef.current;
return () => {
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
@@ -535,6 +547,8 @@ function CanvasPage({
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
}
objectUrls.forEach((url) => URL.revokeObjectURL(url));
objectUrls.clear();
};
}, []);
@@ -1691,12 +1705,15 @@ function CanvasPage({
const quality = resolveImageQuality(model, imageNode.imageSize || "");
imageGenerationInFlightRef.current.add(nodeId);
const abortRef = { current: false };
imageGenerationAbortRef.current.set(nodeId, abortRef);
setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 });
setGenerationToast("图片正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try {
const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode);
if (abortRef.current) return;
const taskInput: CreatePreviewTaskInput = {
title: imageNode.title || "图片节点生成",
type: "image",
@@ -1732,7 +1749,8 @@ function CanvasPage({
? "图片生成完成"
: "图片生成失败";
setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
}));
}, abortRef));
if (abortRef.current || !outputUrl) return;
setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 });
removeCanvasGenKeepalive(task.id);
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
@@ -1794,13 +1812,15 @@ function CanvasPage({
);
}
} catch (error) {
if (abortRef.current) return;
setImageGenerationStatus(nodeId, {
status: "error",
message: error instanceof Error ? error.message : "图片生成失败",
});
} finally {
imageGenerationInFlightRef.current.delete(nodeId);
if (task?.id) removeCanvasGenKeepalive(task.id);
imageGenerationAbortRef.current.delete(nodeId);
if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
}
};
@@ -1843,12 +1863,15 @@ function CanvasPage({
const duration = Number(videoNode.duration) || 4;
videoGenerationInFlightRef.current.add(nodeId);
const abortRef = { current: false };
videoGenerationAbortRef.current.set(nodeId, abortRef);
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
setGenerationToast("视频正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try {
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
if (abortRef.current) return;
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
throw new Error("图生视频需要先连接至少一个可用的图片节点");
}
@@ -1892,7 +1915,8 @@ function CanvasPage({
? "视频生成完成"
: "视频生成失败";
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
}));
}, abortRef));
if (abortRef.current || !outputUrl) return;
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
removeCanvasGenKeepalive(taskId);
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
@@ -1948,13 +1972,15 @@ function CanvasPage({
);
}
} catch (error) {
if (abortRef.current) return;
setVideoGenerationStatus(nodeId, {
status: "error",
message: error instanceof Error ? error.message : "视频生成失败",
});
} finally {
videoGenerationInFlightRef.current.delete(nodeId);
if (task?.id) removeCanvasGenKeepalive(task.id);
videoGenerationAbortRef.current.delete(nodeId);
if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
}
};
@@ -1965,7 +1991,7 @@ function CanvasPage({
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const imageUrl = URL.createObjectURL(file);
const imageUrl = trackObjectUrl(file);
if (pendingImageToImageNodeId) {
const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId);
if (sourceNode) {
@@ -2047,7 +2073,7 @@ function CanvasPage({
let offsetX = 0;
let offsetY = 0;
for (const file of files) {
const imageUrl = URL.createObjectURL(file);
const imageUrl = trackObjectUrl(file);
addImageNode(imageUrl, file.name, {
x: dropPosition.x + offsetX,
y: dropPosition.y + offsetY,
@@ -2103,7 +2129,7 @@ function CanvasPage({
let offsetX = 0;
let offsetY = 0;
for (const file of files) {
const imageUrl = URL.createObjectURL(file);
const imageUrl = trackObjectUrl(file);
addImageNode(imageUrl, file.name, {
x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX,
y: sourceNode.position.y + offsetY,
@@ -4470,6 +4496,7 @@ function CanvasPage({
progress={imageNodeProgress}
status={imageTaskState?.status || "running"}
message={imageTaskState?.message || "图片生成中"}
expectedDurationMs={DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS}
/>
) : null}
{imageNodeFocusActive && imageFocusSelectionReady ? (
@@ -4844,6 +4871,7 @@ function CanvasPage({
progress={videoNodeProgress}
status={videoTaskState?.status || "running"}
message={videoTaskState?.message || "视频生成中"}
expectedDurationMs={DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS}
/>
) : null}
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")}
@@ -5279,7 +5307,7 @@ function CanvasPage({
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
setAssetCoverUrl(URL.createObjectURL(file));
setAssetCoverUrl(trackObjectUrl(file));
setCoverSourceOpen(false);
}}
/>
@@ -1,4 +1,8 @@
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import {
DEFAULT_GENERATION_EXPECTED_DURATION_MS,
useSmoothedProgress,
type ProgressSource,
} from "../../hooks/useSmoothedProgress";
import { canvasGenerationProgressStyle } from "./canvasUtils";
type NodeGenStatus = "submitting" | "running" | "success" | "error";
@@ -7,10 +11,24 @@ interface CanvasSmoothedProgressRingProps {
progress: number;
status: NodeGenStatus;
message?: string;
progressSource?: ProgressSource;
startedAt?: number | string | Date | null;
expectedDurationMs?: number | null;
}
export function CanvasSmoothedProgressRing({ progress, status, message }: CanvasSmoothedProgressRingProps) {
const smoothed = useSmoothedProgress(progress, status);
export function CanvasSmoothedProgressRing({
progress,
status,
message,
progressSource = "estimated",
startedAt,
expectedDurationMs = DEFAULT_GENERATION_EXPECTED_DURATION_MS,
}: CanvasSmoothedProgressRingProps) {
const smoothed = useSmoothedProgress(progress, status, {
progressSource,
startedAt,
expectedDurationMs,
});
return (
<div
className="studio-canvas-node-generation-progress"
@@ -18,7 +36,10 @@ export function CanvasSmoothedProgressRing({ progress, status, message }: Canvas
aria-live="polite"
style={canvasGenerationProgressStyle(smoothed)}
>
<span className="studio-canvas-node-generation-progress__ring" aria-hidden="true" />
<span
className="studio-canvas-node-generation-progress__ring"
aria-hidden="true"
/>
<strong>{message}</strong>
<em>{smoothed}%</em>
</div>
+36 -4
View File
@@ -252,28 +252,60 @@ export function blobToDataUrl(blob: Blob) {
});
}
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
export async function waitForImageTaskResult(
taskId: string,
onStatus?: (status: AiTaskStatus) => void,
abortRef?: { current: boolean },
) {
const resultUrl = await waitForTask(taskId, {
kind: "image",
abortRef,
onProgress: (e) => {
if (onStatus) {
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
onStatus({
taskId,
status: e.status,
progress: e.progress,
progressSource: e.progressSource,
stage: e.stage,
startedAt: e.startedAt,
expectedDurationMs: e.expectedDurationMs,
resultUrl: e.resultUrl ?? undefined,
error: e.error ?? undefined,
} as AiTaskStatus);
}
},
});
if (abortRef?.current) return "";
if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
return resultUrl;
}
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
export async function waitForVideoTaskResult(
taskId: string,
onStatus?: (status: AiTaskStatus) => void,
abortRef?: { current: boolean },
) {
const resultUrl = await waitForTask(taskId, {
kind: "video",
abortRef,
onProgress: (e) => {
if (onStatus) {
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
onStatus({
taskId,
status: e.status,
progress: e.progress,
progressSource: e.progressSource,
stage: e.stage,
startedAt: e.startedAt,
expectedDurationMs: e.expectedDurationMs,
resultUrl: e.resultUrl ?? undefined,
error: e.error ?? undefined,
} as AiTaskStatus);
}
},
});
if (abortRef?.current) return "";
if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
return resultUrl;
}
+37 -8
View File
@@ -1,4 +1,4 @@
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from "react";
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import type {
CanvasImageGenerationState,
CanvasImageNode,
@@ -66,6 +66,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
const videoGenerationInFlightRef = useRef(new Set<string>());
const textGenerationInFlightRef = useRef(new Set<string>());
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
const imageGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
const videoGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
const canvasGenKeepaliveRestoredRef = useRef(false);
const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => {
@@ -80,6 +82,15 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
setVideoGenerationState((current) => ({ ...current, [nodeId]: state }));
};
const abortAllGenerationPollers = useCallback(() => {
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
textGenerationAbortControllersRef.current.clear();
imageGenerationAbortRef.current.forEach((ref) => { ref.current = true; });
imageGenerationAbortRef.current.clear();
videoGenerationAbortRef.current.forEach((ref) => { ref.current = true; });
videoGenerationAbortRef.current.clear();
}, []);
// Toast auto-dismiss
useEffect(() => {
if (!generationToast) return undefined;
@@ -103,11 +114,14 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
}
if (entry.nodeKind === "image") {
imageGenerationInFlightRef.current.add(entry.nodeId);
const abortRef = { current: false };
imageGenerationAbortRef.current.set(entry.nodeId, abortRef);
setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 });
void waitForImageTaskResult(entry.taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress });
}).then(async (outputUrl) => {
}, abortRef).then(async (outputUrl) => {
if (abortRef.current || !outputUrl) return;
removeCanvasGenKeepalive(entry.taskId);
setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
const ref = createCanvasAssetRefFromGeneratedResult({
@@ -128,18 +142,23 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
));
}
}).catch(() => {
if (abortRef.current) return;
removeCanvasGenKeepalive(entry.taskId);
setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" });
}).finally(() => {
imageGenerationInFlightRef.current.delete(entry.nodeId);
imageGenerationAbortRef.current.delete(entry.nodeId);
});
} else if (entry.nodeKind === "video") {
videoGenerationInFlightRef.current.add(entry.nodeId);
const abortRef = { current: false };
videoGenerationAbortRef.current.set(entry.nodeId, abortRef);
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
void waitForVideoTaskResult(entry.taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress });
}).then(async (outputUrl) => {
}, abortRef).then(async (outputUrl) => {
if (abortRef.current || !outputUrl) return;
removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
const ref = createCanvasAssetRefFromGeneratedResult({
@@ -160,18 +179,19 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
));
}
}).catch(() => {
if (abortRef.current) return;
removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
}).finally(() => {
videoGenerationInFlightRef.current.delete(entry.nodeId);
videoGenerationAbortRef.current.delete(entry.nodeId);
});
}
}
};
const resetGenerationState = () => {
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
textGenerationAbortControllersRef.current.clear();
abortAllGenerationPollers();
textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear();
@@ -180,11 +200,18 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
setVideoGenerationState({});
};
// Stop all in-flight front-end polling/setState when the canvas unmounts (route change).
// Keepalive records are intentionally preserved so restoreKeepaliveTasks can resume on return.
useEffect(() => {
return () => {
abortAllGenerationPollers();
};
}, [abortAllGenerationPollers]);
useEffect(() => {
const handlePageHide = () => {
cancelCanvasGenKeepaliveOnUnload();
textGenerationAbortControllersRef.current.forEach((controller) => controller.abort());
textGenerationAbortControllersRef.current.clear();
abortAllGenerationPollers();
textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear();
@@ -202,7 +229,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("online", handleOnline);
};
}, []);
}, [abortAllGenerationPollers]);
return {
textGenerationState,
@@ -214,6 +241,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
videoGenerationInFlightRef,
textGenerationInFlightRef,
textGenerationAbortControllersRef,
imageGenerationAbortRef,
videoGenerationAbortRef,
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus,
setImageGenerationStatus,
@@ -1,4 +1,9 @@
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import { useRef } from "react";
import {
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
formatEstimatedRemainingLabel,
useSmoothedProgress,
} from "../../hooks/useSmoothedProgress";
interface EcommerceProgressBarProps {
status: "idle" | "generating" | "done" | "failed" | string;
@@ -12,19 +17,42 @@ function mapStatus(status: string): "running" | "completed" | "failed" {
return "running";
}
export function EcommerceProgressBar({ status, label }: EcommerceProgressBarProps) {
const progress = mapStatus(status) === "running" ? 50 : 100;
const smoothed = useSmoothedProgress(progress, mapStatus(status));
export function EcommerceProgressBar({
status,
label,
}: EcommerceProgressBarProps) {
const startedAtRef = useRef(Date.now());
const mappedStatus = mapStatus(status);
const progress = mappedStatus === "running" ? 50 : 100;
const smoothed = useSmoothedProgress(progress, mappedStatus, {
progressSource: mappedStatus === "running" ? "estimated" : "real",
startedAt: startedAtRef.current,
expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
});
const remainingLabel =
mappedStatus === "running"
? formatEstimatedRemainingLabel({
nowMs: Date.now(),
startedAt: startedAtRef.current,
expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
})
: null;
if (status === "idle") return null;
return (
<div className="ecommerce-progress-bar">
<span className="ecommerce-progress-bar__label">{label || "AI 正在生成"}</span>
<span className="ecommerce-progress-bar__label">
{label || "AI 正在生成"}
{remainingLabel ? ` / ${remainingLabel}` : ""}
</span>
<div className="ecommerce-progress-bar__track">
<div className="ecommerce-progress-bar__fill" style={{ width: `${smoothed}%` }} />
<div
className="ecommerce-progress-bar__fill"
style={{ width: `${smoothed}%` }}
/>
</div>
<span className="ecommerce-progress-bar__value">{smoothed}%</span>
</div>
);
}
}
+21 -3
View File
@@ -1,4 +1,8 @@
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import {
DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
useSmoothedProgress,
type ProgressSource,
} from "../../hooks/useSmoothedProgress";
type MessageStatus = "thinking" | "completed" | "failed" | string;
@@ -6,6 +10,9 @@ interface SmoothedProgressBarProps {
progress: number;
status: MessageStatus;
label?: string;
progressSource?: ProgressSource;
startedAt?: number | string | Date | null;
expectedDurationMs?: number | null;
}
function mapMessageStatus(status: MessageStatus) {
@@ -15,8 +22,19 @@ function mapMessageStatus(status: MessageStatus) {
return "running" as const;
}
export function SmoothedProgressBar({ progress, status, label }: SmoothedProgressBarProps) {
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status));
export function SmoothedProgressBar({
progress,
status,
label,
progressSource = "estimated",
startedAt,
expectedDurationMs = DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
}: SmoothedProgressBarProps) {
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status), {
progressSource,
startedAt,
expectedDurationMs,
});
return (
<>
<span>{label || "超分处理中..."}</span>
+42 -3
View File
@@ -40,6 +40,7 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import { publicPricingClient, type PublicModelPrice } from "../../api/publicPricingClient";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import type { WebProjectSummary } from "../../types";
import {
@@ -58,6 +59,8 @@ import {
import { translateTaskError } from "../../utils/translateTaskError";
import {
buildLocalTimeoutMessage,
FALLBACK_TEXT_TOKEN_CREDIT_RATE,
formatTextTokenCreditRule,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../../utils/taskLifecycle";
@@ -69,7 +72,12 @@ import {
import { isViduModel } from "../../utils/viduRouting";
import { isPixverseModel } from "../../utils/pixverseRouting";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
import {
calculateEnterpriseVideoCredits,
ENTERPRISE_DEFAULT_VIDEO_MODEL,
type EnterpriseVideoPricingConfig,
} from "../../utils/enterpriseVideoPolicy";
import { resolveTextTokenCreditRate } from "../../utils/modelPricing";
import {
getImageQualityOptionsForContext,
getDefaultImageQuality,
@@ -404,9 +412,32 @@ function WorkbenchPage({
const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value));
const [chatModel, setChatModel] = useState(CHAT_MODEL_OPTIONS[0].value);
const [modelPrices, setModelPrices] = useState<PublicModelPrice[]>([]);
const [enterpriseVideoPricing, setEnterpriseVideoPricing] = useState<EnterpriseVideoPricingConfig | null>(null);
const [thinkingSpeed, setThinkingSpeed] = useState(THINKING_SPEED_OPTIONS[0].value);
const [thinkingDepth, setThinkingDepth] = useState(THINKING_DEPTH_OPTIONS[0].value);
useEffect(() => {
let cancelled = false;
publicPricingClient
.getPricing()
.then((pricing) => {
if (cancelled) return;
setModelPrices(pricing.modelPrices);
setEnterpriseVideoPricing(pricing.enterpriseVideoPricing);
})
.catch(() => {
if (cancelled) return;
setModelPrices([]);
setEnterpriseVideoPricing(null);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
@@ -525,6 +556,10 @@ function WorkbenchPage({
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
const selectedChatTokenRate = useMemo(
() => resolveTextTokenCreditRate(modelPrices, chatModel) || FALLBACK_TEXT_TOKEN_CREDIT_RATE,
[chatModel, modelPrices],
);
const billingEstimate = useMemo(() => {
if (activeMode === "image") {
return {
@@ -541,7 +576,7 @@ function WorkbenchPage({
durationSeconds,
muted: false,
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
});
}, enterpriseVideoPricing || undefined);
return {
label: `预计 ${formatCreditValue(credits)} 积分`,
title: `${activeModel}${videoQualityLabel}${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
@@ -553,16 +588,20 @@ function WorkbenchPage({
};
}
}
const textBillingPrefix =
selectedChatTokenRate.source === "server" ? "文本计费" : "服务端价格暂不可用,按默认预估";
return {
label: "按 Token 结算",
title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分",
title: `${textBillingPrefix}${activeModel}${formatTextTokenCreditRule(selectedChatTokenRate)}`,
};
}, [
activeMode,
activeModel,
activeModelValue,
imageSettingsSummary,
enterpriseVideoPricing,
referenceItems,
selectedChatTokenRate,
videoDuration,
videoQuality,
videoQualityLabel,
@@ -19,7 +19,13 @@ import { assetClient } from "../../../api/assetClient";
import { communityClient } from "../../../api/communityClient";
import { saveAssetToLocalLibrary } from "../../assets/localAssetStore";
import { SmoothedProgressBar } from "../SmoothedProgressBar";
import { useSmoothedProgress } from "../../../hooks/useSmoothedProgress";
import {
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS,
formatEstimatedRemainingLabel,
useSmoothedProgress,
} from "../../../hooks/useSmoothedProgress";
import { renderMarkdownBlocks } from "../markdownRenderer";
import { downloadResultAsset } from "../workbenchDownload";
import type { WorkbenchChatAttachment, WorkbenchChatMessage, WorkbenchResultActionPayload } from "../workbenchChatTypes";
@@ -456,6 +462,8 @@ export const ResultCard = memo(function ResultCard({
progress={message.taskProgress ?? 18}
status={message.status || "thinking"}
label={message.taskStatusLabel || "超分处理中..."}
progressSource="estimated"
expectedDurationMs={DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS}
/>
</div>
) : null}
@@ -575,7 +583,23 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
const specs = message.result?.specs || [];
const prompt = message.prompt || message.body;
const isVideo = message.mode === "video";
const smoothed = useSmoothedProgress(message.taskProgress ?? 5, message.status === "thinking" ? "running" : "completed");
const expectedDurationMs = isVideo
? DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS
: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS;
const progressStatus = message.status === "thinking" ? "running" : "completed";
const smoothed = useSmoothedProgress(message.taskProgress ?? 5, progressStatus, {
progressSource: progressStatus === "running" ? "estimated" : "real",
startedAt: message.createdAt,
expectedDurationMs,
});
const remainingLabel = progressStatus === "running"
? formatEstimatedRemainingLabel({
nowMs: Date.now(),
startedAt: message.createdAt,
expectedDurationMs,
})
: null;
const statusLabel = message.taskStatusLabel || (isVideo ? "视频生成中" : "图片生成中");
return (
<div className={`ai-generation-pending-card${isVideo ? " is-video" : " is-image"}`}>
@@ -590,7 +614,7 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
</div>
<div className="ai-generation-pending-card__meta">
<div>
<strong>{message.taskStatusLabel || "Generating..."}</strong>
<strong>{remainingLabel ? `${statusLabel} / ${remainingLabel}` : statusLabel}</strong>
<span>{prompt}</span>
</div>
{specs.length > 0 && (
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "../test/testHarness";
import {
calculateEstimatedProgress,
formatEstimatedRemainingLabel,
resolveProgressStartedAt,
} from "./useSmoothedProgress";
describe("useSmoothedProgress helpers", () => {
it("calculates estimated progress from elapsed time with an easing curve", () => {
expect(
Math.round(
calculateEstimatedProgress({
nowMs: 220_000,
startedAtMs: 100_000,
expectedDurationMs: 120_000,
}),
),
).toBe(85);
});
it("caps estimated progress below completion", () => {
expect(
Math.round(
calculateEstimatedProgress({
nowMs: 900_000,
startedAtMs: 0,
expectedDurationMs: 120_000,
}),
),
).toBe(92);
});
it("parses local workbench timestamps", () => {
expect(resolveProgressStartedAt("2026-06-10 09:30")).toBe(
new Date(2026, 5, 10, 9, 30).getTime(),
);
});
it("formats remaining time for estimated tasks", () => {
expect(
formatEstimatedRemainingLabel({
nowMs: new Date(2026, 5, 10, 9, 30).getTime(),
startedAt: "2026-06-10 09:29",
expectedDurationMs: 120_000,
}),
).toBe("预计还需 1 分钟");
});
});
+168 -5
View File
@@ -1,6 +1,14 @@
import { useEffect, useRef, useState } from "react";
type ProgressStatus = "queued" | "running" | "submitting" | "completed" | "failed" | "success" | "error";
type ProgressStatus =
| "queued"
| "running"
| "submitting"
| "completed"
| "failed"
| "success"
| "error";
export type ProgressSource = "real" | "estimated";
interface SmoothedProgressOptions {
creepSpeed?: number;
@@ -8,6 +16,11 @@ interface SmoothedProgressOptions {
creepCeiling?: number;
creepAhead?: number;
completionDuration?: number;
progressSource?: ProgressSource;
startedAt?: number | string | Date | null;
expectedDurationMs?: number | null;
estimatedFloor?: number;
estimatedCeiling?: number;
}
const DEFAULT_CREEP_SPEED = 0.5;
@@ -15,15 +28,109 @@ const DEFAULT_CHASE_RATE = 0.09;
const DEFAULT_CREEP_CEILING = 97;
const DEFAULT_CREEP_AHEAD = 25;
const DEFAULT_COMPLETION_DURATION = 450;
export const DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS = 120_000;
export const DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS = 240_000;
export const DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS = 180_000;
export const DEFAULT_GENERATION_EXPECTED_DURATION_MS =
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS;
const DEFAULT_ESTIMATED_FLOOR = 12;
const DEFAULT_ESTIMATED_CEILING = 92;
const ESTIMATED_PROGRESS_CURVE = 2.4;
const MIN_EXPECTED_DURATION_MS = 1_000;
function isTerminal(status: ProgressStatus): boolean {
return status === "completed" || status === "success" || status === "failed" || status === "error";
return (
status === "completed" ||
status === "success" ||
status === "failed" ||
status === "error"
);
}
function isSuccess(status: ProgressStatus): boolean {
return status === "completed" || status === "success";
}
export function resolveProgressStartedAt(
value: number | string | Date | null | undefined,
): number | null {
if (value instanceof Date) {
const time = value.getTime();
return Number.isFinite(time) ? time : null;
}
if (typeof value === "number") {
return Number.isFinite(value) && value > 0 ? value : null;
}
if (typeof value !== "string" || !value.trim()) return null;
const normalized = value.trim();
const localMatch = normalized.match(
/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?/,
);
if (localMatch) {
const [, year, month, day, hours, minutes, seconds] = localMatch;
const time = new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hours),
Number(minutes),
Number(seconds || 0),
).getTime();
return Number.isFinite(time) ? time : null;
}
const parsed = Date.parse(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
export function calculateEstimatedProgress(input: {
nowMs: number;
startedAtMs: number;
expectedDurationMs?: number | null;
floor?: number;
ceiling?: number;
}): number {
const floor = Math.max(
0,
Math.min(99, input.floor ?? DEFAULT_ESTIMATED_FLOOR),
);
const ceiling = Math.max(
floor,
Math.min(99, input.ceiling ?? DEFAULT_ESTIMATED_CEILING),
);
const duration = Math.max(
MIN_EXPECTED_DURATION_MS,
Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS,
);
const elapsed = Math.max(0, input.nowMs - input.startedAtMs);
const ratio = elapsed / duration;
const eased = 1 - Math.exp(-ratio * ESTIMATED_PROGRESS_CURVE);
return Math.min(ceiling, floor + eased * (ceiling - floor));
}
export function formatEstimatedRemainingLabel(input: {
nowMs: number;
startedAt: number | string | Date | null | undefined;
expectedDurationMs?: number | null;
}): string | null {
const startedAtMs = resolveProgressStartedAt(input.startedAt);
if (!startedAtMs) return null;
const duration = Math.max(
MIN_EXPECTED_DURATION_MS,
Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS,
);
const remainingSeconds = Math.max(
0,
Math.ceil((startedAtMs + duration - input.nowMs) / 1000),
);
if (remainingSeconds <= 0) return "即将完成";
if (remainingSeconds < 60) return `预计还需 ${remainingSeconds}`;
return `预计还需 ${Math.ceil(remainingSeconds / 60)} 分钟`;
}
/**
* Smoothly interpolates between coarse server-reported progress values.
* On completion, animates quickly to 100% instead of jumping.
@@ -37,24 +144,59 @@ export function useSmoothedProgress(
const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE;
const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING;
const creepAhead = options?.creepAhead ?? DEFAULT_CREEP_AHEAD;
const completionDuration = options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
const completionDuration =
options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
const progressSource = options?.progressSource ?? "real";
const startedAt = options?.startedAt;
const expectedDurationMs =
options?.expectedDurationMs ?? DEFAULT_GENERATION_EXPECTED_DURATION_MS;
const estimatedFloor = options?.estimatedFloor ?? DEFAULT_ESTIMATED_FLOOR;
const estimatedCeiling =
options?.estimatedCeiling ?? DEFAULT_ESTIMATED_CEILING;
const [displayed, setDisplayed] = useState(0);
const rafRef = useRef(0);
const targetRef = useRef(realProgress);
const statusRef = useRef(status);
const progressSourceRef = useRef(progressSource);
const expectedDurationMsRef = useRef(expectedDurationMs);
const estimatedFloorRef = useRef(estimatedFloor);
const estimatedCeilingRef = useRef(estimatedCeiling);
const estimatedStartedAtRef = useRef<number | null>(null);
const completionStartRef = useRef<number | null>(null);
const completionBaseRef = useRef(0);
useEffect(() => {
targetRef.current = realProgress;
statusRef.current = status;
progressSourceRef.current = progressSource;
expectedDurationMsRef.current = expectedDurationMs;
estimatedFloorRef.current = estimatedFloor;
estimatedCeilingRef.current = estimatedCeiling;
if (progressSource === "estimated" && !isTerminal(status)) {
estimatedStartedAtRef.current =
resolveProgressStartedAt(startedAt) ??
estimatedStartedAtRef.current ??
Date.now();
}
if (isSuccess(status) && completionStartRef.current === null) {
completionStartRef.current = performance.now();
completionBaseRef.current = displayed;
} else if (!isSuccess(status)) {
completionStartRef.current = null;
}
}, [realProgress, status]);
}, [
displayed,
estimatedCeiling,
estimatedFloor,
expectedDurationMs,
progressSource,
realProgress,
startedAt,
status,
]);
useEffect(() => {
if (status === "failed" || status === "error") {
@@ -82,6 +224,20 @@ export function useSmoothedProgress(
if (isTerminal(currentStatus)) return current;
if (progressSourceRef.current === "estimated") {
const target = calculateEstimatedProgress({
nowMs: Date.now(),
startedAtMs: estimatedStartedAtRef.current ?? Date.now(),
expectedDurationMs: expectedDurationMsRef.current,
floor: estimatedFloorRef.current,
ceiling: estimatedCeilingRef.current,
});
if (current >= target) return current;
const gap = target - current;
const step = (gap * chaseRate + 0.2) * dt * 60;
return Math.min(current + step, target);
}
const target = targetRef.current;
if (current >= target) {
@@ -100,7 +256,14 @@ export function useSmoothedProgress(
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [status, chaseRate, creepSpeed, creepCeiling, creepAhead, completionDuration]);
}, [
status,
chaseRate,
creepSpeed,
creepCeiling,
creepAhead,
completionDuration,
]);
if (status === "failed" || status === "error") return Math.round(displayed);
if (isSuccess(status) && displayed >= 99.5) return 100;
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it } from "../test/testHarness";
import { useGenerationStore } from "./useGenerationStore";
import type { WebGenerationPreviewTask } from "../types";
function previewTask(id: string, status: WebGenerationPreviewTask["status"] = "running"): WebGenerationPreviewTask {
return {
id,
title: "Task",
type: "image",
status,
progress: status === "completed" ? 100 : 10,
prompt: "prompt",
createdAt: "2026-06-10T08:00:00.000Z",
source: "server",
};
}
describe("useGenerationStore task state", () => {
afterEach(() => {
useGenerationStore.setState({ queue: [], tasks: [] });
});
it("merges server preview tasks without duplicating local rows", () => {
const store = useGenerationStore.getState();
store.appendTask(previewTask("server-1"));
store.mergeServerTasks([previewTask("server-1", "completed"), previewTask("server-2")]);
const tasks = useGenerationStore.getState().tasks;
expect(tasks.map((task) => task.id)).toEqual(["server-1", "server-2"]);
expect(tasks[0].status).toBe("completed");
});
it("syncs running queue updates into matching preview tasks", () => {
const store = useGenerationStore.getState();
store.addTask({
id: "local-task-1",
taskId: "server-task-1",
title: "Image",
type: "image",
status: "running",
progress: 5,
prompt: "prompt",
createdAt: Date.now(),
sourceView: "workbench",
});
expect(useGenerationStore.getState().tasks[0].id).toBe("server-task-1");
expect(useGenerationStore.getState().tasks[0].status).toBe("running");
store.updateTask("local-task-1", {
status: "completed",
progress: 100,
resultUrl: "https://oss.example/result.png",
});
const task = useGenerationStore.getState().tasks[0];
expect(task.status).toBe("completed");
expect(task.progress).toBe(100);
expect(task.outputUrl).toBe("https://oss.example/result.png");
});
it("clears preview tasks and running queue together", () => {
const store = useGenerationStore.getState();
store.appendTask(previewTask("server-task-1"));
store.addTask({
id: "local-task-1",
title: "Image",
type: "image",
status: "running",
progress: 5,
prompt: "prompt",
createdAt: Date.now(),
sourceView: "workbench",
});
store.clearTasks();
expect(useGenerationStore.getState().tasks).toEqual([]);
expect(useGenerationStore.getState().queue).toEqual([]);
});
});
+112 -2
View File
@@ -1,4 +1,5 @@
import { create } from "zustand";
import type { WebGenerationPreviewTask } from "../types";
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
@@ -17,6 +18,8 @@ export interface GenerationQueueItem {
params?: Record<string, unknown>;
}
type PreviewTaskPatch = Partial<WebGenerationPreviewTask>;
interface PersistedQueueSnapshot {
version: 1;
items: GenerationQueueItem[];
@@ -53,9 +56,14 @@ function persistQueue(items: GenerationQueueItem[]): void {
interface GenerationStoreState {
queue: GenerationQueueItem[];
tasks: WebGenerationPreviewTask[];
addTask: (item: GenerationQueueItem) => void;
updateTask: (id: string, patch: Partial<GenerationQueueItem>) => void;
removeTask: (id: string) => void;
setTasks: (tasks: WebGenerationPreviewTask[]) => void;
appendTask: (task: WebGenerationPreviewTask) => void;
mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void;
clearTasks: () => void;
getRunningTasks: () => GenerationQueueItem[];
getPendingTasks: () => GenerationQueueItem[];
getTasksByView: (sourceView: string) => GenerationQueueItem[];
@@ -64,14 +72,87 @@ interface GenerationStoreState {
const initialQueue = loadPersistedQueue();
function trimTasks(tasks: WebGenerationPreviewTask[]): WebGenerationPreviewTask[] {
return tasks.slice(0, MAX_ITEMS);
}
function mergePreviewTaskById(
tasks: WebGenerationPreviewTask[],
taskId: string | undefined,
patch: PreviewTaskPatch,
): WebGenerationPreviewTask[] {
if (!taskId) return tasks;
let changed = false;
const next = tasks.map((task) => {
if (task.id !== taskId) return task;
changed = true;
return { ...task, ...patch };
});
return changed ? next : tasks;
}
function toPreviewTaskStatus(status: GenerationQueueItem["status"]): WebGenerationPreviewTask["status"] {
if (status === "pending") return "queued";
if (status === "cancelled") return "failed";
return status;
}
function toPreviewTaskPatch(item: GenerationQueueItem): PreviewTaskPatch {
const status = toPreviewTaskStatus(item.status);
return {
status,
progress: item.status === "completed" ? 100 : item.progress,
outputUrl: item.resultUrl || undefined,
errorMessage: item.error || undefined,
};
}
function toPreviewTask(item: GenerationQueueItem): WebGenerationPreviewTask | null {
if (item.type === "ecommerce-video") return null;
const type = item.type;
const createdAt = Number.isFinite(item.createdAt)
? new Date(item.createdAt).toISOString()
: new Date().toISOString();
return {
id: item.taskId || item.id,
title: item.title,
type,
status: toPreviewTaskStatus(item.status),
progress: item.status === "completed" ? 100 : item.progress,
prompt: item.prompt,
createdAt,
projectId:
typeof item.params?.projectId === "string" ? item.params.projectId : undefined,
outputUrl: item.resultUrl || undefined,
source: "preview",
errorMessage: item.error || undefined,
};
}
function upsertPreviewTask(
tasks: WebGenerationPreviewTask[],
task: WebGenerationPreviewTask | null,
): WebGenerationPreviewTask[] {
if (!task) return tasks;
return trimTasks([task, ...tasks.filter((item) => item.id !== task.id)]);
}
function previewTaskIdsForItem(item: GenerationQueueItem): string[] {
return Array.from(new Set([item.taskId, item.id].filter(Boolean) as string[]));
}
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
queue: initialQueue,
tasks: [],
addTask: (item) => {
set((state) => {
const next = [item, ...state.queue].slice(0, MAX_ITEMS);
const previewTasks = upsertPreviewTask(state.tasks, toPreviewTask(item));
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
return { queue: next };
return { queue: next, tasks: previewTasks };
});
},
@@ -80,8 +161,16 @@ export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
const next = state.queue.map((item) =>
item.id === id ? { ...item, ...patch } : item,
);
const updated = next.find((item) => item.id === id);
let previewTasks = state.tasks;
if (updated) {
const previewPatch = toPreviewTaskPatch(updated);
for (const previewTaskId of previewTaskIdsForItem(updated)) {
previewTasks = mergePreviewTaskById(previewTasks, previewTaskId, previewPatch);
}
}
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
return { queue: next };
return { queue: next, tasks: previewTasks };
});
},
@@ -93,6 +182,27 @@ export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
});
},
setTasks: (tasks) => set({ tasks: trimTasks(tasks) }),
appendTask: (task) => set((state) => ({
tasks: trimTasks([task, ...state.tasks.filter((item) => item.id !== task.id)]),
})),
mergeServerTasks: (serverTasks) => set((state) => {
const serverIds = new Set(serverTasks.map((task) => task.id));
return {
tasks: trimTasks([
...serverTasks,
...state.tasks.filter((task) => !serverIds.has(task.id)),
]),
};
}),
clearTasks: () => {
persistQueue([]);
set({ tasks: [], queue: [] });
},
getRunningTasks: () => get().queue.filter((i) => i.status === "running" || i.status === "pending"),
getPendingTasks: () => get().queue.filter((i) => i.status === "pending"),
getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView),
+1 -36
View File
@@ -1,36 +1 @@
import { create } from 'zustand';
import type { WebGenerationPreviewTask } from '../types';
interface TaskState {
tasks: WebGenerationPreviewTask[];
}
interface TaskActions {
setTasks: (tasks: WebGenerationPreviewTask[]) => void;
appendTask: (task: WebGenerationPreviewTask) => void;
mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void;
clearTasks: () => void;
}
const initialState: TaskState = {
tasks: [],
};
export const useTaskStore = create<TaskState & TaskActions>((set) => ({
...initialState,
setTasks: (tasks) => set({ tasks }),
appendTask: (task) => set((state) => ({
tasks: [task, ...state.tasks],
})),
mergeServerTasks: (serverTasks) => set((state) => {
const serverIds = new Set(serverTasks.map((task) => task.id));
return {
tasks: [...serverTasks, ...state.tasks.filter((task) => !serverIds.has(task.id))].slice(0, 80),
};
}),
clearTasks: () => set({ tasks: [] }),
}));
export { useGenerationStore as useTaskStore } from "./useGenerationStore";
+37
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "../test/testHarness";
import {
calculateEnterpriseVideoCredits,
type EnterpriseVideoPricingConfig,
getEnterpriseVideoCreditRate,
normalizeEnterpriseResolution,
} from "./enterpriseVideoPolicy";
@@ -45,4 +46,40 @@ describe("enterpriseVideoPolicy", () => {
}),
).toBe(1);
});
it("uses server-provided pricing config before fallback pricing", () => {
const serverPricing: EnterpriseVideoPricingConfig = {
creditsPerCny: 100,
defaultResolution: "1080P",
rules: [
{
id: "happyhorse-server",
modelIncludes: ["happyhorse"],
rates: { "720P": 2, "1080P": 3 },
},
],
};
expect(
getEnterpriseVideoCreditRate(
{
model: "happyhorse-1.0",
resolution: "1080P",
durationSeconds: 5,
},
serverPricing,
),
).toBe(3);
expect(
calculateEnterpriseVideoCredits(
{
model: "happyhorse-1.0",
resolution: "1080P",
durationSeconds: 5,
},
serverPricing,
),
).toBe(1500);
});
});
+102 -33
View File
@@ -50,50 +50,119 @@ export interface EnterpriseVideoPricingInput {
hasReferenceVideo?: boolean;
}
export interface EnterpriseVideoPricingRule {
id: string;
modelIncludes: string[];
when?: {
muted?: boolean;
hasReferenceVideo?: boolean;
};
rates: Record<string, number>;
}
export interface EnterpriseVideoPricingConfig {
currency?: string;
creditsPerCny?: number;
billingUnit?: "per_second" | string;
defaultResolution?: string;
resolutions?: string[];
rules: EnterpriseVideoPricingRule[];
}
export const FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG: EnterpriseVideoPricingConfig = {
currency: "CNY",
creditsPerCny: CREDITS_PER_CNY,
billingUnit: "per_second",
defaultResolution: ENTERPRISE_DEFAULT_VIDEO_RESOLUTION,
resolutions: ["720P", "1080P"],
rules: [
{
id: "happyhorse",
modelIncludes: ["happyhorse"],
rates: { "720P": 0.72, "1080P": 1.28 },
},
{
id: "wanxiang-i2v",
modelIncludes: ["wan2.7-i2v", "wanxiang"],
rates: { "720P": 0.6, "1080P": 1 },
},
{
id: "wan-animate-s2v",
modelIncludes: ["animate-mix", "s2v"],
rates: { "720P": 0.6, "1080P": 1 },
},
{
id: "kling-muted-reference",
modelIncludes: ["kling"],
when: { muted: true, hasReferenceVideo: true },
rates: { "720P": 0.9, "1080P": 1.2 },
},
{
id: "kling-muted",
modelIncludes: ["kling"],
when: { muted: true, hasReferenceVideo: false },
rates: { "720P": 0.6, "1080P": 0.8 },
},
{
id: "kling-default",
modelIncludes: ["kling"],
rates: { "720P": 0.9, "1080P": 1.2 },
},
{
id: "vidu",
modelIncludes: ["vidu"],
rates: { "720P": 0.6, "1080P": 1 },
},
{
id: "pixverse",
modelIncludes: ["pixverse"],
rates: { "720P": 0.6, "1080P": 1 },
},
],
};
export function normalizeEnterpriseResolution(value: string): "720P" | "1080P" {
return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P";
}
export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput): number {
function enterpriseVideoPricingRuleMatches(
rule: EnterpriseVideoPricingRule,
input: EnterpriseVideoPricingInput,
model: string,
): boolean {
if (!rule.modelIncludes.some((pattern) => model.includes(String(pattern || "").toLowerCase()))) return false;
if (!rule.when) return true;
if ("muted" in rule.when && Boolean(input.muted) !== rule.when.muted) return false;
if ("hasReferenceVideo" in rule.when && Boolean(input.hasReferenceVideo) !== rule.when.hasReferenceVideo) {
return false;
}
return true;
}
export function getEnterpriseVideoCreditRate(
input: EnterpriseVideoPricingInput,
config: EnterpriseVideoPricingConfig = FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG,
): number {
const resolution = normalizeEnterpriseResolution(input.resolution);
const model = String(input.model || "").toLowerCase();
const fallbackResolution = normalizeEnterpriseResolution(
config.defaultResolution || ENTERPRISE_DEFAULT_VIDEO_RESOLUTION,
);
const rule = config.rules.find((candidate) => enterpriseVideoPricingRuleMatches(candidate, input, model));
if (model.includes("happyhorse")) {
return resolution === "720P" ? 0.72 : 1.28;
}
if (model.includes("wan2.7-i2v") || model.includes("wanxiang")) {
return resolution === "720P" ? 0.6 : 1;
}
if (model.includes("animate-mix")) {
return resolution === "720P" ? 0.6 : 1;
}
if (model.includes("s2v")) {
return resolution === "720P" ? 0.6 : 1;
}
if (model.includes("vidu")) {
return resolution === "720P" ? 0.6 : 1.0;
}
if (model.includes("pixverse")) {
return resolution === "720P" ? 0.6 : 1.0;
}
if (model.includes("kling")) {
if (input.muted) {
if (input.hasReferenceVideo) return resolution === "720P" ? 0.9 : 1.2;
return resolution === "720P" ? 0.6 : 0.8;
}
return resolution === "720P" ? 0.9 : 1.2;
if (rule) {
const rate = rule.rates[resolution] ?? rule.rates[fallbackResolution];
if (Number.isFinite(rate) && rate >= 0) return rate;
}
throw new Error(`Unsupported enterprise video model: ${input.model}`);
}
export function calculateEnterpriseVideoCredits(input: EnterpriseVideoPricingInput): number {
export function calculateEnterpriseVideoCredits(
input: EnterpriseVideoPricingInput,
config: EnterpriseVideoPricingConfig = FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG,
): number {
const duration = Math.max(1, Math.ceil(Number(input.durationSeconds) || 1));
return Number((getEnterpriseVideoCreditRate(input) * duration * CREDITS_PER_CNY).toFixed(2));
const creditsPerCny = Number(config.creditsPerCny || CREDITS_PER_CNY);
return Number((getEnterpriseVideoCreditRate(input, config) * duration * creditsPerCny).toFixed(2));
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "../test/testHarness";
import {
millsPerThousandTokensToCreditsPerMillion,
modelPriceToTextTokenCreditRate,
resolveTextTokenCreditRate,
} from "./modelPricing";
describe("modelPricing", () => {
it("converts backend mills per thousand tokens to credits per million tokens", () => {
expect(millsPerThousandTokensToCreditsPerMillion(27)).toBe(2_700);
expect(millsPerThousandTokensToCreditsPerMillion(108)).toBe(10_800);
});
it("converts a token model price row to a text token credit rate", () => {
expect(
modelPriceToTextTokenCreditRate({
modelKey: "gpt-4o",
inputPriceMills: 27,
outputPriceMills: 108,
flatPriceMills: null,
currency: "CNY",
enabled: true,
}),
).toEqual({
inputCreditsPerMillion: 2_700,
outputCreditsPerMillion: 10_800,
source: "server",
modelKey: "gpt-4o",
});
});
it("resolves token pricing by exact or fuzzy model key without accepting flat prices", () => {
const prices = [
{
modelKey: "gemini-3-pro-image",
inputPriceMills: null,
outputPriceMills: null,
flatPriceMills: 200,
currency: "CNY",
enabled: true,
},
{
modelKey: "gemini-3.1-pro",
inputPriceMills: 12,
outputPriceMills: 48,
flatPriceMills: null,
currency: "CNY",
enabled: true,
},
];
expect(resolveTextTokenCreditRate(prices, "gemini")).toEqual({
inputCreditsPerMillion: 1_200,
outputCreditsPerMillion: 4_800,
source: "server",
modelKey: "gemini-3.1-pro",
});
});
});
+104
View File
@@ -0,0 +1,104 @@
import type { PublicModelPrice } from "../api/publicPricingClient";
import type { TextTokenCreditRate } from "./taskLifecycle";
const TOKENS_PER_MILLION = 1_000_000;
const BACKEND_TOKEN_PRICE_UNIT = 1_000;
const CREDITS_PER_CNY = 100;
const MILLS_PER_CNY = 1_000;
const CREDITS_PER_MILL = CREDITS_PER_CNY / MILLS_PER_CNY;
function isUsablePrice(value: number | null | undefined): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function normalizeModelKey(value: string): string {
return value.trim().toLowerCase();
}
function compactModelKey(value: string): string {
return normalizeModelKey(value).replace(/[^a-z0-9]+/g, "");
}
function addCandidate(
candidates: PublicModelPrice[],
seen: Set<string>,
price: PublicModelPrice,
): void {
const key = normalizeModelKey(price.modelKey);
if (seen.has(key)) return;
seen.add(key);
candidates.push(price);
}
export function millsPerThousandTokensToCreditsPerMillion(
priceMills: number,
): number {
if (!isUsablePrice(priceMills)) return 0;
return (
priceMills *
(TOKENS_PER_MILLION / BACKEND_TOKEN_PRICE_UNIT) *
CREDITS_PER_MILL
);
}
export function modelPriceToTextTokenCreditRate(
price: PublicModelPrice,
): TextTokenCreditRate | null {
if (
!isUsablePrice(price.inputPriceMills) ||
!isUsablePrice(price.outputPriceMills)
)
return null;
return {
inputCreditsPerMillion: millsPerThousandTokensToCreditsPerMillion(
price.inputPriceMills,
),
outputCreditsPerMillion: millsPerThousandTokensToCreditsPerMillion(
price.outputPriceMills,
),
source: "server",
modelKey: price.modelKey,
};
}
export function resolveTextTokenCreditRate(
prices: PublicModelPrice[],
modelKey: string | null | undefined,
): TextTokenCreditRate | null {
const normalizedTarget = normalizeModelKey(modelKey || "");
if (!normalizedTarget) return null;
const compactTarget = compactModelKey(normalizedTarget);
const candidates: PublicModelPrice[] = [];
const seen = new Set<string>();
for (const price of prices) {
if (normalizeModelKey(price.modelKey) === normalizedTarget) {
addCandidate(candidates, seen, price);
}
}
for (const price of prices) {
if (compactModelKey(price.modelKey) === compactTarget) {
addCandidate(candidates, seen, price);
}
}
for (const price of prices) {
const compactPriceKey = compactModelKey(price.modelKey);
if (
compactPriceKey.includes(compactTarget) ||
compactTarget.includes(compactPriceKey)
) {
addCandidate(candidates, seen, price);
}
}
for (const price of candidates) {
const rate = modelPriceToTextTokenCreditRate(price);
if (rate) return rate;
}
return null;
}
+30 -1
View File
@@ -4,12 +4,13 @@ import {
TEXT_INPUT_CREDITS_PER_MILLION,
TEXT_OUTPUT_CREDITS_PER_MILLION,
estimateTextTokenCredits,
formatTextTokenCreditRule,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "./taskLifecycle";
describe("taskLifecycle", () => {
it("keeps text token billing at 1 CNY to 100 credits", () => {
it("keeps fallback text token billing at 1 CNY to 100 credits", () => {
expect(TEXT_INPUT_CREDITS_PER_MILLION).toBe(200);
expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500);
expect(
@@ -20,6 +21,23 @@ describe("taskLifecycle", () => {
).toBe(700);
});
it("estimates text billing from dynamic server pricing rates", () => {
expect(
estimateTextTokenCredits(
{
promptTokens: 1_000_000,
completionTokens: 1_000_000,
},
{
inputCreditsPerMillion: 2_700,
outputCreditsPerMillion: 10_800,
source: "server",
modelKey: "gpt-4o",
},
),
).toBe(13_500);
});
it("ignores negative token counts when estimating text billing", () => {
expect(
estimateTextTokenCredits({
@@ -29,6 +47,17 @@ describe("taskLifecycle", () => {
).toBe(250);
});
it("formats text billing rules from the selected rate", () => {
expect(
formatTextTokenCreditRule({
inputCreditsPerMillion: 2_700,
outputCreditsPerMillion: 10_800,
}),
).toBe(
"输入 Token 每百万 2,700 积分,输出 Token 每百万 10,800 积分,实际以服务端结算为准。",
);
});
it("marks unstarted tasks locally timed out after submit timeout", () => {
const policy = getTaskTimeoutPolicy({ kind: "image" });
+45 -8
View File
@@ -32,11 +32,24 @@ export interface TextTokenUsage {
totalTokens?: number;
}
export interface TextTokenCreditRate {
inputCreditsPerMillion: number;
outputCreditsPerMillion: number;
source?: "server" | "fallback";
modelKey?: string;
}
const CREDITS_PER_CNY = 100;
export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5 * CREDITS_PER_CNY;
export const FALLBACK_TEXT_TOKEN_CREDIT_RATE: TextTokenCreditRate = {
inputCreditsPerMillion: TEXT_INPUT_CREDITS_PER_MILLION,
outputCreditsPerMillion: TEXT_OUTPUT_CREDITS_PER_MILLION,
source: "fallback",
};
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 90_000,
noProgressTimeoutMs: 120_000,
@@ -145,18 +158,42 @@ export function getRefundHint(status: TaskRefundStatus): string {
}
}
export function estimateTextTokenCredits(usage: TextTokenUsage): number {
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
return (promptTokens / 1_000_000) * TEXT_INPUT_CREDITS_PER_MILLION +
(completionTokens / 1_000_000) * TEXT_OUTPUT_CREDITS_PER_MILLION;
function sanitizeCreditRate(value: number): number {
return Number.isFinite(value) && value >= 0 ? value : 0;
}
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
const rule = "文本计费规则:输入 Token 每百万 200 积分,输出 Token 每百万 500 积分,实际以服务端结算为准。";
function formatCreditRate(value: number): string {
const safeValue = sanitizeCreditRate(value);
if (safeValue >= 100) return Math.round(safeValue).toLocaleString("zh-CN");
return Number(safeValue.toFixed(4)).toString();
}
export function formatTextTokenCreditRule(
rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE,
): string {
return `输入 Token 每百万 ${formatCreditRate(rate.inputCreditsPerMillion)} 积分,输出 Token 每百万 ${formatCreditRate(rate.outputCreditsPerMillion)} 积分,实际以服务端结算为准。`;
}
export function estimateTextTokenCredits(
usage: TextTokenUsage,
rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE,
): number {
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
return (
(promptTokens / 1_000_000) * sanitizeCreditRate(rate.inputCreditsPerMillion) +
(completionTokens / 1_000_000) * sanitizeCreditRate(rate.outputCreditsPerMillion)
);
}
export function formatTextTokenUsage(
usage?: TextTokenUsage | null,
rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE,
): string {
const rule = `文本计费规则:${formatTextTokenCreditRule(rate)}`;
if (!usage) return rule;
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens });
const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens }, rate);
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
}