Compare commits
11 Commits
a6626beb32
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 45fe601e17 | |||
| 9d9c3ce186 | |||
| 228e89cfb6 | |||
| 0fbb5372d5 | |||
| aa5ba96764 | |||
| ba2e7cfda2 | |||
| e9601a651c | |||
| 82bd939e26 | |||
| 9e080bbb8f | |||
| d28889fd0c | |||
| bfb70bab26 |
@@ -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.
|
||||||
@@ -150,6 +150,10 @@ export interface AiTaskStatus {
|
|||||||
type: "image" | "video";
|
type: "image" | "video";
|
||||||
status: "pending" | "running" | "completed" | "failed" | "cancelled";
|
status: "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||||
progress: number;
|
progress: number;
|
||||||
|
progressSource?: "real" | "estimated" | string | null;
|
||||||
|
stage?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
resultUrl: string | null;
|
resultUrl: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
@@ -514,7 +518,20 @@ export const aiGenerationClient = {
|
|||||||
|
|
||||||
subscribeTaskStatus(
|
subscribeTaskStatus(
|
||||||
taskId: string,
|
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 {
|
): () => void {
|
||||||
const url = buildApiUrl(`ai/tasks/${taskId}/stream`);
|
const url = buildApiUrl(`ai/tasks/${taskId}/stream`);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -9,6 +9,10 @@ export interface TaskProgressEvent {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
status: string;
|
status: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
progressSource?: "real" | "estimated" | string | null;
|
||||||
|
stage?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
resultUrl?: string | null;
|
resultUrl?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
@@ -37,7 +41,8 @@ export function waitForTask(
|
|||||||
operation: options.operation,
|
operation: options.operation,
|
||||||
});
|
});
|
||||||
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
|
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
|
||||||
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
|
const noProgressTimeoutMs =
|
||||||
|
options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
|
||||||
const startedAt = options.startedAt ?? Date.now();
|
const startedAt = options.startedAt ?? Date.now();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -58,7 +63,10 @@ export function waitForTask(
|
|||||||
};
|
};
|
||||||
|
|
||||||
timeoutId = setTimeout(
|
timeoutId = setTimeout(
|
||||||
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
|
() =>
|
||||||
|
settle(() =>
|
||||||
|
reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))),
|
||||||
|
),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,7 +113,11 @@ export function waitForTask(
|
|||||||
policy: { ...timeoutPolicy, noProgressTimeoutMs },
|
policy: { ...timeoutPolicy, noProgressTimeoutMs },
|
||||||
});
|
});
|
||||||
if (timeoutReason) {
|
if (timeoutReason) {
|
||||||
settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))));
|
settle(() =>
|
||||||
|
reject(
|
||||||
|
new Error(buildLocalTimeoutMessage(options.kind || "video")),
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -114,6 +126,10 @@ export function waitForTask(
|
|||||||
taskId,
|
taskId,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
progress: task.progress || 0,
|
progress: task.progress || 0,
|
||||||
|
progressSource: task.progressSource,
|
||||||
|
stage: task.stage,
|
||||||
|
startedAt: task.startedAt,
|
||||||
|
expectedDurationMs: task.expectedDurationMs,
|
||||||
resultUrl: task.resultUrl,
|
resultUrl: task.resultUrl,
|
||||||
error: task.error,
|
error: task.error,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ import { communityClient } from "../../api/communityClient";
|
|||||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
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 { WebCanvasWorkflow } from "../../types";
|
||||||
import type { AssetLibraryCategory } from "../assets/localAssetStore";
|
import type { AssetLibraryCategory } from "../assets/localAssetStore";
|
||||||
import {
|
import {
|
||||||
@@ -371,12 +375,19 @@ function CanvasPage({
|
|||||||
const textNodeIdRef = useRef(9);
|
const textNodeIdRef = useRef(9);
|
||||||
const imageNodeIdRef = useRef(1);
|
const imageNodeIdRef = useRef(1);
|
||||||
const videoNodeIdRef = 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 { pushSnapshot, undo, redo } = useCanvasHistory();
|
||||||
const {
|
const {
|
||||||
textGenerationState, imageGenerationState, videoGenerationState,
|
textGenerationState, imageGenerationState, videoGenerationState,
|
||||||
generationToast, setGenerationToast,
|
generationToast, setGenerationToast,
|
||||||
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
|
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
|
||||||
|
imageGenerationAbortRef, videoGenerationAbortRef,
|
||||||
canvasGenKeepaliveRestoredRef,
|
canvasGenKeepaliveRestoredRef,
|
||||||
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
|
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
|
||||||
restoreKeepaliveTasks, resetGenerationState,
|
restoreKeepaliveTasks, resetGenerationState,
|
||||||
@@ -527,6 +538,7 @@ function CanvasPage({
|
|||||||
const autoSaveStatusTimerRef = useRef<number | null>(null);
|
const autoSaveStatusTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const objectUrls = objectUrlsRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
|
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
|
||||||
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
||||||
@@ -535,6 +547,8 @@ function CanvasPage({
|
|||||||
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
|
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
|
||||||
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
|
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
|
||||||
}
|
}
|
||||||
|
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
objectUrls.clear();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1691,12 +1705,15 @@ function CanvasPage({
|
|||||||
const quality = resolveImageQuality(model, imageNode.imageSize || "");
|
const quality = resolveImageQuality(model, imageNode.imageSize || "");
|
||||||
|
|
||||||
imageGenerationInFlightRef.current.add(nodeId);
|
imageGenerationInFlightRef.current.add(nodeId);
|
||||||
|
const abortRef = { current: false };
|
||||||
|
imageGenerationAbortRef.current.set(nodeId, abortRef);
|
||||||
setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 });
|
setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 });
|
||||||
setGenerationToast("图片正在生成");
|
setGenerationToast("图片正在生成");
|
||||||
|
|
||||||
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
|
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
|
||||||
try {
|
try {
|
||||||
const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode);
|
const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode);
|
||||||
|
if (abortRef.current) return;
|
||||||
const taskInput: CreatePreviewTaskInput = {
|
const taskInput: CreatePreviewTaskInput = {
|
||||||
title: imageNode.title || "图片节点生成",
|
title: imageNode.title || "图片节点生成",
|
||||||
type: "image",
|
type: "image",
|
||||||
@@ -1732,7 +1749,8 @@ function CanvasPage({
|
|||||||
? "图片生成完成"
|
? "图片生成完成"
|
||||||
: "图片生成失败";
|
: "图片生成失败";
|
||||||
setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
|
setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
|
||||||
}));
|
}, abortRef));
|
||||||
|
if (abortRef.current || !outputUrl) return;
|
||||||
setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 });
|
setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 });
|
||||||
removeCanvasGenKeepalive(task.id);
|
removeCanvasGenKeepalive(task.id);
|
||||||
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
|
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
|
||||||
@@ -1794,13 +1812,15 @@ function CanvasPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (abortRef.current) return;
|
||||||
setImageGenerationStatus(nodeId, {
|
setImageGenerationStatus(nodeId, {
|
||||||
status: "error",
|
status: "error",
|
||||||
message: error instanceof Error ? error.message : "图片生成失败",
|
message: error instanceof Error ? error.message : "图片生成失败",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
imageGenerationInFlightRef.current.delete(nodeId);
|
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;
|
const duration = Number(videoNode.duration) || 4;
|
||||||
|
|
||||||
videoGenerationInFlightRef.current.add(nodeId);
|
videoGenerationInFlightRef.current.add(nodeId);
|
||||||
|
const abortRef = { current: false };
|
||||||
|
videoGenerationAbortRef.current.set(nodeId, abortRef);
|
||||||
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
|
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
|
||||||
setGenerationToast("视频正在生成");
|
setGenerationToast("视频正在生成");
|
||||||
|
|
||||||
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
|
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
|
||||||
try {
|
try {
|
||||||
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
|
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
|
||||||
|
if (abortRef.current) return;
|
||||||
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
|
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
|
||||||
throw new Error("图生视频需要先连接至少一个可用的图片节点");
|
throw new Error("图生视频需要先连接至少一个可用的图片节点");
|
||||||
}
|
}
|
||||||
@@ -1892,7 +1915,8 @@ function CanvasPage({
|
|||||||
? "视频生成完成"
|
? "视频生成完成"
|
||||||
: "视频生成失败";
|
: "视频生成失败";
|
||||||
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
|
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
|
||||||
}));
|
}, abortRef));
|
||||||
|
if (abortRef.current || !outputUrl) return;
|
||||||
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
|
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
|
||||||
removeCanvasGenKeepalive(taskId);
|
removeCanvasGenKeepalive(taskId);
|
||||||
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
|
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
|
||||||
@@ -1948,13 +1972,15 @@ function CanvasPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (abortRef.current) return;
|
||||||
setVideoGenerationStatus(nodeId, {
|
setVideoGenerationStatus(nodeId, {
|
||||||
status: "error",
|
status: "error",
|
||||||
message: error instanceof Error ? error.message : "视频生成失败",
|
message: error instanceof Error ? error.message : "视频生成失败",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
videoGenerationInFlightRef.current.delete(nodeId);
|
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];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const imageUrl = URL.createObjectURL(file);
|
const imageUrl = trackObjectUrl(file);
|
||||||
if (pendingImageToImageNodeId) {
|
if (pendingImageToImageNodeId) {
|
||||||
const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId);
|
const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId);
|
||||||
if (sourceNode) {
|
if (sourceNode) {
|
||||||
@@ -2047,7 +2073,7 @@ function CanvasPage({
|
|||||||
let offsetX = 0;
|
let offsetX = 0;
|
||||||
let offsetY = 0;
|
let offsetY = 0;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const imageUrl = URL.createObjectURL(file);
|
const imageUrl = trackObjectUrl(file);
|
||||||
addImageNode(imageUrl, file.name, {
|
addImageNode(imageUrl, file.name, {
|
||||||
x: dropPosition.x + offsetX,
|
x: dropPosition.x + offsetX,
|
||||||
y: dropPosition.y + offsetY,
|
y: dropPosition.y + offsetY,
|
||||||
@@ -2103,7 +2129,7 @@ function CanvasPage({
|
|||||||
let offsetX = 0;
|
let offsetX = 0;
|
||||||
let offsetY = 0;
|
let offsetY = 0;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const imageUrl = URL.createObjectURL(file);
|
const imageUrl = trackObjectUrl(file);
|
||||||
addImageNode(imageUrl, file.name, {
|
addImageNode(imageUrl, file.name, {
|
||||||
x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX,
|
x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX,
|
||||||
y: sourceNode.position.y + offsetY,
|
y: sourceNode.position.y + offsetY,
|
||||||
@@ -4470,6 +4496,7 @@ function CanvasPage({
|
|||||||
progress={imageNodeProgress}
|
progress={imageNodeProgress}
|
||||||
status={imageTaskState?.status || "running"}
|
status={imageTaskState?.status || "running"}
|
||||||
message={imageTaskState?.message || "图片生成中"}
|
message={imageTaskState?.message || "图片生成中"}
|
||||||
|
expectedDurationMs={DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{imageNodeFocusActive && imageFocusSelectionReady ? (
|
{imageNodeFocusActive && imageFocusSelectionReady ? (
|
||||||
@@ -4844,6 +4871,7 @@ function CanvasPage({
|
|||||||
progress={videoNodeProgress}
|
progress={videoNodeProgress}
|
||||||
status={videoTaskState?.status || "running"}
|
status={videoTaskState?.status || "running"}
|
||||||
message={videoTaskState?.message || "视频生成中"}
|
message={videoTaskState?.message || "视频生成中"}
|
||||||
|
expectedDurationMs={DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")}
|
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")}
|
||||||
@@ -5279,7 +5307,7 @@ function CanvasPage({
|
|||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setAssetCoverUrl(URL.createObjectURL(file));
|
setAssetCoverUrl(trackObjectUrl(file));
|
||||||
setCoverSourceOpen(false);
|
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";
|
import { canvasGenerationProgressStyle } from "./canvasUtils";
|
||||||
|
|
||||||
type NodeGenStatus = "submitting" | "running" | "success" | "error";
|
type NodeGenStatus = "submitting" | "running" | "success" | "error";
|
||||||
@@ -7,10 +11,24 @@ interface CanvasSmoothedProgressRingProps {
|
|||||||
progress: number;
|
progress: number;
|
||||||
status: NodeGenStatus;
|
status: NodeGenStatus;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
progressSource?: ProgressSource;
|
||||||
|
startedAt?: number | string | Date | null;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasSmoothedProgressRing({ progress, status, message }: CanvasSmoothedProgressRingProps) {
|
export function CanvasSmoothedProgressRing({
|
||||||
const smoothed = useSmoothedProgress(progress, status);
|
progress,
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
progressSource = "estimated",
|
||||||
|
startedAt,
|
||||||
|
expectedDurationMs = DEFAULT_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
}: CanvasSmoothedProgressRingProps) {
|
||||||
|
const smoothed = useSmoothedProgress(progress, status, {
|
||||||
|
progressSource,
|
||||||
|
startedAt,
|
||||||
|
expectedDurationMs,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="studio-canvas-node-generation-progress"
|
className="studio-canvas-node-generation-progress"
|
||||||
@@ -18,7 +36,10 @@ export function CanvasSmoothedProgressRing({ progress, status, message }: Canvas
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
style={canvasGenerationProgressStyle(smoothed)}
|
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>
|
<strong>{message}</strong>
|
||||||
<em>{smoothed}%</em>
|
<em>{smoothed}%</em>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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, {
|
const resultUrl = await waitForTask(taskId, {
|
||||||
kind: "image",
|
kind: "image",
|
||||||
|
abortRef,
|
||||||
onProgress: (e) => {
|
onProgress: (e) => {
|
||||||
if (onStatus) {
|
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("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
||||||
return resultUrl;
|
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, {
|
const resultUrl = await waitForTask(taskId, {
|
||||||
kind: "video",
|
kind: "video",
|
||||||
|
abortRef,
|
||||||
onProgress: (e) => {
|
onProgress: (e) => {
|
||||||
if (onStatus) {
|
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("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
||||||
return resultUrl;
|
return resultUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
import type {
|
||||||
CanvasImageGenerationState,
|
CanvasImageGenerationState,
|
||||||
CanvasImageNode,
|
CanvasImageNode,
|
||||||
@@ -66,6 +66,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
const videoGenerationInFlightRef = useRef(new Set<string>());
|
const videoGenerationInFlightRef = useRef(new Set<string>());
|
||||||
const textGenerationInFlightRef = useRef(new Set<string>());
|
const textGenerationInFlightRef = useRef(new Set<string>());
|
||||||
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
|
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 canvasGenKeepaliveRestoredRef = useRef(false);
|
||||||
|
|
||||||
const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => {
|
const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => {
|
||||||
@@ -80,6 +82,15 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
setVideoGenerationState((current) => ({ ...current, [nodeId]: state }));
|
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
|
// Toast auto-dismiss
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!generationToast) return undefined;
|
if (!generationToast) return undefined;
|
||||||
@@ -103,11 +114,14 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
}
|
}
|
||||||
if (entry.nodeKind === "image") {
|
if (entry.nodeKind === "image") {
|
||||||
imageGenerationInFlightRef.current.add(entry.nodeId);
|
imageGenerationInFlightRef.current.add(entry.nodeId);
|
||||||
|
const abortRef = { current: false };
|
||||||
|
imageGenerationAbortRef.current.set(entry.nodeId, abortRef);
|
||||||
setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 });
|
setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 });
|
||||||
void waitForImageTaskResult(entry.taskId, (status) => {
|
void waitForImageTaskResult(entry.taskId, (status) => {
|
||||||
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
||||||
setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress });
|
setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress });
|
||||||
}).then(async (outputUrl) => {
|
}, abortRef).then(async (outputUrl) => {
|
||||||
|
if (abortRef.current || !outputUrl) return;
|
||||||
removeCanvasGenKeepalive(entry.taskId);
|
removeCanvasGenKeepalive(entry.taskId);
|
||||||
setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
|
setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
|
||||||
const ref = createCanvasAssetRefFromGeneratedResult({
|
const ref = createCanvasAssetRefFromGeneratedResult({
|
||||||
@@ -128,18 +142,23 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
|
if (abortRef.current) return;
|
||||||
removeCanvasGenKeepalive(entry.taskId);
|
removeCanvasGenKeepalive(entry.taskId);
|
||||||
setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" });
|
setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" });
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
imageGenerationInFlightRef.current.delete(entry.nodeId);
|
imageGenerationInFlightRef.current.delete(entry.nodeId);
|
||||||
|
imageGenerationAbortRef.current.delete(entry.nodeId);
|
||||||
});
|
});
|
||||||
} else if (entry.nodeKind === "video") {
|
} else if (entry.nodeKind === "video") {
|
||||||
videoGenerationInFlightRef.current.add(entry.nodeId);
|
videoGenerationInFlightRef.current.add(entry.nodeId);
|
||||||
|
const abortRef = { current: false };
|
||||||
|
videoGenerationAbortRef.current.set(entry.nodeId, abortRef);
|
||||||
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
|
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
|
||||||
void waitForVideoTaskResult(entry.taskId, (status) => {
|
void waitForVideoTaskResult(entry.taskId, (status) => {
|
||||||
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
||||||
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress });
|
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress });
|
||||||
}).then(async (outputUrl) => {
|
}, abortRef).then(async (outputUrl) => {
|
||||||
|
if (abortRef.current || !outputUrl) return;
|
||||||
removeCanvasGenKeepalive(entry.taskId);
|
removeCanvasGenKeepalive(entry.taskId);
|
||||||
setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
|
setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
|
||||||
const ref = createCanvasAssetRefFromGeneratedResult({
|
const ref = createCanvasAssetRefFromGeneratedResult({
|
||||||
@@ -160,18 +179,19 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
|
if (abortRef.current) return;
|
||||||
removeCanvasGenKeepalive(entry.taskId);
|
removeCanvasGenKeepalive(entry.taskId);
|
||||||
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
|
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
videoGenerationInFlightRef.current.delete(entry.nodeId);
|
videoGenerationInFlightRef.current.delete(entry.nodeId);
|
||||||
|
videoGenerationAbortRef.current.delete(entry.nodeId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetGenerationState = () => {
|
const resetGenerationState = () => {
|
||||||
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
|
abortAllGenerationPollers();
|
||||||
textGenerationAbortControllersRef.current.clear();
|
|
||||||
textGenerationInFlightRef.current.clear();
|
textGenerationInFlightRef.current.clear();
|
||||||
imageGenerationInFlightRef.current.clear();
|
imageGenerationInFlightRef.current.clear();
|
||||||
videoGenerationInFlightRef.current.clear();
|
videoGenerationInFlightRef.current.clear();
|
||||||
@@ -180,11 +200,18 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
setVideoGenerationState({});
|
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(() => {
|
useEffect(() => {
|
||||||
const handlePageHide = () => {
|
const handlePageHide = () => {
|
||||||
cancelCanvasGenKeepaliveOnUnload();
|
cancelCanvasGenKeepaliveOnUnload();
|
||||||
textGenerationAbortControllersRef.current.forEach((controller) => controller.abort());
|
abortAllGenerationPollers();
|
||||||
textGenerationAbortControllersRef.current.clear();
|
|
||||||
textGenerationInFlightRef.current.clear();
|
textGenerationInFlightRef.current.clear();
|
||||||
imageGenerationInFlightRef.current.clear();
|
imageGenerationInFlightRef.current.clear();
|
||||||
videoGenerationInFlightRef.current.clear();
|
videoGenerationInFlightRef.current.clear();
|
||||||
@@ -202,7 +229,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
window.removeEventListener("pagehide", handlePageHide);
|
window.removeEventListener("pagehide", handlePageHide);
|
||||||
window.removeEventListener("online", handleOnline);
|
window.removeEventListener("online", handleOnline);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [abortAllGenerationPollers]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
textGenerationState,
|
textGenerationState,
|
||||||
@@ -214,6 +241,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
videoGenerationInFlightRef,
|
videoGenerationInFlightRef,
|
||||||
textGenerationInFlightRef,
|
textGenerationInFlightRef,
|
||||||
textGenerationAbortControllersRef,
|
textGenerationAbortControllersRef,
|
||||||
|
imageGenerationAbortRef,
|
||||||
|
videoGenerationAbortRef,
|
||||||
canvasGenKeepaliveRestoredRef,
|
canvasGenKeepaliveRestoredRef,
|
||||||
setTextGenerationStatus,
|
setTextGenerationStatus,
|
||||||
setImageGenerationStatus,
|
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 {
|
interface EcommerceProgressBarProps {
|
||||||
status: "idle" | "generating" | "done" | "failed" | string;
|
status: "idle" | "generating" | "done" | "failed" | string;
|
||||||
@@ -12,17 +17,40 @@ function mapStatus(status: string): "running" | "completed" | "failed" {
|
|||||||
return "running";
|
return "running";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EcommerceProgressBar({ status, label }: EcommerceProgressBarProps) {
|
export function EcommerceProgressBar({
|
||||||
const progress = mapStatus(status) === "running" ? 50 : 100;
|
status,
|
||||||
const smoothed = useSmoothedProgress(progress, mapStatus(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;
|
if (status === "idle") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ecommerce-progress-bar">
|
<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__track">
|
||||||
<div className="ecommerce-progress-bar__fill" style={{ width: `${smoothed}%` }} />
|
<div
|
||||||
|
className="ecommerce-progress-bar__fill"
|
||||||
|
style={{ width: `${smoothed}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="ecommerce-progress-bar__value">{smoothed}%</span>
|
<span className="ecommerce-progress-bar__value">{smoothed}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
type MessageStatus = "thinking" | "completed" | "failed" | string;
|
||||||
|
|
||||||
@@ -6,6 +10,9 @@ interface SmoothedProgressBarProps {
|
|||||||
progress: number;
|
progress: number;
|
||||||
status: MessageStatus;
|
status: MessageStatus;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
progressSource?: ProgressSource;
|
||||||
|
startedAt?: number | string | Date | null;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapMessageStatus(status: MessageStatus) {
|
function mapMessageStatus(status: MessageStatus) {
|
||||||
@@ -15,8 +22,19 @@ function mapMessageStatus(status: MessageStatus) {
|
|||||||
return "running" as const;
|
return "running" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SmoothedProgressBar({ progress, status, label }: SmoothedProgressBarProps) {
|
export function SmoothedProgressBar({
|
||||||
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status));
|
progress,
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
progressSource = "estimated",
|
||||||
|
startedAt,
|
||||||
|
expectedDurationMs = DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
|
||||||
|
}: SmoothedProgressBarProps) {
|
||||||
|
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status), {
|
||||||
|
progressSource,
|
||||||
|
startedAt,
|
||||||
|
expectedDurationMs,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span>{label || "超分处理中..."}</span>
|
<span>{label || "超分处理中..."}</span>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
|||||||
|
|
||||||
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
||||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||||
|
import { publicPricingClient, type PublicModelPrice } from "../../api/publicPricingClient";
|
||||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||||
import type { WebProjectSummary } from "../../types";
|
import type { WebProjectSummary } from "../../types";
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +59,8 @@ import {
|
|||||||
import { translateTaskError } from "../../utils/translateTaskError";
|
import { translateTaskError } from "../../utils/translateTaskError";
|
||||||
import {
|
import {
|
||||||
buildLocalTimeoutMessage,
|
buildLocalTimeoutMessage,
|
||||||
|
FALLBACK_TEXT_TOKEN_CREDIT_RATE,
|
||||||
|
formatTextTokenCreditRule,
|
||||||
getTaskTimeoutPolicy,
|
getTaskTimeoutPolicy,
|
||||||
isTaskLocallyTimedOut,
|
isTaskLocallyTimedOut,
|
||||||
} from "../../utils/taskLifecycle";
|
} from "../../utils/taskLifecycle";
|
||||||
@@ -69,7 +72,12 @@ import {
|
|||||||
import { isViduModel } from "../../utils/viduRouting";
|
import { isViduModel } from "../../utils/viduRouting";
|
||||||
import { isPixverseModel } from "../../utils/pixverseRouting";
|
import { isPixverseModel } from "../../utils/pixverseRouting";
|
||||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
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 {
|
import {
|
||||||
getImageQualityOptionsForContext,
|
getImageQualityOptionsForContext,
|
||||||
getDefaultImageQuality,
|
getDefaultImageQuality,
|
||||||
@@ -404,9 +412,32 @@ function WorkbenchPage({
|
|||||||
const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value));
|
const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value));
|
||||||
|
|
||||||
const [chatModel, setChatModel] = useState(CHAT_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 [thinkingSpeed, setThinkingSpeed] = useState(THINKING_SPEED_OPTIONS[0].value);
|
||||||
const [thinkingDepth, setThinkingDepth] = useState(THINKING_DEPTH_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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -525,6 +556,10 @@ function WorkbenchPage({
|
|||||||
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
|
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
|
||||||
|
|
||||||
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
|
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
|
||||||
|
const selectedChatTokenRate = useMemo(
|
||||||
|
() => resolveTextTokenCreditRate(modelPrices, chatModel) || FALLBACK_TEXT_TOKEN_CREDIT_RATE,
|
||||||
|
[chatModel, modelPrices],
|
||||||
|
);
|
||||||
const billingEstimate = useMemo(() => {
|
const billingEstimate = useMemo(() => {
|
||||||
if (activeMode === "image") {
|
if (activeMode === "image") {
|
||||||
return {
|
return {
|
||||||
@@ -541,7 +576,7 @@ function WorkbenchPage({
|
|||||||
durationSeconds,
|
durationSeconds,
|
||||||
muted: false,
|
muted: false,
|
||||||
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
|
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
|
||||||
});
|
}, enterpriseVideoPricing || undefined);
|
||||||
return {
|
return {
|
||||||
label: `预计 ${formatCreditValue(credits)} 积分`,
|
label: `预计 ${formatCreditValue(credits)} 积分`,
|
||||||
title: `${activeModel},${videoQualityLabel},${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
|
title: `${activeModel},${videoQualityLabel},${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
|
||||||
@@ -553,16 +588,20 @@ function WorkbenchPage({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const textBillingPrefix =
|
||||||
|
selectedChatTokenRate.source === "server" ? "文本计费" : "服务端价格暂不可用,按默认预估";
|
||||||
return {
|
return {
|
||||||
label: "按 Token 结算",
|
label: "按 Token 结算",
|
||||||
title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分",
|
title: `${textBillingPrefix}:${activeModel},${formatTextTokenCreditRule(selectedChatTokenRate)}`,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeMode,
|
activeMode,
|
||||||
activeModel,
|
activeModel,
|
||||||
activeModelValue,
|
activeModelValue,
|
||||||
imageSettingsSummary,
|
imageSettingsSummary,
|
||||||
|
enterpriseVideoPricing,
|
||||||
referenceItems,
|
referenceItems,
|
||||||
|
selectedChatTokenRate,
|
||||||
videoDuration,
|
videoDuration,
|
||||||
videoQuality,
|
videoQuality,
|
||||||
videoQualityLabel,
|
videoQualityLabel,
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import { assetClient } from "../../../api/assetClient";
|
|||||||
import { communityClient } from "../../../api/communityClient";
|
import { communityClient } from "../../../api/communityClient";
|
||||||
import { saveAssetToLocalLibrary } from "../../assets/localAssetStore";
|
import { saveAssetToLocalLibrary } from "../../assets/localAssetStore";
|
||||||
import { SmoothedProgressBar } from "../SmoothedProgressBar";
|
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 { renderMarkdownBlocks } from "../markdownRenderer";
|
||||||
import { downloadResultAsset } from "../workbenchDownload";
|
import { downloadResultAsset } from "../workbenchDownload";
|
||||||
import type { WorkbenchChatAttachment, WorkbenchChatMessage, WorkbenchResultActionPayload } from "../workbenchChatTypes";
|
import type { WorkbenchChatAttachment, WorkbenchChatMessage, WorkbenchResultActionPayload } from "../workbenchChatTypes";
|
||||||
@@ -456,6 +462,8 @@ export const ResultCard = memo(function ResultCard({
|
|||||||
progress={message.taskProgress ?? 18}
|
progress={message.taskProgress ?? 18}
|
||||||
status={message.status || "thinking"}
|
status={message.status || "thinking"}
|
||||||
label={message.taskStatusLabel || "超分处理中..."}
|
label={message.taskStatusLabel || "超分处理中..."}
|
||||||
|
progressSource="estimated"
|
||||||
|
expectedDurationMs={DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -575,7 +583,23 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
|
|||||||
const specs = message.result?.specs || [];
|
const specs = message.result?.specs || [];
|
||||||
const prompt = message.prompt || message.body;
|
const prompt = message.prompt || message.body;
|
||||||
const isVideo = message.mode === "video";
|
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 (
|
return (
|
||||||
<div className={`ai-generation-pending-card${isVideo ? " is-video" : " is-image"}`}>
|
<div className={`ai-generation-pending-card${isVideo ? " is-video" : " is-image"}`}>
|
||||||
@@ -590,7 +614,7 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="ai-generation-pending-card__meta">
|
<div className="ai-generation-pending-card__meta">
|
||||||
<div>
|
<div>
|
||||||
<strong>{message.taskStatusLabel || "Generating..."}</strong>
|
<strong>{remainingLabel ? `${statusLabel} / ${remainingLabel}` : statusLabel}</strong>
|
||||||
<span>{prompt}</span>
|
<span>{prompt}</span>
|
||||||
</div>
|
</div>
|
||||||
{specs.length > 0 && (
|
{specs.length > 0 && (
|
||||||
|
|||||||
@@ -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 分钟");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 {
|
interface SmoothedProgressOptions {
|
||||||
creepSpeed?: number;
|
creepSpeed?: number;
|
||||||
@@ -8,6 +16,11 @@ interface SmoothedProgressOptions {
|
|||||||
creepCeiling?: number;
|
creepCeiling?: number;
|
||||||
creepAhead?: number;
|
creepAhead?: number;
|
||||||
completionDuration?: number;
|
completionDuration?: number;
|
||||||
|
progressSource?: ProgressSource;
|
||||||
|
startedAt?: number | string | Date | null;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
|
estimatedFloor?: number;
|
||||||
|
estimatedCeiling?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CREEP_SPEED = 0.5;
|
const DEFAULT_CREEP_SPEED = 0.5;
|
||||||
@@ -15,15 +28,109 @@ const DEFAULT_CHASE_RATE = 0.09;
|
|||||||
const DEFAULT_CREEP_CEILING = 97;
|
const DEFAULT_CREEP_CEILING = 97;
|
||||||
const DEFAULT_CREEP_AHEAD = 25;
|
const DEFAULT_CREEP_AHEAD = 25;
|
||||||
const DEFAULT_COMPLETION_DURATION = 450;
|
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 {
|
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 {
|
function isSuccess(status: ProgressStatus): boolean {
|
||||||
return status === "completed" || status === "success";
|
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.
|
* Smoothly interpolates between coarse server-reported progress values.
|
||||||
* On completion, animates quickly to 100% instead of jumping.
|
* On completion, animates quickly to 100% instead of jumping.
|
||||||
@@ -37,24 +144,59 @@ export function useSmoothedProgress(
|
|||||||
const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE;
|
const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE;
|
||||||
const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING;
|
const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING;
|
||||||
const creepAhead = options?.creepAhead ?? DEFAULT_CREEP_AHEAD;
|
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 [displayed, setDisplayed] = useState(0);
|
||||||
const rafRef = useRef(0);
|
const rafRef = useRef(0);
|
||||||
const targetRef = useRef(realProgress);
|
const targetRef = useRef(realProgress);
|
||||||
const statusRef = useRef(status);
|
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 completionStartRef = useRef<number | null>(null);
|
||||||
const completionBaseRef = useRef(0);
|
const completionBaseRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
targetRef.current = realProgress;
|
targetRef.current = realProgress;
|
||||||
statusRef.current = status;
|
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) {
|
if (isSuccess(status) && completionStartRef.current === null) {
|
||||||
completionStartRef.current = performance.now();
|
completionStartRef.current = performance.now();
|
||||||
completionBaseRef.current = displayed;
|
completionBaseRef.current = displayed;
|
||||||
|
} else if (!isSuccess(status)) {
|
||||||
|
completionStartRef.current = null;
|
||||||
}
|
}
|
||||||
}, [realProgress, status]);
|
}, [
|
||||||
|
displayed,
|
||||||
|
estimatedCeiling,
|
||||||
|
estimatedFloor,
|
||||||
|
expectedDurationMs,
|
||||||
|
progressSource,
|
||||||
|
realProgress,
|
||||||
|
startedAt,
|
||||||
|
status,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "failed" || status === "error") {
|
if (status === "failed" || status === "error") {
|
||||||
@@ -82,6 +224,20 @@ export function useSmoothedProgress(
|
|||||||
|
|
||||||
if (isTerminal(currentStatus)) return current;
|
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;
|
const target = targetRef.current;
|
||||||
|
|
||||||
if (current >= target) {
|
if (current >= target) {
|
||||||
@@ -100,7 +256,14 @@ export function useSmoothedProgress(
|
|||||||
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(rafRef.current);
|
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 (status === "failed" || status === "error") return Math.round(displayed);
|
||||||
if (isSuccess(status) && displayed >= 99.5) return 100;
|
if (isSuccess(status) && displayed >= 99.5) return 100;
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import type { WebGenerationPreviewTask } from "../types";
|
||||||
|
|
||||||
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ export interface GenerationQueueItem {
|
|||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PreviewTaskPatch = Partial<WebGenerationPreviewTask>;
|
||||||
|
|
||||||
interface PersistedQueueSnapshot {
|
interface PersistedQueueSnapshot {
|
||||||
version: 1;
|
version: 1;
|
||||||
items: GenerationQueueItem[];
|
items: GenerationQueueItem[];
|
||||||
@@ -53,9 +56,14 @@ function persistQueue(items: GenerationQueueItem[]): void {
|
|||||||
|
|
||||||
interface GenerationStoreState {
|
interface GenerationStoreState {
|
||||||
queue: GenerationQueueItem[];
|
queue: GenerationQueueItem[];
|
||||||
|
tasks: WebGenerationPreviewTask[];
|
||||||
addTask: (item: GenerationQueueItem) => void;
|
addTask: (item: GenerationQueueItem) => void;
|
||||||
updateTask: (id: string, patch: Partial<GenerationQueueItem>) => void;
|
updateTask: (id: string, patch: Partial<GenerationQueueItem>) => void;
|
||||||
removeTask: (id: string) => void;
|
removeTask: (id: string) => void;
|
||||||
|
setTasks: (tasks: WebGenerationPreviewTask[]) => void;
|
||||||
|
appendTask: (task: WebGenerationPreviewTask) => void;
|
||||||
|
mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void;
|
||||||
|
clearTasks: () => void;
|
||||||
getRunningTasks: () => GenerationQueueItem[];
|
getRunningTasks: () => GenerationQueueItem[];
|
||||||
getPendingTasks: () => GenerationQueueItem[];
|
getPendingTasks: () => GenerationQueueItem[];
|
||||||
getTasksByView: (sourceView: string) => GenerationQueueItem[];
|
getTasksByView: (sourceView: string) => GenerationQueueItem[];
|
||||||
@@ -64,14 +72,87 @@ interface GenerationStoreState {
|
|||||||
|
|
||||||
const initialQueue = loadPersistedQueue();
|
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) => ({
|
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
||||||
queue: initialQueue,
|
queue: initialQueue,
|
||||||
|
tasks: [],
|
||||||
|
|
||||||
addTask: (item) => {
|
addTask: (item) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next = [item, ...state.queue].slice(0, MAX_ITEMS);
|
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"));
|
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) =>
|
const next = state.queue.map((item) =>
|
||||||
item.id === id ? { ...item, ...patch } : 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"));
|
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"),
|
getRunningTasks: () => get().queue.filter((i) => i.status === "running" || i.status === "pending"),
|
||||||
getPendingTasks: () => get().queue.filter((i) => i.status === "pending"),
|
getPendingTasks: () => get().queue.filter((i) => i.status === "pending"),
|
||||||
getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView),
|
getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView),
|
||||||
|
|||||||
@@ -1,36 +1 @@
|
|||||||
import { create } from 'zustand';
|
export { useGenerationStore as useTaskStore } from "./useGenerationStore";
|
||||||
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: [] }),
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "../test/testHarness";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
calculateEnterpriseVideoCredits,
|
calculateEnterpriseVideoCredits,
|
||||||
|
type EnterpriseVideoPricingConfig,
|
||||||
getEnterpriseVideoCreditRate,
|
getEnterpriseVideoCreditRate,
|
||||||
normalizeEnterpriseResolution,
|
normalizeEnterpriseResolution,
|
||||||
} from "./enterpriseVideoPolicy";
|
} from "./enterpriseVideoPolicy";
|
||||||
@@ -45,4 +46,40 @@ describe("enterpriseVideoPolicy", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(1);
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,50 +50,119 @@ export interface EnterpriseVideoPricingInput {
|
|||||||
hasReferenceVideo?: boolean;
|
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" {
|
export function normalizeEnterpriseResolution(value: string): "720P" | "1080P" {
|
||||||
return String(value || "").toUpperCase() === "720P" ? "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 resolution = normalizeEnterpriseResolution(input.resolution);
|
||||||
const model = String(input.model || "").toLowerCase();
|
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")) {
|
if (rule) {
|
||||||
return resolution === "720P" ? 0.72 : 1.28;
|
const rate = rule.rates[resolution] ?? rule.rates[fallbackResolution];
|
||||||
}
|
if (Number.isFinite(rate) && rate >= 0) return rate;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unsupported enterprise video model: ${input.model}`);
|
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));
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ import {
|
|||||||
TEXT_INPUT_CREDITS_PER_MILLION,
|
TEXT_INPUT_CREDITS_PER_MILLION,
|
||||||
TEXT_OUTPUT_CREDITS_PER_MILLION,
|
TEXT_OUTPUT_CREDITS_PER_MILLION,
|
||||||
estimateTextTokenCredits,
|
estimateTextTokenCredits,
|
||||||
|
formatTextTokenCreditRule,
|
||||||
getTaskTimeoutPolicy,
|
getTaskTimeoutPolicy,
|
||||||
isTaskLocallyTimedOut,
|
isTaskLocallyTimedOut,
|
||||||
} from "./taskLifecycle";
|
} from "./taskLifecycle";
|
||||||
|
|
||||||
describe("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_INPUT_CREDITS_PER_MILLION).toBe(200);
|
||||||
expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500);
|
expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500);
|
||||||
expect(
|
expect(
|
||||||
@@ -20,6 +21,23 @@ describe("taskLifecycle", () => {
|
|||||||
).toBe(700);
|
).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", () => {
|
it("ignores negative token counts when estimating text billing", () => {
|
||||||
expect(
|
expect(
|
||||||
estimateTextTokenCredits({
|
estimateTextTokenCredits({
|
||||||
@@ -29,6 +47,17 @@ describe("taskLifecycle", () => {
|
|||||||
).toBe(250);
|
).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", () => {
|
it("marks unstarted tasks locally timed out after submit timeout", () => {
|
||||||
const policy = getTaskTimeoutPolicy({ kind: "image" });
|
const policy = getTaskTimeoutPolicy({ kind: "image" });
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,24 @@ export interface TextTokenUsage {
|
|||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TextTokenCreditRate {
|
||||||
|
inputCreditsPerMillion: number;
|
||||||
|
outputCreditsPerMillion: number;
|
||||||
|
source?: "server" | "fallback";
|
||||||
|
modelKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const CREDITS_PER_CNY = 100;
|
const CREDITS_PER_CNY = 100;
|
||||||
|
|
||||||
export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY;
|
export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY;
|
||||||
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5 * 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 = {
|
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
||||||
submitTimeoutMs: 90_000,
|
submitTimeoutMs: 90_000,
|
||||||
noProgressTimeoutMs: 120_000,
|
noProgressTimeoutMs: 120_000,
|
||||||
@@ -145,18 +158,42 @@ export function getRefundHint(status: TaskRefundStatus): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function estimateTextTokenCredits(usage: TextTokenUsage): number {
|
function sanitizeCreditRate(value: number): number {
|
||||||
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
return Number.isFinite(value) && value >= 0 ? value : 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
|
function formatCreditRate(value: number): string {
|
||||||
const rule = "文本计费规则:输入 Token 每百万 200 积分,输出 Token 每百万 500 积分,实际以服务端结算为准。";
|
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;
|
if (!usage) return rule;
|
||||||
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
||||||
const completionTokens = Math.max(0, Number(usage.completionTokens || 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}`;
|
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user