feat: update video billing and agent workspace improvements
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-10 16:25:35 +08:00
parent 02f13a045a
commit 477f215259
26 changed files with 1923 additions and 898 deletions

View File

@ -167,6 +167,11 @@ import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import {
buildVideoDurationCostMap,
getConfiguredVideoDurationOptions,
normalizeVideoDurationOptions,
} from "@/lib/ai/video-model-config";
import type { AgentWorkflowUiConfig } from "@/types/agent";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
@ -360,10 +365,59 @@ type ImageGenConfig = {
supportsSeed?: boolean; // 是否支持随机种子
creditCostPerImage?: number; // 每张图片积分消耗
creditCostPerVideo?: number; // 每条视频积分消耗
videoDurationOptions?: number[]; // 视频秒数选项
creditCostPerVideoByDuration?: Record<string, number>; // 各秒数积分
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
};
function createDefaultImageGenConfig(): ImageGenConfig {
return {
maxImages: 2,
supportsSize: false,
supports1K: true,
supports2K: true,
supports4K: true,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
creditCostPerVideo: 0,
videoDurationOptions: [],
creditCostPerVideoByDuration: {},
enableLoadingPlaceholder: true,
loadingColor: "purple",
};
}
function formatVideoDurationOptionsInput(options?: number[]) {
return getConfiguredVideoDurationOptions({ videoDurationOptions: options }).join(", ");
}
function parseVideoDurationOptionsInput(value: string) {
return normalizeVideoDurationOptions(
String(value || "")
.split(/[,\n,、\s]+/)
.map((item) => item.trim())
.filter(Boolean)
);
}
function syncVideoDurationPricing(config: ImageGenConfig | undefined, durationOptions: number[]) {
const nextConfig = config || createDefaultImageGenConfig();
return {
...nextConfig,
videoDurationOptions: durationOptions,
creditCostPerVideoByDuration:
buildVideoDurationCostMap({
durationOptions,
costMap: nextConfig.creditCostPerVideoByDuration,
fallbackCost: nextConfig.creditCostPerVideo,
}) || {},
};
}
type AiModel = {
id: string;
providerId: string;
@ -2316,21 +2370,7 @@ export function AdminSettingsClient() {
fallbackModelId2: "", // 备用模型 ID 2
excludeFromFailover: false,
// 🆕 图片生成模型特性配置
imageGenConfig: {
maxImages: 2,
supportsSize: false,
supports1K: true,
supports2K: true,
supports4K: true,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
creditCostPerVideo: 0,
enableLoadingPlaceholder: true,
loadingColor: "purple",
} as ImageGenConfig,
imageGenConfig: createDefaultImageGenConfig(),
});
const [showModelDialog, setShowModelDialog] = useState(false);
const [editingModel, setEditingModel] = useState<string | null>(null);
@ -2394,6 +2434,9 @@ export function AdminSettingsClient() {
modelId: modelForm.modelId,
supportsImageGen: modelForm.supportsImageGen,
});
const configuredVideoDurationOptions = getConfiguredVideoDurationOptions(
modelForm.imageGenConfig
);
const modelKindHint =
selectedModelProviderType === "runway" && !modelForm.modelId.trim()
? "Runway 同时支持图片与视频模型,填写模型 ID 后系统会自动识别标签。"
@ -4830,18 +4873,7 @@ export function AdminSettingsClient() {
fallbackModelId1: "",
fallbackModelId2: "",
excludeFromFailover: false,
imageGenConfig: {
maxImages: 2,
supportsSize: false,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
creditCostPerVideo: 0,
enableLoadingPlaceholder: true,
loadingColor: "purple",
},
imageGenConfig: createDefaultImageGenConfig(),
});
setEditingModel(null);
};
@ -11596,6 +11628,104 @@ export function AdminSettingsClient() {
</p>
</div>
{isVideoProviderForModelConfig && (
<div className="col-span-2 rounded-lg border border-dashed p-4 space-y-4 bg-background/80">
<div className="space-y-2">
<Label>支持视频秒数</Label>
<Input
value={formatVideoDurationOptionsInput(
modelForm.imageGenConfig?.videoDurationOptions
)}
onChange={(e) => {
const durationOptions = parseVideoDurationOptionsInput(
e.target.value
);
setModelForm({
...modelForm,
imageGenConfig: syncVideoDurationPricing(
modelForm.imageGenConfig,
durationOptions
),
});
}}
placeholder="例如:4, 6, 8 或 5, 10, 15"
/>
<p className="text-xs text-muted-foreground">
前端会按这里显示可选视频秒数。留空时继续使用服务商默认能力。
</p>
</div>
{configuredVideoDurationOptions.length > 0 ? (
<div className="space-y-3">
<div className="text-sm font-medium">
各秒数积分消耗
</div>
<div className="grid grid-cols-2 gap-4">
{configuredVideoDurationOptions.map((duration) => {
const durationKey = String(duration);
return (
<div
key={durationKey}
className="space-y-2"
>
<Label>{duration} 秒积分</Label>
<Input
type="number"
min={0}
step={1}
value={
modelForm.imageGenConfig
?.creditCostPerVideoByDuration?.[
durationKey
] ??
modelForm.imageGenConfig
?.creditCostPerVideo ??
0
}
onChange={(e) => {
const nextValue = Math.max(
0,
Number.parseInt(
e.target.value,
10
) || 0
);
setModelForm({
...modelForm,
imageGenConfig: {
...syncVideoDurationPricing(
modelForm.imageGenConfig,
configuredVideoDurationOptions
),
creditCostPerVideoByDuration: {
...(syncVideoDurationPricing(
modelForm.imageGenConfig,
configuredVideoDurationOptions
)
.creditCostPerVideoByDuration ||
{}),
[durationKey]: nextValue,
},
},
});
}}
/>
</div>
);
})}
</div>
<p className="text-xs text-muted-foreground">
配置后会优先按用户选择的秒数扣积分;未命中的秒数才回退到“每条视频积分消耗”。
</p>
</div>
) : (
<p className="text-xs text-muted-foreground">
当前还没有配置秒数档位,将按“每条视频积分消耗”统一扣费。
</p>
)}
</div>
)}
<div className="space-y-2">
<Label>占位图颜色主题</Label>
<Select
@ -11918,21 +12048,8 @@ export function AdminSettingsClient() {
(model as any).fallbackModelId2 || "",
excludeFromFailover:
(model as any).excludeFromFailover || false,
imageGenConfig: (model as any).imageGenConfig || {
maxImages: 2,
supportsSize: false,
supports1K: true,
supports2K: true,
supports4K: true,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
creditCostPerVideo: 0,
enableLoadingPlaceholder: true,
loadingColor: "purple",
},
imageGenConfig:
(model as any).imageGenConfig || createDefaultImageGenConfig(),
});
setEditingModel(model.id);
setShowModelDialog(true);

View File

@ -497,6 +497,18 @@ export function PsdTemplatesSettingsClient({
});
}
function removeDraftLayer(layerId: string) {
setDraftTemplate((current) => {
if (!current) return current;
const nextLayers = current.layers.filter((layer) => layer.id !== layerId);
return {
...current,
layers: nextLayers,
layerCount: nextLayers.length,
};
});
}
function handleTemplateFileChange(event: ChangeEvent<HTMLInputElement>) {
const nextFile = event.target.files?.[0] || null;
setUploadFile(nextFile);
@ -2287,9 +2299,9 @@ export function PsdTemplatesSettingsClient({
{getTemplateParseLabel(draftTemplate)}
</Badge>
<Badge variant="secondary">
上传图层数:
当前图层数:
{draftTemplate.parseStatus === "completed"
? draftTemplate.layerCount
? draftTemplate.layers.length
: "待解析"}
</Badge>
<Button
@ -2383,7 +2395,9 @@ export function PsdTemplatesSettingsClient({
<div className="space-y-4">
{filteredLayers.length === 0 ? (
<div className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
当前筛选条件下没有图层,请换个关键词试试。
{draftTemplate.layers.length === 0
? "当前模板已没有保留图层,可重新解析 PSD,或直接保存当前删除结果。"
: "当前筛选条件下没有图层,请换个关键词试试。"}
</div>
) : (
filteredLayers.map((layer, index) => (
@ -2422,11 +2436,33 @@ export function PsdTemplatesSettingsClient({
</div>
</div>
<div className="flex items-center gap-2 rounded-full border px-3 py-1">
<Layers className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
深度 {layer.depth}
</span>
<div className="flex flex-wrap items-center justify-end gap-2">
<div className="flex items-center gap-2 rounded-full border px-3 py-1">
<Layers className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
深度 {layer.depth}
</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => {
if (
typeof window !== "undefined" &&
!window.confirm(
`确认删除图层“${layer.layerName}”吗?删除后需要保存模板才会正式生效。`
)
) {
return;
}
removeDraftLayer(layer.id);
}}
>
<Trash2 className="mr-1.5 h-4 w-4" />
删除图层
</Button>
</div>
</div>

View File

@ -109,6 +109,7 @@ import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config";
import {
bootstrapConversationStateProtocol,
composeConversationStateProtocol,
@ -144,6 +145,22 @@ import {
resolveClarificationMessage,
} from "@/lib/ai/runtime/clarification-interruption";
import { resolveCanvasAgentClarificationGate } from "@/lib/ai/runtime/middlewares/canvas-agent-clarification-gate";
import { buildDirectAgentImageAnalysisAnswer } from "@/lib/ai/runtime/direct-agent-analysis-answer";
import {
analyzeOptimizedPromptMerge,
buildDirectAgentExecutionPrompt,
buildOptimizedPromptPreview,
} from "@/lib/ai/runtime/optimized-prompt";
import {
findAgentToolCallArgs,
getDirectAgentToolErrorMessage,
getDirectAgentToolLabel,
getDirectAgentToolSuccessMessage,
getDirectAgentToolTargetMissingMessage,
pickAgentSourceImageUrls,
resolveDirectAgentToolExecution,
type DirectAgentToolCapability,
} from "@/lib/ai/runtime/direct-agent-execution";
import {
pruneTaskBoundaryContext,
pruneTaskBoundaryHistoryMessages,
@ -186,6 +203,10 @@ import type { ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { convertToUIMessages, generateUUID } from "@/lib/utils";
import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags";
import type {
AgentExecutionPlan,
AgentPreparedExecution,
} from "@/lib/ai/auto-agent-graph";
import { generateTitleFromUserMessage } from "../../actions";
import { type PostRequestBody, postRequestBodySchema } from "./schema";
import { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
@ -310,57 +331,6 @@ function applyOptimizedPromptToLatestUserMessage(
return cloned;
}
function analyzeOptimizedPromptMerge(
originalText: string,
optimizedPrompt?: string,
hiddenPromptPrefix = "",
) {
const prompt = optimizedPrompt?.trim() || "";
const trimmedOriginal = String(originalText || "").trim();
const normalizedOriginal =
hiddenPromptPrefix && trimmedOriginal.startsWith(hiddenPromptPrefix)
? trimmedOriginal.slice(hiddenPromptPrefix.length).trim()
: trimmedOriginal;
const hasOriginalDemand = normalizedOriginal.length > 0;
const hasOriginalInOptimized =
hasOriginalDemand &&
(prompt.includes(normalizedOriginal) || normalizedOriginal.includes(prompt));
return {
prompt,
normalizedOriginal,
hasOriginalDemand,
hasOriginalInOptimized,
};
}
function buildOptimizedPromptPreview(params: {
originalText: string;
optimizedPrompt?: string;
hiddenPromptPrefix?: string;
}): string | undefined {
const { prompt, normalizedOriginal, hasOriginalDemand, hasOriginalInOptimized } =
analyzeOptimizedPromptMerge(
params.originalText,
params.optimizedPrompt,
params.hiddenPromptPrefix || "",
);
if (!prompt) return undefined;
const normalizedPrompt = prompt.replace(/\s+/g, " ").trim();
if (!normalizedPrompt) return undefined;
if (hasOriginalDemand && !hasOriginalInOptimized) {
return [
`- 核心目标:${normalizedOriginal.replace(/\s+/g, " ").trim()}`,
`- 优化提示词:${normalizedPrompt}`,
].join("\n");
}
return `- ${normalizedPrompt}`;
}
function injectOptimizedPromptSection(params: {
message?: string;
originalText: string;
@ -1098,483 +1068,11 @@ type AgentVideoPlan = {
recommendedVideoParams?: AgentRecommendedVideoParams;
};
type AgentPlanNextAction =
| "respond"
| "clarify"
| "analyze_image"
| "generate_image"
| "edit_image"
| "generate_video";
type AgentPlannedToolCall = {
name: string;
reason?: string;
args?: Record<string, unknown>;
};
type AgentExecutionPlan = {
goal: string;
nextAction: AgentPlanNextAction;
intent: "image_edit" | "vision" | "image_gen" | "video_gen" | "chat";
subIntent?: string;
targetSource:
| "explicit_selection"
| "current_upload"
| "text_reference"
| "working_memory"
| "latest_result"
| "clarification"
| "none";
preferredCapability:
| "respond"
| "analyze"
| "generic_edit"
| "remove_background"
| "enhance"
| "extend"
| "resize"
| "box_cutout"
| "generate_image"
| "generate_video"
| "publish_canvas";
responseMode: "direct_answer" | "tool_then_answer" | "tool_then_execute" | "clarify";
shouldUseHistoryImage: boolean;
shouldUseStructuredReply: boolean;
confidence: number;
toolCalls: AgentPlannedToolCall[];
};
type AgentPreparedExecution = {
mode: AgentPlanNextAction;
routeIntent: "image_edit" | "vision" | "image_gen" | "video_gen" | "chat";
routeSubIntent?: string;
targetSource: AgentExecutionPlan["targetSource"];
preferredCapability: AgentExecutionPlan["preferredCapability"];
shouldUseStructuredReply: boolean;
shouldOptimizePrompt: boolean;
shouldApplyImagePipeline: boolean;
shouldApplyVideoPipeline: boolean;
shouldStayConversational: boolean;
shouldPublishToCanvas: boolean;
toolCalls: AgentPlannedToolCall[];
availableTools: string[];
};
type DirectAgentToolCapability =
| "analyze"
| "generic_edit"
| "remove_background"
| "enhance"
| "resize"
| "extend"
| "box_cutout"
| "generate_image"
| "generate_video";
type DirectAgentToolName =
| "analyzeImage"
| "editImage"
| "removeBackground"
| "enhanceImage"
| "resizeImage"
| "extendImage"
| "boxCutout"
| "generateImage"
| "generateVideo";
type DirectAgentSourceToolName =
| "useSelectedCanvasImage"
| "useLatestResultImage"
| "useOriginalUploadImage";
type ResolvedDirectAgentToolExecution = {
toolName: DirectAgentToolName;
capability: DirectAgentToolCapability;
sourceTools: DirectAgentSourceToolName[];
publishToCanvas: boolean;
};
function collectAgentToolCalls(
execution?: AgentPreparedExecution,
plan?: AgentExecutionPlan,
): AgentPlannedToolCall[] {
const merged = [...(execution?.toolCalls || []), ...(plan?.toolCalls || [])];
const seen = new Set<string>();
const result: AgentPlannedToolCall[] = [];
for (const tool of merged) {
const name = String(tool?.name || "").trim();
if (!name || seen.has(name)) continue;
seen.add(name);
result.push(tool);
}
return result;
}
function mapDirectToolNameToCapability(
toolName: DirectAgentToolName,
): DirectAgentToolCapability {
switch (toolName) {
case "analyzeImage":
return "analyze";
case "editImage":
return "generic_edit";
case "removeBackground":
return "remove_background";
case "enhanceImage":
return "enhance";
case "resizeImage":
return "resize";
case "extendImage":
return "extend";
case "boxCutout":
return "box_cutout";
case "generateImage":
return "generate_image";
case "generateVideo":
return "generate_video";
default:
return "generate_image";
}
}
function resolveDirectAgentToolExecution(
execution?: AgentPreparedExecution,
plan?: AgentExecutionPlan,
): ResolvedDirectAgentToolExecution | null {
const mergedToolCalls = collectAgentToolCalls(execution, plan);
const toolNames = mergedToolCalls.map((tool) => tool.name);
const hasToolCall = (toolName: DirectAgentToolName) => toolNames.includes(toolName);
const sourceTools = toolNames.filter((toolName): toolName is DirectAgentSourceToolName =>
toolName === "useSelectedCanvasImage" ||
toolName === "useLatestResultImage" ||
toolName === "useOriginalUploadImage",
);
const directToolName =
(hasToolCall("analyzeImage")
? "analyzeImage"
: hasToolCall("removeBackground")
? "removeBackground"
: hasToolCall("enhanceImage")
? "enhanceImage"
: hasToolCall("resizeImage")
? "resizeImage"
: hasToolCall("extendImage")
? "extendImage"
: hasToolCall("boxCutout")
? "boxCutout"
: hasToolCall("editImage")
? "editImage"
: hasToolCall("generateImage")
? "generateImage"
: hasToolCall("generateVideo")
? "generateVideo"
: null) ||
(() => {
if (
execution?.preferredCapability === "analyze" ||
execution?.routeIntent === "vision" ||
execution?.mode === "analyze_image"
) {
return "analyzeImage";
}
if (
execution?.preferredCapability === "remove_background" ||
execution?.routeSubIntent === "remove_background"
) {
return "removeBackground";
}
if (
execution?.preferredCapability === "enhance" ||
execution?.routeSubIntent === "enhance"
) {
return "enhanceImage";
}
if (
execution?.preferredCapability === "resize" ||
execution?.routeSubIntent === "resize"
) {
return "resizeImage";
}
if (
execution?.preferredCapability === "extend" ||
execution?.routeSubIntent === "extend"
) {
return "extendImage";
}
if (
execution?.preferredCapability === "box_cutout" ||
execution?.routeSubIntent === "box_cutout"
) {
return "boxCutout";
}
if (
execution?.preferredCapability === "generic_edit" ||
execution?.routeIntent === "image_edit" ||
execution?.mode === "edit_image"
) {
return "editImage";
}
if (execution?.mode === "generate_image") {
return "generateImage";
}
if (execution?.mode === "generate_video") {
return "generateVideo";
}
return null;
})();
if (!directToolName) {
return null;
}
return {
toolName: directToolName,
capability: mapDirectToolNameToCapability(directToolName),
sourceTools,
publishToCanvas:
execution?.shouldPublishToCanvas === true || toolNames.includes("publishToCanvas"),
};
}
function getDirectAgentToolLabel(capability: DirectAgentToolCapability): string {
switch (capability) {
case "analyze":
return "Agent工具:图片分析";
case "generic_edit":
return "Agent工具:图片编辑";
case "remove_background":
return "Agent工具:抠图";
case "enhance":
return "Agent工具:图像增强";
case "resize":
return "Agent工具:尺寸调整";
case "extend":
return "Agent工具:图像拓展";
case "box_cutout":
return "Agent工具:框选抠图";
case "generate_image":
return "Agent工具:图片生成";
case "generate_video":
return "Agent工具:视频生成";
default:
return "Agent工具";
}
}
function findAgentToolCallArgs(
toolName: AgentPlannedToolCall["name"],
execution?: AgentPreparedExecution,
plan?: AgentExecutionPlan,
): Record<string, unknown> | undefined {
const matchedTool =
execution?.toolCalls?.find((tool) => tool.name === toolName) ||
plan?.toolCalls?.find((tool) => tool.name === toolName);
if (!matchedTool?.args || typeof matchedTool.args !== "object") {
return undefined;
}
return matchedTool.args;
}
function toFiniteNumber(value: unknown): number | undefined {
const normalized = Number(value);
return Number.isFinite(normalized) ? normalized : undefined;
}
function getDirectAgentToolSuccessMessage(capability: DirectAgentToolCapability): string {
switch (capability) {
case "analyze":
return "已完成当前目标图分析,结果如下:";
case "generic_edit":
return "已完成图片编辑,结果如下:";
case "remove_background":
return "已完成抠图处理,结果如下:";
case "enhance":
return "已完成图像增强,结果如下:";
case "resize":
return "已完成尺寸调整,结果如下:";
case "extend":
return "已完成图像拓展,结果如下:";
case "box_cutout":
return "已完成框选抠图,结果如下:";
case "generate_image":
return "已完成图片生成,结果如下:";
case "generate_video":
return "已完成视频生成,结果如下:";
default:
return "已完成处理,结果如下:";
}
}
function getDirectAgentToolTargetMissingMessage(
capability: DirectAgentToolCapability,
): string {
switch (capability) {
case "analyze":
return "请先上传或选中一张图片,我才能继续帮你分析。";
case "generic_edit":
return "请先上传或选中一张图片,我才能继续帮你修改。";
case "remove_background":
return "请先上传或选中一张图片,我才能继续帮你执行抠图。";
case "enhance":
return "请先上传或选中一张图片,我才能继续帮你做图像增强。";
case "resize":
return "请先上传或选中一张图片,我才能继续帮你调整尺寸。";
case "extend":
return "请先上传或选中一张图片,我才能继续帮你做图像拓展。";
case "box_cutout":
return "请先选中一张图片并提供框选区域,我才能继续帮你做框选抠图。";
case "generate_image":
return "请先告诉我你想生成什么,或者提供参考图。";
case "generate_video":
return "请先告诉我你想生成什么视频,必要时再提供参考图。";
default:
return "请先提供要处理的图片。";
}
}
function getDirectAgentToolErrorMessage(
capability: DirectAgentToolCapability,
error: unknown,
): string {
const errorMessage =
error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
const errorDetail =
error && typeof error === "object" && "detail" in error
? String((error as { detail?: unknown }).detail || "").trim()
: "";
const detail = errorDetail || errorMessage || "请稍后重试。";
switch (capability) {
case "analyze":
return `图片分析暂时失败:${detail}`;
case "generic_edit":
return `图片编辑暂时失败:${detail}`;
case "remove_background":
return `抠图暂时失败:${detail}`;
case "enhance":
return `图像增强暂时失败:${detail}`;
case "resize":
return `尺寸调整暂时失败:${detail}`;
case "extend":
return `图像拓展暂时失败:${detail}`;
case "box_cutout":
return `框选抠图暂时失败:${detail}`;
case "generate_image":
return `图片生成暂时失败:${detail}`;
case "generate_video":
return `视频生成暂时失败:${detail}`;
default:
return `处理暂时失败:${detail}`;
}
}
async function buildDirectAgentImageAnalysisAnswer(params: {
question: string;
summary: string;
modelId?: string;
}) {
const normalizedQuestion = String(params.question || "").trim();
const normalizedSummary = String(params.summary || "").trim();
if (!normalizedSummary) {
return "";
}
const buildFallbackAnswer = () => {
const intro = normalizedQuestion
? "我先根据当前锁定图片给你一个直接结论:"
: "我先根据当前锁定图片做一个直接分析:";
return [
intro,
normalizedSummary,
normalizedQuestion
? "如果你要,我可以继续往主体构图、风格、问题点或优化建议里再拆细。"
: "",
]
.filter(Boolean)
.join("\n\n");
};
const candidateModelIds = Array.from(
new Set(
[params.modelId]
.map((value) => String(value || "").trim())
.filter(Boolean),
),
);
for (const candidateModelId of candidateModelIds) {
try {
const model = await getLanguageModelById(candidateModelId);
if (!model) continue;
const { text } = await generateText({
model,
system: [
"你是无限画布里的图片分析助手。",
"只能根据给定的图像观察摘要回答,不要编造图片中不可见的事实。",
"如果用户问题超出已知信息,要明确说信息不足,不要硬猜。",
"优先给出直接结论,再补充2到4条简洁依据或建议。",
].join("\n"),
prompt: [
normalizedQuestion ? `用户问题:${normalizedQuestion}` : "用户要求:请分析当前图片。",
`图像观察摘要:\n${normalizedSummary}`,
"请用中文直接回答,不要复述“根据摘要/根据图片”,也不要输出多余寒暄。",
].join("\n\n"),
temperature: 0.2,
maxTokens: 320,
abortSignal: AbortSignal.timeout(20_000),
} as any);
const normalizedText = String(text || "").trim();
if (normalizedText) {
return normalizedText;
}
} catch (error) {
console.warn("[Chat API] Direct agent image analysis answer failed:", {
modelId: candidateModelId,
error,
});
}
}
return buildFallbackAnswer();
}
function buildDirectAgentExecutionPrompt(params: {
originalText: string;
optimizedPrompt?: string;
hiddenPromptPrefix?: string;
fallback?: string;
}) {
const originalText = String(params.originalText || "").trim();
const optimizedPrompt = String(params.optimizedPrompt || "").trim();
const hiddenPromptPrefix = String(params.hiddenPromptPrefix || "");
if (!optimizedPrompt) {
return `${hiddenPromptPrefix}${originalText || params.fallback || ""}`.trim();
}
const { normalizedOriginal, hasOriginalDemand, hasOriginalInOptimized } =
analyzeOptimizedPromptMerge(originalText, optimizedPrompt, hiddenPromptPrefix);
return hasOriginalDemand && !hasOriginalInOptimized
? [
hiddenPromptPrefix.trim(),
`【核心目标(必须优先满足)】${normalizedOriginal}`,
`【执行优化细节】${optimizedPrompt}`,
]
.filter(Boolean)
.join("\n\n")
.trim()
: `${hiddenPromptPrefix}${optimizedPrompt}`.trim();
}
async function executeDirectAgentEnhanceJob(params: {
requestUrl: string;
requestHeaders: Headers;
@ -1761,52 +1259,6 @@ function buildAgentToolVideoMarkdown(videoUrl: string) {
].join("\n\n");
}
function pickAgentSourceImageUrls(params: {
sourceTools: DirectAgentSourceToolName[];
requestResolvedMediaInput?: ResolvedChatMediaInput | null;
requestTargetResolution?: ResolvedTargetAssets | null;
requestWorkingState?: {
lastResolvedTargetImageUrls?: string[] | null;
lastGeneratedImageUrls?: string[] | null;
lastUploadedImageUrls?: string[] | null;
} | null;
}): string[] {
const preferredBuckets: string[][] = [];
const sourceTools = params.sourceTools || [];
if (sourceTools.includes("useSelectedCanvasImage")) {
preferredBuckets.push(params.requestResolvedMediaInput?.selectedImageUrls || []);
}
if (sourceTools.includes("useLatestResultImage")) {
preferredBuckets.push(params.requestWorkingState?.lastGeneratedImageUrls || []);
preferredBuckets.push(params.requestResolvedMediaInput?.historyAssistantImageUrls || []);
}
if (sourceTools.includes("useOriginalUploadImage")) {
preferredBuckets.push(params.requestResolvedMediaInput?.currentMessageImageUrls || []);
preferredBuckets.push(params.requestWorkingState?.lastUploadedImageUrls || []);
preferredBuckets.push(params.requestResolvedMediaInput?.historyUserImageUrls || []);
}
preferredBuckets.push(
params.requestTargetResolution?.targetImageUrls || [],
params.requestResolvedMediaInput?.selectedImageUrls || [],
params.requestResolvedMediaInput?.currentMessageImageUrls || [],
params.requestWorkingState?.lastResolvedTargetImageUrls || [],
params.requestWorkingState?.lastGeneratedImageUrls || [],
params.requestResolvedMediaInput?.historyAssistantImageUrls || [],
params.requestResolvedMediaInput?.historyUserImageUrls || [],
);
return Array.from(
new Set(
preferredBuckets
.flat()
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url)),
),
);
}
const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"alibaba-image",
"bfl-image",
@ -4644,11 +4096,19 @@ function buildAgentReplacementPrompt(params: {
const MODEL_UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function getModelCreditCost(modelConfig: any, target: "image" | "video"): number {
function getModelCreditCost(
modelConfig: any,
target: "image" | "video",
options?: { duration?: number | null }
): number {
const config = (modelConfig as any)?.imageGenConfig || {};
const sourceValue =
target === "video"
? config.creditCostPerVideo ?? config.creditCostPerImage ?? 0
? (() => {
const resolvedVideoCost = getVideoCreditCostFromConfig(config, options?.duration);
if (resolvedVideoCost > 0) return resolvedVideoCost;
return config.creditCostPerVideo ?? config.creditCostPerImage ?? 0;
})()
: config.creditCostPerImage ?? 0;
const normalized = Number(sourceValue);
return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0;
@ -4673,6 +4133,7 @@ async function consumeGeneratedVideoPoints(params: {
chatId: string;
messageId: string;
modelRefs: Array<string | null | undefined>;
duration?: number | null;
}) {
try {
const pointsConfig = await getPointsSystemConfig();
@ -4690,12 +4151,14 @@ async function consumeGeneratedVideoPoints(params: {
const resolved = await resolveBillingModelByReference(ref);
if (!resolved) continue;
billingModel = resolved;
if (getModelCreditCost(resolved, "video") > 0) break;
if (getModelCreditCost(resolved, "video", { duration: params.duration }) > 0) break;
}
if (!billingModel) return;
const unitCost = getModelCreditCost(billingModel, "video");
const unitCost = getModelCreditCost(billingModel, "video", {
duration: params.duration,
});
if (unitCost <= 0) return;
const consumeResult = await consumeUserPoints({
@ -4705,7 +4168,7 @@ async function consumeGeneratedVideoPoints(params: {
modelId: billingModel.id,
chatId: params.chatId,
messageId: params.messageId,
note: `模型(${billingModel.displayName || billingModel.modelId || billingModel.id}) 生视频 1 条`,
note: `模型(${billingModel.displayName || billingModel.modelId || billingModel.id}) 生视频 1 条${params.duration ? `(${params.duration} 秒)` : ""}`,
});
if (!consumeResult.ok) {
@ -7343,6 +6806,7 @@ export async function POST(request: Request) {
chatId: id,
messageId: finalMessageId,
modelRefs: executionResult.modelRefs,
duration: Number((finalVideoMetadata as any)?.duration) || undefined,
});
scheduleVideoArchive({
@ -8646,6 +8110,7 @@ export async function POST(request: Request) {
chatId: id,
messageId: assistantMessageId,
modelRefs: [result.modelRef || presetModelId],
duration: Number((result.metadata as any)?.duration) || undefined,
});
scheduleVideoArchive({
@ -8792,7 +8257,7 @@ export async function POST(request: Request) {
: null;
const directAgentToolTargetImageUrls = directAgentExecution
? pickAgentSourceImageUrls({
sourceTools: directAgentExecution.sourceTools,
sourcePreferences: directAgentExecution.sourcePreferences,
requestResolvedMediaInput,
requestTargetResolution,
requestWorkingState,
@ -9393,6 +8858,10 @@ export async function POST(request: Request) {
chatId: id,
messageId: assistantMessageId,
modelRefs: [result.modelRef || finalModelId],
duration:
Number((result.metadata as any)?.duration) ||
Number(effectiveVideoParams?.duration) ||
undefined,
});
if (videoUrl) {
@ -10142,7 +9611,9 @@ export async function POST(request: Request) {
const modelCreditCostPerImage = Number(
(currentModelConfig as any)?.imageGenConfig?.creditCostPerImage ?? 0
);
const modelCreditCostPerVideo = getModelCreditCost(currentModelConfig, "video");
const modelCreditCostPerVideo = getModelCreditCost(currentModelConfig, "video", {
duration: Number(effectiveVideoParams?.duration) || undefined,
});
if (isImageGenerationModel) {
const quota = await checkUserImageQuota(session.user.id);
@ -10219,6 +9690,10 @@ export async function POST(request: Request) {
normalizedVideoUnitCost > 0 &&
pointsConfig.blockOnInsufficient !== false
) {
const requestedVideoDuration = Math.max(
1,
Math.round(Number(effectiveVideoParams?.duration || 0)) || 0
);
const expectedCost = normalizedVideoUnitCost;
const userData = await getUserById({ id: session.user.id });
if (!userData?.isHighVip) {
@ -10228,10 +9703,13 @@ export async function POST(request: Request) {
return NextResponse.json(
{
error: "insufficient_points",
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${expectedCost}(${normalizedVideoUnitCost} x 1 条视频)。`,
message: requestedVideoDuration
? `积分不足,当前余额 ${currentBalance},本次预计消耗 ${expectedCost}(${requestedVideoDuration} 秒视频)。`
: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${expectedCost}(1 条视频)。`,
balance: currentBalance,
expectedCost,
unitCost: normalizedVideoUnitCost,
duration: requestedVideoDuration || undefined,
},
{ status: 402 }
);
@ -11775,6 +11253,7 @@ export async function POST(request: Request) {
chatId: id,
messageId,
modelRefs: billedModelRefs,
duration: Number(effectiveVideoParams?.duration) || undefined,
});
}
@ -12764,6 +12243,7 @@ export async function POST(request: Request) {
chatId: id,
messageId: videoMessageId,
modelRefs: [finalVideoModelData.id, providerModelData.id, finalModelId],
duration: Number(videoOptions.duration) || undefined,
});
dataStream.write({

View File

@ -93,6 +93,9 @@ export async function GET() {
providerType: provider.type,
modelId: model.modelId,
rawCapabilities: model.capabilities,
imageGenConfig: (model.imageGenConfig || null) as
| { videoDurationOptions?: number[] | null }
| null,
}),
imageGenConfig: model.imageGenConfig,
sortOrder: model.sortOrder ?? 0,

View File

@ -30,6 +30,9 @@ export async function GET(request: Request) {
providerType: provider.type,
modelId: model.modelId,
rawCapabilities: model.capabilities,
imageGenConfig: (model.imageGenConfig || null) as
| { videoDurationOptions?: number[] | null }
| null,
});
return Response.json({

View File

@ -15,6 +15,10 @@ import {
isAiModelAgentAuto,
isAiModelMultimodal,
} from "@/lib/db/ai-model-flags";
import {
buildVideoDurationCostMap,
getConfiguredVideoDurationOptions,
} from "@/lib/ai/video-model-config";
// 🆕 图片生成模型特性配置 Schema
const imageGenConfigSchema = z.object({
@ -29,10 +33,29 @@ const imageGenConfigSchema = z.object({
supportsSeed: z.boolean().optional(),
creditCostPerImage: z.number().min(0).max(100000).optional(),
creditCostPerVideo: z.number().min(0).max(100000).optional(),
videoDurationOptions: z.array(z.number().min(1).max(600)).max(20).optional(),
creditCostPerVideoByDuration: z.record(z.string(), z.number().min(0).max(100000)).optional(),
enableLoadingPlaceholder: z.boolean().optional(),
loadingColor: z.string().optional(),
}).optional();
function normalizeImageGenConfig(config: z.infer<typeof imageGenConfigSchema>) {
if (!config) return config;
const videoDurationOptions = getConfiguredVideoDurationOptions(config);
const creditCostPerVideoByDuration = buildVideoDurationCostMap({
durationOptions: videoDurationOptions,
costMap: config.creditCostPerVideoByDuration,
fallbackCost: config.creditCostPerVideo,
});
return {
...config,
videoDurationOptions: videoDurationOptions.length ? videoDurationOptions : undefined,
creditCostPerVideoByDuration,
};
}
const createModelSchema = z.object({
providerId: z.string().uuid(),
modelId: z.string().min(1).max(128),
@ -142,7 +165,10 @@ export async function POST(request: Request) {
return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
}
const model = await createAiModel(data);
const model = await createAiModel({
...data,
imageGenConfig: normalizeImageGenConfig(data.imageGenConfig),
});
return Response.json({ model: normalizeModel(model[0]) });
} catch (error) {
@ -165,7 +191,10 @@ export async function PUT(request: Request) {
const data = updateModelSchema.parse(body);
console.log("[Admin API] Validation passed, updating model..."); // Debug log
const model = await updateAiModel(data);
const model = await updateAiModel({
...data,
imageGenConfig: normalizeImageGenConfig(data.imageGenConfig),
});
if (model.length === 0) {
console.error("[Admin API] Model not found:", data.id); // Debug log

View File

@ -112,9 +112,6 @@ export async function PUT(
const nextMode = normalizeAgentPsdTemplateMode(body.mode);
const nextCategoryTags = normalizeAgentPsdTemplateCategoryTags(body.categoryTags);
let nextLayers = parseAgentPsdTemplateLayers(body.layers);
if (nextLayers.length === 0) {
nextLayers = currentTemplate.layers;
}
if (currentTemplate.parseStatus !== "completed") {
if (nextStatus === "active") {
@ -224,7 +221,7 @@ export async function PUT(
renderBaseUrl: uploadedRenderBase.url,
renderBasePath: uploadedRenderBase.pathname,
layers: nextLayers,
layerCount: Math.max(currentTemplate.layerCount, nextLayers.length),
layerCount: nextLayers.length,
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,
updatedAt: new Date().toISOString(),
};

View File

@ -1159,7 +1159,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
}
}, [isVideoMode]);
const [appearanceRecognitionEnabled, setAppearanceRecognitionEnabled] =
useState(true);
useState(false);
const [selectedBrandModel, setSelectedBrandModel] =
useState<AgentWorkflowSelectedBrandModel | null>(null);
const workflowInputsRef = useRef(workflowInputs);
@ -2153,7 +2153,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
const persistedWorkflow = result.data.canvasData.workflowState;
if (!requestedAgentMode && persistedWorkflow) {
const nextAppearanceRecognitionEnabled =
persistedWorkflow.appearanceRecognitionEnabled !== false;
persistedWorkflow.appearanceRecognitionEnabled === true;
const nextWorkflowMaterials = nextAppearanceRecognitionEnabled
? normalizeAgentWorkflowMaterialSelections(
persistedWorkflow.materials
@ -2567,7 +2567,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
persistedState.workflowState ||
createEmptyAgentWorkflowPersistedState();
const nextAppearanceRecognitionEnabled =
persistedWorkflow.appearanceRecognitionEnabled !== false;
persistedWorkflow.appearanceRecognitionEnabled === true;
const nextWorkflowMaterials = nextAppearanceRecognitionEnabled
? normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials)
: clearAppearanceSummaryFromMaterials(
@ -2650,7 +2650,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
const parsed = JSON.parse(raw) as AgentWorkflowPersistedState;
const nextAppearanceRecognitionEnabled =
parsed.appearanceRecognitionEnabled !== false;
parsed.appearanceRecognitionEnabled === true;
const nextWorkflowMaterials = nextAppearanceRecognitionEnabled
? normalizeAgentWorkflowMaterialSelections(parsed.materials)
: clearAppearanceSummaryFromMaterials(

View File

@ -80,7 +80,6 @@ import {
sanitizeDetailImageModelPrompt,
} from "@/lib/agents/detail-generation-config";
import {
getAgentRequiredUploadCount,
normalizeAgentPresetModeConfig,
} from "@/lib/agents/preset-mode-config";
import { buildAgentSceneImageRolePrompt } from "@/lib/agents/scene-image-role-prompt";
@ -1044,6 +1043,7 @@ export function AgentGenerationWorkspaceDarkV2({
useState<AgentGenerationResultModeType>("scene_generation");
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState<string | null>(null);
const [isPsdTemplateDialogOpen, setIsPsdTemplateDialogOpen] = useState(false);
const [psdTemplateDialogSourceUrl, setPsdTemplateDialogSourceUrl] = useState<string | null>(null);
const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main");
const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(null);
@ -1074,6 +1074,28 @@ export function AgentGenerationWorkspaceDarkV2({
const [detailGenerationRunResults, setDetailGenerationRunResults] = useState<
DirectDetailGenerationImage[]
>([]);
const closePsdTemplateDialog = useCallback(() => {
setIsPsdTemplateDialogOpen(false);
setPsdTemplateDialogSourceUrl(null);
setPsdTemplateDialogMode("main");
}, []);
const openPsdTemplateDialog = useCallback(
(params?: {
sourceImageUrl?: string | null;
mode?: AgentPsdTemplateMode | null;
}) => {
setPsdTemplateDialogSourceUrl(
isValidChatHttpUrl(params?.sourceImageUrl)
? String(params?.sourceImageUrl).trim()
: null
);
setPsdTemplateDialogMode(
params?.mode === "sku" ? "sku" : params?.mode === "main" ? "main" : "main"
);
setIsPsdTemplateDialogOpen(true);
},
[]
);
const normalizedDeletedResultCollections = useMemo(
() => ({
scene_generation: normalizeResultCollectionUrls(
@ -1215,13 +1237,7 @@ export function AgentGenerationWorkspaceDarkV2({
},
[hasAgentUploadSlots, modelSlotEnabled, modelSlotIndex, productSlotCount]
);
const requiredUploadCount = hasAgentUploadSlots
? getAgentRequiredUploadCount({
presetType: selectedAgentPreset?.type || "image",
defaultParams: selectedAgentPreset?.defaultParams || null,
fallback: modelSlotEnabled ? 3 : productSlotCount,
})
: 0;
const requiredProductUploadCount = hasAgentUploadSlots ? productSlotCount : 0;
const detailReferenceTarget = presetModeConfig.detail.referenceImageSource;
const skuReferenceTarget = presetModeConfig.sku.referenceImageSource;
const detailReferenceSourceLabel = getTaskDetailReferenceSourceLabel(detailReferenceTarget);
@ -1365,16 +1381,20 @@ export function AgentGenerationWorkspaceDarkV2({
selectedModelAttachment,
selectedProductAttachments,
]);
const filledAttachmentCount = attachments.filter(Boolean).length;
const missingAttachmentCount = Math.max(requiredUploadCount - filledAttachmentCount, 0);
const filledProductAttachmentCount = selectedProductAttachments.length;
const missingAttachmentCount = Math.max(
requiredProductUploadCount - filledProductAttachmentCount,
0
);
const productAttachmentSummary = `${selectedProductAttachments.length}/${productSlotCount} 张产品图`;
const modelAttachmentSummary = modelSlotEnabled
? selectedModelAttachment
? "模特图已带入"
: "模特图待补充"
: "模特图可不选"
: null;
const attachmentSummaryReady =
hasAgentUploadSlots && (requiredUploadCount === 0 || filledAttachmentCount >= requiredUploadCount);
hasAgentUploadSlots &&
(requiredProductUploadCount === 0 || filledProductAttachmentCount >= requiredProductUploadCount);
const sceneImageRolePromptPreview = useMemo(() => {
const sceneHasModelReference = Boolean(selectedModelAttachment);
if (
@ -1463,18 +1483,13 @@ export function AgentGenerationWorkspaceDarkV2({
`产品图不足(${detailGenerationProductCount}/${detailGenerationRequiredProductCount})`
);
}
if (modelSlotEnabled && !detailGenerationModelReady) {
issues.push("缺少模特图(1/1)");
}
return issues;
}, [
detailGenerationBrief.coreNeed,
detailGenerationBrief.targetAudience,
detailGenerationBrief.usageScenario,
detailGenerationModelReady,
detailGenerationProductCount,
detailGenerationRequiredProductCount,
modelSlotEnabled,
]);
const skuReplacementState = resolvedAgentModeState.skuReplacement;
const skuReplacementQueryInput = skuReplacementState.query;
@ -2228,17 +2243,17 @@ export function AgentGenerationWorkspaceDarkV2({
((activeResultModeType === "scene_generation" && isSceneResultModeGenerating) ||
(activeResultModeType === "detail_generation" &&
(isDetailGenerationResultModeGenerating || hasDetailGenerationPlaceholderState)));
const attachmentCount = attachments.filter(Boolean).length;
const hasBrandModelSelection = Boolean(
selectedBrandModel?.imageUrl || referenceImages.some((image) => image.source === "brandModel")
);
const hasMaterialSelections = referenceImages.some(
(image) => image.source === "primary" || image.source === "secondary"
);
const hasSatisfiedSlotRequirement = !hasAgentUploadSlots || attachmentCount >= requiredUploadCount;
const hasSatisfiedSlotRequirement =
!hasAgentUploadSlots || filledProductAttachmentCount >= requiredProductUploadCount;
const slotRequirementMessage =
hasAgentUploadSlots && !hasSatisfiedSlotRequirement
? `当前还缺少 ${missingAttachmentCount} 张输入图,请先在“选择 SPU / SKU”步骤补齐后再继续。`
? `当前还缺少 ${missingAttachmentCount} 张产品图,请先在“选择 SPU / SKU”步骤补齐后再继续。`
: null;
const mergedSettingsStepLabel = hasMaterialStep ? "选图与生成" : "生成参数";
const mergedSettingsStepDescription = hasMaterialStep
@ -2693,9 +2708,9 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
return;
}
const uploadedAttachmentCount = attachments.filter(Boolean).length;
if (hasAgentUploadSlots && uploadedAttachmentCount < requiredUploadCount) {
toast.error(`当前模式需要上传 ${requiredUploadCount} 张图片`);
const uploadedProductAttachmentCount = selectedProductAttachments.length;
if (hasAgentUploadSlots && uploadedProductAttachmentCount < requiredProductUploadCount) {
toast.error(`当前模式需要上传 ${requiredProductUploadCount} 张产品图`);
return;
}
const resolvedModelId =
@ -2715,8 +2730,12 @@ export function AgentGenerationWorkspaceDarkV2({
}
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
const sanitizedAttachments = sceneMessageAttachments;
if (hasAgentUploadSlots && sanitizedAttachments.length < requiredUploadCount) {
toast.error(`当前模式需要上传 ${requiredUploadCount} 张有效图片`);
if (
hasAgentUploadSlots &&
selectedProductAttachments.filter((attachment) => Boolean(attachment?.url)).length <
requiredProductUploadCount
) {
toast.error(`当前模式需要上传 ${requiredProductUploadCount} 张有效产品图`);
return;
}
const primaryAttachmentCountForMessage = Math.min(
@ -2818,7 +2837,7 @@ export function AgentGenerationWorkspaceDarkV2({
isDetailAiGenerationMethod,
isDetailReplacementPreset,
isSkuReplacementPreset,
requiredUploadCount,
requiredProductUploadCount,
selectedAgentPreset,
selectedAgentPresetId,
selectableModels,
@ -4008,7 +4027,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="text-[11px] text-white/45">
产品图 {detailGenerationProductCount}/{detailGenerationRequiredProductCount}
{modelSlotEnabled
? ` · 模特图 ${detailGenerationModelReady ? "1/1" : "0/1"}`
? ` · 模特图 ${detailGenerationModelReady ? "已带入" : "可不选"}`
: ""}
{` · 搭配图 ${detailGenerationBundleCount}/3`}
</div>
@ -4112,7 +4131,7 @@ export function AgentGenerationWorkspaceDarkV2({
"rounded-full border px-3 py-1 text-xs",
selectedModelAttachment
? "border-emerald-400/25 bg-emerald-500/[0.10] text-emerald-100"
: "border-amber-400/25 bg-amber-500/[0.10] text-amber-100"
: "border-white/10 bg-black/20 text-white/60"
)}
>
{modelAttachmentSummary}
@ -4125,8 +4144,8 @@ export function AgentGenerationWorkspaceDarkV2({
<p className="text-xs leading-6 text-white/55">
{missingAttachmentCount > 0
? `当前还需补充 ${missingAttachmentCount} 张输入素材,展开后可继续检查或替换。`
: "素材已自动带入并准备完成,默认折叠显示;需要时再展开检查或替换即可。"}
? `当前还需补充 ${missingAttachmentCount} 张产品图,展开后可继续检查或替换。`
: "产品图已自动带入并准备完成;模特图槽位可按需补充,不选也可以继续生成。"}
</p>
</>
) : (
@ -5006,6 +5025,18 @@ export function AgentGenerationWorkspaceDarkV2({
{detailGenerationExpectedCount}
</div>
) : null}
<button
type="button"
onClick={() =>
openPsdTemplateDialog({
mode: getPsdTemplateModeByResultMode(activeResultModeType),
})
}
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/72 transition-colors hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
<ImagePlus className="h-3.5 w-3.5" />
<span>套PSD</span>
</button>
</div>
</div>
@ -5845,10 +5876,10 @@ export function AgentGenerationWorkspaceDarkV2({
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
setPsdTemplateDialogSourceUrl(card.url!);
setPsdTemplateDialogMode(
getPsdTemplateModeByResultMode(activeResultModeType)
);
openPsdTemplateDialog({
sourceImageUrl: card.url!,
mode: getPsdTemplateModeByResultMode(activeResultModeType),
});
}}
className="absolute left-14 top-3 flex items-center gap-1 rounded-full border border-white/10 bg-black/60 px-2.5 py-1 text-[11px] text-white transition-colors hover:border-white/20"
aria-label="套用 PSD 模板"
@ -5867,10 +5898,10 @@ export function AgentGenerationWorkspaceDarkV2({
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
setPsdTemplateDialogSourceUrl(card.url!);
setPsdTemplateDialogMode(
getPsdTemplateModeByResultMode(activeResultModeType)
);
openPsdTemplateDialog({
sourceImageUrl: card.url!,
mode: getPsdTemplateModeByResultMode(activeResultModeType),
});
}}
className="absolute left-3 top-3 flex items-center gap-1 rounded-full border border-white/10 bg-black/60 px-2.5 py-1 text-[11px] text-white transition-colors hover:border-white/20"
aria-label="套用 PSD 模板"
@ -5965,6 +5996,22 @@ export function AgentGenerationWorkspaceDarkV2({
? "切换到对应生图模式并开始生成后,结果会保留在当前项目的对应分类中。"
: "请先从顶部流程线选择一个生图模式"}
</p>
<Button
type="button"
variant="outline"
onClick={() =>
openPsdTemplateDialog({
mode: getPsdTemplateModeByResultMode(activeResultModeType),
})
}
className="mt-5 rounded-full border-white/10 bg-white/[0.03] px-5 text-white hover:bg-white/[0.08]"
>
<ImagePlus className="mr-2 h-4 w-4" />
套PSD
</Button>
<p className="mt-2 text-xs text-white/35">
可直接进入模板页,并通过手动上传图片开始套版。
</p>
</motion.div>
)}
</AnimatePresence>
@ -5993,6 +6040,7 @@ export function AgentGenerationWorkspaceDarkV2({
<ClientErrorBoundary
resetKeys={[
isPsdTemplateDialogOpen ? "open" : "closed",
psdTemplateDialogSourceUrl || "",
workflowContext?.brand || "",
workflowContext?.fifthCategory || "",
@ -6000,11 +6048,10 @@ export function AgentGenerationWorkspaceDarkV2({
]}
fallback={
<Dialog
open={Boolean(psdTemplateDialogSourceUrl)}
open={isPsdTemplateDialogOpen}
onOpenChange={(open) => {
if (!open) {
setPsdTemplateDialogSourceUrl(null);
setPsdTemplateDialogMode("main");
closePsdTemplateDialog();
}
}}
>
@ -6027,11 +6074,10 @@ export function AgentGenerationWorkspaceDarkV2({
}
>
<AgentPsdTemplateEditorDialog
open={Boolean(psdTemplateDialogSourceUrl)}
open={isPsdTemplateDialogOpen}
onOpenChange={(open) => {
if (!open) {
setPsdTemplateDialogSourceUrl(null);
setPsdTemplateDialogMode("main");
closePsdTemplateDialog();
}
}}
sourceImageUrl={psdTemplateDialogSourceUrl}

View File

@ -1,9 +1,11 @@
"use client";
import {
Expand,
Image as ImageIcon,
Loader2,
Move,
Pointer,
RefreshCcw,
Sparkles,
Type,
@ -46,6 +48,12 @@ import {
normalizeAgentPsdImagePlacement,
resolveAgentPsdImageBaseFrame,
} from "@/lib/agents/psd-image-placement";
import {
AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG,
defaultAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTags,
} from "@/lib/agents/psd-template-category-tag-config";
import {
type AgentPsdTemplate,
type AgentPsdTemplateImageLayerSlot,
@ -62,12 +70,6 @@ import {
defaultAgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import {
AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG,
defaultAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTags,
} from "@/lib/agents/psd-template-category-tag-config";
import {
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
@ -223,6 +225,62 @@ function getTemplateMetaLabel(
return metaParts.join(" · ");
}
function getTemplateCategoryTagLabels(template: AgentPsdTemplate) {
return normalizeAgentPsdTemplateCategoryTags(template.categoryTags)
.map((tag) =>
tag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG ? "适合所有" : tag
)
.filter(Boolean);
}
function renderTemplateCategoryTagRow(
template: AgentPsdTemplate,
params?: {
compact?: boolean;
}
) {
const compact = params?.compact === true;
const labels = getTemplateCategoryTagLabels(template);
if (labels.length === 0) {
return null;
}
const visibleLabels = compact ? labels.slice(0, 2) : labels.slice(0, 3);
const hiddenCount = Math.max(0, labels.length - visibleLabels.length);
return (
<div
className={
compact ? "mt-1.5 flex flex-wrap gap-1" : "mt-2 flex flex-wrap gap-1.5"
}
>
{visibleLabels.map((label) => (
<span
className={
compact
? "rounded-full border border-white/10 bg-white/[0.05] px-1.5 py-0.5 text-[10px] text-white/68 leading-none"
: "rounded-full border border-white/10 bg-white/[0.05] px-2 py-0.5 text-[10px] text-white/72 leading-none"
}
key={`${template.id}-category-tag-${label}`}
>
{label}
</span>
))}
{hiddenCount > 0 ? (
<span
className={
compact
? "rounded-full border border-white/10 bg-white/[0.03] px-1.5 py-0.5 text-[10px] text-white/45 leading-none"
: "rounded-full border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[10px] text-white/45 leading-none"
}
>
+{hiddenCount}
</span>
) : null}
</div>
);
}
function renderTemplateSelectContent(
template: AgentPsdTemplate,
params?: {
@ -234,7 +292,11 @@ function renderTemplateSelectContent(
const showSecondaryCategory = params?.showSecondaryCategory === true;
const showBrand = params?.showBrand === true;
const compact = params?.compact === true;
const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory, showBrand);
const metaLabel = getTemplateMetaLabel(
template,
showSecondaryCategory,
showBrand
);
return (
<div
@ -278,6 +340,7 @@ function renderTemplateSelectContent(
{metaLabel}
</div>
) : null}
{renderTemplateCategoryTagRow(template, { compact })}
</div>
</div>
);
@ -294,11 +357,15 @@ function renderTemplatePickerCardContent(
const showSecondaryCategory = params?.showSecondaryCategory === true;
const showBrand = params?.showBrand === true;
const emphasize = params?.emphasize === true;
const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory, showBrand);
const metaLabel = getTemplateMetaLabel(
template,
showSecondaryCategory,
showBrand
);
return (
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5">
<div className="flex min-w-0 items-center gap-3.5">
<div className="flex h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5 shadow-inner">
<img
alt={template.name}
className="h-full w-full object-cover"
@ -309,8 +376,8 @@ function renderTemplatePickerCardContent(
<div
className={
emphasize
? "truncate font-semibold text-sm text-white"
: "truncate font-medium text-sm text-white"
? "truncate font-semibold text-[15px] text-white leading-tight"
: "truncate font-medium text-sm text-white leading-tight"
}
>
{template.name}
@ -320,6 +387,7 @@ function renderTemplatePickerCardContent(
{metaLabel}
</div>
) : null}
{renderTemplateCategoryTagRow(template)}
</div>
</div>
);
@ -642,11 +710,11 @@ function getInlineTextEditorStyle(
}
function getTextLayerNameBadgeClassName() {
return "pointer-events-none absolute left-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-black/78 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg";
return "pointer-events-none absolute left-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-black/70 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg backdrop-blur-md";
}
function getTextLayerHintBadgeClassName() {
return "pointer-events-none absolute right-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-sky-500/90 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg";
return "pointer-events-none absolute right-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-sky-500/90 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg backdrop-blur-md";
}
export function AgentPsdTemplateEditorDialog({
@ -747,6 +815,7 @@ export function AgentPsdTemplateEditorDialog({
Record<string, string>
>({});
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
const [extendingSlotId, setExtendingSlotId] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
const [renderedResultUrl, setRenderedResultUrl] = useState<string>("");
const [rightPanelView, setRightPanelView] = useState<"editor" | "export">(
@ -771,7 +840,8 @@ export function AgentPsdTemplateEditorDialog({
[selectedTemplateId, templates]
);
const effectiveTemplateBrandName = useMemo(
() => normalizedBrandName || String(selectedTemplate?.brandName || "").trim(),
() =>
normalizedBrandName || String(selectedTemplate?.brandName || "").trim(),
[normalizedBrandName, selectedTemplate?.brandName]
);
const textColorConfig = useMemo(
@ -785,7 +855,9 @@ export function AgentPsdTemplateEditorDialog({
const shouldShowTemplateBrand = useMemo(
() =>
!normalizedBrandName &&
templates.some((template) => Boolean(normalizeManagedBrandName(template.brandName))),
templates.some((template) =>
Boolean(normalizeManagedBrandName(template.brandName))
),
[normalizedBrandName, templates]
);
const selectedCategorySummaryText = useMemo(
@ -991,12 +1063,21 @@ export function AgentPsdTemplateEditorDialog({
? effectiveTextColorAssignments[selectedTextLayer.id] ||
selectedTextLayer.textColor
: "#111111";
const isSelectedImageExtending = Boolean(
selectedImageLayer && extendingSlotId === selectedImageLayer.id
);
const selectedImageAdjustmentPanel = selectedImageLayer ? (
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-3.5">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
<div
className={
sourceImageUrl
? "grid grid-cols-1 gap-2 sm:grid-cols-3"
: "grid grid-cols-1 gap-2 sm:grid-cols-2"
}
>
{sourceImageUrl ? (
<Button
className="h-11 w-full min-w-0 justify-center gap-1.5 px-3 text-[13px]"
className="h-11 w-full min-w-0 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.02] px-3 text-[13px] text-white/80 transition-all hover:border-emerald-500/30 hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={() => {
setImageAssignments((current) => ({
...current,
@ -1020,8 +1101,11 @@ export function AgentPsdTemplateEditorDialog({
</Button>
) : null}
<Button
className="h-11 w-full min-w-0 justify-center gap-1.5 px-3 text-[13px]"
disabled={uploadingSlotId === selectedImageLayer.id}
className="h-11 w-full min-w-0 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.02] px-3 text-[13px] text-white/80 transition-all hover:border-emerald-500/30 hover:bg-emerald-500/10 hover:text-emerald-400 disabled:opacity-50"
disabled={
uploadingSlotId === selectedImageLayer.id ||
isSelectedImageExtending
}
onClick={() => openImageUpload(selectedImageLayer.id)}
size="sm"
type="button"
@ -1034,13 +1118,44 @@ export function AgentPsdTemplateEditorDialog({
)}
<span className="min-w-0 truncate">上传替换图片</span>
</Button>
<Button
className="h-11 w-full min-w-0 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.02] px-3 text-[13px] text-white/80 transition-all hover:border-emerald-500/30 hover:bg-emerald-500/10 hover:text-emerald-400 disabled:opacity-50"
disabled={
!selectedImageAssignmentUrl || Boolean(isSelectedImageExtending)
}
onClick={() =>
void handleImageExtend(
selectedImageLayer.id,
selectedImageAssignmentUrl
)
}
size="sm"
type="button"
variant="outline"
>
{isSelectedImageExtending ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
) : (
<Expand className="h-4 w-4 shrink-0" />
)}
<span className="min-w-0 truncate">图片拓展</span>
</Button>
</div>
{selectedImageAssignmentUrl ? (
<>
<div className="flex justify-end">
<div className="flex justify-end gap-2">
<Button
className="px-2 text-white/70 hover:text-white"
className="rounded-lg border-white/10 bg-white/[0.02] px-3 text-white/70 transition-all hover:border-white/20 hover:bg-white/[0.06] hover:text-white"
onClick={() => resetImagePlacement(selectedImageLayer.id)}
size="sm"
type="button"
variant="outline"
>
重置位置
</Button>
<Button
className="rounded-lg px-3 text-red-400/80 transition-all hover:bg-red-500/10 hover:text-red-400"
onClick={() => {
setImageAssignments((current) => {
const nextAssignments = { ...current };
@ -1069,20 +1184,20 @@ export function AgentPsdTemplateEditorDialog({
<div className="h-px bg-white/10" />
<div className="space-y-2">
<div className="flex items-center justify-between text-muted-foreground text-xs">
<div className="flex items-center justify-between text-white/70 text-xs">
<div className="flex items-center gap-2">
<ZoomIn className="h-3.5 w-3.5" />
缩放
<ZoomIn className="h-4 w-4" />
<span className="font-medium">图片缩放</span>
</div>
<span className="rounded-full border border-emerald-300/35 bg-emerald-500/15 px-2 py-0.5 font-medium text-emerald-100">
<span className="rounded-md border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 font-medium font-mono text-[11px] text-emerald-400">
{Math.round(selectedImagePlacement.scale * 100)}%
</span>
</div>
<div className="rounded-xl border border-white/12 bg-black/45 p-2.5">
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-3 shadow-inner">
<div className="grid grid-cols-[34px_minmax(0,1fr)_34px] items-center gap-2">
<button
aria-label="缩小图片"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-white/70 transition-colors hover:bg-white/[0.1] hover:text-white"
onClick={() => nudgeImageScale(selectedImageLayer.id, -0.05)}
title="缩小"
type="button"
@ -1100,15 +1215,15 @@ export function AgentPsdTemplateEditorDialog({
scale: newScale,
}));
}}
rangeClassName="bg-gradient-to-r from-emerald-300 via-green-400 to-teal-400 shadow-[0_0_12px_rgba(16,185,129,0.38)]"
rangeClassName="bg-gradient-to-r from-emerald-500 to-emerald-400"
step={0.01}
thumbClassName="h-5 w-5 border-2 border-emerald-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(16,185,129,0.2)]"
trackClassName="h-2.5 rounded-full border border-white/20 bg-[#0f1014]"
thumbClassName="h-4 w-4 border-2 border-[#1a1a1a] bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.3)] hover:scale-110 transition-transform"
trackClassName="h-1.5 rounded-full bg-white/10"
value={[selectedImagePlacement.scale]}
/>
<button
aria-label="放大图片"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-white/70 transition-colors hover:bg-white/[0.1] hover:text-white"
onClick={() => nudgeImageScale(selectedImageLayer.id, 0.05)}
title="放大"
type="button"
@ -1126,7 +1241,7 @@ export function AgentPsdTemplateEditorDialog({
<div className="flex justify-end">
<Button
className="px-2 text-white/70 hover:text-white"
className="rounded-lg px-3 text-white/60 transition-all hover:bg-white/5 hover:text-white/90"
onClick={() => resetImagePlacement(selectedImageLayer.id)}
size="sm"
type="button"
@ -1433,6 +1548,61 @@ export function AgentPsdTemplateEditorDialog({
}
}
async function handleImageExtend(slotId: string, imageUrl: string) {
const normalizedImageUrl = String(imageUrl || "").trim();
if (!normalizedImageUrl) {
toast.error("请先选择或上传一张替换图片");
return;
}
try {
setExtendingSlotId(slotId);
const response = await fetch("/api/extend", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_url: normalizedImageUrl,
prompt: "PSD 套版图片拓展处理",
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(
String(payload?.error || payload?.detail || "").trim() ||
"图片拓展失败"
);
}
const nextImageUrl = String(
payload?.original_url || payload?.thumbnail_url || ""
).trim();
if (!nextImageUrl) {
throw new Error("图片拓展成功,但未返回可用图片地址");
}
setImageAssignments((current) => ({
...current,
[slotId]: nextImageUrl,
}));
setImagePlacements((current) => ({
...current,
[slotId]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[slotId];
return next;
});
toast.success("图片拓展完成,已替换当前槽位");
} catch (error) {
toast.error(error instanceof Error ? error.message : "图片拓展失败");
} finally {
setExtendingSlotId(null);
}
}
function openImageUpload(slotId: string) {
setSelectedLayerId(slotId);
if (uploadInputRef.current) {
@ -1611,32 +1781,101 @@ export function AgentPsdTemplateEditorDialog({
/>
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="flex h-[96vh] w-[96vw] max-w-[1700px] flex-col overflow-hidden border border-white/10 bg-[#09090b] p-0 text-white shadow-[0_30px_120px_rgba(0,0,0,0.65)]"
overlayClassName="bg-black/85 backdrop-blur-sm"
className="flex h-[96vh] w-[96vw] max-w-[1700px] flex-col overflow-hidden rounded-[24px] border border-white/10 bg-[#0a0a0a] p-0 text-white shadow-[0_40px_100px_rgba(0,0,0,0.8)]"
hideCloseButton
overlayClassName="bg-black/80 backdrop-blur-md"
>
{fontFaceCss ? <style>{fontFaceCss}</style> : null}
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[300px_minmax(0,1fr)_380px]">
<div className="min-h-0 border-white/10 border-b bg-[#09090b] p-5 xl:border-r xl:border-b-0">
{/* Top Header Bar */}
<div className="flex h-16 shrink-0 items-center justify-between border-white/10 border-b bg-[#0a0a0a] px-6">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/20 text-emerald-400">
<Sparkles className="h-4 w-4" />
</div>
<h2 className="font-semibold text-base text-white tracking-wide">
PSD 模板编辑器
</h2>
</div>
<div className="flex items-center gap-3">
{selectedTemplate && (
<Button
className="h-9 rounded-xl bg-emerald-500 px-4 font-medium text-white shadow-[0_2px_10px_rgba(16,185,129,0.2)] ring-1 ring-emerald-500/50 transition-all hover:bg-emerald-400 hover:shadow-[0_4px_15px_rgba(16,185,129,0.3)] disabled:opacity-50"
disabled={isRendering}
onClick={handleRender}
>
{isRendering ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
应用并导出
</Button>
)}
<div className="h-4 w-[1px] bg-white/10" />
<button
className="flex h-8 w-8 items-center justify-center rounded-full text-white/50 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => onOpenChange(false)}
type="button"
>
<svg
fill="none"
height="18"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<line x1="18" x2="6" y1="6" y2="18" />
<line x1="6" x2="18" y1="6" y2="18" />
</svg>
</button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)_380px]">
<div className="min-h-0 border-white/10 border-b bg-[#0a0a0a] p-5 xl:border-r xl:border-b-0">
<ScrollArea className="h-full pr-4">
<div className="space-y-4">
<div className="space-y-2">
<Label>
{selectedTemplate ? "选择模板" : "主图模式模板"}
</Label>
{selectedTemplate ? (
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-base text-white">
选择模板
</div>
</div>
</div>
) : (
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-base text-white">
主图模式模板
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 font-medium text-white/70 text-xs">
{mainModeTemplates.length} 个模板
</div>
</div>
)}
{selectedCategorySummaryText ? (
<div className="text-white/45 text-xs">
当前按品类“{selectedCategorySummaryText}”筛选模板,同时保留“适合所有”的模板。
当前按品类“{selectedCategorySummaryText}
”筛选模板,同时保留“适合所有”的模板。
</div>
) : null}
{selectedTemplate && hasTemplateOptions ? (
<div className="space-y-2.5">
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.03] p-1">
<div className="inline-flex rounded-[14px] bg-white/5 p-1 shadow-inner ring-1 ring-white/10">
<button
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
selectedTemplateMode === "main"
? "bg-emerald-500/[0.16] text-emerald-100"
: "text-white/60 hover:text-white"
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
: "text-white/60 hover:bg-white/5 hover:text-white"
}`}
onClick={() =>
handleSwitchSelectedTemplateMode("main")
@ -1646,10 +1885,10 @@ export function AgentPsdTemplateEditorDialog({
主图模式
</button>
<button
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
selectedTemplateMode === "sku"
? "bg-emerald-500/[0.16] text-emerald-100"
: "text-white/60 hover:text-white"
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
: "text-white/60 hover:bg-white/5 hover:text-white"
}`}
onClick={() =>
handleSwitchSelectedTemplateMode("sku")
@ -1664,7 +1903,7 @@ export function AgentPsdTemplateEditorDialog({
onValueChange={setSelectedTemplateId}
value={selectedTemplateSelectValue}
>
<SelectTrigger className="h-auto min-h-[58px] items-center px-2.5 py-2 [&>span]:contents">
<SelectTrigger className="h-auto min-h-[58px] items-center rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2 text-white/90 shadow-inner transition-colors hover:border-white/20 hover:bg-white/[0.04] focus:ring-1 focus:ring-emerald-500/50 data-[state=open]:border-emerald-500/50 [&>span]:contents">
{selectedTemplate ? (
<div className="min-w-0 flex-1 pr-3">
{renderTemplateSelectContent(selectedTemplate, {
@ -1682,9 +1921,9 @@ export function AgentPsdTemplateEditorDialog({
)}
</SelectTrigger>
<SelectContent
className="border-white/10 bg-[#101114] text-white shadow-2xl"
className="border-white/10 bg-[#141416] text-white shadow-2xl"
style={{
backgroundColor: "#101114",
backgroundColor: "#141416",
color: "#ffffff",
}}
>
@ -1733,7 +1972,7 @@ export function AgentPsdTemplateEditorDialog({
<>
{Array.from({ length: 2 }).map((_, index) => (
<div
className="rounded-2xl border border-white/15 border-dashed bg-white/[0.02] px-4 py-4 text-muted-foreground text-sm"
className="rounded-2xl border border-white/10 border-dashed bg-white/[0.02] px-4 py-5 text-center text-sm text-white/50 shadow-inner"
key={`main-template-loading-${index}`}
>
正在加载主图模式模板...
@ -1743,10 +1982,10 @@ export function AgentPsdTemplateEditorDialog({
) : mainModeTemplates.length > 0 ? (
mainModeTemplates.map((template) => (
<button
className={`w-full rounded-2xl border p-3 text-left transition ${
className={`w-full rounded-2xl border p-3 text-left transition-all duration-200 ${
selectedTemplateId === template.id
? "border-emerald-400/45 bg-emerald-500/10"
: "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]"
? "border-emerald-500/50 bg-emerald-500/10 shadow-[0_0_15px_rgba(16,185,129,0.1)]"
: "border-white/[0.08] bg-white/[0.02] hover:border-white/[0.15] hover:bg-white/[0.04]"
}`}
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
@ -1761,12 +2000,12 @@ export function AgentPsdTemplateEditorDialog({
</button>
))
) : (
<div className="rounded-xl border border-white/15 border-dashed bg-white/[0.02] px-3 py-3 text-muted-foreground text-sm">
<div className="flex flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/50 shadow-inner">
{selectedCategorySummaryText
? `当前品类“${selectedCategorySummaryText}”下暂无主图模式模板`
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下暂无主图模式模板`
: "当前暂无可用的主图模式模板,请先去后台配置。"}
: "当前暂无可用的主图模式模板,请先去后台配置。"}
</div>
)}
</div>
@ -1790,7 +2029,7 @@ export function AgentPsdTemplateEditorDialog({
</div>
<Button
className="w-full bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
className="h-12 w-full rounded-xl bg-gradient-to-r from-emerald-500 to-teal-400 font-medium text-white shadow-[0_4px_20px_rgba(16,185,129,0.25)] ring-1 ring-emerald-500/50 transition-all hover:from-emerald-400 hover:to-teal-300 hover:shadow-[0_6px_25px_rgba(16,185,129,0.35)] disabled:opacity-50"
disabled={isRendering}
onClick={handleRender}
>
@ -1802,10 +2041,17 @@ export function AgentPsdTemplateEditorDialog({
应用模板并导出
</Button>
{sourceImageUrl ? null : (
<div className="rounded-2xl border border-white/12 border-dashed bg-white/[0.03] px-3.5 py-3 text-white/60 text-xs leading-5">
当前未从生图结果带入图片。请先在下方图片图层中点击“上传替换图片”,再开始套用
PSD 模板。
</div>
)}
{selectedImageAdjustmentPanel}
</>
) : (
<div className="rounded-2xl border border-white/15 border-dashed p-5 text-muted-foreground text-sm">
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/50 shadow-inner">
{isLoading
? "正在加载可用模板..."
: hasTemplateOptions
@ -1814,18 +2060,18 @@ export function AgentPsdTemplateEditorDialog({
? `当前品牌“${normalizedBrandName}”下没有命中品类“${selectedCategorySummaryText}”的 PSD 模板,请先去后台配置。`
: selectedCategorySummaryText
? `当前品类“${selectedCategorySummaryText}”下还没有可用 PSD 模板,请先去后台配置。`
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。`
: "当前还没有启用的 PSD 模板,请先去后台配置。"}
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。`
: "当前还没有启用的 PSD 模板,请先去后台配置。"}
</div>
)}
</div>
</ScrollArea>
</div>
<div className="flex min-h-0 flex-col border-white/10 border-b bg-[#09090b] p-5 xl:border-r xl:border-b-0">
<div className="flex min-h-0 flex-col border-white/10 border-b bg-[#0a0a0a] p-6 xl:border-r xl:border-b-0">
<div
className="psd-preview-viewport relative flex min-h-0 flex-1 items-center justify-center overflow-auto rounded-2xl border border-white/10 bg-[#111114] p-4"
className="psd-preview-viewport relative flex min-h-0 flex-1 items-center justify-center overflow-auto rounded-[20px] border border-white/[0.08] bg-[#141416] p-4 shadow-[inset_0_2px_20px_rgba(0,0,0,0.4)]"
ref={previewViewportRef}
>
{selectedTemplate ? (
@ -2072,40 +2318,41 @@ export function AgentPsdTemplateEditorDialog({
return (
<div
className="absolute flex items-center gap-1 rounded-full bg-black/72 p-1 text-white shadow-lg"
className="absolute flex items-center gap-1 rounded-full border border-white/10 bg-black/80 p-1 text-white shadow-xl backdrop-blur-md"
key={`${layer.id}-controls`}
style={{
left: layer.x + layer.width - 112,
top: Math.max(8, layer.y - 40),
top: Math.max(8, layer.y - 44),
zIndex: 25,
}}
>
<button
aria-label="缩小图片"
className="rounded-full p-1.5 transition hover:bg-white/10"
className="rounded-full p-1.5 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
onClick={() => nudgeImageScale(layer.id, -0.1)}
title="缩小"
type="button"
>
<ZoomOut className="h-3.5 w-3.5" />
<ZoomOut className="h-4 w-4" />
</button>
<button
aria-label="放大图片"
className="rounded-full p-1.5 transition hover:bg-white/10"
className="rounded-full p-1.5 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
onClick={() => nudgeImageScale(layer.id, 0.1)}
title="放大"
type="button"
>
<ZoomIn className="h-3.5 w-3.5" />
<ZoomIn className="h-4 w-4" />
</button>
<div className="h-3.5 w-px bg-white/20" />
<button
aria-label="重置图片位置和缩放"
className="rounded-full p-1.5 transition hover:bg-white/10"
className="rounded-full p-1.5 text-emerald-400 transition-colors hover:bg-white/20 hover:text-emerald-300"
onClick={() => resetImagePlacement(layer.id)}
title="重置"
type="button"
>
<RefreshCcw className="h-3.5 w-3.5" />
<RefreshCcw className="h-4 w-4" />
</button>
</div>
);
@ -2229,20 +2476,20 @@ export function AgentPsdTemplateEditorDialog({
<div className="flex h-full w-full flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-sm text-white">
<div className="font-semibold text-base text-white">
SKU图模式模板
</div>
<div className="mt-1 text-white/45 text-xs">
<div className="mt-1.5 text-white/50 text-xs">
{normalizedTemplateMode === "sku"
? "当前结果属于 SKU 图模式,可直接从这里选择并进入编辑。"
: selectedCategorySummaryText
? `当前按品类“${selectedCategorySummaryText}”筛选,同时展示“适合所有”的 SKU 图模式模板。`
: normalizedBrandName
? "这里集中展示当前品牌下的 SKU 图模式模板。"
: "当前未选择品牌,这里展示全部品牌下的 SKU 图模式模板。"}
: normalizedBrandName
? "这里集中展示当前品牌下的 SKU 图模式模板。"
: "当前未选择品牌,这里展示全部品牌下的 SKU 图模式模板。"}
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-white/65 text-xs">
<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 font-medium text-white/70 text-xs">
{skuModeTemplates.length} 个模板
</div>
</div>
@ -2251,7 +2498,7 @@ export function AgentPsdTemplateEditorDialog({
<div className="grid gap-3 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div
className="rounded-2xl border border-white/15 border-dashed bg-white/[0.02] px-4 py-5 text-muted-foreground text-sm"
className="rounded-2xl border border-white/10 border-dashed bg-white/[0.02] px-4 py-5 text-center text-sm text-white/50 shadow-inner"
key={`sku-template-loading-${index}`}
>
正在加载 SKU 图模式模板...
@ -2262,10 +2509,10 @@ export function AgentPsdTemplateEditorDialog({
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{skuModeTemplates.map((template) => (
<button
className={`rounded-2xl border p-3 text-left transition ${
className={`rounded-2xl border p-3 text-left transition-all duration-200 ${
selectedTemplateId === template.id
? "border-emerald-400/45 bg-emerald-500/10"
: "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]"
? "border-emerald-500/50 bg-emerald-500/10 shadow-[0_0_15px_rgba(16,185,129,0.1)]"
: "border-white/[0.08] bg-white/[0.02] hover:border-white/[0.15] hover:bg-white/[0.04]"
}`}
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
@ -2281,12 +2528,12 @@ export function AgentPsdTemplateEditorDialog({
))}
</div>
) : (
<div className="flex h-full w-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] px-6 py-10 text-center text-sm text-white/50 shadow-inner">
{selectedCategorySummaryText
? `当前品类“${selectedCategorySummaryText}”下暂无 SKU 图模式模板`
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下暂无 SKU 图模式模板`
: "当前暂无可用的 SKU 图模式模板,请先去后台配置。"}
: "当前暂无可用的 SKU 图模式模板,请先去后台配置。"}
</div>
)}
</div>
@ -2294,15 +2541,15 @@ export function AgentPsdTemplateEditorDialog({
</div>
</div>
<div className="flex min-h-0 flex-col bg-[#09090b] p-4 xl:p-5">
<div className="flex min-h-0 flex-col bg-[#0a0a0a] p-5 shadow-[-10px_0_20px_rgba(0,0,0,0.4)] xl:p-6">
{renderedResultUrl ? (
<div className="mb-3 flex items-center justify-end gap-3">
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.03] p-1">
<div className="mb-4 flex items-center justify-end gap-3">
<div className="inline-flex rounded-[14px] bg-white/5 p-1 shadow-inner ring-1 ring-white/10">
<button
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
rightPanelView === "editor"
? "bg-emerald-500/[0.16] text-emerald-100"
: "text-white/60 hover:text-white"
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
: "text-white/60 hover:bg-white/5 hover:text-white"
}`}
onClick={() => setRightPanelView("editor")}
type="button"
@ -2310,10 +2557,10 @@ export function AgentPsdTemplateEditorDialog({
编辑面板
</button>
<button
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
rightPanelView === "export"
? "bg-emerald-500/[0.16] text-emerald-100"
: "text-white/60 hover:text-white"
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
: "text-white/60 hover:bg-white/5 hover:text-white"
}`}
onClick={() => setRightPanelView("export")}
type="button"
@ -2345,10 +2592,10 @@ export function AgentPsdTemplateEditorDialog({
sizeInfoText={sizeInfoText}
/>
) : selectedTextLayer ? (
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
<div className="flex h-full flex-col gap-3">
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-4 shadow-inner xl:p-5">
<div className="flex h-full flex-col gap-4">
<Textarea
className="min-h-[180px] flex-1 resize-none xl:min-h-[200px]"
className="min-h-[180px] flex-1 resize-none rounded-xl border border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-white/90 shadow-inner placeholder:text-white/30 focus-visible:border-emerald-500/50 focus-visible:bg-white/[0.04] focus-visible:ring-1 focus-visible:ring-emerald-500/50 xl:min-h-[200px]"
onChange={(event) =>
setTextAssignments((current) => ({
...current,
@ -2363,69 +2610,51 @@ export function AgentPsdTemplateEditorDialog({
effectiveTextAssignments[selectedTextLayer.id] || ""
}
/>
<div className="flex flex-wrap gap-2 text-muted-foreground text-xs">
<span>
字体:
{selectedTextLayer.fontLabel ||
selectedTextLayer.fontFamily ||
"系统字体"}
<div className="flex flex-wrap gap-x-4 gap-y-2 rounded-xl bg-white/[0.02] px-4 py-3 text-white/60 text-xs shadow-inner">
<span className="flex items-center gap-1.5">
<span className="text-white/40">字体:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.fontLabel ||
selectedTextLayer.fontFamily ||
"系统字体"}
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">字号:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.fontSize}px
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">行高:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.lineHeight}
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">字距:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.letterSpacing}px
</span>
</span>
<span>字号:{selectedTextLayer.fontSize}px</span>
<span>行高:{selectedTextLayer.lineHeight}</span>
<span>字距:{selectedTextLayer.letterSpacing}px</span>
</div>
{textColorConfig.enabled ? (
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-medium text-sm">文字颜色</div>
<div className="text-muted-foreground text-xs">
<div className="font-semibold text-sm text-white">
文字颜色
</div>
<div className="mt-1.5 text-white/50 text-xs">
选中文字后,可直接点击下面按钮切换品牌预设文字颜色。
</div>
</div>
<div
className="h-8 w-8 rounded-full border border-white/15"
className="h-10 w-10 shrink-0 rounded-full border-2 border-white/20 shadow-[0_0_15px_rgba(0,0,0,0.5)]"
style={{ backgroundColor: selectedTextColor }}
/>
</div>
<div className="grid grid-cols-2 gap-2">
{textColorConfig.colors.map((color, index) => {
const isActive =
getResolvedTextLayerColor(
selectedTextLayer,
color
) ===
getResolvedTextLayerColor(
selectedTextLayer,
selectedTextColor
);
return (
<Button
className={`h-11 justify-start gap-2.5 rounded-xl border px-3 text-left ${
isActive
? "border-emerald-400/60 bg-emerald-500/[0.14] text-white hover:bg-emerald-500/[0.18]"
: "border-white/10 bg-white/[0.02] text-white/80 hover:bg-white/[0.06]"
}`}
key={`text-color-button-${index + 1}`}
onClick={() =>
setTextColorAssignments((current) => ({
...current,
[selectedTextLayer.id]: color,
}))
}
type="button"
variant="ghost"
>
<span
className="h-5 w-5 shrink-0 rounded-full border border-white/20"
style={{ backgroundColor: color }}
/>
<span className="min-w-0 flex-1 truncate font-medium text-sm">{`切换为颜色 ${index + 1}`}</span>
</Button>
);
})}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-2 gap-2.5">
{textColorConfig.colors.map((color, index) => {
const isActive =
getResolvedTextLayerColor(
@ -2438,10 +2667,10 @@ export function AgentPsdTemplateEditorDialog({
);
return (
<button
className={`flex items-center gap-3 rounded-xl border px-3 py-3 text-left transition ${
className={`group flex items-center gap-3 rounded-xl border px-3 py-3 text-left transition-all duration-200 ${
isActive
? "border-emerald-400/55 bg-emerald-500/[0.12] text-white"
: "border-white/10 bg-white/[0.02] text-white/80 hover:bg-white/[0.06]"
? "border-emerald-500/50 bg-emerald-500/10 text-white shadow-[0_0_15px_rgba(16,185,129,0.15)] ring-1 ring-emerald-500/50"
: "border-white/[0.08] bg-white/[0.02] text-white/80 hover:border-white/[0.15] hover:bg-white/[0.04]"
}`}
key={`text-color-option-${index + 1}`}
onClick={() =>
@ -2453,12 +2682,20 @@ export function AgentPsdTemplateEditorDialog({
type="button"
>
<span
className="h-6 w-6 rounded-full border border-white/20"
className={`h-6 w-6 shrink-0 rounded-full border-2 transition-all ${
isActive
? "scale-110 border-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.3)]"
: "border-white/20 shadow-inner"
}`}
style={{ backgroundColor: color }}
/>
<span className="flex min-w-0 flex-col">
<span className="font-medium text-xs">{`颜色 ${index + 1}`}</span>
<span className="font-mono text-[11px] text-white/55 uppercase">
<span
className={`font-medium text-xs transition-colors ${isActive ? "text-emerald-400" : "text-white/90 group-hover:text-white"}`}
>{`颜色 ${index + 1}`}</span>
<span
className={`font-mono text-[11px] uppercase transition-colors ${isActive ? "text-emerald-500/70" : "text-white/50 group-hover:text-white/70"}`}
>
{color}
</span>
</span>
@ -2471,7 +2708,12 @@ export function AgentPsdTemplateEditorDialog({
</div>
</div>
) : (
<div className="flex-1" />
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/40 shadow-inner">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-white/10 bg-white/5 shadow-inner">
<Pointer className="h-6 w-6 text-white/50" />
</div>
请在左侧预览区点击图层进行编辑
</div>
)}
</div>
</div>

View File

@ -121,7 +121,7 @@ export function AgentWorkflowBrandModelPicker({
isDark ? "text-white/55" : "text-[#5b6472]"
)}
>
根据第一步选择的品牌,展示管理员预先上传的模特图。选中后会自动带入右侧“模特图”槽位。
根据第一步选择的品牌,展示管理员预先上传的模特图。选中后会自动带入右侧“模特图”槽位;如果当前模式只是开启了模特槽位,这里也可以不选,后续直接继续生成。
</div>
</div>
@ -159,6 +159,14 @@ export function AgentWorkflowBrandModelPicker({
已选 1 张
</span>
) : null}
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/58" : "bg-background text-muted-foreground"
)}
>
可不选
</span>
</div>
<Button
type="button"

View File

@ -234,7 +234,7 @@ export function AgentWorkflowMaterialSelectionPanel({
onInputsChange,
materials,
onMaterialsChange,
appearanceRecognitionEnabled = true,
appearanceRecognitionEnabled = false,
onAppearanceRecognitionEnabledChange,
productSelectionLimit = null,
totalUploadRequirement = null,

View File

@ -40,7 +40,7 @@ export function createEmptyAgentWorkflowPersistedState(): AgentWorkflowPersisted
priceBand: "",
},
materials: createEmptyAgentWorkflowMaterialSelections(),
appearanceRecognitionEnabled: true,
appearanceRecognitionEnabled: false,
selections: {},
selectedBrandModel: null,
selectedAgentPresetId: null,
@ -239,7 +239,7 @@ function normalizeWorkflowState(value: unknown): AgentWorkflowPersistedState {
priceBand: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).priceBand || "" : "").trim(),
},
materials: normalizeAgentWorkflowMaterialSelections(raw.materials),
appearanceRecognitionEnabled: raw.appearanceRecognitionEnabled !== false,
appearanceRecognitionEnabled: raw.appearanceRecognitionEnabled === true,
selections:
raw.selections && typeof raw.selections === "object" && !Array.isArray(raw.selections)
? (raw.selections as Record<string, string>)

View File

@ -231,16 +231,22 @@ function inferPreferredCapability(
analysis: AnalysisResult,
toolCalls?: AgentPlannedToolCall[],
): AgentPreferredCapability {
const firstTool = toolCalls?.[0]?.name;
const firstTool =
toolCalls?.find((tool) =>
tool.name !== "resolveTargetImage" &&
tool.name !== "useSelectedCanvasImage" &&
tool.name !== "useLatestResultImage" &&
tool.name !== "useOriginalUploadImage"
)?.name || toolCalls?.[0]?.name;
if (firstTool === "removeBackground") return "remove_background";
if (firstTool === "enhanceImage") return "enhance";
if (firstTool === "extendImage") return "extend";
if (firstTool === "resizeImage") return "resize";
if (firstTool === "boxCutout") return "box_cutout";
if (firstTool === "publishToCanvas") return "publish_canvas";
if (firstTool === "publishToCanvas" || firstTool === "publishResultToCanvas") return "publish_canvas";
if (firstTool === "generateVideo") return "generate_video";
if (firstTool === "generateImage") return "generate_image";
if (firstTool === "analyzeImage") return "analyze";
if (firstTool === "generateImage" || firstTool === "generateNewImage") return "generate_image";
if (firstTool === "analyzeImage" || firstTool === "analyzeTargetImage") return "analyze";
if (analysis.intent === "video_gen") return "generate_video";
if (analysis.intent === "image_gen") return "generate_image";
@ -264,11 +270,11 @@ function inferPreferredCapability(
function defaultToolForAction(action: AgentPlanNextAction): AgentProjectToolName {
switch (action) {
case "analyze_image":
return "analyzeImage";
return "analyzeTargetImage";
case "edit_image":
return "editImage";
return "editTargetImage";
case "generate_image":
return "generateImage";
return "generateNewImage";
case "generate_video":
return "generateVideo";
case "clarify":
@ -340,6 +346,9 @@ function sanitizePlannedToolCalls(
rawToolCalls: unknown,
availableTools: AgentProjectToolDefinition[],
fallbackAction: AgentPlanNextAction,
options?: {
targetSource?: AgentTargetResolutionMode;
},
): AgentPlannedToolCall[] {
const availableNames = new Set(availableTools.map((tool) => tool.name));
const list = Array.isArray(rawToolCalls) ? rawToolCalls : [];
@ -357,9 +366,64 @@ function sanitizePlannedToolCalls(
})
.filter(Boolean) as AgentPlannedToolCall[];
const shouldInjectTargetResolver =
(fallbackAction === "analyze_image" || fallbackAction === "edit_image") &&
options?.targetSource &&
options.targetSource !== "none" &&
options.targetSource !== "clarification" &&
availableNames.has("resolveTargetImage") &&
!sanitized.some((tool) =>
tool.name === "resolveTargetImage" ||
tool.name === "useSelectedCanvasImage" ||
tool.name === "useLatestResultImage" ||
tool.name === "useOriginalUploadImage"
);
if (shouldInjectTargetResolver) {
const source =
options?.targetSource === "explicit_selection"
? "selected_canvas"
: options?.targetSource === "current_upload"
? "original_upload"
: options?.targetSource === "latest_result" || options?.targetSource === "working_memory"
? "latest_result"
: "auto";
sanitized.unshift({
name: "resolveTargetImage",
reason: "先锁定本轮要操作的目标图,再执行分析或编辑。",
args: { source },
});
}
if (sanitized.length > 0) return sanitized;
const fallbackTool = defaultToolForAction(fallbackAction);
if (
(fallbackAction === "analyze_image" || fallbackAction === "edit_image") &&
options?.targetSource &&
options.targetSource !== "none" &&
options.targetSource !== "clarification" &&
availableNames.has("resolveTargetImage")
) {
const source =
options.targetSource === "explicit_selection"
? "selected_canvas"
: options.targetSource === "current_upload"
? "original_upload"
: options.targetSource === "latest_result" || options.targetSource === "working_memory"
? "latest_result"
: "auto";
if (availableNames.has(fallbackTool)) {
return [
{
name: "resolveTargetImage",
reason: "先确定要操作的目标图。",
args: { source },
},
{ name: fallbackTool },
];
}
}
if (availableNames.has(fallbackTool)) {
return [{ name: fallbackTool }];
}
@ -380,7 +444,9 @@ function buildFallbackPlan(params: {
const baseAction = params.analysis.needsClarification
? "clarify"
: mapIntentToAction(params.analysis.intent);
const toolCalls = sanitizePlannedToolCalls([], params.availableTools, baseAction);
const toolCalls = sanitizePlannedToolCalls([], params.availableTools, baseAction, {
targetSource: params.targetResolutionMode,
});
return {
goal: createPlanGoalSummary(params.text, params.hasImage),
nextAction: baseAction,
@ -433,7 +499,9 @@ function prepareExecutionFromPlan(params: {
shouldApplyImagePipeline: mode === "edit_image" || mode === "generate_image",
shouldApplyVideoPipeline: mode === "generate_video",
shouldStayConversational: mode === "respond" || mode === "analyze_image" || mode === "clarify",
shouldPublishToCanvas: params.plan.toolCalls.some((tool) => tool.name === "publishToCanvas"),
shouldPublishToCanvas: params.plan.toolCalls.some(
(tool) => tool.name === "publishToCanvas" || tool.name === "publishResultToCanvas"
),
toolCalls: params.plan.toolCalls,
availableTools: availableToolNames,
};
@ -1199,7 +1267,9 @@ function sanitizeAgentPlan(
].includes(rawTargetSource)
? rawTargetSource
: fallbackPlan.targetSource;
const sanitizedToolCalls = sanitizePlannedToolCalls(payload.toolCalls, availableTools, nextAction);
const sanitizedToolCalls = sanitizePlannedToolCalls(payload.toolCalls, availableTools, nextAction, {
targetSource,
});
const rawCapability = String(payload.preferredCapability || "").trim() as AgentPreferredCapability;
const derivedAnalysis: AnalysisResult = {
intent,
@ -1284,6 +1354,7 @@ async function buildExecutionPlanWithAgent(
}
const toolCatalog = availableTools
.filter((tool) => tool.plannerHidden !== true)
.map((tool) => `- ${tool.name}: ${tool.title}|${tool.description}`)
.join("\n");
const historyContext = (state.historyMessages || [])
@ -1309,11 +1380,13 @@ async function buildExecutionPlanWithAgent(
1. 用户只是聊天、问图、讨论方案时,绝不能走生图/生视频。
2. 只有用户明确要求“生成/修改/做视频”等执行任务时,才走执行型 action。
3. 如果用户上传了图片但只是问“这是什么/适不适合/帮我分析一下”,应选择 analyze_image 或 respond。
4. 优先保持一次只选择一个主工具;toolCalls 只从提供的工具列表里选。
5. shouldUseStructuredReply 只在 edit_image / generate_image / generate_video 时设为 true。
6. targetSource 要忠实反映当前目标图来源:explicit_selection/current_upload/text_reference/working_memory/latest_result/clarification/none。
7. preferredCapability 要尽量具体,例如 remove_background / enhance / extend / resize / box_cutout / publish_canvas。
8. 必须遵守当前专家路由约束;如果约束要求纯对话,则绝不能输出 generate_image / edit_image / generate_video。
4. 对 analyze_image / edit_image 任务,若目标图来源不是 none/clarification,应优先先调用 resolveTargetImage,再调用主工具。
5. 优先保持一次只选择一个主工具;toolCalls 只从提供的工具列表里选。
6. shouldUseStructuredReply 只在 edit_image / generate_image / generate_video 时设为 true。
7. targetSource 要忠实反映当前目标图来源:explicit_selection/current_upload/text_reference/working_memory/latest_result/clarification/none。
8. preferredCapability 要尽量具体,例如 remove_background / enhance / extend / resize / box_cutout / publish_canvas。
9. 必须优先使用新的强语义工具名:resolveTargetImage / analyzeTargetImage / editTargetImage / generateNewImage / publishResultToCanvas。
10. 必须遵守当前专家路由约束;如果约束要求纯对话,则绝不能输出 generate_image / edit_image / generate_video。
输出格式:
{
@ -1329,7 +1402,12 @@ async function buildExecutionPlanWithAgent(
"confidence": 0.92,
"toolCalls": [
{
"name": "analyzeImage",
"name": "resolveTargetImage",
"reason": "先锁定本轮要处理的目标图",
"args": { "source": "selected_canvas" }
},
{
"name": "analyzeTargetImage",
"reason": "为什么选这个工具",
"args": {}
}

View File

@ -0,0 +1,75 @@
import { generateText } from "ai";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
export async function buildDirectAgentImageAnalysisAnswer(params: {
question: string;
summary: string;
modelId?: string;
}) {
const normalizedQuestion = String(params.question || "").trim();
const normalizedSummary = String(params.summary || "").trim();
if (!normalizedSummary) {
return "";
}
const buildFallbackAnswer = () => {
const intro = normalizedQuestion
? "我先根据当前锁定图片给你一个直接结论:"
: "我先根据当前锁定图片做一个直接分析:";
return [
intro,
normalizedSummary,
normalizedQuestion
? "如果你要,我可以继续往主体构图、风格、问题点或优化建议里再拆细。"
: "",
]
.filter(Boolean)
.join("\n\n");
};
const candidateModelIds = Array.from(
new Set(
[params.modelId]
.map((value) => String(value || "").trim())
.filter(Boolean),
),
);
for (const candidateModelId of candidateModelIds) {
try {
const model = await getLanguageModelById(candidateModelId);
if (!model) continue;
const { text } = await generateText({
model,
system: [
"你是无限画布里的图片分析助手。",
"只能根据给定的图像观察摘要回答,不要编造图片中不可见的事实。",
"如果用户问题超出已知信息,要明确说信息不足,不要硬猜。",
"优先给出直接结论,再补充2到4条简洁依据或建议。",
].join("\n"),
prompt: [
normalizedQuestion ? `用户问题:${normalizedQuestion}` : "用户要求:请分析当前图片。",
`图像观察摘要:\n${normalizedSummary}`,
"请用中文直接回答,不要复述“根据摘要/根据图片”,也不要输出多余寒暄。",
].join("\n\n"),
temperature: 0.2,
maxTokens: 320,
abortSignal: AbortSignal.timeout(20_000),
} as any);
const normalizedText = String(text || "").trim();
if (normalizedText) {
return normalizedText;
}
} catch (error) {
console.warn("[Direct Agent Analysis Answer] generation failed:", {
modelId: candidateModelId,
error,
});
}
}
return buildFallbackAnswer();
}

View File

@ -0,0 +1,461 @@
import type {
AgentExecutionPlan,
AgentPreparedExecution,
AgentPlannedToolCall,
} from "@/lib/ai/auto-agent-graph";
import type {
ResolvedChatMediaInput,
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
export type DirectAgentToolCapability =
| "analyze"
| "generic_edit"
| "remove_background"
| "enhance"
| "resize"
| "extend"
| "box_cutout"
| "generate_image"
| "generate_video";
export type DirectAgentToolName =
| "analyzeImage"
| "editImage"
| "removeBackground"
| "enhanceImage"
| "resizeImage"
| "extendImage"
| "boxCutout"
| "generateImage"
| "generateVideo";
type DirectAgentSourceToolName =
| "useSelectedCanvasImage"
| "useLatestResultImage"
| "useOriginalUploadImage";
export type DirectAgentSourcePreference =
| "selected_canvas"
| "latest_result"
| "original_upload"
| "auto";
export interface ResolvedDirectAgentToolExecution {
toolName: DirectAgentToolName;
capability: DirectAgentToolCapability;
sourcePreferences: DirectAgentSourcePreference[];
publishToCanvas: boolean;
}
export interface DirectAgentWorkingStateSnapshot {
lastResolvedTargetImageUrls?: string[] | null;
lastGeneratedImageUrls?: string[] | null;
lastUploadedImageUrls?: string[] | null;
}
function collectAgentToolCalls(
execution?: AgentPreparedExecution,
plan?: AgentExecutionPlan,
): AgentPlannedToolCall[] {
const merged = [...(execution?.toolCalls || []), ...(plan?.toolCalls || [])];
const seen = new Set<string>();
const result: AgentPlannedToolCall[] = [];
for (const tool of merged) {
const name = String(tool?.name || "").trim();
if (!name || seen.has(name)) continue;
seen.add(name);
result.push(tool);
}
return result;
}
function mapDirectToolNameToCapability(
toolName: DirectAgentToolName,
): DirectAgentToolCapability {
switch (toolName) {
case "analyzeImage":
return "analyze";
case "editImage":
return "generic_edit";
case "removeBackground":
return "remove_background";
case "enhanceImage":
return "enhance";
case "resizeImage":
return "resize";
case "extendImage":
return "extend";
case "boxCutout":
return "box_cutout";
case "generateImage":
return "generate_image";
case "generateVideo":
return "generate_video";
default:
return "generate_image";
}
}
function mapLegacySourceToolToPreference(
toolName: DirectAgentSourceToolName,
): DirectAgentSourcePreference {
switch (toolName) {
case "useSelectedCanvasImage":
return "selected_canvas";
case "useLatestResultImage":
return "latest_result";
case "useOriginalUploadImage":
return "original_upload";
default:
return "auto";
}
}
function normalizeSourcePreference(value: unknown): DirectAgentSourcePreference | null {
const normalized = String(value || "").trim().toLowerCase();
switch (normalized) {
case "selected_canvas":
case "selected":
case "canvas_selection":
case "explicit_selection":
return "selected_canvas";
case "latest_result":
case "latest":
case "working_memory":
case "history":
return "latest_result";
case "original_upload":
case "current_upload":
case "upload":
case "current":
return "original_upload";
case "auto":
return "auto";
default:
return null;
}
}
function mapTargetSourceToSourcePreference(
targetSource: AgentExecutionPlan["targetSource"] | undefined,
): DirectAgentSourcePreference | null {
switch (targetSource) {
case "explicit_selection":
return "selected_canvas";
case "latest_result":
case "working_memory":
return "latest_result";
case "current_upload":
return "original_upload";
case "text_reference":
return "auto";
default:
return null;
}
}
export function resolveDirectAgentToolExecution(
execution?: AgentPreparedExecution,
plan?: AgentExecutionPlan,
): ResolvedDirectAgentToolExecution | null {
const mergedToolCalls = collectAgentToolCalls(execution, plan);
const toolNames = mergedToolCalls.map((tool) => tool.name);
const hasToolCall = (toolName: string) => toolNames.includes(toolName);
const sourceTools = toolNames.filter((toolName): toolName is DirectAgentSourceToolName =>
toolName === "useSelectedCanvasImage" ||
toolName === "useLatestResultImage" ||
toolName === "useOriginalUploadImage",
);
const sourcePreferences: DirectAgentSourcePreference[] = [];
const appendSourcePreference = (value: DirectAgentSourcePreference | null) => {
if (!value || sourcePreferences.includes(value)) return;
sourcePreferences.push(value);
};
for (const sourceTool of sourceTools) {
appendSourcePreference(mapLegacySourceToolToPreference(sourceTool));
}
for (const tool of mergedToolCalls) {
if (tool.name !== "resolveTargetImage") continue;
const sourceArg = normalizeSourcePreference(tool.args?.source ?? tool.args?.sourcePreference);
appendSourcePreference(sourceArg);
const sourceList = Array.isArray(tool.args?.sourcePreferences)
? tool.args?.sourcePreferences
: Array.isArray(tool.args?.sources)
? tool.args?.sources
: [];
for (const item of sourceList) {
appendSourcePreference(normalizeSourcePreference(item));
}
}
if (sourcePreferences.length === 0) {
appendSourcePreference(
mapTargetSourceToSourcePreference(execution?.targetSource || plan?.targetSource),
);
}
const directToolName =
(hasToolCall("analyzeTargetImage") || hasToolCall("analyzeImage")
? "analyzeImage"
: hasToolCall("removeBackground")
? "removeBackground"
: hasToolCall("enhanceImage")
? "enhanceImage"
: hasToolCall("resizeImage")
? "resizeImage"
: hasToolCall("extendImage")
? "extendImage"
: hasToolCall("boxCutout")
? "boxCutout"
: hasToolCall("editTargetImage") || hasToolCall("editImage")
? "editImage"
: hasToolCall("generateNewImage") || hasToolCall("generateImage")
? "generateImage"
: hasToolCall("generateVideo")
? "generateVideo"
: null) ||
(() => {
if (
execution?.preferredCapability === "analyze" ||
execution?.routeIntent === "vision" ||
execution?.mode === "analyze_image"
) {
return "analyzeImage";
}
if (
execution?.preferredCapability === "remove_background" ||
execution?.routeSubIntent === "remove_background"
) {
return "removeBackground";
}
if (
execution?.preferredCapability === "enhance" ||
execution?.routeSubIntent === "enhance"
) {
return "enhanceImage";
}
if (
execution?.preferredCapability === "resize" ||
execution?.routeSubIntent === "resize"
) {
return "resizeImage";
}
if (
execution?.preferredCapability === "extend" ||
execution?.routeSubIntent === "extend"
) {
return "extendImage";
}
if (
execution?.preferredCapability === "box_cutout" ||
execution?.routeSubIntent === "box_cutout"
) {
return "boxCutout";
}
if (
execution?.preferredCapability === "generic_edit" ||
execution?.routeIntent === "image_edit" ||
execution?.mode === "edit_image"
) {
return "editImage";
}
if (execution?.mode === "generate_image") {
return "generateImage";
}
if (execution?.mode === "generate_video") {
return "generateVideo";
}
return null;
})();
if (!directToolName) {
return null;
}
return {
toolName: directToolName,
capability: mapDirectToolNameToCapability(directToolName),
sourcePreferences,
publishToCanvas:
execution?.shouldPublishToCanvas === true ||
toolNames.includes("publishToCanvas") ||
toolNames.includes("publishResultToCanvas"),
};
}
export function getDirectAgentToolLabel(capability: DirectAgentToolCapability): string {
switch (capability) {
case "analyze":
return "Agent工具:图片分析";
case "generic_edit":
return "Agent工具:图片编辑";
case "remove_background":
return "Agent工具:抠图";
case "enhance":
return "Agent工具:图像增强";
case "resize":
return "Agent工具:尺寸调整";
case "extend":
return "Agent工具:图像拓展";
case "box_cutout":
return "Agent工具:框选抠图";
case "generate_image":
return "Agent工具:图片生成";
case "generate_video":
return "Agent工具:视频生成";
default:
return "Agent工具";
}
}
export function findAgentToolCallArgs(
toolName: AgentPlannedToolCall["name"],
execution?: AgentPreparedExecution,
plan?: AgentExecutionPlan,
): Record<string, unknown> | undefined {
const matchedTool =
execution?.toolCalls?.find((tool) => tool.name === toolName) ||
plan?.toolCalls?.find((tool) => tool.name === toolName);
if (!matchedTool?.args || typeof matchedTool.args !== "object") {
return undefined;
}
return matchedTool.args;
}
export function getDirectAgentToolSuccessMessage(
capability: DirectAgentToolCapability,
): string {
switch (capability) {
case "analyze":
return "已完成当前目标图分析,结果如下:";
case "generic_edit":
return "已完成图片编辑,结果如下:";
case "remove_background":
return "已完成抠图处理,结果如下:";
case "enhance":
return "已完成图像增强,结果如下:";
case "resize":
return "已完成尺寸调整,结果如下:";
case "extend":
return "已完成图像拓展,结果如下:";
case "box_cutout":
return "已完成框选抠图,结果如下:";
case "generate_image":
return "已完成图片生成,结果如下:";
case "generate_video":
return "已完成视频生成,结果如下:";
default:
return "已完成处理,结果如下:";
}
}
export function getDirectAgentToolTargetMissingMessage(
capability: DirectAgentToolCapability,
): string {
switch (capability) {
case "analyze":
return "请先上传或选中一张图片,我才能继续帮你分析。";
case "generic_edit":
return "请先上传或选中一张图片,我才能继续帮你修改。";
case "remove_background":
return "请先上传或选中一张图片,我才能继续帮你执行抠图。";
case "enhance":
return "请先上传或选中一张图片,我才能继续帮你做图像增强。";
case "resize":
return "请先上传或选中一张图片,我才能继续帮你调整尺寸。";
case "extend":
return "请先上传或选中一张图片,我才能继续帮你做图像拓展。";
case "box_cutout":
return "请先选中一张图片并提供框选区域,我才能继续帮你做框选抠图。";
case "generate_image":
return "请先告诉我你想生成什么,或者提供参考图。";
case "generate_video":
return "请先告诉我你想生成什么视频,必要时再提供参考图。";
default:
return "请先提供要处理的图片。";
}
}
export function getDirectAgentToolErrorMessage(
capability: DirectAgentToolCapability,
error: unknown,
): string {
const errorMessage =
error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
const errorDetail =
error && typeof error === "object" && "detail" in error
? String((error as { detail?: unknown }).detail || "").trim()
: "";
const detail = errorDetail || errorMessage || "请稍后重试。";
switch (capability) {
case "analyze":
return `图片分析暂时失败:${detail}`;
case "generic_edit":
return `图片编辑暂时失败:${detail}`;
case "remove_background":
return `抠图暂时失败:${detail}`;
case "enhance":
return `图像增强暂时失败:${detail}`;
case "resize":
return `尺寸调整暂时失败:${detail}`;
case "extend":
return `图像拓展暂时失败:${detail}`;
case "box_cutout":
return `框选抠图暂时失败:${detail}`;
case "generate_image":
return `图片生成暂时失败:${detail}`;
case "generate_video":
return `视频生成暂时失败:${detail}`;
default:
return `处理暂时失败:${detail}`;
}
}
export function pickAgentSourceImageUrls(params: {
sourcePreferences: DirectAgentSourcePreference[];
requestResolvedMediaInput?: ResolvedChatMediaInput | null;
requestTargetResolution?: ResolvedTargetAssets | null;
requestWorkingState?: DirectAgentWorkingStateSnapshot | null;
}): string[] {
const preferredBuckets: string[][] = [];
const sourcePreferences = params.sourcePreferences || [];
if (sourcePreferences.includes("selected_canvas")) {
preferredBuckets.push(params.requestResolvedMediaInput?.selectedImageUrls || []);
}
if (sourcePreferences.includes("latest_result")) {
preferredBuckets.push(params.requestWorkingState?.lastGeneratedImageUrls || []);
preferredBuckets.push(params.requestResolvedMediaInput?.historyAssistantImageUrls || []);
}
if (sourcePreferences.includes("original_upload")) {
preferredBuckets.push(params.requestResolvedMediaInput?.currentMessageImageUrls || []);
preferredBuckets.push(params.requestWorkingState?.lastUploadedImageUrls || []);
preferredBuckets.push(params.requestResolvedMediaInput?.historyUserImageUrls || []);
}
preferredBuckets.push(
params.requestTargetResolution?.targetImageUrls || [],
params.requestResolvedMediaInput?.selectedImageUrls || [],
params.requestResolvedMediaInput?.currentMessageImageUrls || [],
params.requestWorkingState?.lastResolvedTargetImageUrls || [],
params.requestWorkingState?.lastGeneratedImageUrls || [],
params.requestResolvedMediaInput?.historyAssistantImageUrls || [],
params.requestResolvedMediaInput?.historyUserImageUrls || [],
);
return Array.from(
new Set(
preferredBuckets.flatMap((bucket) =>
(bucket || []).map((item) => String(item || "").trim()).filter(Boolean),
),
),
);
}

View File

@ -0,0 +1,79 @@
export function analyzeOptimizedPromptMerge(
originalText: string,
optimizedPrompt?: string,
hiddenPromptPrefix = "",
) {
const prompt = optimizedPrompt?.trim() || "";
const trimmedOriginal = String(originalText || "").trim();
const normalizedOriginal =
hiddenPromptPrefix && trimmedOriginal.startsWith(hiddenPromptPrefix)
? trimmedOriginal.slice(hiddenPromptPrefix.length).trim()
: trimmedOriginal;
const hasOriginalDemand = normalizedOriginal.length > 0;
const hasOriginalInOptimized =
hasOriginalDemand &&
(prompt.includes(normalizedOriginal) || normalizedOriginal.includes(prompt));
return {
prompt,
normalizedOriginal,
hasOriginalDemand,
hasOriginalInOptimized,
};
}
export function buildOptimizedPromptPreview(params: {
originalText: string;
optimizedPrompt?: string;
hiddenPromptPrefix?: string;
}): string | undefined {
const { prompt, normalizedOriginal, hasOriginalDemand, hasOriginalInOptimized } =
analyzeOptimizedPromptMerge(
params.originalText,
params.optimizedPrompt,
params.hiddenPromptPrefix || "",
);
if (!prompt) return undefined;
const normalizedPrompt = prompt.replace(/\s+/g, " ").trim();
if (!normalizedPrompt) return undefined;
if (hasOriginalDemand && !hasOriginalInOptimized) {
return [
`- 核心目标:${normalizedOriginal.replace(/\s+/g, " ").trim()}`,
`- 优化提示词:${normalizedPrompt}`,
].join("\n");
}
return `- ${normalizedPrompt}`;
}
export function buildDirectAgentExecutionPrompt(params: {
originalText: string;
optimizedPrompt?: string;
hiddenPromptPrefix?: string;
fallback?: string;
}) {
const originalText = String(params.originalText || "").trim();
const optimizedPrompt = String(params.optimizedPrompt || "").trim();
const hiddenPromptPrefix = String(params.hiddenPromptPrefix || "");
if (!optimizedPrompt) {
return `${hiddenPromptPrefix}${originalText || params.fallback || ""}`.trim();
}
const { normalizedOriginal, hasOriginalDemand, hasOriginalInOptimized } =
analyzeOptimizedPromptMerge(originalText, optimizedPrompt, hiddenPromptPrefix);
return hasOriginalDemand && !hasOriginalInOptimized
? [
hiddenPromptPrefix.trim(),
`【核心目标(必须优先满足)】${normalizedOriginal}`,
`【执行优化细节】${optimizedPrompt}`,
]
.filter(Boolean)
.join("\n\n")
.trim()
: `${hiddenPromptPrefix}${optimizedPrompt}`.trim();
}

View File

@ -88,6 +88,8 @@ export function routeAgentSkill(
planningHints: [],
},
[
"resolveTargetImage",
"analyzeTargetImage",
"analyzeImage",
"respondDirect",
"useSelectedCanvasImage",
@ -108,6 +110,8 @@ export function routeAgentSkill(
planningHints: [],
},
[
"resolveTargetImage",
"editTargetImage",
"editImage",
"removeBackground",
"enhanceImage",
@ -117,6 +121,7 @@ export function routeAgentSkill(
"useSelectedCanvasImage",
"useLatestResultImage",
"useOriginalUploadImage",
"publishResultToCanvas",
"publishToCanvas",
],
[
@ -133,10 +138,13 @@ export function routeAgentSkill(
planningHints: [],
},
[
"resolveTargetImage",
"generateNewImage",
"generateImage",
"useSelectedCanvasImage",
"useLatestResultImage",
"useOriginalUploadImage",
"publishResultToCanvas",
"publishToCanvas",
],
[
@ -154,6 +162,7 @@ export function routeAgentSkill(
},
[
"generateVideo",
"resolveTargetImage",
"useSelectedCanvasImage",
"useLatestResultImage",
"useOriginalUploadImage",
@ -172,9 +181,11 @@ export function routeAgentSkill(
planningHints: [],
},
[
"resolveTargetImage",
"useSelectedCanvasImage",
"useLatestResultImage",
"useOriginalUploadImage",
"publishResultToCanvas",
"publishToCanvas",
],
[

View File

@ -128,6 +128,89 @@ function forcePlanShape(params: {
};
}
function prependTargetResolverTool(params: {
toolCalls: AgentPlannedToolCall[];
availableTools: AgentProjectToolDefinition[];
targetSource: AgentTargetResolutionMode;
hasCanvasSelection?: boolean;
}) {
if (params.targetSource === "none" || params.targetSource === "clarification") {
return params.toolCalls;
}
let source: "selected_canvas" | "latest_result" | "original_upload" | "auto" = "auto";
if (params.targetSource === "explicit_selection" || params.hasCanvasSelection) {
source = "selected_canvas";
} else if (params.targetSource === "current_upload") {
source = "original_upload";
} else if (
params.targetSource === "latest_result" ||
params.targetSource === "working_memory"
) {
source = "latest_result";
}
if (hasTool(params.availableTools, "resolveTargetImage")) {
return upsertTool(
params.toolCalls,
"resolveTargetImage",
"先锁定当前要处理的目标图。",
{
prepend: true,
args: { source },
},
);
}
if (source === "selected_canvas" && hasTool(params.availableTools, "useSelectedCanvasImage")) {
return upsertTool(
params.toolCalls,
"useSelectedCanvasImage",
"先使用当前选中的画布图片作为目标图。",
{ prepend: true },
);
}
if (source === "latest_result" && hasTool(params.availableTools, "useLatestResultImage")) {
return upsertTool(
params.toolCalls,
"useLatestResultImage",
"先使用最近结果图作为目标图。",
{ prepend: true },
);
}
if (source === "original_upload" && hasTool(params.availableTools, "useOriginalUploadImage")) {
return upsertTool(
params.toolCalls,
"useOriginalUploadImage",
"先使用原上传图作为目标图。",
{ prepend: true },
);
}
return params.toolCalls;
}
function appendCanvasPublishTool(
toolCalls: AgentPlannedToolCall[],
availableTools: AgentProjectToolDefinition[],
) {
if (hasTool(availableTools, "publishResultToCanvas")) {
return upsertTool(
toolCalls,
"publishResultToCanvas",
"执行结果需要同步回无限画布。",
);
}
if (hasTool(availableTools, "publishToCanvas")) {
return upsertTool(
toolCalls,
"publishToCanvas",
"执行结果需要同步回无限画布。",
);
}
return toolCalls;
}
function normalizeEditCapability(
analysis: AnalysisResult,
text: string,
@ -206,11 +289,19 @@ export function refinePlanWithSpecialist(
}
if (specialist.domain === "vision") {
if (hasTool(availableTools, "analyzeImage")) {
if (hasTool(availableTools, "analyzeTargetImage")) {
toolCalls = [{ name: "analyzeTargetImage", reason: "视觉分析任务由看图专家处理" }];
} else if (hasTool(availableTools, "analyzeImage")) {
toolCalls = [{ name: "analyzeImage", reason: "视觉分析任务由看图专家处理" }];
} else if (hasTool(availableTools, "respondDirect")) {
toolCalls = [{ name: "respondDirect", reason: "缺少看图工具时退回直接回答" }];
}
toolCalls = prependTargetResolverTool({
toolCalls,
availableTools,
targetSource: plan.targetSource,
hasCanvasSelection: input.hasCanvasSelection,
});
return forcePlanShape({
plan,
nextAction: "analyze_image",
@ -226,9 +317,17 @@ export function refinePlanWithSpecialist(
const editTarget = normalizeEditCapability(analysis, text);
if (hasTool(availableTools, editTarget.toolName)) {
toolCalls = [{ name: editTarget.toolName, reason: "修图专家选择最匹配的编辑能力" }];
} else if (hasTool(availableTools, "editTargetImage")) {
toolCalls = [{ name: "editTargetImage", reason: "修图专家回退到目标图编辑能力" }];
} else if (hasTool(availableTools, "editImage")) {
toolCalls = [{ name: "editImage", reason: "修图专家回退到通用编辑能力" }];
}
toolCalls = prependTargetResolverTool({
toolCalls,
availableTools,
targetSource: plan.targetSource,
hasCanvasSelection: input.hasCanvasSelection,
});
return forcePlanShape({
plan,
nextAction: "edit_image",
@ -252,9 +351,17 @@ export function refinePlanWithSpecialist(
}
if (specialist.domain === "image_generate") {
if (hasTool(availableTools, "generateImage")) {
if (hasTool(availableTools, "generateNewImage")) {
toolCalls = [{ name: "generateNewImage", reason: "生图专家执行图片生成" }];
} else if (hasTool(availableTools, "generateImage")) {
toolCalls = [{ name: "generateImage", reason: "生图专家执行图片生成" }];
}
toolCalls = prependTargetResolverTool({
toolCalls,
availableTools,
targetSource: plan.targetSource,
hasCanvasSelection: input.hasCanvasSelection,
});
return forcePlanShape({
plan,
nextAction: "generate_image",
@ -309,24 +416,24 @@ export function refinePlanWithSpecialist(
? "explicit_selection"
: plan.targetSource;
if (input.hasCanvasSelection && hasTool(availableTools, "useSelectedCanvasImage")) {
toolCalls = upsertTool(
if (
normalizedTargetSource !== "none" &&
normalizedTargetSource !== "clarification"
) {
toolCalls = prependTargetResolverTool({
toolCalls,
"useSelectedCanvasImage",
"画布专家优先使用当前选中的画布图片",
{ prepend: true },
);
availableTools,
targetSource: normalizedTargetSource,
hasCanvasSelection: input.hasCanvasSelection,
});
}
if (
isExecutionAction &&
hasTool(availableTools, "publishToCanvas")
(hasTool(availableTools, "publishResultToCanvas") ||
hasTool(availableTools, "publishToCanvas"))
) {
toolCalls = upsertTool(
toolCalls,
"publishToCanvas",
"画布专家要求结果同步回无限画布",
);
toolCalls = appendCanvasPublishTool(toolCalls, availableTools);
}
return {

View File

@ -10,6 +10,11 @@ export type RuntimeToolName =
export type AgentProjectToolName =
| RuntimeToolName
| "respondDirect"
| "resolveTargetImage"
| "analyzeTargetImage"
| "generateNewImage"
| "editTargetImage"
| "publishResultToCanvas"
| "analyzeImage"
| "generateImage"
| "editImage"
@ -31,6 +36,7 @@ export interface AgentProjectToolDefinition {
description: string;
category: "conversation" | "image" | "video" | "document" | "utility" | "canvas";
runtimeTool?: RuntimeToolName;
plannerHidden?: boolean;
requiresImage?: boolean;
requiresCanvasSelection?: boolean;
requiresHistoryImage?: boolean;
@ -91,12 +97,49 @@ const INTERNAL_AGENT_TOOLS: AgentProjectToolDefinition[] = [
description: "仅对话、解释、建议或策略讨论,不执行生图生视频。",
category: "conversation",
},
{
name: "resolveTargetImage",
title: "解析目标图",
description:
"先确定本轮要操作的是哪张图,可通过 args.source 指定 selected_canvas/latest_result/original_upload/auto。",
category: "canvas",
requiresImage: true,
},
{
name: "analyzeTargetImage",
title: "分析目标图",
description: "对已解析的目标图做识别、风格分析、质量评估与改造建议。",
category: "image",
requiresImage: true,
},
{
name: "generateNewImage",
title: "生成新图",
description: "根据文本或参考风格生成全新图片,需要新建结果图时使用。",
category: "image",
producesMedia: true,
},
{
name: "editTargetImage",
title: "编辑目标图",
description: "基于已解析的目标图进行修改、换色、换背景、增强、扩图或局部重绘。",
category: "image",
requiresImage: true,
producesMedia: true,
},
{
name: "publishResultToCanvas",
title: "发布结果到画布",
description: "把当前结果图发布回无限画布,作为新的结果节点或继续编辑的输入。",
category: "canvas",
},
{
name: "analyzeImage",
title: "图片分析",
description: "对当前图或历史图做识别、风格分析、质量评估、品牌/营销建议。",
category: "image",
requiresImage: true,
plannerHidden: true,
},
{
name: "generateImage",
@ -104,6 +147,7 @@ const INTERNAL_AGENT_TOOLS: AgentProjectToolDefinition[] = [
description: "根据文本或参考风格生成新图片。",
category: "image",
producesMedia: true,
plannerHidden: true,
},
{
name: "editImage",
@ -112,6 +156,7 @@ const INTERNAL_AGENT_TOOLS: AgentProjectToolDefinition[] = [
category: "image",
requiresImage: true,
producesMedia: true,
plannerHidden: true,
},
{
name: "removeBackground",
@ -159,6 +204,7 @@ const INTERNAL_AGENT_TOOLS: AgentProjectToolDefinition[] = [
description: "把当前结果图继续发送到无限画布或作为新的画布结果节点。",
category: "canvas",
producesMedia: false,
plannerHidden: true,
},
{
name: "useSelectedCanvasImage",
@ -167,6 +213,7 @@ const INTERNAL_AGENT_TOOLS: AgentProjectToolDefinition[] = [
category: "canvas",
requiresImage: true,
requiresCanvasSelection: true,
plannerHidden: true,
},
{
name: "useLatestResultImage",
@ -175,6 +222,7 @@ const INTERNAL_AGENT_TOOLS: AgentProjectToolDefinition[] = [
category: "canvas",
requiresImage: true,
requiresHistoryImage: true,
plannerHidden: true,
},
{
name: "useOriginalUploadImage",
@ -183,6 +231,7 @@ const INTERNAL_AGENT_TOOLS: AgentProjectToolDefinition[] = [
category: "canvas",
requiresImage: true,
requiresCurrentUpload: true,
plannerHidden: true,
},
];

View File

@ -10,6 +10,7 @@ import {
getEnabledAiModelsWithProvider,
getSubModelsByParentModelId
} from "@/lib/db/queries";
import { getConfiguredVideoDurationOptions } from "@/lib/ai/video-model-config";
import type {
VideoGenerationMode,
VideoModelCapabilities,
@ -61,6 +62,34 @@ function getSupportedModes(caps: Partial<VideoModelCapabilities> | undefined, mo
return inferSupportedModesFromModelId(modelId);
}
function withConfiguredVideoCapabilities(
capabilities: Partial<VideoModelCapabilities> | undefined,
imageGenConfig?: { videoDurationOptions?: number[] | null } | null
): VideoModelCapabilities {
const baseCapabilities = {
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedResolutions: ["480p", "720p", "1080p"],
supportedRatios: ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"],
durationRange: { min: 2, max: 12 },
maxReferenceImages: 4,
...(capabilities || {}),
} as VideoModelCapabilities;
const durationOptions = getConfiguredVideoDurationOptions(imageGenConfig);
if (durationOptions.length === 0) {
return baseCapabilities;
}
return {
...baseCapabilities,
durationOptions,
durationRange: {
min: durationOptions[0],
max: durationOptions[durationOptions.length - 1],
},
};
}
/**
* 🆕 智能选择最佳子模型
* 根据用户选择的模式和参数,从子模型列表中选择最合适的模型
@ -85,7 +114,10 @@ async function selectBestSubModel(
throw new Error("没有可用的视频生成子模型");
}
const directCapabilities = directModel.model.capabilities as VideoModelCapabilities | undefined;
const directCapabilities = withConfiguredVideoCapabilities(
directModel.model.capabilities as VideoModelCapabilities | undefined,
(directModel.model as any).imageGenConfig
);
const supportedModes = getSupportedModes(directCapabilities, directModel.model.modelId);
if (!supportedModes.includes(mode)) {
throw new Error(`模型 ${directModel.model.displayName} 不支持 ${mode} 模式`);
@ -93,15 +125,10 @@ async function selectBestSubModel(
return {
model: directModel,
capabilities:
directCapabilities ||
({
supportedModes,
supportedResolutions: ["480p", "720p", "1080p"],
supportedRatios: ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"],
durationRange: { min: 2, max: 12 },
maxReferenceImages: 4,
} as VideoModelCapabilities),
capabilities: {
...directCapabilities,
supportedModes,
} as VideoModelCapabilities,
};
}
@ -109,7 +136,10 @@ async function selectBestSubModel(
// 筛选支持指定模式的子模型
const compatibleModels = subModels.filter((m) => {
const caps = m.model.capabilities as VideoModelCapabilities;
const caps = withConfiguredVideoCapabilities(
m.model.capabilities as VideoModelCapabilities,
(m.model as any).imageGenConfig
);
const supportsMode = getSupportedModes(caps, m.model.modelId).includes(mode);
// 检查分辨率支持(如果用户指定了分辨率)
@ -144,7 +174,10 @@ async function selectBestSubModel(
return {
model: bestModel,
capabilities: bestModel.model.capabilities as VideoModelCapabilities,
capabilities: withConfiguredVideoCapabilities(
bestModel.model.capabilities as VideoModelCapabilities,
(bestModel.model as any).imageGenConfig
),
};
}
@ -176,7 +209,10 @@ async function getCompatibleModel(
}
const compatibleModels = volcanoVideoModels.filter((m) => {
const caps = m.model.capabilities as VideoModelCapabilities;
const caps = withConfiguredVideoCapabilities(
m.model.capabilities as VideoModelCapabilities,
(m.model as any).imageGenConfig
);
return getSupportedModes(caps, m.model.modelId).includes(mode);
});
@ -199,7 +235,10 @@ async function getCompatibleModel(
return {
model: bestModel,
capabilities: bestModel.model.capabilities as VideoModelCapabilities,
capabilities: withConfiguredVideoCapabilities(
bestModel.model.capabilities as VideoModelCapabilities,
(bestModel.model as any).imageGenConfig
),
};
}
@ -229,9 +268,18 @@ function validateParamsAgainstCapabilities(
// 检查时长范围
if (params.duration) {
const { min, max } = capabilities.durationRange;
if (params.duration < min || params.duration > max) {
throw new Error(`视频时长必须在 ${min}-${max} 秒之间`);
const allowedDurations = Array.isArray(capabilities.durationOptions)
? capabilities.durationOptions
: [];
if (allowedDurations.length > 0) {
if (!allowedDurations.includes(params.duration)) {
throw new Error(`视频时长仅支持 ${allowedDurations.join(" / ")} 秒`);
}
} else {
const { min, max } = capabilities.durationRange;
if (params.duration < min || params.duration > max) {
throw new Error(`视频时长必须在 ${min}-${max} 秒之间`);
}
}
}

View File

@ -0,0 +1,106 @@
export type VideoDurationCostMap = Record<string, number>;
export type VideoImageGenConfigLike = {
creditCostPerVideo?: number | null;
videoDurationOptions?: number[] | null;
creditCostPerVideoByDuration?: Record<string, number> | null;
};
function normalizePositiveInteger(value: unknown) {
const normalized = Number(value);
if (!Number.isFinite(normalized)) return null;
const rounded = Math.round(normalized);
if (rounded < 1) return null;
return rounded;
}
export function normalizeVideoDurationOptions(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => normalizePositiveInteger(item))
.filter((item): item is number => item !== null)
)
).sort((left, right) => left - right);
}
export function normalizeVideoDurationCostMap(
value: unknown,
durationOptions?: readonly number[] | null
): VideoDurationCostMap | undefined {
if (!value || typeof value !== "object") return undefined;
const allowedDurations = Array.isArray(durationOptions)
? new Set(durationOptions.map((item) => String(item)))
: null;
const entries = Object.entries(value as Record<string, unknown>)
.map(([durationKey, costValue]) => {
const normalizedDuration = normalizePositiveInteger(durationKey);
const normalizedCost = Number(costValue);
if (normalizedDuration === null || !Number.isFinite(normalizedCost)) {
return null;
}
const durationText = String(normalizedDuration);
if (allowedDurations && !allowedDurations.has(durationText)) {
return null;
}
return [durationText, Math.max(0, Math.round(normalizedCost))] as const;
})
.filter((entry): entry is readonly [string, number] => Boolean(entry));
if (entries.length === 0) return undefined;
return Object.fromEntries(entries);
}
export function getConfiguredVideoDurationOptions(
config: VideoImageGenConfigLike | null | undefined
): number[] {
return normalizeVideoDurationOptions(config?.videoDurationOptions);
}
export function buildVideoDurationCostMap(params: {
durationOptions: readonly number[];
costMap?: unknown;
fallbackCost?: number | null | undefined;
}): VideoDurationCostMap | undefined {
const fallbackCost = Number.isFinite(Number(params.fallbackCost))
? Math.max(0, Math.round(Number(params.fallbackCost)))
: 0;
const normalizedCostMap =
normalizeVideoDurationCostMap(params.costMap, params.durationOptions) || {};
if (params.durationOptions.length === 0 && Object.keys(normalizedCostMap).length === 0) {
return undefined;
}
return Object.fromEntries(
params.durationOptions.map((duration) => {
const key = String(duration);
return [key, normalizedCostMap[key] ?? fallbackCost];
})
);
}
export function getVideoCreditCostFromConfig(
config: VideoImageGenConfigLike | null | undefined,
duration?: number | null
): number {
const normalizedDuration = normalizePositiveInteger(duration);
const durationOptions = getConfiguredVideoDurationOptions(config);
const durationCostMap = normalizeVideoDurationCostMap(
config?.creditCostPerVideoByDuration,
durationOptions
);
if (normalizedDuration !== null && durationCostMap) {
const durationKey = String(normalizedDuration);
if (Object.prototype.hasOwnProperty.call(durationCostMap, durationKey)) {
return durationCostMap[durationKey] || 0;
}
}
const fallbackCost = Number(config?.creditCostPerVideo ?? 0);
return Number.isFinite(fallbackCost) ? Math.max(0, Math.round(fallbackCost)) : 0;
}

View File

@ -1,5 +1,6 @@
import type { VideoModelCapabilities } from "@/types/ai";
import { getRunwayVideoModelDefinition } from "@/lib/ai/runway-video-models";
import { getConfiguredVideoDurationOptions } from "@/lib/ai/video-model-config";
import type {
VideoToolCapabilityContract,
VideoToolDefaults,
@ -30,6 +31,7 @@ const STANDARD_DURATIONS = [5, 10];
type ParsedVideoCapabilities = Partial<VideoModelCapabilities> & {
constraints?: Partial<Record<VideoToolMode, VideoToolModeConstraint>>;
durationOptions?: number[];
};
function asObject(value: unknown) {
@ -157,6 +159,12 @@ function normalizeVideoCapabilities(rawCapabilities: unknown): ParsedVideoCapabi
const durationRange = asObject(raw.durationRange);
const min = Number(durationRange?.min);
const max = Number(durationRange?.max);
const durationOptions = Array.isArray(raw.durationOptions)
? raw.durationOptions
.map((item) => Number(item))
.filter((item) => Number.isFinite(item))
.map((item) => Math.round(item))
: undefined;
return {
supportedModes,
@ -166,6 +174,7 @@ function normalizeVideoCapabilities(rawCapabilities: unknown): ParsedVideoCapabi
Number.isFinite(min) && Number.isFinite(max)
? { min: Math.round(min), max: Math.round(max) }
: undefined,
durationOptions: durationOptions?.length ? Array.from(new Set(durationOptions)) : undefined,
maxReferenceImages: Number.isFinite(Number(raw.maxReferenceImages))
? Math.round(Number(raw.maxReferenceImages))
: undefined,
@ -430,6 +439,41 @@ function getModeConstraintOptions(
};
}
function applyConfiguredDurationOptions(
capability: VideoToolCapabilityContract,
configuredDurationOptions: number[]
) {
if (configuredDurationOptions.length === 0) {
return capability;
}
const nextModeConstraints = capability.modeConstraints
? MODE_ORDER.reduce<Partial<Record<VideoToolMode, VideoToolModeConstraint>>>((acc, mode) => {
if (!capability.modeOptions.some((item) => item.mode === mode)) return acc;
const current = capability.modeConstraints?.[mode];
acc[mode] = {
...(current || {}),
durationOptions: configuredDurationOptions,
defaults: {
...(current?.defaults || {}),
duration: pickPreferredDuration(configuredDurationOptions),
},
};
return acc;
}, {})
: undefined;
return {
...capability,
durationOptions: configuredDurationOptions,
modeConstraints: nextModeConstraints,
defaults: {
...capability.defaults,
duration: pickPreferredDuration(configuredDurationOptions),
},
};
}
export function getVideoToolOptionsForMode(
capability: VideoToolCapabilityContract | null | undefined,
mode?: VideoToolMode | null
@ -466,16 +510,18 @@ export function buildVideoToolCapability(params: {
providerType?: string | null;
modelId?: string | null;
rawCapabilities?: unknown;
imageGenConfig?: { videoDurationOptions?: number[] | null } | null;
}): VideoToolCapabilityContract | null {
const providerType = String(params.providerType || "").trim();
if (!providerType) return null;
const base = createProviderDefaultCapability(providerType, params.modelId);
const configuredDurationOptions = getConfiguredVideoDurationOptions(params.imageGenConfig);
if (providerType === "fal-video" || providerType === "qianwen-video" || providerType === "ruxa-video") {
return {
return applyConfiguredDurationOptions({
...base,
modelId: params.modelId || undefined,
};
}, configuredDurationOptions);
}
const parsed = normalizeVideoCapabilities(params.rawCapabilities);
@ -490,10 +536,10 @@ export function buildVideoToolCapability(params: {
);
if (!hasModelCapability) {
return {
return applyConfiguredDurationOptions({
...base,
modelId: params.modelId || undefined,
};
}, configuredDurationOptions);
}
const supportedModes =
@ -512,9 +558,11 @@ export function buildVideoToolCapability(params: {
parsed.supportedResolutions && parsed.supportedResolutions.length > 0
? parsed.supportedResolutions
: base.resolutionOptions;
const durationOptions = parsed.durationRange
? normalizeDurations(parsed.durationRange.min, parsed.durationRange.max)
: base.durationOptions;
const durationOptions = parsed.durationOptions?.length
? parsed.durationOptions
: parsed.durationRange
? normalizeDurations(parsed.durationRange.min, parsed.durationRange.max)
: base.durationOptions;
const baseModeConstraints = base.modeConstraints || {};
const nextModeConstraints =
parsed.constraints && Object.keys(parsed.constraints).length > 0
@ -561,7 +609,7 @@ export function buildVideoToolCapability(params: {
? modeScopedDefaults.resolutionOptions
: resolutionOptions;
return {
return applyConfiguredDurationOptions({
...base,
source: "model-capabilities",
modelId: params.modelId || undefined,
@ -587,5 +635,5 @@ export function buildVideoToolCapability(params: {
duration: pickPreferredDuration(effectiveDurationOptions),
resolution: pickPreferredResolution(effectiveResolutionOptions),
},
};
}, configuredDurationOptions);
}

View File

@ -3737,6 +3737,8 @@ interface ImageGenConfig {
supportsSeed?: boolean;
creditCostPerImage?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>;
enableLoadingPlaceholder?: boolean;
loadingColor?: string;
}

File diff suppressed because one or more lines are too long

View File

@ -30,6 +30,7 @@ export interface VideoModelCapabilities {
supportedResolutions: VideoResolution[];
supportedRatios: VideoRatio[];
durationRange: { min: number; max: number }; // 时长范围(秒)
durationOptions?: number[]; // 可选的离散时长(优先于区间)
supportWatermark: boolean;
supportCameraFixed: boolean;
supportSeed: boolean;
@ -241,4 +242,3 @@ export const VIDEO_PRESETS = {
},
},
};