feat: update video billing and agent workspace improvements
This commit is contained in:
parent
02f13a045a
commit
477f215259
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -234,7 +234,7 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
onInputsChange,
|
||||
materials,
|
||||
onMaterialsChange,
|
||||
appearanceRecognitionEnabled = true,
|
||||
appearanceRecognitionEnabled = false,
|
||||
onAppearanceRecognitionEnabledChange,
|
||||
productSelectionLimit = null,
|
||||
totalUploadRequirement = null,
|
||||
|
||||
@ -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>)
|
||||
|
||||
@ -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": {}
|
||||
}
|
||||
|
||||
75
lib/ai/runtime/direct-agent-analysis-answer.ts
Normal file
75
lib/ai/runtime/direct-agent-analysis-answer.ts
Normal 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();
|
||||
}
|
||||
461
lib/ai/runtime/direct-agent-execution.ts
Normal file
461
lib/ai/runtime/direct-agent-execution.ts
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
79
lib/ai/runtime/optimized-prompt.ts
Normal file
79
lib/ai/runtime/optimized-prompt.ts
Normal 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();
|
||||
}
|
||||
@ -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",
|
||||
],
|
||||
[
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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} 秒之间`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
106
lib/ai/video-model-config.ts
Normal file
106
lib/ai/video-model-config.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
@ -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 = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user