feat(*): 模型生成链路 - 完善 Agnes 结果兼容、价格展示与请求日志追踪
This commit is contained in:
@@ -7,6 +7,7 @@ import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig
|
||||
import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { modelQuoteRequest } from "@/lib/model-pricing";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
@@ -26,6 +27,7 @@ import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins
|
||||
import { createPluginHostContext } from "@/services/plugin-host";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
|
||||
import { quoteLogicalModel } from "@/services/api/logical-models";
|
||||
|
||||
export type CanvasNodeGenerationMode = CanvasGenerationMode;
|
||||
|
||||
@@ -111,13 +113,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
}, [globalConfig, promptOptimizerEnabled, promptOptimizerInstallation]);
|
||||
const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const priceChannel = resolveModelChannel(config, config.model);
|
||||
const credits = requestCreditCost({
|
||||
const configuredCredits = requestCreditCost({
|
||||
channelMode: priceChannel.scope === "system" ? "remote" : "local",
|
||||
modelCosts: priceChannel.modelCosts,
|
||||
model: modelOptionName(config.model),
|
||||
count: mode === "image" ? generationCount : 1,
|
||||
seconds: mode === "video" ? config.videoSeconds : 1,
|
||||
capability: mode,
|
||||
config,
|
||||
requirements,
|
||||
});
|
||||
const quoteRequest = modelQuoteRequest(config, config.model, mode, requirements);
|
||||
const quoteRequestKey = JSON.stringify(quoteRequest || null);
|
||||
const [quotedCredits, setQuotedCredits] = useState<number | null>(null);
|
||||
const credits = quotedCredits ?? configuredCredits;
|
||||
const activeReferenceCount = activeReferences.length;
|
||||
const videoFrameOptions = resolvedMentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
|
||||
const hasVideoPromptTools = mode === "video" && !simpleMode && videoFrameOptions.length > 0;
|
||||
@@ -159,6 +168,23 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
setManualExpandedPromptHeight(null);
|
||||
}, [node.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!creditsEnabled || !quoteRequest) {
|
||||
setQuotedCredits(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setQuotedCredits(null);
|
||||
quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal)
|
||||
.then(({ quote }) => setQuotedCredits(quote.amountMicrocredits / 1_000_000))
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setQuotedCredits(null);
|
||||
});
|
||||
return () => controller.abort();
|
||||
// quoteRequestKey captures the full normalized request without retriggering on object identity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [creditsEnabled, quoteRequestKey]);
|
||||
|
||||
const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]);
|
||||
|
||||
const updatePrompt = (value: string) => {
|
||||
@@ -270,12 +296,12 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
|
||||
const renderSubmitButton = (expanded: boolean) => {
|
||||
const showCost = creditsEnabled && credits !== null;
|
||||
const formattedCredits = credits?.toLocaleString();
|
||||
const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const actionLabel = isRunning ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成";
|
||||
return (
|
||||
<Button
|
||||
type="text"
|
||||
className={`canvas-node-composer-submit ${showCost ? "has-cost" : ""}`}
|
||||
className={`canvas-node-composer-submit canvas-node-composer-submit-canvas ${showCost ? "has-cost" : ""}`}
|
||||
disabled={isRunning || isSubmitDisabled}
|
||||
style={
|
||||
{
|
||||
@@ -295,7 +321,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
</span>
|
||||
) : null}
|
||||
<span className="canvas-node-composer-submit-action" aria-hidden>
|
||||
{isRunning ? <LoaderCircle className="size-3 animate-spin motion-reduce:animate-none" /> : <ArrowUp className="size-3" />}
|
||||
{isRunning ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : <ArrowUp className="size-3.5" strokeWidth={2.4} />}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -4,14 +4,14 @@ import { Popover } from "antd";
|
||||
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { modelQuoteRequest, normalizeTierResolution, priceTiersForCurrentSelection } from "@/lib/model-pricing";
|
||||
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { ModelLogo } from "@/components/model-logo";
|
||||
import { quoteLogicalModel, type LogicalModelQuote, type ModelRequestIntent } from "@/services/api/logical-models";
|
||||
import { quoteLogicalModel, type LogicalModelQuote } from "@/services/api/logical-models";
|
||||
|
||||
type ModelPickerProps = {
|
||||
config: AiConfig;
|
||||
@@ -396,48 +396,6 @@ function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredM
|
||||
return channel.scope === "system" ? displayName : `${displayName}(${channel.name})`;
|
||||
}
|
||||
|
||||
function priceTiersForCurrentSelection(
|
||||
tiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
|
||||
capability: ModelCapability | undefined,
|
||||
config: AiConfig,
|
||||
requirements?: ModelRequirements,
|
||||
) {
|
||||
const requested: Record<string, string> = {};
|
||||
if (capability === "video") {
|
||||
const imageCount = (requirements?.input?.imageCount || 0) + (requirements?.input?.characterCount || 0);
|
||||
if (imageCount > 0) requested.imageCount = String(imageCount);
|
||||
const resolution = normalizeTierResolution(config.vquality);
|
||||
if (resolution !== "*") requested.vquality = resolution;
|
||||
const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0));
|
||||
if (seconds > 0) requested.videoSeconds = String(seconds);
|
||||
}
|
||||
if (capability === "image") {
|
||||
if (config.quality && config.quality !== "auto") requested.quality = config.quality.toLowerCase();
|
||||
if (config.size && config.size !== "auto") requested.size = config.size.toLowerCase();
|
||||
}
|
||||
let bestScore = -1;
|
||||
let matched: typeof tiers = [];
|
||||
for (const tier of tiers) {
|
||||
const selector = tier.selector || {};
|
||||
const conditions = Object.entries(selector).filter(([, value]) => value && value !== "*");
|
||||
if (conditions.some(([key, value]) => requested[key] !== value)) continue;
|
||||
const score = conditions.length;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
matched = [tier];
|
||||
} else if (score === bestScore) {
|
||||
matched.push(tier);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
function normalizeTierResolution(value: string) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw || raw === "*") return "*";
|
||||
return `${normalizeVideoResolution(raw)}p`;
|
||||
}
|
||||
|
||||
function channelTierPriceSummary(
|
||||
visibleTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
|
||||
allTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
|
||||
@@ -533,31 +491,6 @@ function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice |
|
||||
);
|
||||
}
|
||||
|
||||
function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): { logicalModelID: string; intent: ModelRequestIntent } | undefined {
|
||||
if (!capability || !value) return undefined;
|
||||
const channel = resolveModelChannel(config, value);
|
||||
if (channel.scope !== "system") return undefined;
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
|
||||
if (!cost?.logicalModelId) return undefined;
|
||||
const input = requirements?.input;
|
||||
const intent: ModelRequestIntent = {
|
||||
capability,
|
||||
operation: requirements?.videoOperation,
|
||||
inputs: {
|
||||
image: (input?.imageCount || 0) + (input?.characterCount || 0),
|
||||
video: input?.videoCount || 0,
|
||||
audio: input?.audioCount || 0,
|
||||
},
|
||||
options: {
|
||||
...modelRequestOptions(config, capability),
|
||||
...(requirements?.options || {}),
|
||||
...(requirements?.videoSeconds ? { videoSeconds: Number(requirements.videoSeconds) } : {}),
|
||||
...(requirements?.imageSize ? { size: requirements.imageSize } : {}),
|
||||
},
|
||||
};
|
||||
return { logicalModelID: cost.logicalModelId, intent };
|
||||
}
|
||||
|
||||
function modelMenuMeta(model: string, capability?: ModelCapability): { description: string; time?: string } {
|
||||
const name = modelOptionName(model).toLowerCase();
|
||||
if (capability === "image") {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { Coins } from "lucide-react";
|
||||
|
||||
export { requestCreditCost } from "@/lib/model-pricing";
|
||||
|
||||
export function CreditSymbol({ className, ...props }: ComponentProps<"span">) {
|
||||
return (
|
||||
<span {...props} className={`inline-flex items-center justify-center ${className || ""}`}>
|
||||
@@ -9,31 +11,6 @@ export function CreditSymbol({ className, ...props }: ComponentProps<"span">) {
|
||||
);
|
||||
}
|
||||
|
||||
export type ModelCreditCost = {
|
||||
model: string;
|
||||
pricePolicy?: "channel" | "unified";
|
||||
billingMode: "fixed_request" | "per_second" | "token";
|
||||
unitPriceMicrocredits: number;
|
||||
};
|
||||
|
||||
function modelCreditCost(modelCosts: ModelCreditCost[] | undefined, model: string) {
|
||||
return modelCosts?.find((item) => item.model === model) || null;
|
||||
}
|
||||
|
||||
export function formatCredits(value: number, maximumFractionDigits = 6) {
|
||||
return (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits });
|
||||
}
|
||||
|
||||
export function requestCreditCost(options: { channelMode: string; modelCosts?: ModelCreditCost[]; model: string; count?: string | number; seconds?: string | number }) {
|
||||
if (options.channelMode !== "remote") return null;
|
||||
const cost = modelCreditCost(options.modelCosts, options.model);
|
||||
if (!cost) return null;
|
||||
// 跟随供应价格的前台模型只有后端完成能力路由后才能确定价格,前端不展示误导性预估。
|
||||
if (cost.pricePolicy === "channel") return null;
|
||||
// Token 订单由服务端按请求体预授权并在 usage 返回后结算,前端不展示无依据的固定价格。
|
||||
if (cost.billingMode === "token") return null;
|
||||
const quantity = cost.billingMode === "per_second"
|
||||
? Math.max(1, Math.floor(Math.abs(Number(options.seconds)) || 1))
|
||||
: Math.max(1, Math.floor(Math.abs(Number(options.count)) || 1));
|
||||
return (cost.unitPriceMicrocredits / 1_000_000) * quantity;
|
||||
}
|
||||
|
||||
130
web/src/lib/model-pricing.ts
Normal file
130
web/src/lib/model-pricing.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { modelRequestOptions, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import type { ModelRequestIntent } from "@/services/api/logical-models";
|
||||
import { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
export type ModelPriceTier = NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number];
|
||||
|
||||
type ModelCreditCost = {
|
||||
model: string;
|
||||
pricePolicy?: "channel" | "unified";
|
||||
billingMode: "fixed_request" | "per_second" | "token";
|
||||
unitPriceMicrocredits: number;
|
||||
logicalPriceTiers?: ModelPriceTier[];
|
||||
};
|
||||
|
||||
export function requestCreditCost(options: {
|
||||
channelMode: string;
|
||||
modelCosts?: ModelCreditCost[];
|
||||
model: string;
|
||||
count?: string | number;
|
||||
seconds?: string | number;
|
||||
capability?: ModelCapability;
|
||||
config?: AiConfig;
|
||||
requirements?: ModelRequirements;
|
||||
}) {
|
||||
if (options.channelMode !== "remote") return null;
|
||||
const cost = options.modelCosts?.find((item) => item.model === options.model) || null;
|
||||
if (!cost) return null;
|
||||
if (cost.pricePolicy === "channel") {
|
||||
if (!options.config) return null;
|
||||
const tiers = priceTiersForCurrentSelection(cost.logicalPriceTiers || [], options.capability, options.config, options.requirements);
|
||||
if (!tiers.length) return null;
|
||||
const first = tiers[0];
|
||||
if (!first || first.billingMode === "token") return null;
|
||||
// 同一精确规格可能来自多个逻辑路由;只有价格一致时才可在客户端安全展示。
|
||||
if (tiers.some((tier) => tier.billingMode !== first.billingMode || tier.unitPriceMicrocredits !== first.unitPriceMicrocredits)) return null;
|
||||
return creditAmount(first.billingMode, first.unitPriceMicrocredits, options.count, options.seconds);
|
||||
}
|
||||
// Token 订单由服务端按请求体预授权并在 usage 返回后结算,前端不展示无依据的固定价格。
|
||||
if (cost.billingMode === "token") return null;
|
||||
return creditAmount(cost.billingMode, cost.unitPriceMicrocredits, options.count, options.seconds);
|
||||
}
|
||||
|
||||
export function priceTiersForCurrentSelection(tiers: ModelPriceTier[], capability: ModelCapability | undefined, config: AiConfig, requirements?: ModelRequirements) {
|
||||
const requested = priceSelectorForRequest(capability, config, requirements);
|
||||
let bestScore = -1;
|
||||
let matched: ModelPriceTier[] = [];
|
||||
for (const tier of tiers) {
|
||||
const selector = priceSelectorForTier(tier);
|
||||
const conditions = Object.entries(selector).filter(([, value]) => value && value !== "*");
|
||||
if (conditions.some(([key, value]) => requested[key] !== value)) continue;
|
||||
const score = conditions.length;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
matched = [tier];
|
||||
} else if (score === bestScore) {
|
||||
matched.push(tier);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
export function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): { logicalModelID: string; intent: ModelRequestIntent } | undefined {
|
||||
if (!capability || !value) return undefined;
|
||||
const channel = resolveModelChannel(config, value);
|
||||
if (channel.scope !== "system") return undefined;
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
|
||||
if (!cost?.logicalModelId) return undefined;
|
||||
const input = requirements?.input;
|
||||
const intent: ModelRequestIntent = {
|
||||
capability,
|
||||
operation: capability === "video" && input ? resolveVideoOperation(input, requirements?.videoOperation) : requirements?.videoOperation,
|
||||
inputs: {
|
||||
image: (input?.imageCount || 0) + (input?.characterCount || 0),
|
||||
video: input?.videoCount || 0,
|
||||
audio: input?.audioCount || 0,
|
||||
},
|
||||
options: {
|
||||
...modelRequestOptions(config, capability),
|
||||
...(requirements?.options || {}),
|
||||
...(requirements?.videoSeconds ? { videoSeconds: Number(requirements.videoSeconds) } : {}),
|
||||
...(requirements?.imageSize ? { size: requirements.imageSize } : {}),
|
||||
},
|
||||
};
|
||||
return { logicalModelID: cost.logicalModelId, intent };
|
||||
}
|
||||
|
||||
function creditAmount(billingMode: "fixed_request" | "per_second", unitPriceMicrocredits: number, count?: string | number, seconds?: string | number) {
|
||||
const quantity = billingMode === "per_second"
|
||||
? Math.max(1, Math.floor(Math.abs(Number(seconds)) || 1))
|
||||
: Math.max(1, Math.floor(Math.abs(Number(count)) || 1));
|
||||
return (unitPriceMicrocredits / 1_000_000) * quantity;
|
||||
}
|
||||
|
||||
function priceSelectorForRequest(capability: ModelCapability | undefined, config: AiConfig, requirements?: ModelRequirements) {
|
||||
const requested: Record<string, string> = {};
|
||||
if (capability === "video") {
|
||||
const input = requirements?.input;
|
||||
if (input) {
|
||||
const imageCount = (input.imageCount || 0) + (input.characterCount || 0);
|
||||
requested.operation = input.videoCount > 0 ? "video_to_video" : imageCount > 0 ? "image_to_video" : resolveVideoOperation(input, requirements?.videoOperation);
|
||||
if (imageCount > 0) requested.imageCount = String(imageCount);
|
||||
}
|
||||
const resolution = normalizeTierResolution(config.vquality);
|
||||
if (resolution !== "*") requested.vquality = resolution;
|
||||
const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0));
|
||||
if (seconds > 0) requested.videoSeconds = String(seconds);
|
||||
}
|
||||
if (capability === "image") {
|
||||
if (config.quality && config.quality !== "auto") requested.quality = config.quality.toLowerCase();
|
||||
if (config.size && config.size !== "auto") requested.size = config.size.toLowerCase();
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
function priceSelectorForTier(tier: ModelPriceTier) {
|
||||
const selector = { ...(tier.selector || {}) };
|
||||
if (!Object.keys(selector).length) {
|
||||
const resolution = normalizeTierResolution(tier.resolution);
|
||||
if (resolution !== "*") selector.vquality = resolution;
|
||||
if (tier.videoSeconds > 0) selector.videoSeconds = String(tier.videoSeconds);
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
|
||||
export function normalizeTierResolution(value: string) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw || raw === "*") return "*";
|
||||
return `${normalizeVideoResolution(raw)}p`;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type AgnesTaskResponse = {
|
||||
task_id?: string;
|
||||
video_id?: string;
|
||||
status?: string;
|
||||
url?: string;
|
||||
metadata?: { url?: string } | null;
|
||||
error?: { message?: string; detail?: string; code?: string } | null;
|
||||
message?: string;
|
||||
@@ -64,8 +65,8 @@ export async function pollAgnesVideoTask(deps: VideoProviderDeps, config: Resolv
|
||||
const state = deps.response.unwrapEnvelope(raw, "Agnes 接口没有返回任务状态");
|
||||
const status = String(state.status || "").trim().toLowerCase();
|
||||
if (status === "completed" || status === "succeeded" || status === "success" || status === "done") {
|
||||
const url = String(state.metadata?.url || "").trim();
|
||||
if (!url) return { status: "failed", error: "Agnes 任务已完成但没有返回 metadata.url" };
|
||||
const url = String(state.metadata?.url || state.url || "").trim();
|
||||
if (!url) return { status: "failed", error: "Agnes 任务已完成但没有返回视频 URL" };
|
||||
return { status: "completed", result: await deps.response.videoResultFromUrl(url, options) };
|
||||
}
|
||||
if (status === "failed" || status === "cancelled" || status === "canceled") return { status: "failed", error: agnesFailureMessage(state) };
|
||||
|
||||
@@ -8818,6 +8818,22 @@
|
||||
color: var(--canvas-composer-submit-action-fg);
|
||||
}
|
||||
|
||||
.canvas-node-composer-submit.canvas-node-composer-submit-canvas {
|
||||
min-width: var(--space-8) !important;
|
||||
height: var(--space-8) !important;
|
||||
gap: var(--space-1-half) !important;
|
||||
padding: var(--space-half) !important;
|
||||
font-size: var(--fs-caption) !important;
|
||||
}
|
||||
|
||||
.canvas-node-composer-submit.canvas-node-composer-submit-canvas.has-cost {
|
||||
padding-left: var(--space-2-half) !important;
|
||||
}
|
||||
|
||||
.canvas-node-composer-submit-canvas .canvas-node-composer-submit-cost > :first-child {
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.canvas-node-composer-resize-handle {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user