Compare commits
17 Commits
4d7bec0dd7
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 45fe601e17 | |||
| 9d9c3ce186 | |||
| 228e89cfb6 | |||
| 0fbb5372d5 | |||
| a6626beb32 | |||
| aa5ba96764 | |||
| ba2e7cfda2 | |||
| e9601a651c | |||
| 82bd939e26 | |||
| 9e080bbb8f | |||
| d28889fd0c | |||
| bfb70bab26 | |||
| 77ffd01a50 | |||
| f50a5b1f77 | |||
| 2509925644 | |||
| 4562243fd7 | |||
| 52677e33f1 |
@@ -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";
|
||||
status: "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
progress: number;
|
||||
progressSource?: "real" | "estimated" | string | null;
|
||||
stage?: string | null;
|
||||
startedAt?: string | null;
|
||||
expectedDurationMs?: number | null;
|
||||
resultUrl: string | null;
|
||||
error: string | null;
|
||||
params?: Record<string, unknown>;
|
||||
@@ -514,7 +518,20 @@ export const aiGenerationClient = {
|
||||
|
||||
subscribeTaskStatus(
|
||||
taskId: string,
|
||||
onUpdate: (task: Pick<AiTaskStatus, "taskId" | "status" | "progress" | "resultUrl" | "error">) => void,
|
||||
onUpdate: (
|
||||
task: Pick<
|
||||
AiTaskStatus,
|
||||
| "taskId"
|
||||
| "status"
|
||||
| "progress"
|
||||
| "progressSource"
|
||||
| "stage"
|
||||
| "startedAt"
|
||||
| "expectedDurationMs"
|
||||
| "resultUrl"
|
||||
| "error"
|
||||
>,
|
||||
) => void,
|
||||
): () => void {
|
||||
const url = buildApiUrl(`ai/tasks/${taskId}/stream`);
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -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;
|
||||
status: string;
|
||||
progress: number;
|
||||
progressSource?: "real" | "estimated" | string | null;
|
||||
stage?: string | null;
|
||||
startedAt?: string | null;
|
||||
expectedDurationMs?: number | null;
|
||||
resultUrl?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
@@ -37,7 +41,8 @@ export function waitForTask(
|
||||
operation: options.operation,
|
||||
});
|
||||
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
|
||||
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
|
||||
const noProgressTimeoutMs =
|
||||
options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
|
||||
const startedAt = options.startedAt ?? Date.now();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -58,7 +63,10 @@ export function waitForTask(
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(
|
||||
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
|
||||
() =>
|
||||
settle(() =>
|
||||
reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
@@ -105,7 +113,11 @@ export function waitForTask(
|
||||
policy: { ...timeoutPolicy, noProgressTimeoutMs },
|
||||
});
|
||||
if (timeoutReason) {
|
||||
settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))));
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(buildLocalTimeoutMessage(options.kind || "video")),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -114,6 +126,10 @@ export function waitForTask(
|
||||
taskId,
|
||||
status: task.status,
|
||||
progress: task.progress || 0,
|
||||
progressSource: task.progressSource,
|
||||
stage: task.stage,
|
||||
startedAt: task.startedAt,
|
||||
expectedDurationMs: task.expectedDurationMs,
|
||||
resultUrl: task.resultUrl,
|
||||
error: task.error,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ interface BeforeAfterCompareProps {
|
||||
sourceAlt?: string;
|
||||
resultAlt?: string;
|
||||
className?: string;
|
||||
aspectRatio?: string;
|
||||
onSourceLoad?: (width: number, height: number) => void;
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ export default function BeforeAfterCompare({
|
||||
sourceAlt = "原图",
|
||||
resultAlt = "结果",
|
||||
className = "",
|
||||
aspectRatio,
|
||||
onSourceLoad,
|
||||
}: BeforeAfterCompareProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
@@ -43,7 +45,10 @@ export default function BeforeAfterCompare({
|
||||
<div
|
||||
ref={stageRef}
|
||||
className={`before-after-compare ${className}`}
|
||||
style={{ "--compare-position": `${position}%` } as CSSProperties}
|
||||
style={{
|
||||
"--compare-position": `${position}%`,
|
||||
...(aspectRatio ? { "--compare-aspect-ratio": aspectRatio } : {}),
|
||||
} as CSSProperties}
|
||||
aria-label="前后对比"
|
||||
>
|
||||
<div className="before-after-compare__layer before-after-compare__layer--source">
|
||||
|
||||
@@ -33,6 +33,10 @@ import { communityClient } from "../../api/communityClient";
|
||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import {
|
||||
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
|
||||
DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS,
|
||||
} from "../../hooks/useSmoothedProgress";
|
||||
import type { WebCanvasWorkflow } from "../../types";
|
||||
import type { AssetLibraryCategory } from "../assets/localAssetStore";
|
||||
import {
|
||||
@@ -371,12 +375,19 @@ function CanvasPage({
|
||||
const textNodeIdRef = useRef(9);
|
||||
const imageNodeIdRef = useRef(1);
|
||||
const videoNodeIdRef = useRef(1);
|
||||
const objectUrlsRef = useRef(new Set<string>());
|
||||
const trackObjectUrl = (file: Blob) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
objectUrlsRef.current.add(url);
|
||||
return url;
|
||||
};
|
||||
|
||||
const { pushSnapshot, undo, redo } = useCanvasHistory();
|
||||
const {
|
||||
textGenerationState, imageGenerationState, videoGenerationState,
|
||||
generationToast, setGenerationToast,
|
||||
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
|
||||
imageGenerationAbortRef, videoGenerationAbortRef,
|
||||
canvasGenKeepaliveRestoredRef,
|
||||
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
|
||||
restoreKeepaliveTasks, resetGenerationState,
|
||||
@@ -527,6 +538,7 @@ function CanvasPage({
|
||||
const autoSaveStatusTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrls = objectUrlsRef.current;
|
||||
return () => {
|
||||
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
|
||||
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
||||
@@ -535,6 +547,8 @@ function CanvasPage({
|
||||
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
|
||||
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
|
||||
}
|
||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
objectUrls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1691,12 +1705,15 @@ function CanvasPage({
|
||||
const quality = resolveImageQuality(model, imageNode.imageSize || "");
|
||||
|
||||
imageGenerationInFlightRef.current.add(nodeId);
|
||||
const abortRef = { current: false };
|
||||
imageGenerationAbortRef.current.set(nodeId, abortRef);
|
||||
setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 });
|
||||
setGenerationToast("图片正在生成");
|
||||
|
||||
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
|
||||
try {
|
||||
const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode);
|
||||
if (abortRef.current) return;
|
||||
const taskInput: CreatePreviewTaskInput = {
|
||||
title: imageNode.title || "图片节点生成",
|
||||
type: "image",
|
||||
@@ -1732,7 +1749,8 @@ function CanvasPage({
|
||||
? "图片生成完成"
|
||||
: "图片生成失败";
|
||||
setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
|
||||
}));
|
||||
}, abortRef));
|
||||
if (abortRef.current || !outputUrl) return;
|
||||
setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 });
|
||||
removeCanvasGenKeepalive(task.id);
|
||||
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
|
||||
@@ -1794,13 +1812,15 @@ function CanvasPage({
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortRef.current) return;
|
||||
setImageGenerationStatus(nodeId, {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "图片生成失败",
|
||||
});
|
||||
} finally {
|
||||
imageGenerationInFlightRef.current.delete(nodeId);
|
||||
if (task?.id) removeCanvasGenKeepalive(task.id);
|
||||
imageGenerationAbortRef.current.delete(nodeId);
|
||||
if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1843,12 +1863,15 @@ function CanvasPage({
|
||||
const duration = Number(videoNode.duration) || 4;
|
||||
|
||||
videoGenerationInFlightRef.current.add(nodeId);
|
||||
const abortRef = { current: false };
|
||||
videoGenerationAbortRef.current.set(nodeId, abortRef);
|
||||
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
|
||||
setGenerationToast("视频正在生成");
|
||||
|
||||
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
|
||||
try {
|
||||
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
|
||||
if (abortRef.current) return;
|
||||
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
|
||||
throw new Error("图生视频需要先连接至少一个可用的图片节点");
|
||||
}
|
||||
@@ -1892,7 +1915,8 @@ function CanvasPage({
|
||||
? "视频生成完成"
|
||||
: "视频生成失败";
|
||||
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
|
||||
}));
|
||||
}, abortRef));
|
||||
if (abortRef.current || !outputUrl) return;
|
||||
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
|
||||
removeCanvasGenKeepalive(taskId);
|
||||
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
|
||||
@@ -1948,13 +1972,15 @@ function CanvasPage({
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortRef.current) return;
|
||||
setVideoGenerationStatus(nodeId, {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "视频生成失败",
|
||||
});
|
||||
} finally {
|
||||
videoGenerationInFlightRef.current.delete(nodeId);
|
||||
if (task?.id) removeCanvasGenKeepalive(task.id);
|
||||
videoGenerationAbortRef.current.delete(nodeId);
|
||||
if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1965,7 +1991,7 @@ function CanvasPage({
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
const imageUrl = trackObjectUrl(file);
|
||||
if (pendingImageToImageNodeId) {
|
||||
const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId);
|
||||
if (sourceNode) {
|
||||
@@ -2047,7 +2073,7 @@ function CanvasPage({
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
for (const file of files) {
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
const imageUrl = trackObjectUrl(file);
|
||||
addImageNode(imageUrl, file.name, {
|
||||
x: dropPosition.x + offsetX,
|
||||
y: dropPosition.y + offsetY,
|
||||
@@ -2103,7 +2129,7 @@ function CanvasPage({
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
for (const file of files) {
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
const imageUrl = trackObjectUrl(file);
|
||||
addImageNode(imageUrl, file.name, {
|
||||
x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX,
|
||||
y: sourceNode.position.y + offsetY,
|
||||
@@ -4470,6 +4496,7 @@ function CanvasPage({
|
||||
progress={imageNodeProgress}
|
||||
status={imageTaskState?.status || "running"}
|
||||
message={imageTaskState?.message || "图片生成中"}
|
||||
expectedDurationMs={DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS}
|
||||
/>
|
||||
) : null}
|
||||
{imageNodeFocusActive && imageFocusSelectionReady ? (
|
||||
@@ -4844,6 +4871,7 @@ function CanvasPage({
|
||||
progress={videoNodeProgress}
|
||||
status={videoTaskState?.status || "running"}
|
||||
message={videoTaskState?.message || "视频生成中"}
|
||||
expectedDurationMs={DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS}
|
||||
/>
|
||||
) : null}
|
||||
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")}
|
||||
@@ -5279,7 +5307,7 @@ function CanvasPage({
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAssetCoverUrl(URL.createObjectURL(file));
|
||||
setAssetCoverUrl(trackObjectUrl(file));
|
||||
setCoverSourceOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
||||
import {
|
||||
DEFAULT_GENERATION_EXPECTED_DURATION_MS,
|
||||
useSmoothedProgress,
|
||||
type ProgressSource,
|
||||
} from "../../hooks/useSmoothedProgress";
|
||||
import { canvasGenerationProgressStyle } from "./canvasUtils";
|
||||
|
||||
type NodeGenStatus = "submitting" | "running" | "success" | "error";
|
||||
@@ -7,10 +11,24 @@ interface CanvasSmoothedProgressRingProps {
|
||||
progress: number;
|
||||
status: NodeGenStatus;
|
||||
message?: string;
|
||||
progressSource?: ProgressSource;
|
||||
startedAt?: number | string | Date | null;
|
||||
expectedDurationMs?: number | null;
|
||||
}
|
||||
|
||||
export function CanvasSmoothedProgressRing({ progress, status, message }: CanvasSmoothedProgressRingProps) {
|
||||
const smoothed = useSmoothedProgress(progress, status);
|
||||
export function CanvasSmoothedProgressRing({
|
||||
progress,
|
||||
status,
|
||||
message,
|
||||
progressSource = "estimated",
|
||||
startedAt,
|
||||
expectedDurationMs = DEFAULT_GENERATION_EXPECTED_DURATION_MS,
|
||||
}: CanvasSmoothedProgressRingProps) {
|
||||
const smoothed = useSmoothedProgress(progress, status, {
|
||||
progressSource,
|
||||
startedAt,
|
||||
expectedDurationMs,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className="studio-canvas-node-generation-progress"
|
||||
@@ -18,7 +36,10 @@ export function CanvasSmoothedProgressRing({ progress, status, message }: Canvas
|
||||
aria-live="polite"
|
||||
style={canvasGenerationProgressStyle(smoothed)}
|
||||
>
|
||||
<span className="studio-canvas-node-generation-progress__ring" aria-hidden="true" />
|
||||
<span
|
||||
className="studio-canvas-node-generation-progress__ring"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<strong>{message}</strong>
|
||||
<em>{smoothed}%</em>
|
||||
</div>
|
||||
|
||||
@@ -252,28 +252,60 @@ export function blobToDataUrl(blob: Blob) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||
export async function waitForImageTaskResult(
|
||||
taskId: string,
|
||||
onStatus?: (status: AiTaskStatus) => void,
|
||||
abortRef?: { current: boolean },
|
||||
) {
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
kind: "image",
|
||||
abortRef,
|
||||
onProgress: (e) => {
|
||||
if (onStatus) {
|
||||
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
onStatus({
|
||||
taskId,
|
||||
status: e.status,
|
||||
progress: e.progress,
|
||||
progressSource: e.progressSource,
|
||||
stage: e.stage,
|
||||
startedAt: e.startedAt,
|
||||
expectedDurationMs: e.expectedDurationMs,
|
||||
resultUrl: e.resultUrl ?? undefined,
|
||||
error: e.error ?? undefined,
|
||||
} as AiTaskStatus);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (abortRef?.current) return "";
|
||||
if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
||||
return resultUrl;
|
||||
}
|
||||
|
||||
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||
export async function waitForVideoTaskResult(
|
||||
taskId: string,
|
||||
onStatus?: (status: AiTaskStatus) => void,
|
||||
abortRef?: { current: boolean },
|
||||
) {
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
kind: "video",
|
||||
abortRef,
|
||||
onProgress: (e) => {
|
||||
if (onStatus) {
|
||||
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
onStatus({
|
||||
taskId,
|
||||
status: e.status,
|
||||
progress: e.progress,
|
||||
progressSource: e.progressSource,
|
||||
stage: e.stage,
|
||||
startedAt: e.startedAt,
|
||||
expectedDurationMs: e.expectedDurationMs,
|
||||
resultUrl: e.resultUrl ?? undefined,
|
||||
error: e.error ?? undefined,
|
||||
} as AiTaskStatus);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (abortRef?.current) return "";
|
||||
if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
||||
return resultUrl;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
CanvasImageGenerationState,
|
||||
CanvasImageNode,
|
||||
@@ -66,6 +66,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
const videoGenerationInFlightRef = useRef(new Set<string>());
|
||||
const textGenerationInFlightRef = useRef(new Set<string>());
|
||||
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
|
||||
const imageGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
|
||||
const videoGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
|
||||
const canvasGenKeepaliveRestoredRef = useRef(false);
|
||||
|
||||
const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => {
|
||||
@@ -80,6 +82,15 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
setVideoGenerationState((current) => ({ ...current, [nodeId]: state }));
|
||||
};
|
||||
|
||||
const abortAllGenerationPollers = useCallback(() => {
|
||||
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
|
||||
textGenerationAbortControllersRef.current.clear();
|
||||
imageGenerationAbortRef.current.forEach((ref) => { ref.current = true; });
|
||||
imageGenerationAbortRef.current.clear();
|
||||
videoGenerationAbortRef.current.forEach((ref) => { ref.current = true; });
|
||||
videoGenerationAbortRef.current.clear();
|
||||
}, []);
|
||||
|
||||
// Toast auto-dismiss
|
||||
useEffect(() => {
|
||||
if (!generationToast) return undefined;
|
||||
@@ -103,11 +114,14 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
}
|
||||
if (entry.nodeKind === "image") {
|
||||
imageGenerationInFlightRef.current.add(entry.nodeId);
|
||||
const abortRef = { current: false };
|
||||
imageGenerationAbortRef.current.set(entry.nodeId, abortRef);
|
||||
setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 });
|
||||
void waitForImageTaskResult(entry.taskId, (status) => {
|
||||
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
||||
setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress });
|
||||
}).then(async (outputUrl) => {
|
||||
}, abortRef).then(async (outputUrl) => {
|
||||
if (abortRef.current || !outputUrl) return;
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
|
||||
const ref = createCanvasAssetRefFromGeneratedResult({
|
||||
@@ -128,18 +142,23 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
));
|
||||
}
|
||||
}).catch(() => {
|
||||
if (abortRef.current) return;
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" });
|
||||
}).finally(() => {
|
||||
imageGenerationInFlightRef.current.delete(entry.nodeId);
|
||||
imageGenerationAbortRef.current.delete(entry.nodeId);
|
||||
});
|
||||
} else if (entry.nodeKind === "video") {
|
||||
videoGenerationInFlightRef.current.add(entry.nodeId);
|
||||
const abortRef = { current: false };
|
||||
videoGenerationAbortRef.current.set(entry.nodeId, abortRef);
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
|
||||
void waitForVideoTaskResult(entry.taskId, (status) => {
|
||||
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress });
|
||||
}).then(async (outputUrl) => {
|
||||
}, abortRef).then(async (outputUrl) => {
|
||||
if (abortRef.current || !outputUrl) return;
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
|
||||
const ref = createCanvasAssetRefFromGeneratedResult({
|
||||
@@ -160,18 +179,19 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
));
|
||||
}
|
||||
}).catch(() => {
|
||||
if (abortRef.current) return;
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
|
||||
}).finally(() => {
|
||||
videoGenerationInFlightRef.current.delete(entry.nodeId);
|
||||
videoGenerationAbortRef.current.delete(entry.nodeId);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetGenerationState = () => {
|
||||
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
|
||||
textGenerationAbortControllersRef.current.clear();
|
||||
abortAllGenerationPollers();
|
||||
textGenerationInFlightRef.current.clear();
|
||||
imageGenerationInFlightRef.current.clear();
|
||||
videoGenerationInFlightRef.current.clear();
|
||||
@@ -180,11 +200,18 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
setVideoGenerationState({});
|
||||
};
|
||||
|
||||
// Stop all in-flight front-end polling/setState when the canvas unmounts (route change).
|
||||
// Keepalive records are intentionally preserved so restoreKeepaliveTasks can resume on return.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortAllGenerationPollers();
|
||||
};
|
||||
}, [abortAllGenerationPollers]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePageHide = () => {
|
||||
cancelCanvasGenKeepaliveOnUnload();
|
||||
textGenerationAbortControllersRef.current.forEach((controller) => controller.abort());
|
||||
textGenerationAbortControllersRef.current.clear();
|
||||
abortAllGenerationPollers();
|
||||
textGenerationInFlightRef.current.clear();
|
||||
imageGenerationInFlightRef.current.clear();
|
||||
videoGenerationInFlightRef.current.clear();
|
||||
@@ -202,7 +229,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
window.removeEventListener("pagehide", handlePageHide);
|
||||
window.removeEventListener("online", handleOnline);
|
||||
};
|
||||
}, []);
|
||||
}, [abortAllGenerationPollers]);
|
||||
|
||||
return {
|
||||
textGenerationState,
|
||||
@@ -214,6 +241,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
videoGenerationInFlightRef,
|
||||
textGenerationInFlightRef,
|
||||
textGenerationAbortControllersRef,
|
||||
imageGenerationAbortRef,
|
||||
videoGenerationAbortRef,
|
||||
canvasGenKeepaliveRestoredRef,
|
||||
setTextGenerationStatus,
|
||||
setImageGenerationStatus,
|
||||
|
||||
@@ -281,6 +281,94 @@ function CharacterMixPage({
|
||||
}
|
||||
};
|
||||
|
||||
const clearCharacterAsset = () => {
|
||||
if (characterPreview) URL.revokeObjectURL(characterPreview);
|
||||
setCharacterFile("");
|
||||
setCharacterPreview("");
|
||||
setCharacterDataUrl("");
|
||||
setFaceHint(null);
|
||||
if (characterInputRef.current) characterInputRef.current.value = "";
|
||||
setNotice("已移除人物图");
|
||||
};
|
||||
|
||||
const clearReferenceVideo = () => {
|
||||
if (videoPreview) URL.revokeObjectURL(videoPreview);
|
||||
setVideoFile("");
|
||||
setVideoPreview("");
|
||||
setVideoDataUrl("");
|
||||
if (videoInputRef.current) videoInputRef.current.value = "";
|
||||
setNotice("已移除参考视频");
|
||||
};
|
||||
|
||||
const characterMixSettingsPanel = (
|
||||
<div className="studio-panel__section character-mix-settings-panel">
|
||||
<div className="studio-panel__section-head">
|
||||
<span className="studio-panel__section-title">迁移设置</span>
|
||||
</div>
|
||||
<div className="studio-panel__section-body">
|
||||
<div className="character-mix-prompt-field">
|
||||
<div className="studio-label">驱动提示词</div>
|
||||
<textarea
|
||||
value={promptInput}
|
||||
onChange={(e) => setPromptInput(e.target.value)}
|
||||
placeholder="保持角色原有服装,动作流畅自然"
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</div>
|
||||
<div className="studio-toggle-row">
|
||||
<div className="studio-toggle-row__copy">
|
||||
<span className="studio-toggle-row__title">图像检测</span>
|
||||
<span className="studio-toggle-row__desc">检测人物朝向</span>
|
||||
</div>
|
||||
<button type="button" className={`studio-toggle${checkImage ? " is-on" : ""}`} onClick={() => setCheckImage(!checkImage)}>
|
||||
<span className="studio-toggle__thumb" />
|
||||
</button>
|
||||
</div>
|
||||
{checkImage && characterPreview && faceHint && (
|
||||
<div className={`character-mix-face-hint character-mix-face-hint--${faceHint}`}>
|
||||
{faceHint === "analyzing" ? (
|
||||
<>
|
||||
<InfoCircleOutlined />
|
||||
<span>正在分析人物图像...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircleOutlined />
|
||||
<span>图像已就绪,将自动检测人物面部朝向</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-toggle-row">
|
||||
<div className="studio-toggle-row__copy">
|
||||
<span className="studio-toggle-row__title">水印输出</span>
|
||||
<span className="studio-toggle-row__desc">含水印版本</span>
|
||||
</div>
|
||||
<button type="button" className={`studio-toggle${watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
|
||||
<span className="studio-toggle__thumb" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !characterDataUrl || !videoDataUrl}>
|
||||
{isCreating ? <LoadingOutlined /> : <PlayCircleOutlined />}
|
||||
{isCreating ? "生成中..." : "开始迁移"}
|
||||
</button>
|
||||
{resultUrl && (
|
||||
<div className="studio-result-actions">
|
||||
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
|
||||
<DownloadOutlined />
|
||||
{isDownloadingResult ? "保存中" : "保存本地"}
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
|
||||
<InboxOutlined />
|
||||
{isSavingResultAsset ? "加入中" : "加入资产库"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
|
||||
<header className="image-workbench-topbar">
|
||||
@@ -339,8 +427,10 @@ function CharacterMixPage({
|
||||
|
||||
<StudioToolLayout
|
||||
noTop
|
||||
noRight
|
||||
leftPanel={
|
||||
<div
|
||||
className="character-mix-source-panel"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -389,6 +479,20 @@ function CharacterMixPage({
|
||||
<strong>{characterFile || "上传人物图"}</strong>
|
||||
<small>单人正面或半身更稳定</small>
|
||||
</span>
|
||||
{characterPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-upload-slot__remove"
|
||||
aria-label="移除人物图"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearCharacterAsset();
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,9 +531,24 @@ function CharacterMixPage({
|
||||
<strong>{videoFile || "上传参考视频"}</strong>
|
||||
<small>MP4 / MOV / AVI</small>
|
||||
</span>
|
||||
{videoPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-upload-slot__remove"
|
||||
aria-label="移除参考视频"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearReferenceVideo();
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{characterMixSettingsPanel}
|
||||
</div>
|
||||
}
|
||||
canvas={
|
||||
@@ -480,80 +599,6 @@ function CharacterMixPage({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
rightPanel={
|
||||
<div className="studio-panel__section">
|
||||
<div className="studio-panel__section-head">
|
||||
<span className="studio-panel__section-title">迁移设置</span>
|
||||
</div>
|
||||
<div className="studio-panel__section-body">
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div className="studio-label" style={{ fontSize: 11, color: "var(--fg-muted, #999)", marginBottom: 4 }}>驱动提示词</div>
|
||||
<textarea
|
||||
value={promptInput}
|
||||
onChange={(e) => setPromptInput(e.target.value)}
|
||||
placeholder="保持角色原有服装,动作流畅自然"
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
style={{
|
||||
width: "100%", resize: "vertical", background: "var(--bg-elevated, #1a1a1a)",
|
||||
border: "1px solid var(--border-subtle, #333)", borderRadius: 6,
|
||||
padding: "6px 8px", fontSize: 12, color: "var(--fg-body, #eee)",
|
||||
fontFamily: "inherit", outline: "none", boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="studio-toggle-row">
|
||||
<div className="studio-toggle-row__copy">
|
||||
<span className="studio-toggle-row__title">图像检测</span>
|
||||
<span className="studio-toggle-row__desc">检测人物朝向</span>
|
||||
</div>
|
||||
<button type="button" className={`studio-toggle${checkImage ? " is-on" : ""}`} onClick={() => setCheckImage(!checkImage)}>
|
||||
<span className="studio-toggle__thumb" />
|
||||
</button>
|
||||
</div>
|
||||
{checkImage && characterPreview && faceHint && (
|
||||
<div className={`character-mix-face-hint character-mix-face-hint--${faceHint}`}>
|
||||
{faceHint === "analyzing" ? (
|
||||
<>
|
||||
<InfoCircleOutlined />
|
||||
<span>正在分析人物图像...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircleOutlined />
|
||||
<span>图像已就绪,将自动检测人物面部朝向</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-toggle-row">
|
||||
<div className="studio-toggle-row__copy">
|
||||
<span className="studio-toggle-row__title">水印输出</span>
|
||||
<span className="studio-toggle-row__desc">含水印版本</span>
|
||||
</div>
|
||||
<button type="button" className={`studio-toggle${watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
|
||||
<span className="studio-toggle__thumb" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !characterDataUrl || !videoDataUrl}>
|
||||
{isCreating ? <LoadingOutlined /> : <PlayCircleOutlined />}
|
||||
{isCreating ? "生成中..." : "开始迁移"}
|
||||
</button>
|
||||
{resultUrl && (
|
||||
<div className="studio-result-actions">
|
||||
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
|
||||
<DownloadOutlined />
|
||||
{isDownloadingResult ? "保存中" : "保存本地"}
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
|
||||
<InboxOutlined />
|
||||
{isSavingResultAsset ? "加入中" : "加入资产库"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
statusBar={
|
||||
<>
|
||||
<span className="studio-status-bar__badge studio-status-bar__badge--idle">就绪</span>
|
||||
|
||||
@@ -206,6 +206,24 @@ function DigitalHumanPage({
|
||||
}
|
||||
};
|
||||
|
||||
const clearImageAsset = () => {
|
||||
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||
setImageName("");
|
||||
setImageFile(null);
|
||||
setImagePreview("");
|
||||
if (imageInputRef.current) imageInputRef.current.value = "";
|
||||
setNotice("已移除参考人像");
|
||||
};
|
||||
|
||||
const clearAudioAsset = () => {
|
||||
if (audioPreview) URL.revokeObjectURL(audioPreview);
|
||||
setAudioName("");
|
||||
setAudioFile(null);
|
||||
setAudioPreview("");
|
||||
if (audioInputRef.current) audioInputRef.current.value = "";
|
||||
setNotice("已移除音频源");
|
||||
};
|
||||
|
||||
const handleDownloadResult = async () => {
|
||||
if (!resultVideoUrl || isDownloadingResult) return;
|
||||
setIsDownloadingResult(true);
|
||||
@@ -418,6 +436,76 @@ function DigitalHumanPage({
|
||||
}
|
||||
};
|
||||
|
||||
const digitalHumanSettingsPanel = (
|
||||
<div className="studio-panel__section digital-human-settings-panel">
|
||||
<div className="studio-panel__section-head">
|
||||
<span className="studio-panel__section-title">参数</span>
|
||||
</div>
|
||||
<div className="studio-panel__section-body">
|
||||
<div className="digital-human-prompt-field">
|
||||
<div className="studio-label">提示词</div>
|
||||
<textarea
|
||||
value={promptInput}
|
||||
onChange={(e) => setPromptInput(e.target.value)}
|
||||
placeholder="例如:自然微笑,边说边轻微点头"
|
||||
rows={3}
|
||||
maxLength={2500}
|
||||
/>
|
||||
</div>
|
||||
<div className="studio-toggle-row">
|
||||
<div className="studio-toggle-row__copy">
|
||||
<span className="studio-toggle-row__title">去水印</span>
|
||||
<span className="studio-toggle-row__desc">生成无水印预览</span>
|
||||
</div>
|
||||
<button type="button" className={`studio-toggle${!watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
|
||||
<span className="studio-toggle__thumb" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="studio-toggle-row">
|
||||
<div className="studio-toggle-row__copy">
|
||||
<span className="studio-toggle-row__title">保留原声</span>
|
||||
<span className="studio-toggle-row__desc">音频作为驱动源</span>
|
||||
</div>
|
||||
<button type="button" className={`studio-toggle${keepOriginalAudio ? " is-on" : ""}`} onClick={() => setKeepOriginalAudio(!keepOriginalAudio)}>
|
||||
<span className="studio-toggle__thumb" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !imageFile || !audioFile}>
|
||||
<PlayCircleOutlined />
|
||||
{isCreating ? "生成中..." : "开始生成"}
|
||||
</button>
|
||||
{isCreating && (
|
||||
<button type="button" className="studio-generate-btn digital-human-cancel-btn" onClick={handleCancel} aria-label="取消生成任务">
|
||||
<CloseCircleOutlined />
|
||||
取消生成
|
||||
</button>
|
||||
)}
|
||||
{resultVideoUrl && (
|
||||
<button type="button" className="studio-generate-btn" onClick={() => {
|
||||
setResultVideoUrl("");
|
||||
setActiveTaskId("");
|
||||
setTaskProgress(0);
|
||||
setNotice("已清空工作区");
|
||||
}}>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
{resultVideoUrl && (
|
||||
<div className="studio-result-actions">
|
||||
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
|
||||
<DownloadOutlined />
|
||||
{isDownloadingResult ? "保存中" : "保存本地"}
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
|
||||
<InboxOutlined />
|
||||
{isSavingResultAsset ? "加入中" : "加入资产库"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="image-workbench-page digital-human-page" aria-label="数字人">
|
||||
<header className="image-workbench-topbar">
|
||||
@@ -476,8 +564,10 @@ function DigitalHumanPage({
|
||||
|
||||
<StudioToolLayout
|
||||
noTop
|
||||
noRight
|
||||
leftPanel={
|
||||
<div
|
||||
className="digital-human-source-panel"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -523,6 +613,20 @@ function DigitalHumanPage({
|
||||
<strong>{imageName || "上传参考图"}</strong>
|
||||
<small>PNG / JPG / WEBP</small>
|
||||
</span>
|
||||
{imagePreview ? (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-upload-slot__remove"
|
||||
aria-label="移除参考人像"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearImageAsset();
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -558,10 +662,26 @@ function DigitalHumanPage({
|
||||
<strong>{audioName || "上传音频"}</strong>
|
||||
<small>MP3 / WAV / M4A,建议 5 分钟内</small>
|
||||
</span>
|
||||
{audioPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-upload-slot__remove"
|
||||
aria-label="移除音频源"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearAudioAsset();
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
{audioPreview ? <audio src={audioPreview} controls className="studio-audio-preview" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{digitalHumanSettingsPanel}
|
||||
</div>
|
||||
}
|
||||
canvas={
|
||||
@@ -596,83 +716,6 @@ function DigitalHumanPage({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
rightPanel={
|
||||
<>
|
||||
<div className="studio-panel__section">
|
||||
<div className="studio-panel__section-head">
|
||||
<span className="studio-panel__section-title">参数</span>
|
||||
</div>
|
||||
<div className="studio-panel__section-body">
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div className="studio-label" style={{ fontSize: 11, color: "var(--fg-muted, #999)", marginBottom: 4 }}>提示词</div>
|
||||
<textarea
|
||||
value={promptInput}
|
||||
onChange={(e) => setPromptInput(e.target.value)}
|
||||
placeholder="例如:自然微笑,边说边轻微点头"
|
||||
rows={3}
|
||||
maxLength={2500}
|
||||
style={{
|
||||
width: "100%", resize: "vertical", background: "var(--bg-elevated, #1a1a1a)",
|
||||
border: "1px solid var(--border-subtle, #333)", borderRadius: 6,
|
||||
padding: "6px 8px", fontSize: 12, color: "var(--fg-body, #eee)",
|
||||
fontFamily: "inherit", outline: "none", boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="studio-toggle-row">
|
||||
<div className="studio-toggle-row__copy">
|
||||
<span className="studio-toggle-row__title">去水印</span>
|
||||
<span className="studio-toggle-row__desc">生成无水印预览</span>
|
||||
</div>
|
||||
<button type="button" className={`studio-toggle${!watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
|
||||
<span className="studio-toggle__thumb" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="studio-toggle-row">
|
||||
<div className="studio-toggle-row__copy">
|
||||
<span className="studio-toggle-row__title">保留原声</span>
|
||||
<span className="studio-toggle-row__desc">音频作为驱动源</span>
|
||||
</div>
|
||||
<button type="button" className={`studio-toggle${keepOriginalAudio ? " is-on" : ""}`} onClick={() => setKeepOriginalAudio(!keepOriginalAudio)}>
|
||||
<span className="studio-toggle__thumb" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !imageFile || !audioFile}>
|
||||
<PlayCircleOutlined />
|
||||
{isCreating ? "生成中..." : "开始生成"}
|
||||
</button>
|
||||
{isCreating && (
|
||||
<button type="button" className="studio-generate-btn digital-human-cancel-btn" onClick={handleCancel} aria-label="取消生成任务">
|
||||
<CloseCircleOutlined />
|
||||
取消生成
|
||||
</button>
|
||||
)}
|
||||
{resultVideoUrl && (
|
||||
<button type="button" className="studio-generate-btn" onClick={() => {
|
||||
setResultVideoUrl("");
|
||||
setActiveTaskId("");
|
||||
setTaskProgress(0);
|
||||
setNotice("已清空工作区");
|
||||
}}>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
{resultVideoUrl && (
|
||||
<div className="studio-result-actions">
|
||||
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
|
||||
<DownloadOutlined />
|
||||
{isDownloadingResult ? "保存中" : "保存本地"}
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
|
||||
<InboxOutlined />
|
||||
{isSavingResultAsset ? "加入中" : "加入资产库"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
statusBar={
|
||||
<>
|
||||
<span className="studio-status-bar__badge studio-status-bar__badge--running">预览</span>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
|
||||
formatEstimatedRemainingLabel,
|
||||
useSmoothedProgress,
|
||||
} from "../../hooks/useSmoothedProgress";
|
||||
|
||||
interface EcommerceProgressBarProps {
|
||||
status: "idle" | "generating" | "done" | "failed" | string;
|
||||
@@ -12,19 +17,42 @@ function mapStatus(status: string): "running" | "completed" | "failed" {
|
||||
return "running";
|
||||
}
|
||||
|
||||
export function EcommerceProgressBar({ status, label }: EcommerceProgressBarProps) {
|
||||
const progress = mapStatus(status) === "running" ? 50 : 100;
|
||||
const smoothed = useSmoothedProgress(progress, mapStatus(status));
|
||||
export function EcommerceProgressBar({
|
||||
status,
|
||||
label,
|
||||
}: EcommerceProgressBarProps) {
|
||||
const startedAtRef = useRef(Date.now());
|
||||
const mappedStatus = mapStatus(status);
|
||||
const progress = mappedStatus === "running" ? 50 : 100;
|
||||
const smoothed = useSmoothedProgress(progress, mappedStatus, {
|
||||
progressSource: mappedStatus === "running" ? "estimated" : "real",
|
||||
startedAt: startedAtRef.current,
|
||||
expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
|
||||
});
|
||||
const remainingLabel =
|
||||
mappedStatus === "running"
|
||||
? formatEstimatedRemainingLabel({
|
||||
nowMs: Date.now(),
|
||||
startedAt: startedAtRef.current,
|
||||
expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (status === "idle") return null;
|
||||
|
||||
return (
|
||||
<div className="ecommerce-progress-bar">
|
||||
<span className="ecommerce-progress-bar__label">{label || "AI 正在生成"}</span>
|
||||
<span className="ecommerce-progress-bar__label">
|
||||
{label || "AI 正在生成"}
|
||||
{remainingLabel ? ` / ${remainingLabel}` : ""}
|
||||
</span>
|
||||
<div className="ecommerce-progress-bar__track">
|
||||
<div className="ecommerce-progress-bar__fill" style={{ width: `${smoothed}%` }} />
|
||||
<div
|
||||
className="ecommerce-progress-bar__fill"
|
||||
style={{ width: `${smoothed}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="ecommerce-progress-bar__value">{smoothed}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,11 @@ import "../../styles/pages/home.css";
|
||||
import WelcomeSplash from "./WelcomeSplash";
|
||||
import ToolboxSection from "./ToolboxSection";
|
||||
import ScriptReviewShowcase from "./ScriptReviewShowcase";
|
||||
import ModelGenerationShowcase from "./ModelGenerationShowcase";
|
||||
|
||||
const [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
|
||||
const {
|
||||
ecommerce: featureEcommerceImage,
|
||||
script: featureScriptImage,
|
||||
token: featureTokenImage,
|
||||
} = ossAssets.home.features;
|
||||
|
||||
interface HomePageProps {
|
||||
@@ -42,16 +40,6 @@ const HOME_CAROUSEL_IMAGES = [
|
||||
];
|
||||
|
||||
const HOME_FEATURES = [
|
||||
{
|
||||
key: "model",
|
||||
eyebrow: "AI Generation",
|
||||
title: "模型生成",
|
||||
description: "通过AI模型生成文本、图片、视频,三种模式覆盖全内容类型,Agent对话式交互智能产出。",
|
||||
imageUrl: featureTokenImage,
|
||||
actionLabel: "开始生成",
|
||||
icon: <ThunderboltOutlined />,
|
||||
stats: ["文本生成", "图片生成", "视频生成"],
|
||||
},
|
||||
{
|
||||
key: "ecommerce",
|
||||
eyebrow: "AI Commerce",
|
||||
@@ -184,10 +172,18 @@ function getHomeCarouselCardStyle(offset: number): CSSProperties {
|
||||
const depth = Math.abs(offset);
|
||||
const direction = Math.sign(offset);
|
||||
const isActive = depth === 0;
|
||||
const xByDepth = [0, 190, 320, 430, 520, 590];
|
||||
const xByDepth = [
|
||||
"0px",
|
||||
"clamp(52px, 13.5vw, 198px)",
|
||||
"clamp(90px, 22.5vw, 334px)",
|
||||
"clamp(122px, 30.5vw, 448px)",
|
||||
"clamp(148px, 37vw, 542px)",
|
||||
"clamp(170px, 42vw, 614px)",
|
||||
];
|
||||
const yByDepth = [8, -2, -8, -13, -18, -24];
|
||||
const scaleByDepth = [1, 1, 1, 1, 1, 1];
|
||||
const x = direction * (xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!);
|
||||
const xDistance = xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!;
|
||||
const x = direction < 0 ? `calc(0px - ${xDistance})` : xDistance;
|
||||
const y = yByDepth[depth] ?? yByDepth[yByDepth.length - 1]!;
|
||||
const z = isActive ? 90 : 28 - depth;
|
||||
const scale = scaleByDepth[depth] ?? scaleByDepth[scaleByDepth.length - 1]!;
|
||||
@@ -196,7 +192,7 @@ function getHomeCarouselCardStyle(offset: number): CSSProperties {
|
||||
"--apple-card-offset": offset,
|
||||
"--apple-card-depth": depth,
|
||||
"--apple-card-z": 80 - depth,
|
||||
"--apple-card-x": `${x}px`,
|
||||
"--apple-card-x": x,
|
||||
"--apple-card-y": `${y}px`,
|
||||
"--apple-card-z-offset": `${z}px`,
|
||||
"--apple-card-rotate-y": "0deg",
|
||||
@@ -612,15 +608,24 @@ function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcomm
|
||||
<div className="omni-home__actions" aria-label="首页入口">
|
||||
<button type="button" className="omni-home__entry" onClick={onStartOnboarding || onOpenGenerate}>
|
||||
<PlusOutlined />
|
||||
<span>新手</span>
|
||||
<span>
|
||||
快速生成
|
||||
<small>新手友好</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="omni-home__entry omni-home__entry--primary" onClick={onOpenCanvas || onOpenGenerate}>
|
||||
<PlayCircleOutlined />
|
||||
<span>老手</span>
|
||||
<span>
|
||||
专业创作
|
||||
<small>画布工作流</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="omni-home__entry" onClick={onOpenEcommerce}>
|
||||
<ShoppingOutlined />
|
||||
<span>电商</span>
|
||||
<span>
|
||||
电商出图
|
||||
<small>商品视觉</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -629,7 +634,7 @@ function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcomm
|
||||
<main className="omni-home__feature-pages" aria-label="OmniAI 功能介绍">
|
||||
{HOME_FEATURES.map((feature, index) => (
|
||||
<section key={feature.key} className={`omni-home__feature-page is-${feature.key}${index % 2 ? " is-alt" : ""}`}>
|
||||
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
|
||||
{feature.key !== "script" && feature.key !== "ecommerce" ? (
|
||||
<div className="omni-home__feature-copy">
|
||||
<span>
|
||||
{feature.icon}
|
||||
@@ -643,18 +648,16 @@ function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcomm
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="omni-home__feature-visual" aria-hidden={feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce"}>
|
||||
<div className="omni-home__feature-visual" aria-hidden={feature.key !== "script" && feature.key !== "ecommerce"}>
|
||||
{feature.key === "script" ? (
|
||||
<ScriptReviewShowcase />
|
||||
) : feature.key === "model" ? (
|
||||
<ModelGenerationShowcase />
|
||||
) : feature.key === "ecommerce" ? (
|
||||
<EcommerceFeatureShowcase />
|
||||
) : (
|
||||
<img src={feature.imageUrl} alt="" />
|
||||
)}
|
||||
</div>
|
||||
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
|
||||
{feature.key !== "script" && feature.key !== "ecommerce" ? (
|
||||
<div className="omni-home__feature-stats" aria-hidden="true">
|
||||
{feature.stats.map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
|
||||
@@ -609,6 +609,20 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleRemoveWorkbenchResult = (index: number) => {
|
||||
setResultImages((current) => {
|
||||
const next = current.filter((_, imageIndex) => imageIndex !== index);
|
||||
if (next.length) {
|
||||
saveToolTaskState("imagewb", { taskId: taskIdRef.current || "", resultUrl: next[0], status: "完成", progress: 100 });
|
||||
setStatus(`已移除生成图,剩余 ${next.length} 张`);
|
||||
} else {
|
||||
clearToolTaskState("imagewb");
|
||||
setStatus("已移除生成图");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!referenceImages.length && !prompt.trim()) {
|
||||
setStatus("请先上传参考图或输入提示词");
|
||||
@@ -797,7 +811,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
aria-label="删除局部重绘素材"
|
||||
onClick={handleRemoveInpaintImage}
|
||||
>
|
||||
×
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -830,7 +844,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="image-workbench-control-card">
|
||||
<section className="image-workbench-control-card image-workbench-inpaint-params-card">
|
||||
<h3>参数</h3>
|
||||
<span className="image-workbench-field-label">输出分辨率</span>
|
||||
<div className="image-workbench-segmented">
|
||||
@@ -842,7 +856,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="image-workbench-control-card">
|
||||
<section className="image-workbench-control-card image-workbench-inpaint-prompt-card">
|
||||
<h3>提示词</h3>
|
||||
<textarea
|
||||
className="image-workbench-prompt"
|
||||
@@ -967,7 +981,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
) : activeTool === "camera" ? (
|
||||
<main className="image-workbench-layout image-workbench-layout--camera">
|
||||
<aside className="image-workbench-panel image-workbench-panel--left">
|
||||
<section className="image-workbench-control-card">
|
||||
<section className="image-workbench-control-card image-workbench-camera-material">
|
||||
<div className="image-workbench-section-title">
|
||||
<h3>素材</h3>
|
||||
<span>{cameraImage ? "已导入" : "待上传"}</span>
|
||||
@@ -997,7 +1011,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
aria-label="删除镜头参考图"
|
||||
onClick={handleRemoveCameraImage}
|
||||
>
|
||||
×
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1248,7 +1262,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
aria-label={`删除参考图 ${index + 1}`}
|
||||
onClick={() => handleRemoveReferenceImage(index)}
|
||||
>
|
||||
×
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -1282,7 +1296,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
aria-label="删除参考图"
|
||||
onClick={() => handleRemoveReferenceImage(0)}
|
||||
>
|
||||
×
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1300,7 +1314,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="image-workbench-control-card">
|
||||
<section className="image-workbench-control-card image-workbench-output-card">
|
||||
<h3>输出</h3>
|
||||
<span className="image-workbench-field-label">尺寸</span>
|
||||
<div className="image-workbench-segmented">
|
||||
@@ -1365,6 +1379,14 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
<div className="image-workbench-result-grid">
|
||||
{resultImages.map((url, i) => (
|
||||
<div key={url} className="image-workbench-result-card">
|
||||
<button
|
||||
type="button"
|
||||
className="image-workbench-result-remove"
|
||||
aria-label={`移除生成结果 ${i + 1}`}
|
||||
onClick={() => handleRemoveWorkbenchResult(i)}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="image-workbench-result-item">
|
||||
<img src={url} alt={`生成结果 ${i + 1}`} />
|
||||
</a>
|
||||
|
||||
@@ -40,12 +40,14 @@ interface MoreTool {
|
||||
}
|
||||
|
||||
const toolPreviewImages: Record<string, string> = {
|
||||
workbench: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/toolbox/image-workbench-20260609132455.png",
|
||||
inpaint: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%B1%80%E9%83%A8%E9%87%8D%E7%BB%98.PNG",
|
||||
camera: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E9%95%9C%E5%A4%B4%E5%AE%9E%E9%AA%8C%E5%AE%A4.PNG",
|
||||
upscale: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%88%86%E8%BE%A8%E7%8E%87%E6%8F%90%E5%8D%87.PNG",
|
||||
watermarkRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%8E%BB%E6%B0%B4%E5%8D%B0.PNG",
|
||||
dialogGenerator: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E4%BA%A4%E4%BA%92%E5%BC%8F%E5%AF%B9%E8%AF%9D%E6%A1%86%E7%94%9F%E6%88%90%E5%99%A8.PNG",
|
||||
subtitleRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%AD%97%E5%B9%95%E5%8E%BB%E9%99%A4.PNG",
|
||||
digitalHuman: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/toolbox/digital-human-20260609132455.png",
|
||||
characterMix: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E8%A7%92%E8%89%B2%E8%BF%81%E7%A7%BB.PNG",
|
||||
avatarConsole: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E6%95%B0%E5%AD%97%E4%BA%BA%E6%8E%A7%E5%88%B6%E5%8F%B0.PNG",
|
||||
};
|
||||
@@ -345,11 +347,12 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
|
||||
key={tool.id}
|
||||
type="button"
|
||||
className={`more-card more-card--featured${getPreviewClassName(tool.id)}`}
|
||||
style={{ "--card-gradient": coreToolGradients[tool.id] ?? "linear-gradient(135deg, rgba(var(--accent-rgb), 0.12), rgba(var(--accent-rgb), 0.04))" } as CSSProperties}
|
||||
style={{
|
||||
"--card-gradient": coreToolGradients[tool.id] ?? "linear-gradient(135deg, rgba(var(--accent-rgb), 0.12), rgba(var(--accent-rgb), 0.04))",
|
||||
} as CSSProperties}
|
||||
aria-label={`打开核心工具:${tool.title},${tool.text}`}
|
||||
onClick={() => openTool(tool)}
|
||||
>
|
||||
<span className="more-card__featured-icon">{tool.icon}</span>
|
||||
<div className="more-card__featured-body">
|
||||
<span className="more-card__featured-kicker">{categoryLabels[tool.category]}</span>
|
||||
<strong>{tool.title}</strong>
|
||||
@@ -388,7 +391,6 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
|
||||
onClick={() => openTool(tool)}
|
||||
disabled={!tool.ready}
|
||||
>
|
||||
<span className="more-card__icon">{tool.icon}</span>
|
||||
<span className="more-card__topline">
|
||||
{tool.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
|
||||
@@ -439,7 +439,7 @@ function ResolutionUpscalePage({
|
||||
</button>
|
||||
{sourcePreview ? (
|
||||
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
|
||||
×
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -459,13 +459,20 @@ function ResolutionUpscalePage({
|
||||
<section className="image-workbench-control-card">
|
||||
<h3>参数</h3>
|
||||
{mode === "image" ? (
|
||||
<label className="image-workbench-select">
|
||||
<span>放大倍数</span>
|
||||
<select value={imageScale} onChange={(event) => setImageScale(event.target.value as ImageScale)}>
|
||||
<option value="2x">2x</option>
|
||||
<option value="4x">4x</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="resolution-upscale-scale-options" role="radiogroup" aria-label="放大倍数">
|
||||
{(["2x", "4x"] as ImageScale[]).map((scale) => (
|
||||
<button
|
||||
key={scale}
|
||||
type="button"
|
||||
className={`resolution-upscale-scale-option${imageScale === scale ? " is-active" : ""}`}
|
||||
aria-pressed={imageScale === scale}
|
||||
onClick={() => setImageScale(scale)}
|
||||
>
|
||||
<strong>{scale}</strong>
|
||||
<span>{scale === "2x" ? "日常清晰增强" : "高倍细节修复"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="resolution-upscale-style-chips">
|
||||
@@ -548,6 +555,7 @@ function ResolutionUpscalePage({
|
||||
resultLabel={resultPreview ? resultSizeText : "等待结果"}
|
||||
sourceAlt="原图预览"
|
||||
resultAlt="超分结果预览"
|
||||
aspectRatio={sourceDimensions ? `${sourceDimensions.width} / ${sourceDimensions.height}` : undefined}
|
||||
onSourceLoad={(width, height) => setSourceDimensions({ width, height })}
|
||||
/>
|
||||
{resultPreview && (
|
||||
|
||||
@@ -360,7 +360,7 @@ function SubtitleRemovalPage({
|
||||
</button>
|
||||
{sourcePreview ? (
|
||||
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
|
||||
×
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -339,7 +339,7 @@ function WatermarkRemovalPage({
|
||||
</button>
|
||||
{sourcePreview ? (
|
||||
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
|
||||
×
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,22 @@ interface ConversationSidebarProps {
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const relativeMatch = dateStr.trim().match(/^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|mo|month|months|y|yr|year|years)\s+ago$/i);
|
||||
if (relativeMatch) {
|
||||
const value = Number(relativeMatch[1]);
|
||||
const unit = relativeMatch[2].toLowerCase();
|
||||
if (unit.startsWith("s")) return "刚刚";
|
||||
if (unit === "m" || unit.startsWith("min")) return `${value} 分钟前`;
|
||||
if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) return `${value} 小时前`;
|
||||
if (unit === "d" || unit.startsWith("day")) return `${value} 天前`;
|
||||
if (unit === "w" || unit.startsWith("week")) return `${value} 周前`;
|
||||
if (unit === "mo" || unit.startsWith("month")) return `${value} 个月前`;
|
||||
if (unit === "y" || unit.startsWith("yr") || unit.startsWith("year")) return `${value} 年前`;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
if (!Number.isFinite(then)) return dateStr;
|
||||
const diff = now - then;
|
||||
if (diff < 60_000) return "刚刚";
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
|
||||
|
||||
@@ -25,13 +25,26 @@ interface ProjectSidebarProps {
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const relativeMatch = dateStr.trim().match(/^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|mo|month|months|y|yr|year|years)\s+ago$/i);
|
||||
if (relativeMatch) {
|
||||
const value = Number(relativeMatch[1]);
|
||||
const unit = relativeMatch[2].toLowerCase();
|
||||
if (unit.startsWith("s")) return "刚刚";
|
||||
if (unit === "m" || unit.startsWith("min")) return `${value} 分钟前`;
|
||||
if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) return `${value} 小时前`;
|
||||
if (unit === "d" || unit.startsWith("day")) return `${value} 天前`;
|
||||
if (unit === "w" || unit.startsWith("week")) return `${value} 周前`;
|
||||
if (unit === "mo" || unit.startsWith("month")) return `${value} 个月前`;
|
||||
if (unit === "y" || unit.startsWith("yr") || unit.startsWith("year")) return `${value} 年前`;
|
||||
}
|
||||
|
||||
const then = new Date(dateStr).getTime();
|
||||
if (!Number.isFinite(then)) return "";
|
||||
if (!Number.isFinite(then)) return dateStr;
|
||||
const diff = Date.now() - then;
|
||||
if (diff < 60_000) return "just now";
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} h ago`;
|
||||
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)} d ago`;
|
||||
if (diff < 60_000) return "刚刚";
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`;
|
||||
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)} 天前`;
|
||||
return new Date(dateStr).toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
||||
import {
|
||||
DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
|
||||
useSmoothedProgress,
|
||||
type ProgressSource,
|
||||
} from "../../hooks/useSmoothedProgress";
|
||||
|
||||
type MessageStatus = "thinking" | "completed" | "failed" | string;
|
||||
|
||||
@@ -6,6 +10,9 @@ interface SmoothedProgressBarProps {
|
||||
progress: number;
|
||||
status: MessageStatus;
|
||||
label?: string;
|
||||
progressSource?: ProgressSource;
|
||||
startedAt?: number | string | Date | null;
|
||||
expectedDurationMs?: number | null;
|
||||
}
|
||||
|
||||
function mapMessageStatus(status: MessageStatus) {
|
||||
@@ -15,8 +22,19 @@ function mapMessageStatus(status: MessageStatus) {
|
||||
return "running" as const;
|
||||
}
|
||||
|
||||
export function SmoothedProgressBar({ progress, status, label }: SmoothedProgressBarProps) {
|
||||
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status));
|
||||
export function SmoothedProgressBar({
|
||||
progress,
|
||||
status,
|
||||
label,
|
||||
progressSource = "estimated",
|
||||
startedAt,
|
||||
expectedDurationMs = DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
|
||||
}: SmoothedProgressBarProps) {
|
||||
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status), {
|
||||
progressSource,
|
||||
startedAt,
|
||||
expectedDurationMs,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<span>{label || "超分处理中..."}</span>
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||
|
||||
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||
import { publicPricingClient, type PublicModelPrice } from "../../api/publicPricingClient";
|
||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||
import type { WebProjectSummary } from "../../types";
|
||||
import {
|
||||
@@ -58,6 +59,8 @@ import {
|
||||
import { translateTaskError } from "../../utils/translateTaskError";
|
||||
import {
|
||||
buildLocalTimeoutMessage,
|
||||
FALLBACK_TEXT_TOKEN_CREDIT_RATE,
|
||||
formatTextTokenCreditRule,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "../../utils/taskLifecycle";
|
||||
@@ -69,7 +72,12 @@ import {
|
||||
import { isViduModel } from "../../utils/viduRouting";
|
||||
import { isPixverseModel } from "../../utils/pixverseRouting";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
||||
import {
|
||||
calculateEnterpriseVideoCredits,
|
||||
ENTERPRISE_DEFAULT_VIDEO_MODEL,
|
||||
type EnterpriseVideoPricingConfig,
|
||||
} from "../../utils/enterpriseVideoPolicy";
|
||||
import { resolveTextTokenCreditRate } from "../../utils/modelPricing";
|
||||
import {
|
||||
getImageQualityOptionsForContext,
|
||||
getDefaultImageQuality,
|
||||
@@ -278,6 +286,7 @@ function WorkbenchPage({
|
||||
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
|
||||
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
|
||||
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
|
||||
const [promptCaseStatus, setPromptCaseStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [promptCaseMeasuredRatios, setPromptCaseMeasuredRatios] = useState<Record<string, number>>({});
|
||||
const [mentionPanelPlacement, setMentionPanelPlacement] = useState<"above" | "below">("above");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
@@ -403,9 +412,32 @@ function WorkbenchPage({
|
||||
const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value));
|
||||
|
||||
const [chatModel, setChatModel] = useState(CHAT_MODEL_OPTIONS[0].value);
|
||||
const [modelPrices, setModelPrices] = useState<PublicModelPrice[]>([]);
|
||||
const [enterpriseVideoPricing, setEnterpriseVideoPricing] = useState<EnterpriseVideoPricingConfig | null>(null);
|
||||
const [thinkingSpeed, setThinkingSpeed] = useState(THINKING_SPEED_OPTIONS[0].value);
|
||||
const [thinkingDepth, setThinkingDepth] = useState(THINKING_DEPTH_OPTIONS[0].value);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
publicPricingClient
|
||||
.getPricing()
|
||||
.then((pricing) => {
|
||||
if (cancelled) return;
|
||||
setModelPrices(pricing.modelPrices);
|
||||
setEnterpriseVideoPricing(pricing.enterpriseVideoPricing);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setModelPrices([]);
|
||||
setEnterpriseVideoPricing(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -524,6 +556,10 @@ function WorkbenchPage({
|
||||
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
|
||||
|
||||
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
|
||||
const selectedChatTokenRate = useMemo(
|
||||
() => resolveTextTokenCreditRate(modelPrices, chatModel) || FALLBACK_TEXT_TOKEN_CREDIT_RATE,
|
||||
[chatModel, modelPrices],
|
||||
);
|
||||
const billingEstimate = useMemo(() => {
|
||||
if (activeMode === "image") {
|
||||
return {
|
||||
@@ -540,7 +576,7 @@ function WorkbenchPage({
|
||||
durationSeconds,
|
||||
muted: false,
|
||||
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
|
||||
});
|
||||
}, enterpriseVideoPricing || undefined);
|
||||
return {
|
||||
label: `预计 ${formatCreditValue(credits)} 积分`,
|
||||
title: `${activeModel},${videoQualityLabel},${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
|
||||
@@ -552,16 +588,20 @@ function WorkbenchPage({
|
||||
};
|
||||
}
|
||||
}
|
||||
const textBillingPrefix =
|
||||
selectedChatTokenRate.source === "server" ? "文本计费" : "服务端价格暂不可用,按默认预估";
|
||||
return {
|
||||
label: "按 Token 结算",
|
||||
title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分",
|
||||
title: `${textBillingPrefix}:${activeModel},${formatTextTokenCreditRule(selectedChatTokenRate)}`,
|
||||
};
|
||||
}, [
|
||||
activeMode,
|
||||
activeModel,
|
||||
activeModelValue,
|
||||
imageSettingsSummary,
|
||||
enterpriseVideoPricing,
|
||||
referenceItems,
|
||||
selectedChatTokenRate,
|
||||
videoDuration,
|
||||
videoQuality,
|
||||
videoQualityLabel,
|
||||
@@ -718,6 +758,7 @@ function WorkbenchPage({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPromptCaseStatus("loading");
|
||||
communityClient
|
||||
.listApprovedCases({ limit: 100, tag: "生成页面社区", sort: "latest" })
|
||||
.then((items) => {
|
||||
@@ -727,10 +768,12 @@ function WorkbenchPage({
|
||||
.map(communityCaseToPromptCase)
|
||||
.filter((item): item is PromptCaseViewModel => Boolean(item)),
|
||||
);
|
||||
setPromptCaseStatus("ready");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setServerPromptCases([]);
|
||||
setPromptCaseStatus("error");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3371,30 +3414,48 @@ function WorkbenchPage({
|
||||
<div className="wb-showcase__header">
|
||||
<h2>图片提示词案例</h2>
|
||||
</div>
|
||||
<div className="wb-prompt-cases__grid">
|
||||
{promptCaseDisplayItems.map((item, index) => {
|
||||
const measuredRatio = promptCaseMeasuredRatios[item.id];
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={getPromptCaseCardClassName(item, index, measuredRatio)}
|
||||
onClick={() => setSelectedPromptCase(item)}
|
||||
>
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
onLoad={(event) => handlePromptCaseImageLoad(item.id, event)}
|
||||
/>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<em>{item.author}</em>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{promptCaseStatus === "loading" ? (
|
||||
<div className="wb-prompt-cases__grid wb-prompt-cases__grid--skeleton" aria-label="图片提示词案例加载中">
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<span key={index} className={`wb-prompt-case-skeleton wb-prompt-case-skeleton--${index % 4}`} />
|
||||
))}
|
||||
</div>
|
||||
) : promptCaseStatus === "error" ? (
|
||||
<div className="wb-prompt-cases__state">
|
||||
<strong>案例暂时没有加载成功</strong>
|
||||
<span>你仍然可以直接输入提示词开始生成。</span>
|
||||
</div>
|
||||
) : promptCaseDisplayItems.length === 0 ? (
|
||||
<div className="wb-prompt-cases__state">
|
||||
<strong>暂无可展示案例</strong>
|
||||
<span>稍后会在这里展示社区精选提示词。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="wb-prompt-cases__grid">
|
||||
{promptCaseDisplayItems.map((item, index) => {
|
||||
const measuredRatio = promptCaseMeasuredRatios[item.id];
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={getPromptCaseCardClassName(item, index, measuredRatio)}
|
||||
onClick={() => setSelectedPromptCase(item)}
|
||||
>
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
onLoad={(event) => handlePromptCaseImageLoad(item.id, event)}
|
||||
/>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<em>{item.author}</em>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,13 @@ import { assetClient } from "../../../api/assetClient";
|
||||
import { communityClient } from "../../../api/communityClient";
|
||||
import { saveAssetToLocalLibrary } from "../../assets/localAssetStore";
|
||||
import { SmoothedProgressBar } from "../SmoothedProgressBar";
|
||||
import { useSmoothedProgress } from "../../../hooks/useSmoothedProgress";
|
||||
import {
|
||||
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
|
||||
DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
|
||||
DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS,
|
||||
formatEstimatedRemainingLabel,
|
||||
useSmoothedProgress,
|
||||
} from "../../../hooks/useSmoothedProgress";
|
||||
import { renderMarkdownBlocks } from "../markdownRenderer";
|
||||
import { downloadResultAsset } from "../workbenchDownload";
|
||||
import type { WorkbenchChatAttachment, WorkbenchChatMessage, WorkbenchResultActionPayload } from "../workbenchChatTypes";
|
||||
@@ -456,6 +462,8 @@ export const ResultCard = memo(function ResultCard({
|
||||
progress={message.taskProgress ?? 18}
|
||||
status={message.status || "thinking"}
|
||||
label={message.taskStatusLabel || "超分处理中..."}
|
||||
progressSource="estimated"
|
||||
expectedDurationMs={DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -575,7 +583,23 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
|
||||
const specs = message.result?.specs || [];
|
||||
const prompt = message.prompt || message.body;
|
||||
const isVideo = message.mode === "video";
|
||||
const smoothed = useSmoothedProgress(message.taskProgress ?? 5, message.status === "thinking" ? "running" : "completed");
|
||||
const expectedDurationMs = isVideo
|
||||
? DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS
|
||||
: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS;
|
||||
const progressStatus = message.status === "thinking" ? "running" : "completed";
|
||||
const smoothed = useSmoothedProgress(message.taskProgress ?? 5, progressStatus, {
|
||||
progressSource: progressStatus === "running" ? "estimated" : "real",
|
||||
startedAt: message.createdAt,
|
||||
expectedDurationMs,
|
||||
});
|
||||
const remainingLabel = progressStatus === "running"
|
||||
? formatEstimatedRemainingLabel({
|
||||
nowMs: Date.now(),
|
||||
startedAt: message.createdAt,
|
||||
expectedDurationMs,
|
||||
})
|
||||
: null;
|
||||
const statusLabel = message.taskStatusLabel || (isVideo ? "视频生成中" : "图片生成中");
|
||||
|
||||
return (
|
||||
<div className={`ai-generation-pending-card${isVideo ? " is-video" : " is-image"}`}>
|
||||
@@ -590,7 +614,7 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
|
||||
</div>
|
||||
<div className="ai-generation-pending-card__meta">
|
||||
<div>
|
||||
<strong>{message.taskStatusLabel || "Generating..."}</strong>
|
||||
<strong>{remainingLabel ? `${statusLabel} / ${remainingLabel}` : statusLabel}</strong>
|
||||
<span>{prompt}</span>
|
||||
</div>
|
||||
{specs.length > 0 && (
|
||||
|
||||
@@ -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";
|
||||
|
||||
type ProgressStatus = "queued" | "running" | "submitting" | "completed" | "failed" | "success" | "error";
|
||||
type ProgressStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "submitting"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "success"
|
||||
| "error";
|
||||
export type ProgressSource = "real" | "estimated";
|
||||
|
||||
interface SmoothedProgressOptions {
|
||||
creepSpeed?: number;
|
||||
@@ -8,6 +16,11 @@ interface SmoothedProgressOptions {
|
||||
creepCeiling?: number;
|
||||
creepAhead?: number;
|
||||
completionDuration?: number;
|
||||
progressSource?: ProgressSource;
|
||||
startedAt?: number | string | Date | null;
|
||||
expectedDurationMs?: number | null;
|
||||
estimatedFloor?: number;
|
||||
estimatedCeiling?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CREEP_SPEED = 0.5;
|
||||
@@ -15,15 +28,109 @@ const DEFAULT_CHASE_RATE = 0.09;
|
||||
const DEFAULT_CREEP_CEILING = 97;
|
||||
const DEFAULT_CREEP_AHEAD = 25;
|
||||
const DEFAULT_COMPLETION_DURATION = 450;
|
||||
export const DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS = 120_000;
|
||||
export const DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS = 240_000;
|
||||
export const DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS = 180_000;
|
||||
export const DEFAULT_GENERATION_EXPECTED_DURATION_MS =
|
||||
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS;
|
||||
const DEFAULT_ESTIMATED_FLOOR = 12;
|
||||
const DEFAULT_ESTIMATED_CEILING = 92;
|
||||
const ESTIMATED_PROGRESS_CURVE = 2.4;
|
||||
const MIN_EXPECTED_DURATION_MS = 1_000;
|
||||
|
||||
function isTerminal(status: ProgressStatus): boolean {
|
||||
return status === "completed" || status === "success" || status === "failed" || status === "error";
|
||||
return (
|
||||
status === "completed" ||
|
||||
status === "success" ||
|
||||
status === "failed" ||
|
||||
status === "error"
|
||||
);
|
||||
}
|
||||
|
||||
function isSuccess(status: ProgressStatus): boolean {
|
||||
return status === "completed" || status === "success";
|
||||
}
|
||||
|
||||
export function resolveProgressStartedAt(
|
||||
value: number | string | Date | null | undefined,
|
||||
): number | null {
|
||||
if (value instanceof Date) {
|
||||
const time = value.getTime();
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
|
||||
const normalized = value.trim();
|
||||
const localMatch = normalized.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?/,
|
||||
);
|
||||
if (localMatch) {
|
||||
const [, year, month, day, hours, minutes, seconds] = localMatch;
|
||||
const time = new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hours),
|
||||
Number(minutes),
|
||||
Number(seconds || 0),
|
||||
).getTime();
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function calculateEstimatedProgress(input: {
|
||||
nowMs: number;
|
||||
startedAtMs: number;
|
||||
expectedDurationMs?: number | null;
|
||||
floor?: number;
|
||||
ceiling?: number;
|
||||
}): number {
|
||||
const floor = Math.max(
|
||||
0,
|
||||
Math.min(99, input.floor ?? DEFAULT_ESTIMATED_FLOOR),
|
||||
);
|
||||
const ceiling = Math.max(
|
||||
floor,
|
||||
Math.min(99, input.ceiling ?? DEFAULT_ESTIMATED_CEILING),
|
||||
);
|
||||
const duration = Math.max(
|
||||
MIN_EXPECTED_DURATION_MS,
|
||||
Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS,
|
||||
);
|
||||
const elapsed = Math.max(0, input.nowMs - input.startedAtMs);
|
||||
const ratio = elapsed / duration;
|
||||
const eased = 1 - Math.exp(-ratio * ESTIMATED_PROGRESS_CURVE);
|
||||
return Math.min(ceiling, floor + eased * (ceiling - floor));
|
||||
}
|
||||
|
||||
export function formatEstimatedRemainingLabel(input: {
|
||||
nowMs: number;
|
||||
startedAt: number | string | Date | null | undefined;
|
||||
expectedDurationMs?: number | null;
|
||||
}): string | null {
|
||||
const startedAtMs = resolveProgressStartedAt(input.startedAt);
|
||||
if (!startedAtMs) return null;
|
||||
const duration = Math.max(
|
||||
MIN_EXPECTED_DURATION_MS,
|
||||
Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS,
|
||||
);
|
||||
const remainingSeconds = Math.max(
|
||||
0,
|
||||
Math.ceil((startedAtMs + duration - input.nowMs) / 1000),
|
||||
);
|
||||
if (remainingSeconds <= 0) return "即将完成";
|
||||
if (remainingSeconds < 60) return `预计还需 ${remainingSeconds} 秒`;
|
||||
return `预计还需 ${Math.ceil(remainingSeconds / 60)} 分钟`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoothly interpolates between coarse server-reported progress values.
|
||||
* On completion, animates quickly to 100% instead of jumping.
|
||||
@@ -37,24 +144,59 @@ export function useSmoothedProgress(
|
||||
const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE;
|
||||
const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING;
|
||||
const creepAhead = options?.creepAhead ?? DEFAULT_CREEP_AHEAD;
|
||||
const completionDuration = options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
|
||||
const completionDuration =
|
||||
options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
|
||||
const progressSource = options?.progressSource ?? "real";
|
||||
const startedAt = options?.startedAt;
|
||||
const expectedDurationMs =
|
||||
options?.expectedDurationMs ?? DEFAULT_GENERATION_EXPECTED_DURATION_MS;
|
||||
const estimatedFloor = options?.estimatedFloor ?? DEFAULT_ESTIMATED_FLOOR;
|
||||
const estimatedCeiling =
|
||||
options?.estimatedCeiling ?? DEFAULT_ESTIMATED_CEILING;
|
||||
|
||||
const [displayed, setDisplayed] = useState(0);
|
||||
const rafRef = useRef(0);
|
||||
const targetRef = useRef(realProgress);
|
||||
const statusRef = useRef(status);
|
||||
const progressSourceRef = useRef(progressSource);
|
||||
const expectedDurationMsRef = useRef(expectedDurationMs);
|
||||
const estimatedFloorRef = useRef(estimatedFloor);
|
||||
const estimatedCeilingRef = useRef(estimatedCeiling);
|
||||
const estimatedStartedAtRef = useRef<number | null>(null);
|
||||
const completionStartRef = useRef<number | null>(null);
|
||||
const completionBaseRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
targetRef.current = realProgress;
|
||||
statusRef.current = status;
|
||||
progressSourceRef.current = progressSource;
|
||||
expectedDurationMsRef.current = expectedDurationMs;
|
||||
estimatedFloorRef.current = estimatedFloor;
|
||||
estimatedCeilingRef.current = estimatedCeiling;
|
||||
|
||||
if (progressSource === "estimated" && !isTerminal(status)) {
|
||||
estimatedStartedAtRef.current =
|
||||
resolveProgressStartedAt(startedAt) ??
|
||||
estimatedStartedAtRef.current ??
|
||||
Date.now();
|
||||
}
|
||||
|
||||
if (isSuccess(status) && completionStartRef.current === null) {
|
||||
completionStartRef.current = performance.now();
|
||||
completionBaseRef.current = displayed;
|
||||
} else if (!isSuccess(status)) {
|
||||
completionStartRef.current = null;
|
||||
}
|
||||
}, [realProgress, status]);
|
||||
}, [
|
||||
displayed,
|
||||
estimatedCeiling,
|
||||
estimatedFloor,
|
||||
expectedDurationMs,
|
||||
progressSource,
|
||||
realProgress,
|
||||
startedAt,
|
||||
status,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "failed" || status === "error") {
|
||||
@@ -82,6 +224,20 @@ export function useSmoothedProgress(
|
||||
|
||||
if (isTerminal(currentStatus)) return current;
|
||||
|
||||
if (progressSourceRef.current === "estimated") {
|
||||
const target = calculateEstimatedProgress({
|
||||
nowMs: Date.now(),
|
||||
startedAtMs: estimatedStartedAtRef.current ?? Date.now(),
|
||||
expectedDurationMs: expectedDurationMsRef.current,
|
||||
floor: estimatedFloorRef.current,
|
||||
ceiling: estimatedCeilingRef.current,
|
||||
});
|
||||
if (current >= target) return current;
|
||||
const gap = target - current;
|
||||
const step = (gap * chaseRate + 0.2) * dt * 60;
|
||||
return Math.min(current + step, target);
|
||||
}
|
||||
|
||||
const target = targetRef.current;
|
||||
|
||||
if (current >= target) {
|
||||
@@ -100,7 +256,14 @@ export function useSmoothedProgress(
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [status, chaseRate, creepSpeed, creepCeiling, creepAhead, completionDuration]);
|
||||
}, [
|
||||
status,
|
||||
chaseRate,
|
||||
creepSpeed,
|
||||
creepCeiling,
|
||||
creepAhead,
|
||||
completionDuration,
|
||||
]);
|
||||
|
||||
if (status === "failed" || status === "error") return Math.round(displayed);
|
||||
if (isSuccess(status) && displayed >= 99.5) return 100;
|
||||
|
||||
@@ -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 type { WebGenerationPreviewTask } from "../types";
|
||||
|
||||
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
@@ -17,6 +18,8 @@ export interface GenerationQueueItem {
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type PreviewTaskPatch = Partial<WebGenerationPreviewTask>;
|
||||
|
||||
interface PersistedQueueSnapshot {
|
||||
version: 1;
|
||||
items: GenerationQueueItem[];
|
||||
@@ -53,9 +56,14 @@ function persistQueue(items: GenerationQueueItem[]): void {
|
||||
|
||||
interface GenerationStoreState {
|
||||
queue: GenerationQueueItem[];
|
||||
tasks: WebGenerationPreviewTask[];
|
||||
addTask: (item: GenerationQueueItem) => void;
|
||||
updateTask: (id: string, patch: Partial<GenerationQueueItem>) => void;
|
||||
removeTask: (id: string) => void;
|
||||
setTasks: (tasks: WebGenerationPreviewTask[]) => void;
|
||||
appendTask: (task: WebGenerationPreviewTask) => void;
|
||||
mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void;
|
||||
clearTasks: () => void;
|
||||
getRunningTasks: () => GenerationQueueItem[];
|
||||
getPendingTasks: () => GenerationQueueItem[];
|
||||
getTasksByView: (sourceView: string) => GenerationQueueItem[];
|
||||
@@ -64,14 +72,87 @@ interface GenerationStoreState {
|
||||
|
||||
const initialQueue = loadPersistedQueue();
|
||||
|
||||
function trimTasks(tasks: WebGenerationPreviewTask[]): WebGenerationPreviewTask[] {
|
||||
return tasks.slice(0, MAX_ITEMS);
|
||||
}
|
||||
|
||||
function mergePreviewTaskById(
|
||||
tasks: WebGenerationPreviewTask[],
|
||||
taskId: string | undefined,
|
||||
patch: PreviewTaskPatch,
|
||||
): WebGenerationPreviewTask[] {
|
||||
if (!taskId) return tasks;
|
||||
let changed = false;
|
||||
const next = tasks.map((task) => {
|
||||
if (task.id !== taskId) return task;
|
||||
changed = true;
|
||||
return { ...task, ...patch };
|
||||
});
|
||||
return changed ? next : tasks;
|
||||
}
|
||||
|
||||
function toPreviewTaskStatus(status: GenerationQueueItem["status"]): WebGenerationPreviewTask["status"] {
|
||||
if (status === "pending") return "queued";
|
||||
if (status === "cancelled") return "failed";
|
||||
return status;
|
||||
}
|
||||
|
||||
function toPreviewTaskPatch(item: GenerationQueueItem): PreviewTaskPatch {
|
||||
const status = toPreviewTaskStatus(item.status);
|
||||
|
||||
return {
|
||||
status,
|
||||
progress: item.status === "completed" ? 100 : item.progress,
|
||||
outputUrl: item.resultUrl || undefined,
|
||||
errorMessage: item.error || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toPreviewTask(item: GenerationQueueItem): WebGenerationPreviewTask | null {
|
||||
if (item.type === "ecommerce-video") return null;
|
||||
const type = item.type;
|
||||
const createdAt = Number.isFinite(item.createdAt)
|
||||
? new Date(item.createdAt).toISOString()
|
||||
: new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: item.taskId || item.id,
|
||||
title: item.title,
|
||||
type,
|
||||
status: toPreviewTaskStatus(item.status),
|
||||
progress: item.status === "completed" ? 100 : item.progress,
|
||||
prompt: item.prompt,
|
||||
createdAt,
|
||||
projectId:
|
||||
typeof item.params?.projectId === "string" ? item.params.projectId : undefined,
|
||||
outputUrl: item.resultUrl || undefined,
|
||||
source: "preview",
|
||||
errorMessage: item.error || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertPreviewTask(
|
||||
tasks: WebGenerationPreviewTask[],
|
||||
task: WebGenerationPreviewTask | null,
|
||||
): WebGenerationPreviewTask[] {
|
||||
if (!task) return tasks;
|
||||
return trimTasks([task, ...tasks.filter((item) => item.id !== task.id)]);
|
||||
}
|
||||
|
||||
function previewTaskIdsForItem(item: GenerationQueueItem): string[] {
|
||||
return Array.from(new Set([item.taskId, item.id].filter(Boolean) as string[]));
|
||||
}
|
||||
|
||||
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
||||
queue: initialQueue,
|
||||
tasks: [],
|
||||
|
||||
addTask: (item) => {
|
||||
set((state) => {
|
||||
const next = [item, ...state.queue].slice(0, MAX_ITEMS);
|
||||
const previewTasks = upsertPreviewTask(state.tasks, toPreviewTask(item));
|
||||
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
|
||||
return { queue: next };
|
||||
return { queue: next, tasks: previewTasks };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -80,8 +161,16 @@ export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
||||
const next = state.queue.map((item) =>
|
||||
item.id === id ? { ...item, ...patch } : item,
|
||||
);
|
||||
const updated = next.find((item) => item.id === id);
|
||||
let previewTasks = state.tasks;
|
||||
if (updated) {
|
||||
const previewPatch = toPreviewTaskPatch(updated);
|
||||
for (const previewTaskId of previewTaskIdsForItem(updated)) {
|
||||
previewTasks = mergePreviewTaskById(previewTasks, previewTaskId, previewPatch);
|
||||
}
|
||||
}
|
||||
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
|
||||
return { queue: next };
|
||||
return { queue: next, tasks: previewTasks };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -93,6 +182,27 @@ export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
setTasks: (tasks) => set({ tasks: trimTasks(tasks) }),
|
||||
|
||||
appendTask: (task) => set((state) => ({
|
||||
tasks: trimTasks([task, ...state.tasks.filter((item) => item.id !== task.id)]),
|
||||
})),
|
||||
|
||||
mergeServerTasks: (serverTasks) => set((state) => {
|
||||
const serverIds = new Set(serverTasks.map((task) => task.id));
|
||||
return {
|
||||
tasks: trimTasks([
|
||||
...serverTasks,
|
||||
...state.tasks.filter((task) => !serverIds.has(task.id)),
|
||||
]),
|
||||
};
|
||||
}),
|
||||
|
||||
clearTasks: () => {
|
||||
persistQueue([]);
|
||||
set({ tasks: [], queue: [] });
|
||||
},
|
||||
|
||||
getRunningTasks: () => get().queue.filter((i) => i.status === "running" || i.status === "pending"),
|
||||
getPendingTasks: () => get().queue.filter((i) => i.status === "pending"),
|
||||
getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView),
|
||||
|
||||
@@ -1,36 +1 @@
|
||||
import { create } from 'zustand';
|
||||
import type { WebGenerationPreviewTask } from '../types';
|
||||
|
||||
interface TaskState {
|
||||
tasks: WebGenerationPreviewTask[];
|
||||
}
|
||||
|
||||
interface TaskActions {
|
||||
setTasks: (tasks: WebGenerationPreviewTask[]) => void;
|
||||
appendTask: (task: WebGenerationPreviewTask) => void;
|
||||
mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void;
|
||||
clearTasks: () => void;
|
||||
}
|
||||
|
||||
const initialState: TaskState = {
|
||||
tasks: [],
|
||||
};
|
||||
|
||||
export const useTaskStore = create<TaskState & TaskActions>((set) => ({
|
||||
...initialState,
|
||||
|
||||
setTasks: (tasks) => set({ tasks }),
|
||||
|
||||
appendTask: (task) => set((state) => ({
|
||||
tasks: [task, ...state.tasks],
|
||||
})),
|
||||
|
||||
mergeServerTasks: (serverTasks) => set((state) => {
|
||||
const serverIds = new Set(serverTasks.map((task) => task.id));
|
||||
return {
|
||||
tasks: [...serverTasks, ...state.tasks.filter((task) => !serverIds.has(task.id))].slice(0, 80),
|
||||
};
|
||||
}),
|
||||
|
||||
clearTasks: () => set({ tasks: [] }),
|
||||
}));
|
||||
export { useGenerationStore as useTaskStore } from "./useGenerationStore";
|
||||
|
||||
+533
-15
@@ -402,21 +402,6 @@
|
||||
transform: translateZ(20px) scale(1.02);
|
||||
}
|
||||
|
||||
.omni-home__carousel-card-label {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 14px;
|
||||
z-index: 2;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--accent-rgb, 0, 255, 136), 0.16);
|
||||
border: 1px solid rgba(var(--accent-rgb, 0, 255, 136), 0.24);
|
||||
color: var(--fg-body, #f3f5f2);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-home__carousel-card:hover {
|
||||
box-shadow:
|
||||
0 28px 58px rgb(0 0 0 / 34%),
|
||||
@@ -2479,3 +2464,536 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Home page product polish and responsive hardening ===== */
|
||||
.web-shell[data-view="home"] .omni-home {
|
||||
--home-card-radius: 14px;
|
||||
--home-panel-radius: 18px;
|
||||
--home-safe-inline: clamp(16px, 4.6vw, 72px);
|
||||
scroll-padding-top: 16px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__scrim {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(5, 8, 13, 0.82), rgba(5, 8, 13, 0.52) 38%, rgba(5, 8, 13, 0.9)),
|
||||
radial-gradient(circle at 50% 24%, rgba(var(--accent-rgb), 0.1), transparent 36%),
|
||||
linear-gradient(90deg, rgba(5, 8, 13, 0.86), rgba(5, 8, 13, 0.48) 48%, rgba(5, 8, 13, 0.86));
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__shell {
|
||||
padding: clamp(28px, 4.4vw, 58px) var(--home-safe-inline) clamp(34px, 4.8vw, 70px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__hero {
|
||||
width: min(100%, 1240px);
|
||||
gap: clamp(16px, 2vw, 24px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__copy {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__copy h1 {
|
||||
max-width: min(100%, 920px);
|
||||
white-space: normal;
|
||||
text-wrap: balance;
|
||||
font-size: clamp(34px, 4vw, 62px);
|
||||
letter-spacing: 0;
|
||||
text-shadow: 0 18px 54px rgb(0 0 0 / 34%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__copy p {
|
||||
max-width: 680px;
|
||||
color: rgb(235 245 240 / 72%);
|
||||
font-size: clamp(14px, 1.25vw, 18px);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__actions {
|
||||
width: min(100%, 620px);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry {
|
||||
min-height: 64px;
|
||||
border-color: rgb(255 255 255 / 11%);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 7%), rgb(255 255 255 / 3%)),
|
||||
rgb(12 16 17 / 78%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 8%),
|
||||
0 16px 34px rgb(0 0 0 / 22%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry > span:not(.anticon) {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
justify-items: center;
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry small {
|
||||
color: rgb(235 245 240 / 48%);
|
||||
font-size: 11px;
|
||||
font-weight: 760;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry--primary small {
|
||||
color: rgb(6 16 20 / 62%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry:hover {
|
||||
border-color: rgb(0 255 136 / 34%);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%)),
|
||||
rgb(17 22 22 / 88%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry--primary,
|
||||
.web-shell[data-view="home"] .omni-home__entry--primary:hover {
|
||||
border-color: rgb(0 255 136 / 82%);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(88 255 172 / 100%), rgb(0 255 136 / 100%));
|
||||
box-shadow:
|
||||
0 18px 42px rgb(0 255 136 / 14%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 28%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel {
|
||||
width: min(100%, 1180px);
|
||||
min-height: clamp(380px, 36vw, 560px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage {
|
||||
width: min(100%, 1180px);
|
||||
height: clamp(360px, 34vw, 530px);
|
||||
overflow: hidden;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 5%, #000 95%, transparent 100%);
|
||||
mask-image: linear-gradient(90deg, transparent 0%, #000 5%, #000 95%, transparent 100%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card {
|
||||
width: clamp(500px, 45vw, 760px);
|
||||
height: clamp(281px, 25.31vw, 428px);
|
||||
border: 1px solid rgb(255 255 255 / 5%);
|
||||
border-radius: var(--home-panel-radius);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 4%), rgb(0 0 0 / 5%)),
|
||||
#07100f;
|
||||
box-shadow:
|
||||
0 18px 46px rgb(0 0 0 / 30%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 8%),
|
||||
inset 0 0 0 1px rgb(255 255 255 / 2%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card::before {
|
||||
background:
|
||||
linear-gradient(180deg, rgb(2 12 12 / 18%), transparent 34%, rgb(3 9 10 / 30%)),
|
||||
linear-gradient(90deg, rgb(4 10 12 / 38%), transparent 24%, transparent 76%, rgb(4 10 12 / 38%)),
|
||||
radial-gradient(circle at 50% 58%, rgb(0 255 136 / 8%), transparent 54%);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(255 255 255 / 4%),
|
||||
inset 0 -42px 64px rgb(2 8 9 / 32%),
|
||||
inset 0 34px 56px rgb(255 255 255 / 4%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card.is-active {
|
||||
border-color: rgb(255 255 255 / 7%);
|
||||
box-shadow:
|
||||
0 26px 70px rgb(0 0 0 / 38%),
|
||||
0 0 0 1px rgb(255 255 255 / 4%),
|
||||
0 0 46px rgb(0 0 0 / 18%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card:not(.is-active) {
|
||||
filter: saturate(0.62) brightness(0.66) contrast(0.96);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage .omni-home__carousel-card img,
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage .omni-home__carousel-card.is-active img {
|
||||
object-fit: contain;
|
||||
transform: translateZ(12px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage .omni-home__carousel-card img {
|
||||
filter:
|
||||
saturate(0.72)
|
||||
contrast(0.98)
|
||||
brightness(0.72)
|
||||
hue-rotate(8deg)
|
||||
drop-shadow(0 18px 18px rgb(0 0 0 / 18%));
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage .omni-home__carousel-card.is-active img {
|
||||
filter:
|
||||
saturate(0.9)
|
||||
contrast(1.03)
|
||||
brightness(0.88)
|
||||
hue-rotate(6deg)
|
||||
drop-shadow(0 22px 22px rgb(0 0 0 / 18%));
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page {
|
||||
padding-inline: var(--home-safe-inline);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page::before {
|
||||
background:
|
||||
linear-gradient(90deg, rgb(5 8 13 / 96%) 0%, rgb(5 8 13 / 80%) 40%, rgb(5 8 13 / 52%) 100%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 4%), transparent 34%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-visual {
|
||||
border-radius: var(--home-panel-radius);
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 5%), rgb(255 255 255 / 2%)),
|
||||
#0d1111;
|
||||
box-shadow:
|
||||
0 24px 68px rgb(0 0 0 / 34%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-copy h2,
|
||||
.web-shell[data-view="home"] .omni-home__experience-copy h2 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-copy p,
|
||||
.web-shell[data-view="home"] .omni-home__experience-copy p {
|
||||
color: rgb(232 238 236 / 74%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-script,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-model,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-ecommerce {
|
||||
padding-inline: clamp(10px, 1.8vw, 28px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-script .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-model .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-ecommerce .omni-home__feature-visual {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience {
|
||||
background:
|
||||
linear-gradient(112deg, rgb(0 255 136 / 10%) 0 1px, transparent 1px 20%),
|
||||
linear-gradient(68deg, rgb(0 255 136 / 7%) 0 1px, transparent 1px 22%),
|
||||
linear-gradient(180deg, #070b10 0%, #05080d 100%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-route {
|
||||
border-radius: var(--home-card-radius);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] :is(
|
||||
.omni-home__shell,
|
||||
.omni-home__feature-page,
|
||||
.omni-home__toolbox-page,
|
||||
.omni-home__experience
|
||||
) {
|
||||
scroll-margin-top: 64px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent {
|
||||
right: clamp(14px, 2vw, 24px);
|
||||
bottom: 12px;
|
||||
width: min(560px, calc(100vw - 28px));
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-color: rgb(0 255 136 / 20%);
|
||||
border-radius: 13px;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 6%), rgb(255 255 255 / 3%)),
|
||||
rgb(13 17 18 / 92%);
|
||||
box-shadow:
|
||||
0 18px 46px rgb(0 0 0 / 30%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent p {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1.32;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent__actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent__actions a,
|
||||
.web-shell[data-view="home"] .cookie-consent__actions button {
|
||||
min-height: 32px;
|
||||
padding-inline: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.web-shell[data-view="home"] .omni-home__carousel {
|
||||
min-height: clamp(340px, 42vw, 500px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage {
|
||||
width: min(100%, 1020px);
|
||||
height: clamp(320px, 39vw, 470px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card {
|
||||
width: clamp(470px, 57vw, 660px);
|
||||
height: clamp(264px, 32.06vw, 371px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.web-shell[data-view="home"] .omni-home {
|
||||
scroll-snap-type: none;
|
||||
overscroll-behavior-y: auto;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__shell {
|
||||
min-height: auto;
|
||||
padding-top: clamp(24px, 4vw, 38px);
|
||||
padding-bottom: clamp(34px, 6vw, 58px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__hero {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__actions {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry {
|
||||
min-height: 58px;
|
||||
padding-inline: 18px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry small {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel {
|
||||
width: min(100%, 820px);
|
||||
min-height: clamp(290px, 54vw, 430px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage {
|
||||
width: min(100%, 820px);
|
||||
height: clamp(280px, 51vw, 410px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-deck {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page,
|
||||
.web-shell[data-view="home"] .omni-home__experience {
|
||||
min-height: auto;
|
||||
padding-block: clamp(44px, 7vw, 72px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-copy h2,
|
||||
.web-shell[data-view="home"] .omni-home__experience-copy h2 {
|
||||
font-size: clamp(34px, 7vw, 58px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-copy p,
|
||||
.web-shell[data-view="home"] .omni-home__experience-copy p {
|
||||
font-size: clamp(15px, 2.4vw, 20px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-script .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-model .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-ecommerce .omni-home__feature-visual {
|
||||
height: auto;
|
||||
min-height: clamp(520px, 78vw, 720px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-visual {
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.web-shell[data-view="home"] .omni-home__copy h1 {
|
||||
font-size: clamp(30px, 9vw, 44px);
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__copy p {
|
||||
max-width: 520px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__actions {
|
||||
grid-template-columns: 1fr;
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry {
|
||||
justify-content: center;
|
||||
min-height: 54px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry > span:not(.anticon) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel {
|
||||
min-height: clamp(260px, 70vw, 360px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage {
|
||||
height: clamp(248px, 66vw, 340px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-deck {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card {
|
||||
width: min(94vw, 520px);
|
||||
height: min(52.88vw, 292px);
|
||||
min-height: 206px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-stats {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
margin-top: 18px;
|
||||
max-width: none;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-routes {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-route {
|
||||
min-height: 84px;
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-route b {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.web-shell[data-view="home"] .omni-home {
|
||||
--home-safe-inline: 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__shell {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__hero {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__copy {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel {
|
||||
min-height: clamp(228px, 68vw, 286px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage {
|
||||
height: clamp(218px, 64vw, 274px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-deck {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card {
|
||||
width: 94vw;
|
||||
height: 52.88vw;
|
||||
min-height: 188px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page,
|
||||
.web-shell[data-view="home"] .omni-home__experience {
|
||||
padding-block: 34px 44px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] :is(
|
||||
.omni-home__shell,
|
||||
.omni-home__feature-page,
|
||||
.omni-home__toolbox-page,
|
||||
.omni-home__experience
|
||||
) {
|
||||
scroll-margin-top: 60px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-script .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-model .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-ecommerce .omni-home__feature-visual {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-routes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent {
|
||||
right: 10px;
|
||||
bottom: max(10px, env(safe-area-inset-bottom));
|
||||
grid-template-columns: 1fr;
|
||||
width: calc(100vw - 20px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent p {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: initial;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent__actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent__actions a,
|
||||
.web-shell[data-view="home"] .cookie-consent__actions button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+11
-32
@@ -216,10 +216,10 @@
|
||||
|
||||
.more-card--featured {
|
||||
display: grid;
|
||||
grid-template-columns: 54px minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
justify-items: stretch;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
min-height: 336px;
|
||||
padding: 20px;
|
||||
border-color: rgba(var(--accent-rgb), 0.2);
|
||||
@@ -251,22 +251,6 @@
|
||||
box-shadow: var(--more-card-shadow), 0 0 0 1px rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
|
||||
.more-card__featured-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.24);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(var(--accent-rgb), 0.08)),
|
||||
var(--bg-inset);
|
||||
color: var(--accent);
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.more-card__featured-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -281,7 +265,12 @@
|
||||
.more-card--featured .more-card__preview {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.more-card--featured .more-card__preview-frame img {
|
||||
padding: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.more-card--featured.more-card--no-preview {
|
||||
@@ -449,7 +438,7 @@
|
||||
}
|
||||
|
||||
.more-card__icon {
|
||||
display: grid;
|
||||
display: none;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
@@ -464,6 +453,7 @@
|
||||
}
|
||||
|
||||
.more-card--recent .more-card__icon {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 14px;
|
||||
@@ -1221,18 +1211,12 @@
|
||||
}
|
||||
|
||||
.more-card--featured {
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.more-card__featured-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.more-card__featured-body strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -1312,11 +1296,6 @@
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.more-card__featured-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.more-card {
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
|
||||
@@ -684,12 +684,6 @@
|
||||
}
|
||||
|
||||
@container (max-width: 1120px) {
|
||||
.omni-script-review-showcase {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.srs-left-panel {
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 16px;
|
||||
@@ -711,13 +705,32 @@
|
||||
}
|
||||
|
||||
.srs-results-panel {
|
||||
grid-template-rows: auto minmax(320px, auto) auto;
|
||||
grid-template-rows: auto minmax(300px, auto) auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.omni-script-review-showcase {
|
||||
padding: 22px;
|
||||
@container (max-width: 880px) {
|
||||
.srs-brand-section {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.srs-brand-section h1 {
|
||||
max-width: 100%;
|
||||
font-size: clamp(28px, 8cqw, 44px);
|
||||
}
|
||||
|
||||
.srs-brand-section p {
|
||||
font-size: clamp(14px, 3.2cqw, 17px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.srs-point-card {
|
||||
min-height: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.srs-results-panel {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.srs-score-hero {
|
||||
@@ -729,33 +742,118 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.srs-chart-body,
|
||||
.srs-triple-section,
|
||||
.srs-point-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.srs-chart-col {
|
||||
grid-template-columns: minmax(70px, 92px) minmax(0, 1fr);
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.srs-chart-bar-wrap {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.srs-chart-col-label {
|
||||
text-align: left;
|
||||
.srs-score-summary {
|
||||
font-size: clamp(13px, 3cqw, 16px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.omni-script-review-showcase {
|
||||
@container (max-width: 720px) {
|
||||
.srs-chart-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.srs-chart-title {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.srs-chart-body {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.srs-chart-col {
|
||||
grid-template-columns: minmax(94px, 0.36fr) minmax(0, 1fr);
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
justify-items: stretch;
|
||||
min-height: 46px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.055);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.srs-chart-bar-wrap {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.srs-chart-bar-bg,
|
||||
.srs-chart-bar-fill {
|
||||
width: 100%;
|
||||
height: 100% !important;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.srs-chart-bar-bg {
|
||||
left: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.srs-chart-bar-fill {
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.srs-chart-col:nth-child(1) .srs-chart-bar-fill { width: 80%; }
|
||||
.srs-chart-col:nth-child(2) .srs-chart-bar-fill { width: 80%; }
|
||||
.srs-chart-col:nth-child(3) .srs-chart-bar-fill { width: 100%; }
|
||||
.srs-chart-col:nth-child(4) .srs-chart-bar-fill { width: 80%; }
|
||||
.srs-chart-col:nth-child(5) .srs-chart-bar-fill { width: 66%; }
|
||||
.srs-chart-col:nth-child(6) .srs-chart-bar-fill { width: 53%; }
|
||||
|
||||
.srs-chart-bar-score {
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
left: auto;
|
||||
font-size: 12px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.srs-chart-bar-sub,
|
||||
.srs-chart-bar-star {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.srs-chart-col-label {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.srs-chart-col-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.srs-chart-col-desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.srs-triple-section {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.srs-section-card {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.srs-section-item-text {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
-webkit-line-clamp: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 560px) {
|
||||
.srs-flow-card {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: stretch;
|
||||
@@ -769,4 +867,45 @@
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.srs-score-tags {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.srs-score-tag {
|
||||
min-height: 24px;
|
||||
padding-inline: 9px;
|
||||
}
|
||||
|
||||
.srs-chart-col {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.srs-chart-bar-wrap,
|
||||
.srs-chart-col-label {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.omni-script-review-showcase {
|
||||
grid-template-columns: 1fr;
|
||||
gap: clamp(16px, 2.8vw, 24px);
|
||||
overflow-y: visible;
|
||||
padding: clamp(20px, 3vw, 34px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.omni-script-review-showcase {
|
||||
padding: clamp(16px, 3.8vw, 24px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.omni-script-review-showcase {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,50 +2,74 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.subtitle-removal-preset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
min-height: 92px;
|
||||
padding: 10px 8px;
|
||||
border: 1.5px solid var(--border-weak, #e5e5e5);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--border-subtle, #333) 82%, white 10%);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.018), transparent),
|
||||
var(--bg-inset, #111);
|
||||
color: var(--fg-muted, #aaa);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.subtitle-removal-preset:hover {
|
||||
border-color: var(--accent, #0d9488);
|
||||
background: var(--bg-elevated, #f8f8f8);
|
||||
border-color: color-mix(in srgb, var(--accent, #0d9488) 42%, var(--border-subtle, #333));
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--accent, #0d9488) 8%, transparent), transparent),
|
||||
var(--bg-hover, #171717);
|
||||
color: var(--fg-body, #eee);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.subtitle-removal-preset.is-active {
|
||||
border-color: var(--accent, #0d9488);
|
||||
background: color-mix(in srgb, var(--accent, #0d9488) 6%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent, #0d9488) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--accent, #0d9488) 13%, var(--bg-inset, #111));
|
||||
color: var(--accent, #0d9488);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #0d9488) 8%, transparent) inset;
|
||||
}
|
||||
|
||||
.subtitle-removal-preset strong {
|
||||
max-width: 100%;
|
||||
color: var(--fg-body, #eee);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle-removal-preset span {
|
||||
max-width: 100%;
|
||||
color: var(--fg-muted, #aaa);
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
line-height: 1.35;
|
||||
opacity: 0.82;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle-removal-preset__visual {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-page, #f0f0f0);
|
||||
border: 1px solid var(--border-weak, #e0e0e0);
|
||||
width: 54px;
|
||||
height: 34px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-subtle, #333) 78%, white 10%);
|
||||
border-radius: 6px;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.026), transparent),
|
||||
color-mix(in srgb, var(--bg-elevated, #161616) 92%, black 8%);
|
||||
box-shadow: 0 1px 0 rgb(255 255 255 / 0.035) inset;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -55,9 +79,9 @@
|
||||
right: 0;
|
||||
top: var(--region-top);
|
||||
height: var(--region-height);
|
||||
background: color-mix(in srgb, var(--accent, #0d9488) 30%, transparent);
|
||||
border-top: 1.5px dashed var(--accent, #0d9488);
|
||||
border-bottom: 1.5px dashed var(--accent, #0d9488);
|
||||
background: color-mix(in srgb, var(--accent, #0d9488) 24%, transparent);
|
||||
border-top: 1px dashed var(--accent, #0d9488);
|
||||
border-bottom: 1px dashed var(--accent, #0d9488);
|
||||
}
|
||||
|
||||
.subtitle-removal-preview {
|
||||
|
||||
@@ -889,3 +889,252 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Home toolbox polish and responsive hardening ===== */
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-page {
|
||||
--toolbox-radius-card: 16px;
|
||||
--toolbox-radius-inner: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, #070b10 0%, #05080d 100%),
|
||||
radial-gradient(ellipse 70% 48% at 58% 42%, rgba(0, 255, 136, 0.045) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-shell {
|
||||
width: min(100%, 1440px);
|
||||
margin-inline: auto;
|
||||
padding: clamp(34px, 5vw, 64px) clamp(18px, 5vw, 72px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-left {
|
||||
width: clamp(300px, 28vw, 420px);
|
||||
gap: 14px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 13px;
|
||||
box-shadow: 0 16px 32px rgba(0, 255, 136, 0.12);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon .anticon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-brand-text {
|
||||
font-size: clamp(24px, 2.3vw, 32px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-title {
|
||||
color: #f7fff9;
|
||||
background: none;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
font-size: clamp(32px, 3.4vw, 46px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-subtitle {
|
||||
color: rgba(232, 238, 236, 0.68);
|
||||
font-size: clamp(15px, 1.18vw, 17px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item,
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow,
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.065), rgba(255, 255, 255, 0.028)),
|
||||
rgba(10, 15, 16, 0.78);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.075),
|
||||
0 18px 42px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item:hover {
|
||||
border-color: rgba(0, 255, 136, 0.24);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 11px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item-desc {
|
||||
font-size: 13px;
|
||||
color: rgba(232, 238, 236, 0.48);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow {
|
||||
margin-top: 4px;
|
||||
border-radius: 14px;
|
||||
padding: 15px 17px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow-label {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow-steps {
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-grid {
|
||||
gap: 14px;
|
||||
min-height: clamp(500px, 48vw, 680px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card {
|
||||
border-radius: var(--toolbox-radius-card);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(0, 255, 136, 0.24);
|
||||
box-shadow:
|
||||
0 22px 54px rgba(0, 0, 0, 0.28),
|
||||
0 0 0 1px rgba(0, 255, 136, 0.07);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-header {
|
||||
padding: 15px 16px 0;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-content {
|
||||
padding: 10px 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-footer {
|
||||
padding: 7px 16px 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] :is(.toolbox-card1-side, .toolbox-card3-side, .toolbox-card4-side),
|
||||
.web-shell[data-view="home"] :is(.toolbox-card1-img, .toolbox-card3-portrait, .toolbox-card4-img),
|
||||
.web-shell[data-view="home"] .toolbox-card2-frame {
|
||||
border-radius: var(--toolbox-radius-inner);
|
||||
}
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-shell {
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-left {
|
||||
width: clamp(280px, 32vw, 360px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-grid {
|
||||
min-height: clamp(460px, 58vw, 620px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-page {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-shell {
|
||||
min-height: auto;
|
||||
padding-block: clamp(42px, 7vw, 64px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-left {
|
||||
width: min(100%, 760px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card {
|
||||
min-height: clamp(230px, 34vw, 300px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-shell {
|
||||
padding-inline: 14px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-brand-text {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-title {
|
||||
font-size: clamp(26px, 7vw, 34px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-list,
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card {
|
||||
min-height: 236px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-header {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow-steps {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-footer {
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2319,3 +2319,669 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Workbench new-conversation commercial polish. */
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page {
|
||||
--wb-elevated-line: rgba(210, 255, 232, 0.12);
|
||||
--wb-elevated-line-active: rgba(var(--accent-rgb), 0.38);
|
||||
--wb-elevated-fill: rgba(18, 22, 21, 0.94);
|
||||
--wb-control-fill: rgba(255, 255, 255, 0.052);
|
||||
--wb-control-fill-hover: rgba(var(--accent-rgb), 0.105);
|
||||
--wb-copy-dim: rgba(202, 215, 207, 0.68);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__title {
|
||||
color: rgba(246, 250, 247, 0.96);
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__content {
|
||||
border-color: var(--wb-elevated-line);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.072), rgba(255, 255, 255, 0.024)),
|
||||
var(--wb-elevated-fill);
|
||||
box-shadow:
|
||||
0 20px 46px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.065);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__content:focus-within {
|
||||
border-color: var(--wb-elevated-line-active);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(var(--accent-rgb), 0.12),
|
||||
0 24px 56px rgba(0, 0, 0, 0.32),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__textarea,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__highlight {
|
||||
color: rgba(246, 250, 247, 0.95);
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-top-color: rgba(210, 255, 232, 0.095);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-left {
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-inline-chip__trigger,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-mode-switch__button,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-select-chip__trigger {
|
||||
border-color: rgba(210, 255, 232, 0.105);
|
||||
background: var(--wb-control-fill);
|
||||
color: rgba(246, 250, 247, 0.86);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-inline-chip__trigger:hover:not(:disabled),
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-select-chip__trigger:hover,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-mode-switch__button:hover {
|
||||
border-color: rgba(var(--accent-rgb), 0.34);
|
||||
background: var(--wb-control-fill-hover);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__billing-estimate {
|
||||
color: var(--wb-copy-dim);
|
||||
font-size: 11px;
|
||||
font-weight: 580;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__send-primary {
|
||||
box-shadow:
|
||||
0 12px 26px rgba(var(--accent-rgb), 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__send-primary.is-loading {
|
||||
cursor: progress;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-upload {
|
||||
border-color: rgba(var(--accent-rgb), 0.36);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.11), rgba(var(--accent-rgb), 0.045)),
|
||||
rgba(255, 255, 255, 0.02);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-label,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-count {
|
||||
color: rgba(210, 255, 232, 0.84);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__suggestions {
|
||||
align-items: center;
|
||||
margin-top: -2px;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-suggestion-chip {
|
||||
border-color: rgba(210, 255, 232, 0.105);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.052), rgba(255, 255, 255, 0.022)),
|
||||
rgba(255, 255, 255, 0.018);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-suggestion-chip__icon {
|
||||
color: rgba(var(--accent-rgb), 0.88);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases .wb-showcase__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-showcase__header h2 {
|
||||
color: rgba(224, 236, 229, 0.72);
|
||||
font-size: 12px;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card {
|
||||
border-color: rgba(210, 255, 232, 0.085);
|
||||
background: #090e0d;
|
||||
box-shadow:
|
||||
0 14px 28px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card:hover,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card:focus-visible {
|
||||
border-color: rgba(var(--accent-rgb), 0.32);
|
||||
box-shadow:
|
||||
0 18px 36px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(var(--accent-rgb), 0.08);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card > div {
|
||||
gap: 5px;
|
||||
background:
|
||||
linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.54) 24%, rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card strong {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card em {
|
||||
color: rgba(210, 224, 216, 0.68);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
min-height: 188px;
|
||||
padding: 26px;
|
||||
border: 1px dashed rgba(210, 255, 232, 0.13);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.014)),
|
||||
rgba(255, 255, 255, 0.018);
|
||||
color: rgba(246, 250, 247, 0.9);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state strong {
|
||||
font-size: 14px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state span {
|
||||
color: var(--wb-copy-dim);
|
||||
font-size: 12px;
|
||||
font-weight: 520;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton {
|
||||
display: block;
|
||||
min-height: 160px;
|
||||
grid-row: span 16;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(210, 255, 232, 0.07);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.035)),
|
||||
rgba(255, 255, 255, 0.025);
|
||||
background-size: 220% 100%;
|
||||
animation: wb-prompt-case-loading 1.15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton--1,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton--3 {
|
||||
grid-row: span 22;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .ai-chat-message-list > .conversation-sidebar__empty {
|
||||
border-color: rgba(210, 255, 232, 0.14);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.018)),
|
||||
rgba(255, 255, 255, 0.018);
|
||||
color: rgba(210, 224, 216, 0.78);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar {
|
||||
border-left-color: rgba(210, 255, 232, 0.095);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 40%),
|
||||
rgba(12, 15, 15, 0.97);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__header {
|
||||
border-bottom-color: rgba(210, 255, 232, 0.095);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__new {
|
||||
border-color: rgba(var(--accent-rgb), 0.38);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.17), rgba(var(--accent-rgb), 0.095)),
|
||||
rgba(255, 255, 255, 0.02);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__item {
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__item:hover {
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__item-title {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .conversation-sidebar__empty {
|
||||
border-color: rgba(210, 255, 232, 0.12);
|
||||
background: rgba(255, 255, 255, 0.022);
|
||||
}
|
||||
|
||||
@keyframes wb-prompt-case-loading {
|
||||
0% {
|
||||
background-position: 120% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -120% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .ai-workbench-shell > .conversation-sidebar.is-collapsed {
|
||||
inset: calc(56px + var(--dg-mobile-nav-space, 70px) + 16px) 8px auto auto !important;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__composer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__content,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__content {
|
||||
padding: 13px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-upload {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-upload:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__textarea,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__highlight,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__textarea,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__highlight {
|
||||
min-height: 88px;
|
||||
padding-right: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
padding-top: 9px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-left {
|
||||
max-width: none;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-left::-webkit-scrollbar,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__suggestions::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__toolbar-right {
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
justify-items: end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__billing-estimate {
|
||||
max-width: 112px;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__state {
|
||||
min-height: 156px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home {
|
||||
padding-right: 12px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__title {
|
||||
font-size: 23px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__input-row {
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-inline-chip__trigger,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-mode-switch__button,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-select-chip__trigger {
|
||||
height: 32px;
|
||||
max-width: 132px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-suggestion-chip {
|
||||
min-height: 32px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases__grid {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Browser feedback: scale the launch composer with large canvases and keep reference previews intact. */
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__composer {
|
||||
width: min(100%, clamp(920px, 72vw, 1160px));
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__content {
|
||||
padding: clamp(18px, 1.25vw, 24px);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__textarea,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__highlight {
|
||||
min-height: clamp(78px, 8svh, 112px);
|
||||
max-height: clamp(180px, 24svh, 260px);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__suggestions {
|
||||
max-width: min(100%, clamp(920px, 72vw, 1160px));
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom {
|
||||
z-index: 12;
|
||||
width: min(280px, calc(100vw - 48px));
|
||||
height: auto;
|
||||
min-height: 188px;
|
||||
max-height: min(340px, calc(100svh - 180px));
|
||||
aspect-ratio: 1 / 1;
|
||||
padding: 8px;
|
||||
border-color: rgba(210, 255, 232, 0.18);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
|
||||
rgba(5, 8, 8, 0.96);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom img,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom video {
|
||||
border-radius: 11px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__composer,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__suggestions {
|
||||
width: min(100%, clamp(1040px, 64vw, 1240px));
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__composer,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-home__suggestions {
|
||||
width: 100%;
|
||||
max-width: 920px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__content {
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__textarea,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__highlight {
|
||||
min-height: 88px;
|
||||
max-height: 190px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-zoom {
|
||||
width: min(230px, calc(100vw - 32px));
|
||||
min-height: 168px;
|
||||
}
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-stack {
|
||||
top: calc(100% + 12px);
|
||||
bottom: auto;
|
||||
isolation: isolate;
|
||||
z-index: 120;
|
||||
width: min(320px, calc(100vw - 64px));
|
||||
max-width: calc(100vw - 64px);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-zoom {
|
||||
top: 0;
|
||||
transform: translateY(0) scale(0.98);
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-card:hover .wb-composer__ref-zoom,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* Keep reference previews above the feed, and open lower-row thumbnails upward. */
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__composer {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home__suggestions {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-cases {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs.has-items.is-open {
|
||||
z-index: 220;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
|
||||
z-index: 240;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-zoom {
|
||||
top: auto;
|
||||
bottom: calc(100% + 12px);
|
||||
transform: translateY(-6px) scale(0.98);
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5):hover .wb-composer__ref-zoom,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 4) .wb-composer__ref-zoom {
|
||||
top: auto;
|
||||
bottom: calc(100% + 10px);
|
||||
transform: translateY(-6px) scale(0.98);
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 4):hover .wb-composer__ref-zoom,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:nth-child(n + 4) .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
|
||||
isolation: isolate;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card {
|
||||
isolation: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-add-more {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:hover,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:focus-within {
|
||||
z-index: 180;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-zoom {
|
||||
left: 0;
|
||||
top: calc(100% + 12px);
|
||||
z-index: 80;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-card:hover .wb-composer__ref-zoom,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-stack {
|
||||
top: calc(100% + 8px);
|
||||
width: min(230px, calc(100vw - 24px));
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:hover,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:focus-within {
|
||||
z-index: 140;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:hover .wb-composer__ref-preview,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__ref-card:focus-within .wb-composer__ref-preview {
|
||||
border-color: rgba(var(--accent-rgb), 0.48);
|
||||
box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.18);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card {
|
||||
isolation: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-add-more {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:hover,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:focus-within {
|
||||
z-index: 180;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-zoom {
|
||||
left: 0;
|
||||
top: calc(100% + 12px);
|
||||
z-index: 80;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-card:hover .wb-composer__ref-zoom,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* Final override for multi-row reference stacks on the launch composer. */
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-zoom {
|
||||
top: auto;
|
||||
bottom: calc(100% + 12px);
|
||||
transform: translateY(-6px) scale(0.98);
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5):hover .wb-composer__ref-zoom,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-launch .wb-composer__refs .wb-composer__ref-card:nth-child(n + 5) .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* Keep the reference stack balanced in the active bottom composer. */
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
|
||||
grid-template-columns: repeat(4, 58px);
|
||||
width: min(286px, calc(100vw - 40px));
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-composer__refs .wb-composer__ref-stack {
|
||||
grid-template-columns: repeat(3, 58px);
|
||||
width: min(218px, calc(100vw - 24px));
|
||||
}
|
||||
}
|
||||
|
||||
/* The active bottom composer sits below the upload stack, so previews should open upward. */
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .wb-composer__refs .wb-composer__ref-card .wb-composer__ref-zoom {
|
||||
top: auto;
|
||||
bottom: calc(100% + 12px);
|
||||
transform: translateY(-6px) scale(0.98);
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .wb-composer__refs .wb-composer__ref-card:hover .wb-composer__ref-zoom,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .ai-workbench-page.is-active .wb-composer__refs .wb-composer__ref-preview:focus-visible + .wb-composer__ref-zoom {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
@@ -144,6 +144,10 @@
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@@ -170,10 +174,15 @@
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__panel {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
top: calc(100% + 12px);
|
||||
right: -88px;
|
||||
z-index: 1200;
|
||||
width: min(420px, calc(100vw - 24px));
|
||||
max-height: min(560px, calc(100vh - 92px));
|
||||
height: auto;
|
||||
max-height: min(460px, calc(100dvh - 84px));
|
||||
border: 1px solid var(--dg-line);
|
||||
border-radius: 16px;
|
||||
background: #151719;
|
||||
@@ -231,9 +240,12 @@
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__list {
|
||||
max-height: min(486px, calc(100vh - 158px));
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: min(386px, calc(100dvh - 158px));
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__item {
|
||||
@@ -10466,6 +10478,21 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__panel {
|
||||
right: clamp(-112px, -24vw, -92px);
|
||||
width: min(360px, calc(100vw - 20px));
|
||||
max-height: min(420px, calc(100dvh - 76px));
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__panel::before {
|
||||
right: clamp(104px, 25vw, 124px);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__list {
|
||||
max-height: min(344px, calc(100dvh - 150px));
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] :is(.creator-button, .member-button) {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "../test/testHarness";
|
||||
|
||||
import {
|
||||
calculateEnterpriseVideoCredits,
|
||||
type EnterpriseVideoPricingConfig,
|
||||
getEnterpriseVideoCreditRate,
|
||||
normalizeEnterpriseResolution,
|
||||
} from "./enterpriseVideoPolicy";
|
||||
@@ -45,4 +46,40 @@ describe("enterpriseVideoPolicy", () => {
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("uses server-provided pricing config before fallback pricing", () => {
|
||||
const serverPricing: EnterpriseVideoPricingConfig = {
|
||||
creditsPerCny: 100,
|
||||
defaultResolution: "1080P",
|
||||
rules: [
|
||||
{
|
||||
id: "happyhorse-server",
|
||||
modelIncludes: ["happyhorse"],
|
||||
rates: { "720P": 2, "1080P": 3 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
getEnterpriseVideoCreditRate(
|
||||
{
|
||||
model: "happyhorse-1.0",
|
||||
resolution: "1080P",
|
||||
durationSeconds: 5,
|
||||
},
|
||||
serverPricing,
|
||||
),
|
||||
).toBe(3);
|
||||
|
||||
expect(
|
||||
calculateEnterpriseVideoCredits(
|
||||
{
|
||||
model: "happyhorse-1.0",
|
||||
resolution: "1080P",
|
||||
durationSeconds: 5,
|
||||
},
|
||||
serverPricing,
|
||||
),
|
||||
).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,50 +50,119 @@ export interface EnterpriseVideoPricingInput {
|
||||
hasReferenceVideo?: boolean;
|
||||
}
|
||||
|
||||
export interface EnterpriseVideoPricingRule {
|
||||
id: string;
|
||||
modelIncludes: string[];
|
||||
when?: {
|
||||
muted?: boolean;
|
||||
hasReferenceVideo?: boolean;
|
||||
};
|
||||
rates: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface EnterpriseVideoPricingConfig {
|
||||
currency?: string;
|
||||
creditsPerCny?: number;
|
||||
billingUnit?: "per_second" | string;
|
||||
defaultResolution?: string;
|
||||
resolutions?: string[];
|
||||
rules: EnterpriseVideoPricingRule[];
|
||||
}
|
||||
|
||||
export const FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG: EnterpriseVideoPricingConfig = {
|
||||
currency: "CNY",
|
||||
creditsPerCny: CREDITS_PER_CNY,
|
||||
billingUnit: "per_second",
|
||||
defaultResolution: ENTERPRISE_DEFAULT_VIDEO_RESOLUTION,
|
||||
resolutions: ["720P", "1080P"],
|
||||
rules: [
|
||||
{
|
||||
id: "happyhorse",
|
||||
modelIncludes: ["happyhorse"],
|
||||
rates: { "720P": 0.72, "1080P": 1.28 },
|
||||
},
|
||||
{
|
||||
id: "wanxiang-i2v",
|
||||
modelIncludes: ["wan2.7-i2v", "wanxiang"],
|
||||
rates: { "720P": 0.6, "1080P": 1 },
|
||||
},
|
||||
{
|
||||
id: "wan-animate-s2v",
|
||||
modelIncludes: ["animate-mix", "s2v"],
|
||||
rates: { "720P": 0.6, "1080P": 1 },
|
||||
},
|
||||
{
|
||||
id: "kling-muted-reference",
|
||||
modelIncludes: ["kling"],
|
||||
when: { muted: true, hasReferenceVideo: true },
|
||||
rates: { "720P": 0.9, "1080P": 1.2 },
|
||||
},
|
||||
{
|
||||
id: "kling-muted",
|
||||
modelIncludes: ["kling"],
|
||||
when: { muted: true, hasReferenceVideo: false },
|
||||
rates: { "720P": 0.6, "1080P": 0.8 },
|
||||
},
|
||||
{
|
||||
id: "kling-default",
|
||||
modelIncludes: ["kling"],
|
||||
rates: { "720P": 0.9, "1080P": 1.2 },
|
||||
},
|
||||
{
|
||||
id: "vidu",
|
||||
modelIncludes: ["vidu"],
|
||||
rates: { "720P": 0.6, "1080P": 1 },
|
||||
},
|
||||
{
|
||||
id: "pixverse",
|
||||
modelIncludes: ["pixverse"],
|
||||
rates: { "720P": 0.6, "1080P": 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function normalizeEnterpriseResolution(value: string): "720P" | "1080P" {
|
||||
return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P";
|
||||
}
|
||||
|
||||
export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput): number {
|
||||
function enterpriseVideoPricingRuleMatches(
|
||||
rule: EnterpriseVideoPricingRule,
|
||||
input: EnterpriseVideoPricingInput,
|
||||
model: string,
|
||||
): boolean {
|
||||
if (!rule.modelIncludes.some((pattern) => model.includes(String(pattern || "").toLowerCase()))) return false;
|
||||
if (!rule.when) return true;
|
||||
if ("muted" in rule.when && Boolean(input.muted) !== rule.when.muted) return false;
|
||||
if ("hasReferenceVideo" in rule.when && Boolean(input.hasReferenceVideo) !== rule.when.hasReferenceVideo) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getEnterpriseVideoCreditRate(
|
||||
input: EnterpriseVideoPricingInput,
|
||||
config: EnterpriseVideoPricingConfig = FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG,
|
||||
): number {
|
||||
const resolution = normalizeEnterpriseResolution(input.resolution);
|
||||
const model = String(input.model || "").toLowerCase();
|
||||
const fallbackResolution = normalizeEnterpriseResolution(
|
||||
config.defaultResolution || ENTERPRISE_DEFAULT_VIDEO_RESOLUTION,
|
||||
);
|
||||
const rule = config.rules.find((candidate) => enterpriseVideoPricingRuleMatches(candidate, input, model));
|
||||
|
||||
if (model.includes("happyhorse")) {
|
||||
return resolution === "720P" ? 0.72 : 1.28;
|
||||
}
|
||||
|
||||
if (model.includes("wan2.7-i2v") || model.includes("wanxiang")) {
|
||||
return resolution === "720P" ? 0.6 : 1;
|
||||
}
|
||||
|
||||
if (model.includes("animate-mix")) {
|
||||
return resolution === "720P" ? 0.6 : 1;
|
||||
}
|
||||
|
||||
if (model.includes("s2v")) {
|
||||
return resolution === "720P" ? 0.6 : 1;
|
||||
}
|
||||
|
||||
if (model.includes("vidu")) {
|
||||
return resolution === "720P" ? 0.6 : 1.0;
|
||||
}
|
||||
|
||||
if (model.includes("pixverse")) {
|
||||
return resolution === "720P" ? 0.6 : 1.0;
|
||||
}
|
||||
|
||||
if (model.includes("kling")) {
|
||||
if (input.muted) {
|
||||
if (input.hasReferenceVideo) return resolution === "720P" ? 0.9 : 1.2;
|
||||
return resolution === "720P" ? 0.6 : 0.8;
|
||||
}
|
||||
return resolution === "720P" ? 0.9 : 1.2;
|
||||
if (rule) {
|
||||
const rate = rule.rates[resolution] ?? rule.rates[fallbackResolution];
|
||||
if (Number.isFinite(rate) && rate >= 0) return rate;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported enterprise video model: ${input.model}`);
|
||||
}
|
||||
|
||||
export function calculateEnterpriseVideoCredits(input: EnterpriseVideoPricingInput): number {
|
||||
export function calculateEnterpriseVideoCredits(
|
||||
input: EnterpriseVideoPricingInput,
|
||||
config: EnterpriseVideoPricingConfig = FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG,
|
||||
): number {
|
||||
const duration = Math.max(1, Math.ceil(Number(input.durationSeconds) || 1));
|
||||
return Number((getEnterpriseVideoCreditRate(input) * duration * CREDITS_PER_CNY).toFixed(2));
|
||||
const creditsPerCny = Number(config.creditsPerCny || CREDITS_PER_CNY);
|
||||
return Number((getEnterpriseVideoCreditRate(input, config) * duration * creditsPerCny).toFixed(2));
|
||||
}
|
||||
|
||||
@@ -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_OUTPUT_CREDITS_PER_MILLION,
|
||||
estimateTextTokenCredits,
|
||||
formatTextTokenCreditRule,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "./taskLifecycle";
|
||||
|
||||
describe("taskLifecycle", () => {
|
||||
it("keeps text token billing at 1 CNY to 100 credits", () => {
|
||||
it("keeps fallback text token billing at 1 CNY to 100 credits", () => {
|
||||
expect(TEXT_INPUT_CREDITS_PER_MILLION).toBe(200);
|
||||
expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500);
|
||||
expect(
|
||||
@@ -20,6 +21,23 @@ describe("taskLifecycle", () => {
|
||||
).toBe(700);
|
||||
});
|
||||
|
||||
it("estimates text billing from dynamic server pricing rates", () => {
|
||||
expect(
|
||||
estimateTextTokenCredits(
|
||||
{
|
||||
promptTokens: 1_000_000,
|
||||
completionTokens: 1_000_000,
|
||||
},
|
||||
{
|
||||
inputCreditsPerMillion: 2_700,
|
||||
outputCreditsPerMillion: 10_800,
|
||||
source: "server",
|
||||
modelKey: "gpt-4o",
|
||||
},
|
||||
),
|
||||
).toBe(13_500);
|
||||
});
|
||||
|
||||
it("ignores negative token counts when estimating text billing", () => {
|
||||
expect(
|
||||
estimateTextTokenCredits({
|
||||
@@ -29,6 +47,17 @@ describe("taskLifecycle", () => {
|
||||
).toBe(250);
|
||||
});
|
||||
|
||||
it("formats text billing rules from the selected rate", () => {
|
||||
expect(
|
||||
formatTextTokenCreditRule({
|
||||
inputCreditsPerMillion: 2_700,
|
||||
outputCreditsPerMillion: 10_800,
|
||||
}),
|
||||
).toBe(
|
||||
"输入 Token 每百万 2,700 积分,输出 Token 每百万 10,800 积分,实际以服务端结算为准。",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks unstarted tasks locally timed out after submit timeout", () => {
|
||||
const policy = getTaskTimeoutPolicy({ kind: "image" });
|
||||
|
||||
|
||||
@@ -32,11 +32,24 @@ export interface TextTokenUsage {
|
||||
totalTokens?: number;
|
||||
}
|
||||
|
||||
export interface TextTokenCreditRate {
|
||||
inputCreditsPerMillion: number;
|
||||
outputCreditsPerMillion: number;
|
||||
source?: "server" | "fallback";
|
||||
modelKey?: string;
|
||||
}
|
||||
|
||||
const CREDITS_PER_CNY = 100;
|
||||
|
||||
export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY;
|
||||
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5 * CREDITS_PER_CNY;
|
||||
|
||||
export const FALLBACK_TEXT_TOKEN_CREDIT_RATE: TextTokenCreditRate = {
|
||||
inputCreditsPerMillion: TEXT_INPUT_CREDITS_PER_MILLION,
|
||||
outputCreditsPerMillion: TEXT_OUTPUT_CREDITS_PER_MILLION,
|
||||
source: "fallback",
|
||||
};
|
||||
|
||||
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
||||
submitTimeoutMs: 90_000,
|
||||
noProgressTimeoutMs: 120_000,
|
||||
@@ -145,18 +158,42 @@ export function getRefundHint(status: TaskRefundStatus): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function estimateTextTokenCredits(usage: TextTokenUsage): number {
|
||||
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
||||
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
|
||||
return (promptTokens / 1_000_000) * TEXT_INPUT_CREDITS_PER_MILLION +
|
||||
(completionTokens / 1_000_000) * TEXT_OUTPUT_CREDITS_PER_MILLION;
|
||||
function sanitizeCreditRate(value: number): number {
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
|
||||
const rule = "文本计费规则:输入 Token 每百万 200 积分,输出 Token 每百万 500 积分,实际以服务端结算为准。";
|
||||
function formatCreditRate(value: number): string {
|
||||
const safeValue = sanitizeCreditRate(value);
|
||||
if (safeValue >= 100) return Math.round(safeValue).toLocaleString("zh-CN");
|
||||
return Number(safeValue.toFixed(4)).toString();
|
||||
}
|
||||
|
||||
export function formatTextTokenCreditRule(
|
||||
rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE,
|
||||
): string {
|
||||
return `输入 Token 每百万 ${formatCreditRate(rate.inputCreditsPerMillion)} 积分,输出 Token 每百万 ${formatCreditRate(rate.outputCreditsPerMillion)} 积分,实际以服务端结算为准。`;
|
||||
}
|
||||
|
||||
export function estimateTextTokenCredits(
|
||||
usage: TextTokenUsage,
|
||||
rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE,
|
||||
): number {
|
||||
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
||||
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
|
||||
return (
|
||||
(promptTokens / 1_000_000) * sanitizeCreditRate(rate.inputCreditsPerMillion) +
|
||||
(completionTokens / 1_000_000) * sanitizeCreditRate(rate.outputCreditsPerMillion)
|
||||
);
|
||||
}
|
||||
|
||||
export function formatTextTokenUsage(
|
||||
usage?: TextTokenUsage | null,
|
||||
rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE,
|
||||
): string {
|
||||
const rule = `文本计费规则:${formatTextTokenCreditRule(rate)}`;
|
||||
if (!usage) return rule;
|
||||
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
||||
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
|
||||
const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens });
|
||||
const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens }, rate);
|
||||
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user