This commit is contained in:
@@ -3,6 +3,19 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
|
||||
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
|
||||
|
||||
function combineAbortSignals(signal: AbortSignal | undefined, timeoutSignal: AbortSignal): AbortSignal {
|
||||
if (!signal) return timeoutSignal;
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
if (signal.aborted || timeoutSignal.aborted) {
|
||||
abort();
|
||||
return controller.signal;
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
timeoutSignal.addEventListener("abort", abort, { once: true });
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export interface AdVideoUserConfig {
|
||||
platform: string;
|
||||
aspectRatio: string;
|
||||
@@ -162,9 +175,7 @@ async function chat(
|
||||
{ role: "user", content: userContent },
|
||||
];
|
||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||
const combinedSignal = options?.signal
|
||||
? AbortSignal.any([options.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
@@ -210,9 +221,7 @@ async function visionChat(
|
||||
let lastError: Error | null = null;
|
||||
for (const model of VISION_MODELS) {
|
||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||
const combinedSignal = signal
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const combinedSignal = combineAbortSignals(signal, timeoutSignal);
|
||||
try {
|
||||
const out = await retryOnTransient(async () => {
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it } from "../test/testHarness";
|
||||
|
||||
import {
|
||||
__resetGenerationConcurrencyForTests,
|
||||
claimGenerationSlot,
|
||||
getActiveGenerationTaskCount,
|
||||
getEffectiveGenerationLimit,
|
||||
getGenerationUserKey,
|
||||
releaseGenerationSlot,
|
||||
setUserMaxConcurrency,
|
||||
} from "./generationConcurrency";
|
||||
|
||||
describe("generationConcurrency", () => {
|
||||
afterEach(() => {
|
||||
__resetGenerationConcurrencyForTests();
|
||||
});
|
||||
|
||||
it("uses the default generation limit until the server provides a user-specific limit", () => {
|
||||
expect(getEffectiveGenerationLimit()).toBe(3);
|
||||
|
||||
setUserMaxConcurrency(5);
|
||||
expect(getEffectiveGenerationLimit()).toBe(5);
|
||||
|
||||
setUserMaxConcurrency(0);
|
||||
expect(getEffectiveGenerationLimit()).toBe(3);
|
||||
});
|
||||
|
||||
it("claims and releases local generation slots so the submit button can recover", () => {
|
||||
const userKey = getGenerationUserKey("user-1");
|
||||
|
||||
const releaseFirst = claimGenerationSlot({
|
||||
userKey,
|
||||
kind: "image",
|
||||
id: "slot-1",
|
||||
});
|
||||
claimGenerationSlot({ userKey, kind: "video", id: "slot-2" });
|
||||
|
||||
expect(getActiveGenerationTaskCount(userKey)).toBe(2);
|
||||
|
||||
releaseFirst();
|
||||
expect(getActiveGenerationTaskCount(userKey)).toBe(1);
|
||||
|
||||
releaseGenerationSlot("slot-2");
|
||||
expect(getActiveGenerationTaskCount(userKey)).toBe(0);
|
||||
});
|
||||
|
||||
it("enforces per-user limits without blocking other users", () => {
|
||||
setUserMaxConcurrency(1);
|
||||
|
||||
claimGenerationSlot({ userKey: "alice", kind: "image", id: "alice-slot" });
|
||||
|
||||
expect(() =>
|
||||
claimGenerationSlot({
|
||||
userKey: "alice",
|
||||
kind: "video",
|
||||
id: "alice-slot-2",
|
||||
}),
|
||||
).toThrow("最多生成 1 个图片/视频任务");
|
||||
expect(() =>
|
||||
claimGenerationSlot({ userKey: "bob", kind: "video", id: "bob-slot" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -75,3 +75,8 @@ export function releaseGenerationSlot(id: string | undefined | null): void {
|
||||
if (!id) return;
|
||||
activeSlots.delete(id);
|
||||
}
|
||||
|
||||
export function __resetGenerationConcurrencyForTests(): void {
|
||||
activeSlots.clear();
|
||||
userMaxConcurrency = null;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type JSX } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactElement } from "react";
|
||||
import "../../styles/pages/assets.css";
|
||||
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
@@ -36,7 +36,7 @@ interface AssetsPageProps {
|
||||
onOpenLogin: () => void;
|
||||
}
|
||||
|
||||
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: JSX.Element | null }> = [
|
||||
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: ReactElement | null }> = [
|
||||
{ key: "all", label: "全部", icon: null },
|
||||
{ key: "character", label: "人物", icon: <UserOutlined /> },
|
||||
{ key: "scene", label: "场景", icon: <FileImageOutlined /> },
|
||||
|
||||
@@ -715,7 +715,7 @@ async function createUploadedImageItems(files: File[], limit: number, prefix: st
|
||||
|
||||
const items = await Promise.all(selectedFiles.map(async (file, index) => {
|
||||
const localPreviewUrl = URL.createObjectURL(file);
|
||||
let dimensions: { width?: number; height?: number } = {};
|
||||
let dimensions: { width?: number; height?: number };
|
||||
try {
|
||||
dimensions = await readImageDimensions(localPreviewUrl);
|
||||
} catch {
|
||||
|
||||
@@ -522,7 +522,6 @@ function WorkbenchPage({
|
||||
[conversations],
|
||||
);
|
||||
const hasSidebarRecords = conversationRecords.length > 0;
|
||||
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
|
||||
|
||||
const activeConversationTitle = useMemo(() => {
|
||||
if (!activeConversationId) return "";
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
type TestFn = () => unknown | Promise<unknown>;
|
||||
|
||||
interface TestCase {
|
||||
name: string;
|
||||
run: TestFn;
|
||||
afterEachFns: TestFn[];
|
||||
}
|
||||
|
||||
const tests: TestCase[] = [];
|
||||
const suiteStack: string[] = [];
|
||||
const afterEachStack: TestFn[][] = [[]];
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
return typeof value === "string" ? `"${value}"` : JSON.stringify(value);
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function assertThrows(
|
||||
actual: unknown,
|
||||
expectedMessage?: string,
|
||||
inverted = false,
|
||||
): void {
|
||||
if (typeof actual !== "function") {
|
||||
fail("Expected value to be a function");
|
||||
}
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
actual();
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
if (inverted) {
|
||||
if (thrown)
|
||||
fail(
|
||||
`Expected function not to throw, but it threw ${thrown instanceof Error ? thrown.message : String(thrown)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!thrown) fail("Expected function to throw");
|
||||
if (expectedMessage) {
|
||||
const message = thrown instanceof Error ? thrown.message : String(thrown);
|
||||
if (!message.includes(expectedMessage)) {
|
||||
fail(
|
||||
`Expected thrown message to include "${expectedMessage}", got "${message}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function describe(name: string, run: () => void): void {
|
||||
suiteStack.push(name);
|
||||
afterEachStack.push([]);
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
afterEachStack.pop();
|
||||
suiteStack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
export function it(name: string, run: TestFn): void {
|
||||
tests.push({
|
||||
name: [...suiteStack, name].join(" > "),
|
||||
run,
|
||||
afterEachFns: afterEachStack.flat(),
|
||||
});
|
||||
}
|
||||
|
||||
export function afterEach(run: TestFn): void {
|
||||
afterEachStack[afterEachStack.length - 1].push(run);
|
||||
}
|
||||
|
||||
export function expect(actual: unknown) {
|
||||
return {
|
||||
toBe(expected: unknown): void {
|
||||
if (!Object.is(actual, expected)) {
|
||||
fail(`Expected ${formatValue(actual)} to be ${formatValue(expected)}`);
|
||||
}
|
||||
},
|
||||
toEqual(expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
fail(`Expected ${actualJson} to equal ${expectedJson}`);
|
||||
}
|
||||
},
|
||||
toThrow(expectedMessage?: string): void {
|
||||
assertThrows(actual, expectedMessage);
|
||||
},
|
||||
not: {
|
||||
toThrow(): void {
|
||||
assertThrows(actual, undefined, true);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runRegisteredTests(): Promise<{
|
||||
passed: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
}> {
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const test of tests) {
|
||||
let failure: unknown;
|
||||
try {
|
||||
await test.run();
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
|
||||
for (const hook of [...test.afterEachFns].reverse()) {
|
||||
try {
|
||||
await hook();
|
||||
} catch (error) {
|
||||
failure = failure || error;
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
failed += 1;
|
||||
console.error(`FAIL ${test.name}`);
|
||||
console.error(
|
||||
failure instanceof Error
|
||||
? ` ${failure.message}`
|
||||
: ` ${String(failure)}`,
|
||||
);
|
||||
} else {
|
||||
passed += 1;
|
||||
console.log(`PASS ${test.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const total = passed + failed;
|
||||
if (total === 0) {
|
||||
console.error("No unit tests were registered.");
|
||||
return { passed, failed: 1, total };
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
return { passed, failed, total };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../testHarness";
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "../test/testHarness";
|
||||
|
||||
import {
|
||||
calculateEnterpriseVideoCredits,
|
||||
getEnterpriseVideoCreditRate,
|
||||
normalizeEnterpriseResolution,
|
||||
} from "./enterpriseVideoPolicy";
|
||||
|
||||
describe("enterpriseVideoPolicy", () => {
|
||||
it("keeps video billing at 1 CNY to 100 credits", () => {
|
||||
expect(
|
||||
calculateEnterpriseVideoCredits({
|
||||
model: "happyhorse-1.0",
|
||||
resolution: "1080P",
|
||||
durationSeconds: 5,
|
||||
}),
|
||||
).toBe(640);
|
||||
|
||||
expect(
|
||||
calculateEnterpriseVideoCredits({
|
||||
model: "wan2.7-i2v",
|
||||
resolution: "720P",
|
||||
durationSeconds: 5,
|
||||
}),
|
||||
).toBe(300);
|
||||
});
|
||||
|
||||
it("rounds duration up to the next second before billing", () => {
|
||||
expect(
|
||||
calculateEnterpriseVideoCredits({
|
||||
model: "vidu-q3-turbo",
|
||||
resolution: "1080P",
|
||||
durationSeconds: 5.2,
|
||||
}),
|
||||
).toBe(600);
|
||||
});
|
||||
|
||||
it("normalizes unsupported resolutions to 1080P", () => {
|
||||
expect(normalizeEnterpriseResolution("4K")).toBe("1080P");
|
||||
expect(
|
||||
getEnterpriseVideoCreditRate({
|
||||
model: "pixverse-c1",
|
||||
resolution: "4K",
|
||||
durationSeconds: 5,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "../test/testHarness";
|
||||
|
||||
import {
|
||||
TEXT_INPUT_CREDITS_PER_MILLION,
|
||||
TEXT_OUTPUT_CREDITS_PER_MILLION,
|
||||
estimateTextTokenCredits,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "./taskLifecycle";
|
||||
|
||||
describe("taskLifecycle", () => {
|
||||
it("keeps text token billing at 1 CNY to 100 credits", () => {
|
||||
expect(TEXT_INPUT_CREDITS_PER_MILLION).toBe(200);
|
||||
expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500);
|
||||
expect(
|
||||
estimateTextTokenCredits({
|
||||
promptTokens: 1_000_000,
|
||||
completionTokens: 1_000_000,
|
||||
}),
|
||||
).toBe(700);
|
||||
});
|
||||
|
||||
it("ignores negative token counts when estimating text billing", () => {
|
||||
expect(
|
||||
estimateTextTokenCredits({
|
||||
promptTokens: -100,
|
||||
completionTokens: 500_000,
|
||||
}),
|
||||
).toBe(250);
|
||||
});
|
||||
|
||||
it("marks unstarted tasks locally timed out after submit timeout", () => {
|
||||
const policy = getTaskTimeoutPolicy({ kind: "image" });
|
||||
|
||||
expect(
|
||||
isTaskLocallyTimedOut({
|
||||
startedAt: 1_000,
|
||||
lastProgressAt: 1_000,
|
||||
now: 1_000 + policy.submitTimeoutMs,
|
||||
policy,
|
||||
progress: 0,
|
||||
}),
|
||||
).toBe("no_progress");
|
||||
});
|
||||
|
||||
it("marks running tasks locally timed out when progress stops", () => {
|
||||
const policy = getTaskTimeoutPolicy({ kind: "video", model: "wan2.7-i2v" });
|
||||
|
||||
expect(
|
||||
isTaskLocallyTimedOut({
|
||||
startedAt: 1_000,
|
||||
lastProgressAt: 2_000,
|
||||
now: 2_000 + policy.noProgressTimeoutMs,
|
||||
policy,
|
||||
progress: 40,
|
||||
}),
|
||||
).toBe("no_progress");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user