refactor: modularize chat route helpers
This commit is contained in:
parent
33bfcadf97
commit
f2d4f9e698
106
app/(chat)/api/chat/_lib/agent-output.ts
Normal file
106
app/(chat)/api/chat/_lib/agent-output.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import type { PostRequestBody } from "../schema";
|
||||
|
||||
export function sanitizeAgentOutputSettingsSection(params: {
|
||||
message?: string;
|
||||
userImageParams?: PostRequestBody["imageParams"];
|
||||
hasUserCustomImageParams: boolean;
|
||||
}): string | undefined {
|
||||
const { message, userImageParams, hasUserCustomImageParams } = params;
|
||||
if (!message?.trim()) return message;
|
||||
|
||||
const outputSectionRegex =
|
||||
/(?:###\s*输出设置|【\s*输出设置\s*】)[\s\S]*?(?=\n(?:###\s*|【\s*[^】]+\s*】)|$)/;
|
||||
if (!outputSectionRegex.test(message)) return message;
|
||||
|
||||
if (!hasUserCustomImageParams) {
|
||||
return message.replace(
|
||||
outputSectionRegex,
|
||||
"【输出设置】\n- 参数:按用户面板当前设置执行(未自动改写)"
|
||||
);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const aspectRatio =
|
||||
typeof userImageParams?.aspectRatio === "string" ? userImageParams.aspectRatio.trim() : "";
|
||||
const imageSize =
|
||||
typeof userImageParams?.imageSize === "string" ? userImageParams.imageSize.trim() : "";
|
||||
const imageCount =
|
||||
typeof userImageParams?.imageCount === "number" ? userImageParams.imageCount : undefined;
|
||||
|
||||
if (aspectRatio && aspectRatio !== "auto") {
|
||||
lines.push(`- 宽高比:${aspectRatio}(来自用户面板设置)`);
|
||||
}
|
||||
if (imageSize && imageSize.toUpperCase() !== "2K") {
|
||||
lines.push(`- 清晰度档位:${imageSize.toUpperCase()}(来自用户面板设置)`);
|
||||
}
|
||||
if (typeof imageCount === "number" && Number.isFinite(imageCount) && imageCount !== 1) {
|
||||
lines.push(`- 出图数量:${Math.max(1, Math.round(imageCount))} 张(来自用户面板设置)`);
|
||||
}
|
||||
lines.push("- 其余参数:保持用户面板当前设置");
|
||||
|
||||
return message.replace(outputSectionRegex, `【输出设置】\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
export function normalizeAgentReportHeadingLine(line: string): string {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return line;
|
||||
|
||||
const markdownHeading = trimmed.match(/^#{1,6}\s*(.+?)\s*$/);
|
||||
const plainTitle = markdownHeading?.[1]?.trim() || trimmed;
|
||||
const normalizedTitle = plainTitle
|
||||
.replace(/^[^\u4e00-\u9fa5A-Za-z0-9]+/, "")
|
||||
.trim();
|
||||
|
||||
const knownTitles = [
|
||||
"需求理解",
|
||||
"图片分析",
|
||||
"执行计划",
|
||||
"优化后提示词",
|
||||
"输出设置",
|
||||
"脚本规划",
|
||||
"分镜规划",
|
||||
"执行说明",
|
||||
"视频生成完成",
|
||||
];
|
||||
|
||||
if (knownTitles.some((title) => normalizedTitle === title)) {
|
||||
return `【${normalizedTitle}】`;
|
||||
}
|
||||
|
||||
if (markdownHeading) {
|
||||
return `【${normalizedTitle || plainTitle}】`;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
export function stripAgentFriendlyMediaEmbeds(message: string): string {
|
||||
if (!message) return message;
|
||||
|
||||
const withoutInlineMedia = message
|
||||
.replace(/!\[[^\]]*]\((https?:\/\/[^\s)]+)\)/gi, "")
|
||||
.replace(/<video[\s\S]*?<\/video>/gi, "")
|
||||
.replace(/<img[^>]*>/gi, "");
|
||||
|
||||
const cleanedLines = withoutInlineMedia
|
||||
.split("\n")
|
||||
.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return true;
|
||||
if (/^https?:\/\/\S+$/i.test(trimmed)) return false;
|
||||
if (/^\[[^\]]*]\(https?:\/\/[^\s)]+\)$/i.test(trimmed)) return false;
|
||||
return true;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return cleanedLines.replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
export function normalizeAgentReportForDisplay(message?: string): string | undefined {
|
||||
if (!message?.trim()) return message;
|
||||
const stripped = stripAgentFriendlyMediaEmbeds(message);
|
||||
if (!stripped) return undefined;
|
||||
return stripped
|
||||
.split("\n")
|
||||
.map((line) => normalizeAgentReportHeadingLine(line))
|
||||
.join("\n");
|
||||
}
|
||||
540
app/(chat)/api/chat/_lib/agent-scene-workflow.ts
Normal file
540
app/(chat)/api/chat/_lib/agent-scene-workflow.ts
Normal file
@ -0,0 +1,540 @@
|
||||
export type AgentPresetMessageImagePart = {
|
||||
type: "file";
|
||||
url: string;
|
||||
name: string;
|
||||
mediaType: string;
|
||||
assetId?: string;
|
||||
};
|
||||
|
||||
export function extractCurrentMessageImageParts(
|
||||
parts: Array<Record<string, any>>,
|
||||
): AgentPresetMessageImagePart[] {
|
||||
return parts
|
||||
.filter(
|
||||
(part): part is AgentPresetMessageImagePart =>
|
||||
part?.type === "file" &&
|
||||
typeof part?.url === "string" &&
|
||||
/^https?:\/\//i.test(part.url) &&
|
||||
typeof part?.mediaType === "string" &&
|
||||
part.mediaType.startsWith("image/")
|
||||
)
|
||||
.map((part) => ({
|
||||
type: "file",
|
||||
url: String(part.url).trim(),
|
||||
name: String(part.name || "图片").trim() || "图片",
|
||||
mediaType: String(part.mediaType || "image/jpeg").trim() || "image/jpeg",
|
||||
assetId:
|
||||
typeof part.assetId === "string" && part.assetId.trim() ? part.assetId.trim() : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSceneUploadedImageBreakdown(params: {
|
||||
uploadedImageParts: AgentPresetMessageImagePart[];
|
||||
fallbackPrimaryCount: number;
|
||||
fallbackIncludeModelImage: boolean;
|
||||
}) {
|
||||
const normalizedNames = params.uploadedImageParts.map((part) => String(part.name || "").trim());
|
||||
const primaryCountFromNames = normalizedNames.filter((name) => name.startsWith("主SKU参考图")).length;
|
||||
const secondaryCountFromNames = normalizedNames.filter((name) => name.startsWith("搭配SKU参考图")).length;
|
||||
const hasModelFromNames = normalizedNames.some((name) => name.startsWith("品牌模特图"));
|
||||
const hasNamedBreakdown =
|
||||
primaryCountFromNames > 0 || secondaryCountFromNames > 0 || hasModelFromNames;
|
||||
|
||||
if (hasNamedBreakdown) {
|
||||
return {
|
||||
primaryProductImageCount: primaryCountFromNames,
|
||||
bundleProductImageCount: secondaryCountFromNames,
|
||||
includeModelImage: hasModelFromNames,
|
||||
};
|
||||
}
|
||||
|
||||
const uploadedImageCount = params.uploadedImageParts.length;
|
||||
const includeModelImage =
|
||||
params.fallbackIncludeModelImage && uploadedImageCount > params.fallbackPrimaryCount;
|
||||
const bundleProductImageCount = Math.max(
|
||||
0,
|
||||
uploadedImageCount - params.fallbackPrimaryCount - (includeModelImage ? 1 : 0)
|
||||
);
|
||||
const primaryProductImageCount = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
params.fallbackPrimaryCount,
|
||||
uploadedImageCount - bundleProductImageCount - (includeModelImage ? 1 : 0)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
primaryProductImageCount,
|
||||
bundleProductImageCount,
|
||||
includeModelImage,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveScenePromptOptimizationImageUrls(params: {
|
||||
uploadedImageParts: AgentPresetMessageImagePart[];
|
||||
primaryProductImageCount: number;
|
||||
bundleProductImageCount: number;
|
||||
includeModelImage: boolean;
|
||||
}) {
|
||||
const namedPrimaryParts = params.uploadedImageParts.filter((part) =>
|
||||
String(part.name || "").trim().startsWith("主SKU参考图")
|
||||
);
|
||||
const namedModelPart = params.uploadedImageParts.find((part) =>
|
||||
String(part.name || "").trim().startsWith("品牌模特图")
|
||||
);
|
||||
|
||||
if (namedPrimaryParts.length > 0 || namedModelPart) {
|
||||
return {
|
||||
primaryImageUrl: namedPrimaryParts[0]?.url || "",
|
||||
modelImageUrl: namedModelPart?.url || null,
|
||||
};
|
||||
}
|
||||
|
||||
const primaryImageUrl =
|
||||
params.uploadedImageParts.slice(0, Math.max(0, params.primaryProductImageCount))[0]?.url || "";
|
||||
const modelIndex = params.primaryProductImageCount + params.bundleProductImageCount;
|
||||
|
||||
return {
|
||||
primaryImageUrl,
|
||||
modelImageUrl:
|
||||
params.includeModelImage && modelIndex < params.uploadedImageParts.length
|
||||
? params.uploadedImageParts[modelIndex]?.url || null
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAgentSupplementPrompt(text: string, presetName?: string | null) {
|
||||
const normalized = String(text || "").trim();
|
||||
if (!normalized) return "";
|
||||
const escapedPresetName = String(presetName || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const genericPattern = escapedPresetName
|
||||
? new RegExp(`^请按「${escapedPresetName}」风格化配置顺序处理这\\d+张图片。?$`)
|
||||
: /^请按「.+」风格化配置顺序处理这\d+张图片。?$/;
|
||||
if (genericPattern.test(normalized)) {
|
||||
return "";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeAgentWorkflowPrompt(text: string) {
|
||||
return String(text || "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseAgentWorkflowPromptSections(text: string) {
|
||||
const sectionMap = new Map<string, string[]>();
|
||||
let activeSection = "__root__";
|
||||
const normalizedPrompt = normalizeAgentWorkflowPrompt(text);
|
||||
if (!normalizedPrompt) {
|
||||
return sectionMap;
|
||||
}
|
||||
|
||||
normalizedPrompt.split("\n").forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) {
|
||||
return;
|
||||
}
|
||||
const headerMatch = trimmedLine.match(/^【([^】]{1,30})】$/);
|
||||
if (headerMatch) {
|
||||
activeSection = headerMatch[1].trim();
|
||||
if (!sectionMap.has(activeSection)) {
|
||||
sectionMap.set(activeSection, []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLines = sectionMap.get(activeSection) || [];
|
||||
currentLines.push(trimmedLine);
|
||||
sectionMap.set(activeSection, currentLines);
|
||||
});
|
||||
|
||||
return sectionMap;
|
||||
}
|
||||
|
||||
export function buildAgentPromptSectionText(
|
||||
title: string,
|
||||
lines: string[],
|
||||
options?: { bullet?: boolean }
|
||||
) {
|
||||
const normalizedLines = lines
|
||||
.map((line) => String(line || "").trim())
|
||||
.filter(Boolean);
|
||||
if (normalizedLines.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const sectionBody = options?.bullet
|
||||
? normalizedLines.map((line) => (line.startsWith("- ") ? line : `- ${line}`)).join("\n")
|
||||
: normalizedLines.join("\n");
|
||||
|
||||
return `【${title}】\n${sectionBody}`;
|
||||
}
|
||||
|
||||
export function buildOptimizedAgentSceneWorkflowPrompt(params: {
|
||||
workflowPrompt: string;
|
||||
optimizationResult?: {
|
||||
sceneGoal: string;
|
||||
environmentSummary: string;
|
||||
propSummary: string;
|
||||
cameraLightingSummary: string;
|
||||
characterSummary: string;
|
||||
negativeConstraints: string[];
|
||||
conflicts: string[];
|
||||
} | null;
|
||||
}) {
|
||||
const normalizedWorkflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt);
|
||||
if (!normalizedWorkflowPrompt) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const optimizationResult = params.optimizationResult || null;
|
||||
if (!optimizationResult) {
|
||||
return normalizedWorkflowPrompt;
|
||||
}
|
||||
|
||||
const sectionMap = parseAgentWorkflowPromptSections(normalizedWorkflowPrompt);
|
||||
const sceneGoalLines = [optimizationResult.sceneGoal, optimizationResult.environmentSummary].filter(
|
||||
Boolean
|
||||
);
|
||||
const propLines = [optimizationResult.propSummary].filter(Boolean);
|
||||
const cameraLightingLines = [optimizationResult.cameraLightingSummary].filter(Boolean);
|
||||
const characterLines = [optimizationResult.characterSummary].filter(Boolean);
|
||||
const negativeLines = Array.from(
|
||||
new Set([
|
||||
...optimizationResult.negativeConstraints,
|
||||
...optimizationResult.conflicts.map((item) => `冲突校正:${item}`),
|
||||
])
|
||||
);
|
||||
|
||||
const optimizedSections = [
|
||||
buildAgentPromptSectionText("项目背景", sectionMap.get("项目背景") || sectionMap.get("__root__") || []),
|
||||
buildAgentPromptSectionText("主SKU锁定", sectionMap.get("主SKU锁定") || []),
|
||||
buildAgentPromptSectionText("搭配SKU锁定", sectionMap.get("搭配SKU锁定") || []),
|
||||
buildAgentPromptSectionText("参考图", sectionMap.get("参考图") || []),
|
||||
buildAgentPromptSectionText("场景创作总纲", sceneGoalLines),
|
||||
buildAgentPromptSectionText("陈列与道具", propLines),
|
||||
buildAgentPromptSectionText("机位与光线", cameraLightingLines),
|
||||
buildAgentPromptSectionText("人物规则", characterLines),
|
||||
buildAgentPromptSectionText("负面约束", negativeLines, { bullet: true }),
|
||||
].filter(Boolean);
|
||||
|
||||
return optimizedSections.join("\n\n").trim();
|
||||
}
|
||||
|
||||
const AGENT_PROMPT_STRUCTURAL_LABELS = new Set([
|
||||
"角色",
|
||||
"技能",
|
||||
"目标",
|
||||
"约束条件",
|
||||
"流程",
|
||||
"输出",
|
||||
]);
|
||||
|
||||
const AGENT_PROMPT_LABEL_SEMANTIC_ALIASES = new Map<string, string>([
|
||||
["角色", "role"],
|
||||
["技能", "skills"],
|
||||
["目标", "goal"],
|
||||
["约束条件", "constraints"],
|
||||
["流程", "process"],
|
||||
["输出", "output"],
|
||||
["人群选择", "audience"],
|
||||
["人物", "audience"],
|
||||
["主题风格", "style"],
|
||||
["整体风格", "style"],
|
||||
["空间选择", "space"],
|
||||
["空间布局", "space"],
|
||||
["主空间", "space"],
|
||||
["子空间选择", "subspace"],
|
||||
["子空间", "subspace"],
|
||||
["硬装方案", "hardDecoration"],
|
||||
["硬装", "hardDecoration"],
|
||||
["软装方案", "softDecoration"],
|
||||
["软装", "softDecoration"],
|
||||
["光氛选择", "lighting"],
|
||||
["光源布置", "lighting"],
|
||||
["灯具", "lighting"],
|
||||
["机位选择", "camera"],
|
||||
["拍摄角度", "camera"],
|
||||
["配饰陈列", "accessories"],
|
||||
["放在产品内的小件配饰", "accessories"],
|
||||
["前景", "sceneLayout"],
|
||||
["中景", "sceneLayout"],
|
||||
["背景", "sceneLayout"],
|
||||
]);
|
||||
|
||||
const AGENT_SCENE_WORKFLOW_OWNED_SEMANTICS = new Set([
|
||||
"role",
|
||||
"skills",
|
||||
"goal",
|
||||
"constraints",
|
||||
"process",
|
||||
"output",
|
||||
"audience",
|
||||
"style",
|
||||
"space",
|
||||
"subspace",
|
||||
"hardDecoration",
|
||||
"softDecoration",
|
||||
"lighting",
|
||||
"camera",
|
||||
"accessories",
|
||||
"sceneLayout",
|
||||
]);
|
||||
|
||||
export function normalizePromptLineForCompare(line: string) {
|
||||
return String(line || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[::]/g, ":")
|
||||
.replace(/[。;;,,、!!??]+$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function parsePromptLineLabel(line: string) {
|
||||
const normalized = String(line || "").replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return { label: "", value: "" };
|
||||
|
||||
const headerOnlyMatch = normalized.match(/^([^::]{1,20})[::]\s*$/);
|
||||
if (headerOnlyMatch) {
|
||||
return {
|
||||
label: headerOnlyMatch[1].trim(),
|
||||
value: "",
|
||||
};
|
||||
}
|
||||
|
||||
const colonMatch = normalized.match(/^([^::]{1,20})[::]\s*(.+)$/);
|
||||
if (colonMatch) {
|
||||
return {
|
||||
label: colonMatch[1].trim(),
|
||||
value: colonMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const whitespaceMatch = normalized.match(/^([^\s]{1,20})\s+(.+)$/);
|
||||
if (whitespaceMatch) {
|
||||
return {
|
||||
label: whitespaceMatch[1].trim(),
|
||||
value: whitespaceMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return { label: "", value: normalized };
|
||||
}
|
||||
|
||||
export function normalizePromptLabel(label: string) {
|
||||
return String(label || "")
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/[::]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function getPromptSemanticLabel(label: string) {
|
||||
const normalized = normalizePromptLabel(label);
|
||||
return AGENT_PROMPT_LABEL_SEMANTIC_ALIASES.get(normalized) || normalized;
|
||||
}
|
||||
|
||||
export function collectPromptSemanticLabelsFromText(text: string) {
|
||||
const semanticLabels = new Set<string>();
|
||||
const normalizedText = String(text || "").replace(/\s+/g, " ").trim();
|
||||
if (!normalizedText) {
|
||||
return semanticLabels;
|
||||
}
|
||||
|
||||
for (const [label, semantic] of AGENT_PROMPT_LABEL_SEMANTIC_ALIASES.entries()) {
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedText.includes(`${label}:`) || normalizedText.includes(`${label}:`)) {
|
||||
semanticLabels.add(semantic);
|
||||
continue;
|
||||
}
|
||||
if (normalizedText.includes(`${label} `) || normalizedText.includes(label)) {
|
||||
semanticLabels.add(semantic);
|
||||
}
|
||||
}
|
||||
|
||||
return semanticLabels;
|
||||
}
|
||||
|
||||
export function shouldDropSceneSlotSemanticLine(
|
||||
slotSemanticLabel: string,
|
||||
workflowSemanticLabels: Set<string>,
|
||||
) {
|
||||
if (!slotSemanticLabel || !AGENT_SCENE_WORKFLOW_OWNED_SEMANTICS.has(slotSemanticLabel)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (slotSemanticLabel === "sceneLayout") {
|
||||
return [
|
||||
"style",
|
||||
"space",
|
||||
"subspace",
|
||||
"hardDecoration",
|
||||
"softDecoration",
|
||||
"accessories",
|
||||
"camera",
|
||||
"lighting",
|
||||
].some((semantic) => workflowSemanticLabels.has(semantic));
|
||||
}
|
||||
|
||||
return workflowSemanticLabels.has(slotSemanticLabel);
|
||||
}
|
||||
|
||||
export function dedupeAgentSlotPromptAgainstWorkflow(params: {
|
||||
workflowPrompt?: string | null;
|
||||
slotPrompt?: string | null;
|
||||
modeType?: "scene_generation" | "detail_replacement" | "sku_replacement" | "generic";
|
||||
}) {
|
||||
const dedupedSlotLines: string[] = [];
|
||||
const seenExactLines = new Set<string>();
|
||||
const workflowStructuralValues = new Map<string, string>();
|
||||
const workflowSemanticLabels = new Set<string>();
|
||||
|
||||
const rememberLine = (line: string) => {
|
||||
const normalizedLine = normalizePromptLineForCompare(line);
|
||||
if (!normalizedLine) {
|
||||
return;
|
||||
}
|
||||
seenExactLines.add(normalizedLine);
|
||||
};
|
||||
|
||||
const normalizedWorkflowLines = normalizeAgentWorkflowPrompt(params.workflowPrompt || "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
normalizedWorkflowLines.forEach((line) => {
|
||||
const parsed = parsePromptLineLabel(line);
|
||||
if (parsed.label) {
|
||||
workflowSemanticLabels.add(getPromptSemanticLabel(parsed.label));
|
||||
}
|
||||
collectPromptSemanticLabelsFromText(line).forEach((semantic) => {
|
||||
workflowSemanticLabels.add(semantic);
|
||||
});
|
||||
collectPromptSemanticLabelsFromText(parsed.value || "").forEach((semantic) => {
|
||||
workflowSemanticLabels.add(semantic);
|
||||
});
|
||||
if (parsed.label && AGENT_PROMPT_STRUCTURAL_LABELS.has(parsed.label)) {
|
||||
workflowStructuralValues.set(
|
||||
parsed.label,
|
||||
normalizePromptLineForCompare(parsed.value || "")
|
||||
);
|
||||
}
|
||||
rememberLine(line);
|
||||
});
|
||||
|
||||
const normalizedSlotLines = String(params.slotPrompt || "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
let activeSlotSemanticLabel = "";
|
||||
normalizedSlotLines.forEach((line) => {
|
||||
const normalizedLine = normalizePromptLineForCompare(line);
|
||||
if (!normalizedLine || seenExactLines.has(normalizedLine)) {
|
||||
return;
|
||||
}
|
||||
const parsed = parsePromptLineLabel(line);
|
||||
const slotSemanticLabel = parsed.label ? getPromptSemanticLabel(parsed.label) : "";
|
||||
if (slotSemanticLabel) {
|
||||
activeSlotSemanticLabel = slotSemanticLabel;
|
||||
}
|
||||
if (
|
||||
params.modeType === "scene_generation" &&
|
||||
shouldDropSceneSlotSemanticLine(
|
||||
slotSemanticLabel || activeSlotSemanticLabel,
|
||||
workflowSemanticLabels,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (parsed.label && AGENT_PROMPT_STRUCTURAL_LABELS.has(parsed.label)) {
|
||||
const normalizedValue = normalizePromptLineForCompare(parsed.value || "");
|
||||
const workflowValue = workflowStructuralValues.get(parsed.label);
|
||||
if (workflowValue && workflowValue === normalizedValue) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
seenExactLines.add(normalizedLine);
|
||||
dedupedSlotLines.push(line);
|
||||
});
|
||||
|
||||
return dedupedSlotLines.join("\n").trim();
|
||||
}
|
||||
|
||||
export function buildAgentSlotPrompt(params: {
|
||||
workflowPrompt?: string | null;
|
||||
slotPrompt: string;
|
||||
slotTitle?: string | null;
|
||||
userSupplementPrompt?: string | null;
|
||||
includeSlotTitle?: boolean;
|
||||
modeType?: "scene_generation" | "detail_replacement" | "sku_replacement" | "generic";
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
const workflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt || "");
|
||||
if (workflowPrompt) {
|
||||
lines.push(workflowPrompt);
|
||||
}
|
||||
const slotTitle = String(params.slotTitle || "").trim();
|
||||
if (params.includeSlotTitle !== false && slotTitle) {
|
||||
lines.push(`【槽位】${slotTitle}`);
|
||||
}
|
||||
const dedupedSlotPrompt = dedupeAgentSlotPromptAgainstWorkflow({
|
||||
workflowPrompt,
|
||||
slotPrompt: params.slotPrompt,
|
||||
modeType: params.modeType || "generic",
|
||||
});
|
||||
if (dedupedSlotPrompt) {
|
||||
lines.push(dedupedSlotPrompt);
|
||||
}
|
||||
const supplement = String(params.userSupplementPrompt || "").trim();
|
||||
if (supplement) {
|
||||
lines.push(`【补充要求】${supplement}`);
|
||||
}
|
||||
return lines.join("\n\n").trim();
|
||||
}
|
||||
|
||||
export function buildAgentSceneWorkflowPrompt(params: {
|
||||
workflowPrompt?: string | null;
|
||||
slotPrompt?: string | null;
|
||||
userSupplementPrompt?: string | null;
|
||||
imageRolePrompt?: string | null;
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
const workflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt || "");
|
||||
if (workflowPrompt) {
|
||||
lines.push(workflowPrompt);
|
||||
}
|
||||
const slotPrompt = String(params.slotPrompt || "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (slotPrompt) {
|
||||
lines.push(slotPrompt);
|
||||
}
|
||||
let imageRolePrompt = String(params.imageRolePrompt || "").trim();
|
||||
if (workflowPrompt.includes("品牌模特图:已选") || workflowPrompt.includes("固定模特图")) {
|
||||
imageRolePrompt = imageRolePrompt
|
||||
.replace(
|
||||
/^如当前请求未附带固定模特图,仅在工作流明确要求人物时再安排辅助人物,且人物不能喧宾夺主。$/m,
|
||||
""
|
||||
)
|
||||
.replace(/^当前没有固定模特图,不要凭空生成强人物主体。$/m, "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
if (imageRolePrompt) {
|
||||
lines.push(imageRolePrompt);
|
||||
}
|
||||
const supplement = String(params.userSupplementPrompt || "").trim();
|
||||
if (supplement) {
|
||||
lines.push(`【补充要求】${supplement}`);
|
||||
}
|
||||
return lines.join("\n\n").trim();
|
||||
}
|
||||
107
app/(chat)/api/chat/_lib/billing.ts
Normal file
107
app/(chat)/api/chat/_lib/billing.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||
import {
|
||||
consumeUserPoints,
|
||||
getAiModelById,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getPointsSystemConfig,
|
||||
getUserById,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
const MODEL_UUID_REGEX =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function getModelCreditCost(
|
||||
modelConfig: any,
|
||||
target: "image" | "video",
|
||||
options?: {
|
||||
duration?: number | null;
|
||||
imageSize?: string | null;
|
||||
resolution?: string | null;
|
||||
},
|
||||
): number {
|
||||
return getModelGenerationCreditCost(modelConfig, target, options);
|
||||
}
|
||||
|
||||
export async function resolveBillingModelByReference(modelRef: string): Promise<any | undefined> {
|
||||
if (!modelRef) return undefined;
|
||||
|
||||
if (MODEL_UUID_REGEX.test(modelRef)) {
|
||||
return await getAiModelById({ id: modelRef }).catch(() => undefined);
|
||||
}
|
||||
|
||||
const allModels = await getEnabledAiModelsWithProvider(true, true).catch(() => []);
|
||||
const matched = allModels.find(
|
||||
({ model }: any) => model.id === modelRef || model.modelId === modelRef,
|
||||
);
|
||||
return matched?.model;
|
||||
}
|
||||
|
||||
export async function consumeGeneratedVideoPoints(params: {
|
||||
userId: string;
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
modelRefs: Array<string | null | undefined>;
|
||||
duration?: number | null;
|
||||
resolution?: string | null;
|
||||
}) {
|
||||
try {
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
if (!pointsConfig.enabled) return;
|
||||
|
||||
const userData = await getUserById({ id: params.userId }).catch(() => undefined);
|
||||
if (userData?.isHighVip) {
|
||||
console.log(`[Points] Skip video points for VIP user=${params.userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let billingModel: any | undefined;
|
||||
for (const ref of params.modelRefs) {
|
||||
if (!ref) continue;
|
||||
const resolved = await resolveBillingModelByReference(ref);
|
||||
if (!resolved) continue;
|
||||
billingModel = resolved;
|
||||
if (
|
||||
getModelCreditCost(resolved, "video", {
|
||||
duration: params.duration,
|
||||
resolution: params.resolution,
|
||||
}) > 0
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!billingModel) return;
|
||||
|
||||
const unitCost = getModelCreditCost(billingModel, "video", {
|
||||
duration: params.duration,
|
||||
resolution: params.resolution,
|
||||
});
|
||||
if (unitCost <= 0) return;
|
||||
|
||||
const consumeResult = await consumeUserPoints({
|
||||
userId: params.userId,
|
||||
amount: unitCost,
|
||||
reason: "video_generation",
|
||||
modelId: billingModel.id,
|
||||
chatId: params.chatId,
|
||||
messageId: params.messageId,
|
||||
note: `模型(${billingModel.displayName || billingModel.modelId || billingModel.id}) 生视频 1 条${params.duration ? `(${params.duration} 秒)` : ""}${params.resolution ? `(${params.resolution})` : ""}`,
|
||||
});
|
||||
|
||||
if (!consumeResult.ok) {
|
||||
console.warn(
|
||||
`[Points] Failed to consume video points. user=${params.userId} model=${billingModel.id} cost=${unitCost}`,
|
||||
);
|
||||
} else if (consumeResult.skipped) {
|
||||
console.log(
|
||||
`[Points] Skipped ${unitCost} video points for VIP user=${params.userId} model=${billingModel.id}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[Points] Consumed ${unitCost} points for video. balance=${consumeResult.balance}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Points] Failed to consume points after video generation:", error);
|
||||
}
|
||||
}
|
||||
1150
app/(chat)/api/chat/_lib/detail-replacement.ts
Normal file
1150
app/(chat)/api/chat/_lib/detail-replacement.ts
Normal file
File diff suppressed because it is too large
Load Diff
259
app/(chat)/api/chat/_lib/image-failover.ts
Normal file
259
app/(chat)/api/chat/_lib/image-failover.ts
Normal file
@ -0,0 +1,259 @@
|
||||
import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { convertMessagesWithImages } from "./image-media";
|
||||
|
||||
export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
||||
"alibaba-image",
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"openai-image",
|
||||
"stepfun-image",
|
||||
"runway",
|
||||
"krea",
|
||||
"grsai-gpt-image",
|
||||
"volcano-vision",
|
||||
"gemini",
|
||||
"huanke-gemini",
|
||||
]);
|
||||
|
||||
const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000;
|
||||
const imageFailoverModelCooldown = new Map<string, { until: number; reason: string }>();
|
||||
|
||||
export function normalizeFailoverErrorMessage(error: unknown): string {
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") return error;
|
||||
if (error instanceof Error) return error.message || String(error);
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
|
||||
const msg = errorMessage.toLowerCase();
|
||||
return (
|
||||
msg.includes("model not found") ||
|
||||
msg.includes("model_not_found") ||
|
||||
msg.includes("no available channel for model") ||
|
||||
msg.includes("unknown model") ||
|
||||
msg.includes("invalid model") ||
|
||||
msg.includes("api key is missing") ||
|
||||
msg.includes("invalid api key") ||
|
||||
msg.includes("unauthorized") ||
|
||||
msg.includes("cannot convert argument to a bytestring")
|
||||
);
|
||||
}
|
||||
|
||||
export function isImageFallbackModelCoolingDown(modelId: string): boolean {
|
||||
const snapshot = imageFailoverModelCooldown.get(modelId);
|
||||
if (!snapshot) return false;
|
||||
if (snapshot.until <= Date.now()) {
|
||||
imageFailoverModelCooldown.delete(modelId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setImageFallbackModelCooldown(modelId: string, reason: string): void {
|
||||
imageFailoverModelCooldown.set(modelId, {
|
||||
until: Date.now() + IMAGE_FAILOVER_MODEL_COOLDOWN_MS,
|
||||
reason: reason.slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
export function isImageFailoverExcludedModel(model: { excludeFromFailover?: boolean } | null | undefined) {
|
||||
return Boolean(model?.excludeFromFailover);
|
||||
}
|
||||
|
||||
export type ImageFallbackCandidate = {
|
||||
model: any;
|
||||
provider: any;
|
||||
configured: boolean;
|
||||
sameProvider: boolean;
|
||||
};
|
||||
|
||||
export function getImageFailoverConvertProvider(providerType?: string | null) {
|
||||
switch (providerType) {
|
||||
case "bfl-image":
|
||||
case "ruxa-image":
|
||||
case "fal-image":
|
||||
case "openai-image":
|
||||
case "stepfun-image":
|
||||
case "midjourney":
|
||||
case "runway":
|
||||
case "krea":
|
||||
case "gemini":
|
||||
case "huanke-gemini":
|
||||
return providerType;
|
||||
case "grsai-gpt-image":
|
||||
return "gemini";
|
||||
case "volcano-vision":
|
||||
return "volcano";
|
||||
default:
|
||||
return "alibaba-image";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildImageFallbackCandidates(
|
||||
allModels: Array<{ model: any; provider: any }>,
|
||||
options: {
|
||||
currentProviderId?: string;
|
||||
currentModelId?: string;
|
||||
currentModelKey?: string;
|
||||
configuredFallbackIds?: string[];
|
||||
}
|
||||
) {
|
||||
const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options;
|
||||
const configuredRefs = configuredFallbackIds
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean);
|
||||
const byModelRef = new Map<string, { model: any; provider: any }>();
|
||||
|
||||
const isEligible = (item: { model: any; provider: any }, sameProvider: boolean) => {
|
||||
if (
|
||||
!item?.model?.supportsImageGen ||
|
||||
!item?.model?.isEnabled ||
|
||||
item?.model?.isSubModel ||
|
||||
isImageFailoverExcludedModel(item?.model) ||
|
||||
item?.model?.modelId === currentModelKey ||
|
||||
item?.model?.id === currentModelId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const providerType = String(item?.provider?.type || "");
|
||||
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
||||
return false;
|
||||
}
|
||||
if (isImageFallbackModelCoolingDown(item.model.id)) {
|
||||
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
||||
console.log(
|
||||
`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
allModels.forEach((item) => {
|
||||
if (!item?.model) return;
|
||||
byModelRef.set(String(item.model.id), item);
|
||||
if (item.model.modelId) {
|
||||
byModelRef.set(String(item.model.modelId), item);
|
||||
}
|
||||
});
|
||||
|
||||
const candidates: ImageFallbackCandidate[] = [];
|
||||
const seenModelIds = new Set<string>();
|
||||
const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => {
|
||||
if (!item?.model) return;
|
||||
const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId);
|
||||
if (!isEligible(item, sameProvider)) return;
|
||||
const modelId = String(item.model.id || "");
|
||||
if (!modelId || seenModelIds.has(modelId)) return;
|
||||
seenModelIds.add(modelId);
|
||||
candidates.push({
|
||||
model: item.model,
|
||||
provider: item.provider,
|
||||
configured,
|
||||
sameProvider,
|
||||
});
|
||||
};
|
||||
|
||||
configuredRefs.forEach((modelRef) => {
|
||||
pushCandidate(byModelRef.get(modelRef), true);
|
||||
});
|
||||
|
||||
if (currentProviderId) {
|
||||
allModels
|
||||
.filter((item) => item.provider?.id === currentProviderId)
|
||||
.forEach((item) => pushCandidate(item, false));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export async function generateImageWithFailoverCandidate(params: {
|
||||
candidate: ImageFallbackCandidate;
|
||||
messages: ChatMessage[];
|
||||
imageParams: any;
|
||||
fallbackImageParams: any;
|
||||
}) {
|
||||
const { candidate, messages, imageParams, fallbackImageParams } = params;
|
||||
const providerType = String(candidate.provider?.type || "");
|
||||
const candidateRuntimeModelId = String(candidate.model?.modelId || candidate.model?.id || "");
|
||||
if (
|
||||
providerType === "fal-image" &&
|
||||
isFalGptImage2EditModelId(candidateRuntimeModelId)
|
||||
) {
|
||||
throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移");
|
||||
}
|
||||
const candidateLanguageModel = await getLanguageModelById(candidate.model.id);
|
||||
if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") {
|
||||
throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`);
|
||||
}
|
||||
|
||||
const candidateMessages = await convertMessagesWithImages(
|
||||
messages,
|
||||
getImageFailoverConvertProvider(providerType)
|
||||
);
|
||||
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
||||
messages: candidateMessages,
|
||||
imageParams: imageParams || fallbackImageParams,
|
||||
modelDisplayName: candidate.model.displayName,
|
||||
overrideModelId: candidate.model.modelId,
|
||||
});
|
||||
|
||||
if (candidateResult?.finishReason === "error") {
|
||||
throw new Error(candidateResult.text || "图片生成失败");
|
||||
}
|
||||
|
||||
return {
|
||||
text: candidateResult.text || "图片生成失败",
|
||||
usedModelId: String(candidate.model.modelId || candidate.model.id || ""),
|
||||
displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function getImageCrossProviderCandidates(
|
||||
allModels: any[],
|
||||
options: {
|
||||
currentModelId: string;
|
||||
currentProviderId?: string;
|
||||
providerPriority: Record<string, number>;
|
||||
}
|
||||
) {
|
||||
return allModels
|
||||
.filter((m) => {
|
||||
if (
|
||||
!m?.model?.supportsImageGen ||
|
||||
!m?.model?.isEnabled ||
|
||||
m?.model?.isSubModel ||
|
||||
isImageFailoverExcludedModel(m?.model)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(String(m?.provider?.type || ""))) {
|
||||
return false;
|
||||
}
|
||||
if (m.model.id === options.currentModelId) return false;
|
||||
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
||||
if (isImageFallbackModelCoolingDown(m.model.id)) {
|
||||
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
||||
console.log(
|
||||
`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "cooldown"}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const left = options.providerPriority[a.provider.type as string] ?? 99;
|
||||
const right = options.providerPriority[b.provider.type as string] ?? 99;
|
||||
if (left !== right) return left - right;
|
||||
return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
|
||||
});
|
||||
}
|
||||
964
app/(chat)/api/chat/_lib/image-media.ts
Normal file
964
app/(chat)/api/chat/_lib/image-media.ts
Normal file
@ -0,0 +1,964 @@
|
||||
import { convertToModelMessages } from "ai";
|
||||
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import { parseImageAltMetadata } from "@/lib/ai/runtime/generated-image-persistence";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { ANNOTATION_SYSTEM_PROMPT, hasAnnotatedImage } from "./message-preparation";
|
||||
|
||||
export function guessImageMediaType(url: string): string {
|
||||
const cleanUrl = url.split("?")[0]?.toLowerCase() || "";
|
||||
if (cleanUrl.endsWith(".jpg") || cleanUrl.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (cleanUrl.endsWith(".webp")) return "image/webp";
|
||||
if (cleanUrl.endsWith(".gif")) return "image/gif";
|
||||
if (cleanUrl.endsWith(".bmp")) return "image/bmp";
|
||||
if (cleanUrl.endsWith(".svg")) return "image/svg+xml";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
export function injectHistoryImagesToLastUserMessage(
|
||||
messages: ChatMessage[],
|
||||
historyImages: string[]
|
||||
): ChatMessage[] {
|
||||
if (!historyImages || historyImages.length === 0) return messages;
|
||||
|
||||
let lastUserIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]?.role === "user") {
|
||||
lastUserIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserIndex < 0) return messages;
|
||||
|
||||
const lastUserMessage = messages[lastUserIndex];
|
||||
const hasExistingImage = lastUserMessage.parts?.some(
|
||||
(p: any) => p.type === "file" || p.type === "image" || p.type === "image_url"
|
||||
);
|
||||
if (hasExistingImage) return messages;
|
||||
|
||||
const imageParts = historyImages.map((url, index) => ({
|
||||
type: "file" as const,
|
||||
url,
|
||||
name: `history-image-${index + 1}`,
|
||||
mediaType: guessImageMediaType(url),
|
||||
mimeType: guessImageMediaType(url),
|
||||
}));
|
||||
|
||||
const updatedMessage: ChatMessage = {
|
||||
...lastUserMessage,
|
||||
parts: [...lastUserMessage.parts, ...imageParts],
|
||||
};
|
||||
|
||||
return messages.map((msg, idx) => (idx === lastUserIndex ? updatedMessage : msg));
|
||||
}
|
||||
|
||||
export function isImageInputPart(part: any) {
|
||||
return part?.type === "file" || part?.type === "image" || part?.type === "image_url";
|
||||
}
|
||||
|
||||
export function normalizeResolvedTargetImageUrls(imageUrls?: string[] | null) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(imageUrls || [])
|
||||
.map((url) => String(url || "").trim())
|
||||
.filter((url) => /^https?:\/\//i.test(url))
|
||||
)
|
||||
).slice(0, 8);
|
||||
}
|
||||
|
||||
export function summarizeTargetImageUrl(url?: string) {
|
||||
const normalized = String(url || "").trim();
|
||||
if (!normalized) return undefined;
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
return `${parsed.origin}${parsed.pathname}`.slice(0, 160);
|
||||
} catch {
|
||||
return normalized.slice(0, 160);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyResolvedTargetImagesToLastUserMessage(
|
||||
messages: ChatMessage[],
|
||||
targetImageUrls: string[]
|
||||
): ChatMessage[] {
|
||||
const normalizedTargetImageUrls = normalizeResolvedTargetImageUrls(targetImageUrls);
|
||||
if (normalizedTargetImageUrls.length === 0) return messages;
|
||||
|
||||
let lastUserIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]?.role === "user") {
|
||||
lastUserIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserIndex < 0) return messages;
|
||||
const lastUserMessage = messages[lastUserIndex];
|
||||
const nonImageParts = (lastUserMessage.parts || []).filter((part: any) => !isImageInputPart(part));
|
||||
const imageParts = normalizedTargetImageUrls.map((url, index) => ({
|
||||
type: "file" as const,
|
||||
url,
|
||||
name: `target-image-${index + 1}`,
|
||||
mediaType: guessImageMediaType(url),
|
||||
mimeType: guessImageMediaType(url),
|
||||
}));
|
||||
|
||||
const updatedMessage: ChatMessage = {
|
||||
...lastUserMessage,
|
||||
parts: [...imageParts, ...nonImageParts],
|
||||
};
|
||||
|
||||
return messages.map((msg, idx) => (idx === lastUserIndex ? updatedMessage : msg));
|
||||
}
|
||||
|
||||
export function compactAutoDemand(text: string, maxLength = 56): string {
|
||||
const normalized = text.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return "按你刚才的描述执行";
|
||||
if (normalized.length <= maxLength) return normalized;
|
||||
return `${normalized.slice(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
export function buildAutoModeFallbackMessage(params: {
|
||||
text: string;
|
||||
hasImage: boolean;
|
||||
fromHistory: boolean;
|
||||
imageSummary?: string;
|
||||
}): string {
|
||||
const demand = compactAutoDemand(params.text);
|
||||
|
||||
if (params.hasImage) {
|
||||
const sourceLine = params.fromHistory
|
||||
? "基于上一轮图片继续处理。"
|
||||
: "基于当前上传图片处理。";
|
||||
|
||||
return [
|
||||
"### 需求理解",
|
||||
`- ${demand}`,
|
||||
"### 图片分析",
|
||||
params.imageSummary
|
||||
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
|
||||
: `- ${sourceLine}`,
|
||||
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
|
||||
"### 执行计划",
|
||||
"- 先完成本轮核心改动,再检查边缘与光影一致性。",
|
||||
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
|
||||
"### 输出设置",
|
||||
"- 默认单图输出,优先稳定命中。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"### 需求理解",
|
||||
`- ${demand}`,
|
||||
"### 执行计划",
|
||||
"- 先锁定主体与场景,再细化风格、材质与光影。",
|
||||
"### 输出设置",
|
||||
"- 默认单图输出,优先稳定命中。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function sanitizePromptOnlyUserIntent(text: string): string {
|
||||
return String(text || "")
|
||||
.replace(/先?分析(?:一下)?(?:这张)?(?:图片|图像|这张图)?/g, "")
|
||||
.replace(/帮我|给我|先给我|只给我|仅给我/g, "")
|
||||
.replace(/优化(?:一下)?提示词|提示词优化|提示词|prompt/gi, "")
|
||||
.replace(/不要生图|先别生图|不需要生图|不要生成|别生成|不直接生成/g, "")
|
||||
.replace(/[,。;:,.!!??]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function buildPromptOnlyOptimizationSeed(params: {
|
||||
text: string;
|
||||
imageSummary?: string;
|
||||
}): string {
|
||||
const cleanedIntent = sanitizePromptOnlyUserIntent(params.text);
|
||||
const summary = String(params.imageSummary || "").replace(/\s+/g, " ").trim();
|
||||
|
||||
if (cleanedIntent && summary) {
|
||||
return `${cleanedIntent}。参考图关键特征:${summary}`;
|
||||
}
|
||||
if (summary) {
|
||||
return `基于参考图生成一段可直接复现核心主体、场景、风格与光线特征的中文提示词。参考图关键特征:${summary}`;
|
||||
}
|
||||
if (cleanedIntent) {
|
||||
return cleanedIntent;
|
||||
}
|
||||
return "基于当前参考图整理一段可直接使用的中文提示词。";
|
||||
}
|
||||
|
||||
export function buildPromptOnlyReply(params: {
|
||||
userText: string;
|
||||
imageSummary?: string;
|
||||
optimizedPrompt: string;
|
||||
}): string {
|
||||
const demand = sanitizePromptOnlyUserIntent(params.userText) || "你希望我先分析这张图并整理可直接使用的提示词";
|
||||
const summaryLines = String(params.imageSummary || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
const formattedSummary =
|
||||
summaryLines.length > 0
|
||||
? summaryLines.map((line) => `- ${line}`).join("\n")
|
||||
: "- 我已基于当前图片梳理主体、场景、风格与光线特征。";
|
||||
|
||||
return [
|
||||
"【需求理解】",
|
||||
`- ${demand}`,
|
||||
"【图片分析】",
|
||||
formattedSummary,
|
||||
"【优化后提示词】",
|
||||
`- ${params.optimizedPrompt.replace(/\s+/g, " ").trim()}`,
|
||||
"【使用建议】",
|
||||
"- 本轮仅输出优化后的提示词,不直接执行生图。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function hasStructuredAgentSections(message?: string): boolean {
|
||||
if (!message) return false;
|
||||
return /(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureStructuredAgentFriendlyMessage(params: {
|
||||
message?: string;
|
||||
text: string;
|
||||
hasImage: boolean;
|
||||
fromHistory: boolean;
|
||||
}): string {
|
||||
const existingMessage = String(params.message || "").trim();
|
||||
if (hasStructuredAgentSections(existingMessage)) {
|
||||
return existingMessage;
|
||||
}
|
||||
|
||||
const fallback = buildAutoModeFallbackMessage({
|
||||
text: params.text,
|
||||
hasImage: params.hasImage,
|
||||
fromHistory: params.fromHistory,
|
||||
});
|
||||
|
||||
if (!existingMessage) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalizedSupplement = existingMessage.replace(/\s+/g, " ").trim();
|
||||
if (!normalizedSupplement) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return fallback.replace(
|
||||
"### 执行计划\n",
|
||||
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
export function extractMessageImageUrls(parts: any[]): string[] {
|
||||
return (parts || [])
|
||||
.map((part: any) => {
|
||||
if (!part) return null;
|
||||
if (part.type === "file") return part.url || part.data || null;
|
||||
if (part.type === "image") return part.url || part.image || null;
|
||||
if (part.type === "image_url") return part.image_url?.url || null;
|
||||
return null;
|
||||
})
|
||||
.filter((url: any): url is string => typeof url === "string" && /^https?:\/\//i.test(url));
|
||||
}
|
||||
|
||||
export function extractMessageImageAssetIds(parts: any[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(parts || [])
|
||||
.map((part: any) => {
|
||||
if (!part || typeof part !== "object") return null;
|
||||
return typeof part.assetId === "string" && part.assetId.trim()
|
||||
? part.assetId.trim()
|
||||
: null;
|
||||
})
|
||||
.filter((assetId: any): assetId is string => typeof assetId === "string" && assetId.length > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldEnrichImageAnalysisText(message?: string): boolean {
|
||||
if (!message) return false;
|
||||
if (!/(###\s*图片分析|【\s*图片分析\s*】)/.test(message)) return false;
|
||||
return /(基于当前上传图片处理|基于你选中的原图处理|基于上一轮图片继续处理)/.test(message);
|
||||
}
|
||||
|
||||
export function injectImageAnalysisSummary(message: string, summary: string): string {
|
||||
const normalizedSummaryLines = summary
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
if (normalizedSummaryLines.length === 0) return message;
|
||||
const lines = message.split("\n");
|
||||
const analysisHeaderIndex = lines.findIndex((line) =>
|
||||
/^(###\s*图片分析|【\s*图片分析\s*】)/.test(line.trim())
|
||||
);
|
||||
if (analysisHeaderIndex < 0) return message;
|
||||
|
||||
for (let i = analysisHeaderIndex + 1; i < lines.length; i += 1) {
|
||||
const current = lines[i]?.trim() || "";
|
||||
if (/^(###\s*|【\s*[^】]+\s*】)/.test(current)) break;
|
||||
if (/^- /.test(current)) {
|
||||
lines.splice(i, 1, ...normalizedSummaryLines.map((line) => `- ${line}`));
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
const insertIndex = analysisHeaderIndex + 1;
|
||||
lines.splice(insertIndex, 0, ...normalizedSummaryLines.map((line) => `- ${line}`));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function ensureImageAnalysisSummarySection(message: string, summary: string): string {
|
||||
const normalizedSummaryLines = summary
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
if (normalizedSummaryLines.length === 0) return message;
|
||||
|
||||
if (/(###\s*图片分析|【\s*图片分析\s*】)/.test(message)) {
|
||||
return injectImageAnalysisSummary(message, normalizedSummaryLines.join("\n"));
|
||||
}
|
||||
|
||||
const trimmed = message.trim();
|
||||
const summaryBlock = normalizedSummaryLines.map((line) => `- ${line}`).join("\n");
|
||||
if (!trimmed) return `【图片分析】\n${summaryBlock}`;
|
||||
return `${trimmed}\n【图片分析】\n${summaryBlock}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义消息转换函数
|
||||
* 注意:gemini-image 和 huanke-gemini 都需要 URL 格式(不是 Base64)
|
||||
* 因为这些 provider 内部会自行处理图片上传到 S3
|
||||
*/
|
||||
export async function convertMessagesWithImages(
|
||||
messages: ChatMessage[],
|
||||
providerType:
|
||||
| string
|
||||
| {
|
||||
providerType?: string;
|
||||
providerBaseUrl?: string | null;
|
||||
modelId?: string | null;
|
||||
displayName?: string | null;
|
||||
forceInlineImageUrls?: boolean;
|
||||
loggerPrefix?: string;
|
||||
},
|
||||
) {
|
||||
type ImageConversionTarget = {
|
||||
providerType?: string;
|
||||
providerBaseUrl?: string | null;
|
||||
modelId?: string | null;
|
||||
displayName?: string | null;
|
||||
forceInlineImageUrls?: boolean;
|
||||
loggerPrefix?: string;
|
||||
};
|
||||
|
||||
const normalizeTarget = (
|
||||
target: string | ImageConversionTarget,
|
||||
): Required<Pick<ImageConversionTarget, "providerType" | "forceInlineImageUrls" | "loggerPrefix">> &
|
||||
Omit<ImageConversionTarget, "providerType" | "forceInlineImageUrls" | "loggerPrefix"> => {
|
||||
if (typeof target === "string") {
|
||||
return {
|
||||
providerType: target,
|
||||
providerBaseUrl: null,
|
||||
modelId: null,
|
||||
displayName: null,
|
||||
forceInlineImageUrls: false,
|
||||
loggerPrefix: "[Chat API] Vision inline",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
providerType: String(target.providerType || ""),
|
||||
providerBaseUrl: target.providerBaseUrl || null,
|
||||
modelId: target.modelId || null,
|
||||
displayName: target.displayName || null,
|
||||
forceInlineImageUrls: target.forceInlineImageUrls === true,
|
||||
loggerPrefix: target.loggerPrefix || "[Chat API] Vision inline",
|
||||
};
|
||||
};
|
||||
|
||||
const extractImagePartUrl = (part: unknown): string | null => {
|
||||
const partAny = part as any;
|
||||
const candidateUrl =
|
||||
partAny?.type === "file"
|
||||
? partAny.url || partAny.data
|
||||
: partAny?.type === "image"
|
||||
? partAny.image || partAny.url
|
||||
: partAny?.type === "image_url"
|
||||
? partAny.image_url?.url
|
||||
: undefined;
|
||||
|
||||
if (typeof candidateUrl !== "string") return null;
|
||||
const normalized = candidateUrl.trim();
|
||||
if (!/^https?:\/\//i.test(normalized)) return null;
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const replaceImagePartUrl = (part: unknown, nextUrl: string): unknown => {
|
||||
const partAny = part as any;
|
||||
if (partAny?.type === "file") {
|
||||
return {
|
||||
...partAny,
|
||||
url: nextUrl,
|
||||
data: nextUrl,
|
||||
mediaType: String(partAny.mediaType || "").startsWith("image/")
|
||||
? partAny.mediaType
|
||||
: "image/jpeg",
|
||||
mimeType: String(partAny.mimeType || "").startsWith("image/")
|
||||
? partAny.mimeType
|
||||
: "image/jpeg",
|
||||
};
|
||||
}
|
||||
if (partAny?.type === "image") {
|
||||
return {
|
||||
...partAny,
|
||||
image: nextUrl,
|
||||
url: nextUrl,
|
||||
};
|
||||
}
|
||||
if (partAny?.type === "image_url") {
|
||||
return {
|
||||
...partAny,
|
||||
image_url: {
|
||||
...(partAny.image_url || {}),
|
||||
url: nextUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
return part;
|
||||
};
|
||||
|
||||
const inlineImageUrlsInMessages = async (
|
||||
sourceMessages: ChatMessage[],
|
||||
target: ReturnType<typeof normalizeTarget>,
|
||||
) => {
|
||||
const uniqueUrls = Array.from(
|
||||
new Set(
|
||||
sourceMessages
|
||||
.flatMap((message) => message.parts || [])
|
||||
.map((part) => extractImagePartUrl(part))
|
||||
.filter((url): url is string => Boolean(url)),
|
||||
),
|
||||
);
|
||||
|
||||
if (uniqueUrls.length === 0) {
|
||||
return sourceMessages;
|
||||
}
|
||||
|
||||
const { inlineDataUrls } = await inlineVisionImages(uniqueUrls, {
|
||||
maxImages: Math.min(8, uniqueUrls.length),
|
||||
maxSide: 1200,
|
||||
timeoutMs: 12000,
|
||||
retries: 2,
|
||||
loggerPrefix: target.loggerPrefix,
|
||||
}).catch((error) => {
|
||||
console.warn(`${target.loggerPrefix} failed to inline message images:`, error);
|
||||
return { inlineDataUrls: [], skipped: [] };
|
||||
});
|
||||
|
||||
if (inlineDataUrls.length === 0) {
|
||||
return sourceMessages;
|
||||
}
|
||||
|
||||
const inlineMap = new Map<string, string>();
|
||||
for (let index = 0; index < inlineDataUrls.length; index += 1) {
|
||||
const originalUrl = uniqueUrls[index];
|
||||
const inlineUrl = inlineDataUrls[index];
|
||||
if (originalUrl && inlineUrl) {
|
||||
inlineMap.set(originalUrl, inlineUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (inlineMap.size === 0) {
|
||||
return sourceMessages;
|
||||
}
|
||||
|
||||
console.log(`${target.loggerPrefix} inlined chat message images:`, {
|
||||
total: uniqueUrls.length,
|
||||
inlined: inlineMap.size,
|
||||
providerType: target.providerType,
|
||||
modelId: target.modelId,
|
||||
displayName: target.displayName,
|
||||
});
|
||||
|
||||
return sourceMessages.map((message) => ({
|
||||
...message,
|
||||
parts: (message.parts || []).map((part) => {
|
||||
const originalUrl = extractImagePartUrl(part);
|
||||
if (!originalUrl) return part;
|
||||
const inlineUrl = inlineMap.get(originalUrl);
|
||||
return inlineUrl ? replaceImagePartUrl(part, inlineUrl) : part;
|
||||
}) as typeof message.parts,
|
||||
})) as ChatMessage[];
|
||||
};
|
||||
|
||||
const target = normalizeTarget(providerType);
|
||||
let preparedMessages = messages;
|
||||
|
||||
if (target.forceInlineImageUrls) {
|
||||
preparedMessages = await inlineImageUrlsInMessages(preparedMessages, target);
|
||||
}
|
||||
|
||||
const normalizedProviderType = target.providerType;
|
||||
|
||||
if (
|
||||
normalizedProviderType === "bfl-image" ||
|
||||
normalizedProviderType === "ruxa-image" ||
|
||||
normalizedProviderType === "fal-image" ||
|
||||
normalizedProviderType === "openai-image" ||
|
||||
normalizedProviderType === "stepfun-image" ||
|
||||
normalizedProviderType === "midjourney" ||
|
||||
normalizedProviderType === "runway" ||
|
||||
normalizedProviderType === "krea"
|
||||
) {
|
||||
const convertedMessages = [];
|
||||
|
||||
for (const message of preparedMessages) {
|
||||
const content: any[] = [];
|
||||
|
||||
for (const part of message.parts) {
|
||||
const partAny = part as any;
|
||||
|
||||
if (partAny.type === "text" && partAny.text) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: partAny.text,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateUrl =
|
||||
partAny.type === "file"
|
||||
? (partAny.url || partAny.data)
|
||||
: partAny.type === "image"
|
||||
? (partAny.image || partAny.url)
|
||||
: partAny.type === "image_url"
|
||||
? partAny.image_url?.url
|
||||
: undefined;
|
||||
|
||||
if (!candidateUrl || typeof candidateUrl !== "string") continue;
|
||||
if (candidateUrl.startsWith("data:")) {
|
||||
throw new Error("当前模型不支持 Base64 图片传输,请先上传图片后再编辑");
|
||||
}
|
||||
if (!candidateUrl.startsWith("http")) continue;
|
||||
|
||||
content.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: candidateUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
convertedMessages.push({
|
||||
role: message.role,
|
||||
content: content.length === 1 && content[0].type === "text" ? content[0].text : content,
|
||||
});
|
||||
}
|
||||
|
||||
return convertedMessages;
|
||||
}
|
||||
|
||||
// 所有 provider 都使用 URL 格式传输图片
|
||||
// gemini-image 和 huanke-gemini 内部会将 Base64 转换为 S3 URL
|
||||
// 这里不再进行 Base64 转换,直接返回标准格式
|
||||
if (normalizedProviderType !== "gemini") {
|
||||
return convertToModelMessages(preparedMessages);
|
||||
}
|
||||
|
||||
// 🆕 检测是否有标注图片
|
||||
const hasAnnotation = hasAnnotatedImage(messages);
|
||||
if (hasAnnotation) {
|
||||
console.log("[Message Convert] 🎨 检测到标注图片,将自动添加系统提示");
|
||||
}
|
||||
|
||||
// 对于 Gemini,需要特殊处理图片
|
||||
const convertedMessages = [];
|
||||
|
||||
for (const message of preparedMessages) {
|
||||
const content: any[] = [];
|
||||
|
||||
// 🆕 如果是用户消息且有标注图片,在文本前添加系统提示
|
||||
let hasAddedAnnotationPrompt = false;
|
||||
|
||||
// 处理消息的各个部分
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "text" && part.text) {
|
||||
let textContent = part.text;
|
||||
|
||||
// 🆕 如果有标注图片且是用户消息,在用户文本前添加系统提示
|
||||
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt) {
|
||||
textContent = ANNOTATION_SYSTEM_PROMPT + "\n\n用户请求:" + textContent;
|
||||
hasAddedAnnotationPrompt = true;
|
||||
console.log("[Message Convert] 🎨 已注入标注提示词");
|
||||
}
|
||||
|
||||
content.push({
|
||||
type: "text",
|
||||
text: textContent,
|
||||
});
|
||||
} else if (part.type === "file" && part.url) {
|
||||
// 直接使用 URL 格式(不再转换为 Base64)
|
||||
// gemini-image 和 huanke-gemini provider 内部会处理图片上传
|
||||
content.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: part.url,
|
||||
},
|
||||
});
|
||||
console.log("[Message Convert] Image kept as URL format:", part.url.substring(0, 80) + "...");
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 如果有标注图片但消息中没有文本部分,添加一个纯提示文本
|
||||
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt && content.length > 0) {
|
||||
content.unshift({
|
||||
type: "text",
|
||||
text: ANNOTATION_SYSTEM_PROMPT,
|
||||
});
|
||||
console.log("[Message Convert] 🎨 已在消息开头注入标注提示词");
|
||||
}
|
||||
|
||||
convertedMessages.push({
|
||||
role: message.role,
|
||||
content: content.length === 1 && content[0].type === "text"
|
||||
? content[0].text
|
||||
: content,
|
||||
});
|
||||
}
|
||||
|
||||
return convertedMessages;
|
||||
}
|
||||
|
||||
export function extractOriginalImageUrlsFromText(text: string): string[] {
|
||||
return extractImageUrlsFromText(text);
|
||||
}
|
||||
|
||||
export function normalizeImageInputRef(rawValue: unknown, requestOrigin?: string): string | null {
|
||||
if (typeof rawValue !== "string") return null;
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^data:image\/[a-z0-9+.-]+;base64,/i.test(trimmed)) {
|
||||
return trimmed.replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (requestOrigin && trimmed.startsWith("/")) {
|
||||
try {
|
||||
return new URL(trimmed, requestOrigin).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function collectImageInputsFromMessageParts(
|
||||
parts: Array<Record<string, any>> | undefined,
|
||||
requestOrigin?: string,
|
||||
): string[] {
|
||||
if (!Array.isArray(parts) || parts.length === 0) return [];
|
||||
|
||||
const result: string[] = [];
|
||||
const pushInput = (rawValue: unknown) => {
|
||||
const normalized = normalizeImageInputRef(rawValue, requestOrigin);
|
||||
if (!normalized) return;
|
||||
if (!result.includes(normalized)) {
|
||||
result.push(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
|
||||
if (part.type === "file") {
|
||||
pushInput(part.url);
|
||||
pushInput(part.data);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.type === "image") {
|
||||
pushInput(part.url);
|
||||
pushInput(part.image);
|
||||
pushInput(part.data);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.type === "image_url") {
|
||||
pushInput(part.image_url?.url);
|
||||
pushInput(part.url);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
const parsedUrls = extractOriginalImageUrlsFromText(part.text);
|
||||
for (const parsedUrl of parsedUrls) {
|
||||
pushInput(parsedUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mimeTypeToExtension(mimeType: string) {
|
||||
const normalized = mimeType.toLowerCase();
|
||||
if (normalized.includes("png")) return "png";
|
||||
if (normalized.includes("webp")) return "webp";
|
||||
if (normalized.includes("gif")) return "gif";
|
||||
if (normalized.includes("bmp")) return "bmp";
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
export function getExtensionFromUrl(url: string): string {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const ext = pathname.split(".").pop()?.toLowerCase();
|
||||
if (!ext) return "jpg";
|
||||
if (["jpg", "jpeg", "png", "webp", "gif", "bmp"].includes(ext)) return ext;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
export async function uploadRemoteImageToStorage(imageUrl: string): Promise<string> {
|
||||
const response = await fetch(imageUrl, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Remote image fetch failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
|
||||
if (contentType && !contentType.startsWith("image/")) {
|
||||
throw new Error(`Remote url is not an image: ${contentType}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
|
||||
throw new Error("Remote image content is empty");
|
||||
}
|
||||
|
||||
const ext = contentType ? mimeTypeToExtension(contentType) : getExtensionFromUrl(imageUrl);
|
||||
const fileName = `remote-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const uploaded = await uploadFile(fileName, arrayBuffer);
|
||||
return uploaded.url;
|
||||
}
|
||||
|
||||
export async function isRemoteImageAccessible(imageUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const headResponse = await fetch(imageUrl, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (headResponse.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (headResponse.status !== 405 && headResponse.status !== 403) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const probeResponse = await fetch(imageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Range: "bytes=0-0",
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
return probeResponse.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureHostedImageUrl(imageUrl: string): Promise<string> {
|
||||
const normalizedUrl = imageUrl.trim();
|
||||
if (!normalizedUrl) {
|
||||
throw new Error("Image URL is empty");
|
||||
}
|
||||
|
||||
if (normalizedUrl.startsWith("data:image")) {
|
||||
return uploadDataUrlImage(normalizedUrl);
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(normalizedUrl)) {
|
||||
throw new Error(`Unsupported image URL: ${normalizedUrl}`);
|
||||
}
|
||||
|
||||
if (/\/uploads\//i.test(normalizedUrl)) {
|
||||
const accessible = await isRemoteImageAccessible(normalizedUrl);
|
||||
if (accessible) {
|
||||
return normalizedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return uploadRemoteImageToStorage(normalizedUrl);
|
||||
}
|
||||
|
||||
export async function uploadDataUrlImage(dataUrl: string): Promise<string> {
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,([\s\S]+)$/i);
|
||||
if (!match) {
|
||||
throw new Error("Invalid data URL image format");
|
||||
}
|
||||
|
||||
const mimeType = match[1] || "image/jpeg";
|
||||
const base64Data = (match[2] || "").replace(/\s+/g, "");
|
||||
if (!base64Data) {
|
||||
throw new Error("Empty base64 image content");
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const fileExt = mimeTypeToExtension(mimeType);
|
||||
const fileName = `inline-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${fileExt}`;
|
||||
const arrayBuffer = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
const uploaded = await uploadFile(fileName, arrayBuffer);
|
||||
return uploaded.url;
|
||||
}
|
||||
|
||||
export async function normalizeAssistantImageMarkdown(
|
||||
text: string,
|
||||
imageSize = "2K"
|
||||
): Promise<string> {
|
||||
if (!text) return text;
|
||||
|
||||
const markdownDataUrlRegex = /!\[([^\]]*)\]\s*\(\s*(data:image\/[a-z0-9+.-]+;base64,[^)]+)\s*\)/gim;
|
||||
const markdownMatches = [...text.matchAll(markdownDataUrlRegex)];
|
||||
let normalizedText = text;
|
||||
const uploadedUrls: string[] = [];
|
||||
|
||||
if (markdownMatches.length > 0) {
|
||||
let rebuilt = "";
|
||||
let cursor = 0;
|
||||
|
||||
for (const match of markdownMatches) {
|
||||
const matchText = match[0];
|
||||
const dataUrl = match[2];
|
||||
const start = match.index ?? -1;
|
||||
if (start < 0) continue;
|
||||
|
||||
rebuilt += normalizedText.slice(cursor, start);
|
||||
|
||||
try {
|
||||
const uploadedUrl = await uploadDataUrlImage(dataUrl);
|
||||
uploadedUrls.push(uploadedUrl);
|
||||
rebuilt += ``;
|
||||
} catch (error) {
|
||||
console.error("[Chat API] Failed to upload inline data URL image:", error);
|
||||
rebuilt += matchText;
|
||||
}
|
||||
|
||||
cursor = start + matchText.length;
|
||||
}
|
||||
|
||||
rebuilt += normalizedText.slice(cursor);
|
||||
normalizedText = rebuilt;
|
||||
}
|
||||
|
||||
if (normalizedText.includes("data:image")) {
|
||||
const rawDataUrlRegex = /data:image\/[a-z0-9+.-]+;base64,[a-z0-9+/=\s]+/gim;
|
||||
const rawMatches = normalizedText.match(rawDataUrlRegex) ?? [];
|
||||
|
||||
for (const rawDataUrl of rawMatches) {
|
||||
try {
|
||||
const uploadedUrl = await uploadDataUrlImage(rawDataUrl);
|
||||
uploadedUrls.push(uploadedUrl);
|
||||
normalizedText = normalizedText.replace(rawDataUrl, uploadedUrl);
|
||||
} catch (error) {
|
||||
console.error("[Chat API] Failed to normalize raw data URL image:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const markdownHttpImageRegex = /!\[([^\]]*)\]\s*\(\s*(https?:\/\/[^)\s]+)\s*\)/gim;
|
||||
const markdownHttpMatches = [...normalizedText.matchAll(markdownHttpImageRegex)];
|
||||
|
||||
if (markdownHttpMatches.length > 0) {
|
||||
let rebuilt = "";
|
||||
let cursor = 0;
|
||||
|
||||
for (const match of markdownHttpMatches) {
|
||||
const matchText = match[0];
|
||||
const altText = match[1] || "";
|
||||
const thumbnailUrl = (match[2] || "").trim();
|
||||
const start = match.index ?? -1;
|
||||
if (start < 0) continue;
|
||||
|
||||
rebuilt += normalizedText.slice(cursor, start);
|
||||
|
||||
try {
|
||||
const { originalUrl, imageSize: parsedImageSize } = parseImageAltMetadata(
|
||||
altText,
|
||||
thumbnailUrl
|
||||
);
|
||||
|
||||
let hostedThumbnailUrl = thumbnailUrl;
|
||||
let hostedOriginalUrl =
|
||||
originalUrl && originalUrl !== thumbnailUrl ? originalUrl : thumbnailUrl;
|
||||
|
||||
try {
|
||||
hostedThumbnailUrl = await ensureHostedImageUrl(thumbnailUrl);
|
||||
} catch (error) {
|
||||
console.error("[Chat API] Failed to normalize remote thumbnail URL:", error);
|
||||
}
|
||||
|
||||
if (originalUrl && originalUrl !== thumbnailUrl) {
|
||||
try {
|
||||
hostedOriginalUrl = await ensureHostedImageUrl(originalUrl);
|
||||
} catch (error) {
|
||||
console.error("[Chat API] Failed to normalize remote original URL:", error);
|
||||
hostedOriginalUrl = hostedThumbnailUrl || originalUrl;
|
||||
}
|
||||
} else {
|
||||
hostedOriginalUrl = hostedThumbnailUrl;
|
||||
}
|
||||
|
||||
const finalThumbnailUrl = hostedThumbnailUrl || thumbnailUrl;
|
||||
const finalOriginalUrl = hostedOriginalUrl || finalThumbnailUrl;
|
||||
|
||||
uploadedUrls.push(finalThumbnailUrl);
|
||||
uploadedUrls.push(finalOriginalUrl);
|
||||
rebuilt += ``;
|
||||
} catch (error) {
|
||||
console.error("[Chat API] Failed to parse remote image markdown metadata:", error);
|
||||
rebuilt += matchText;
|
||||
}
|
||||
|
||||
cursor = start + matchText.length;
|
||||
}
|
||||
|
||||
rebuilt += normalizedText.slice(cursor);
|
||||
normalizedText = rebuilt;
|
||||
}
|
||||
|
||||
const hasMarkdownImage = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/i.test(normalizedText);
|
||||
if (!hasMarkdownImage && uploadedUrls.length > 0) {
|
||||
const uniqueUrls = [...new Set(uploadedUrls)];
|
||||
console.log(
|
||||
`[Chat API] Converted ${uniqueUrls.length} image link(s) to hosted link(s)`
|
||||
);
|
||||
return uniqueUrls
|
||||
.map((url) => ``)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
return normalizedText;
|
||||
}
|
||||
169
app/(chat)/api/chat/_lib/message-preparation.ts
Normal file
169
app/(chat)/api/chat/_lib/message-preparation.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import {
|
||||
analyzeOptimizedPromptMerge,
|
||||
buildOptimizedPromptPreview,
|
||||
} from "@/lib/ai/runtime/optimized-prompt";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* 将图片 URL 转换为 base64 格式
|
||||
* 用于将 S3 URL 的图片转换为 base64 发送给 Gemini API
|
||||
*/
|
||||
export async function convertImageUrlToBase64(url: string): Promise<string> {
|
||||
try {
|
||||
console.log("[Image Convert] Fetching image from:", url);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const base64 = buffer.toString("base64");
|
||||
|
||||
// 获取 MIME 类型
|
||||
const contentType = response.headers.get("content-type") || "image/jpeg";
|
||||
const dataUrl = `data:${contentType};base64,${base64}`;
|
||||
|
||||
console.log("[Image Convert] Converted to base64, size:", base64.length);
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.error("[Image Convert] Failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测消息中是否包含用户标注过的图片(文件名以 annotated- 开头)
|
||||
*/
|
||||
export function hasAnnotatedImage(messages: ChatMessage[]): boolean {
|
||||
for (const message of messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "file" && "name" in part && typeof part.name === "string") {
|
||||
if (part.name.startsWith("annotated-")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标注图片的系统提示词(用户不可见,后台自动添加)
|
||||
*/
|
||||
export const ANNOTATION_SYSTEM_PROMPT =
|
||||
"【重要】用户提供的图片上有标注线条(如箭头、圆圈、画笔痕迹或文字说明),这些标注是用户用来指示需要修改的区域或方向。请仔细理解标注的含义,按照标注指示进行编辑,但在生成的新图片中不要保留这些标注线条,输出干净的编辑结果。";
|
||||
|
||||
export function applyHiddenPromptToMessages(
|
||||
messages: ChatMessage[],
|
||||
hiddenPrompt?: string,
|
||||
): ChatMessage[] {
|
||||
if (!hiddenPrompt || !hiddenPrompt.trim()) return messages;
|
||||
const cloned = messages.map((msg) => ({
|
||||
...msg,
|
||||
parts: msg.parts.map((part) => ({ ...part })),
|
||||
}));
|
||||
for (let i = cloned.length - 1; i >= 0; i--) {
|
||||
const msg = cloned[i];
|
||||
if (msg.role !== "user") continue;
|
||||
const textPart = msg.parts.find((part) => part.type === "text") as
|
||||
| { type: "text"; text: string }
|
||||
| undefined;
|
||||
if (textPart) {
|
||||
textPart.text = `${hiddenPrompt}\n\n${textPart.text}`.trim();
|
||||
} else {
|
||||
msg.parts.unshift({ type: "text", text: hiddenPrompt });
|
||||
}
|
||||
break;
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function applyOptimizedPromptToLatestUserMessage(
|
||||
messages: ChatMessage[],
|
||||
optimizedPrompt?: string,
|
||||
hiddenPromptPrefix = "",
|
||||
): ChatMessage[] {
|
||||
const prompt = optimizedPrompt?.trim();
|
||||
if (!prompt) return messages;
|
||||
|
||||
const cloned = messages.map((msg) => ({
|
||||
...msg,
|
||||
parts: msg.parts.map((part) => ({ ...part })),
|
||||
}));
|
||||
|
||||
for (let i = cloned.length - 1; i >= 0; i -= 1) {
|
||||
const msg = cloned[i];
|
||||
if (msg.role !== "user") continue;
|
||||
|
||||
const textPart = msg.parts.find(
|
||||
(part): part is { type: "text"; text: string } => part.type === "text",
|
||||
);
|
||||
const originalText = textPart?.text?.trim() || "";
|
||||
const { normalizedOriginal, hasOriginalDemand, hasOriginalInOptimized } =
|
||||
analyzeOptimizedPromptMerge(originalText, prompt, hiddenPromptPrefix);
|
||||
const finalPrompt =
|
||||
hasOriginalDemand && !hasOriginalInOptimized
|
||||
? [
|
||||
hiddenPromptPrefix.trim(),
|
||||
`【核心目标(必须优先满足)】${normalizedOriginal}`,
|
||||
`【执行优化细节】${prompt}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim()
|
||||
: `${hiddenPromptPrefix}${prompt}`.trim();
|
||||
console.log("[Prompt Merge] Final image prompt prepared:", {
|
||||
original: normalizedOriginal.substring(0, 120),
|
||||
optimized: prompt.substring(0, 120),
|
||||
merged: finalPrompt.substring(0, 180),
|
||||
forcePreserveOriginal: hasOriginalDemand && !hasOriginalInOptimized,
|
||||
});
|
||||
|
||||
if (textPart) {
|
||||
textPart.text = finalPrompt;
|
||||
} else {
|
||||
msg.parts.unshift({ type: "text", text: finalPrompt });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function injectOptimizedPromptSection(params: {
|
||||
message?: string;
|
||||
originalText: string;
|
||||
optimizedPrompt?: string;
|
||||
hiddenPromptPrefix?: string;
|
||||
}): string | undefined {
|
||||
const { message, originalText, optimizedPrompt, hiddenPromptPrefix } = params;
|
||||
if (!message?.trim()) return message;
|
||||
|
||||
const preview = buildOptimizedPromptPreview({
|
||||
originalText,
|
||||
optimizedPrompt,
|
||||
hiddenPromptPrefix,
|
||||
});
|
||||
if (!preview) return message;
|
||||
|
||||
const optimizedPromptSection = `【优化后提示词】\n${preview}`;
|
||||
const optimizedSectionRegex =
|
||||
/(?:###\s*优化后提示词|【\s*优化后提示词\s*】)[\s\S]*?(?=\n(?:###\s*|【\s*[^】]+\s*】)|$)/;
|
||||
if (optimizedSectionRegex.test(message)) {
|
||||
return message.replace(optimizedSectionRegex, optimizedPromptSection);
|
||||
}
|
||||
|
||||
const executionSectionRegex =
|
||||
/(?:###\s*执行计划|【\s*执行计划\s*】)[\s\S]*?(?=\n(?:###\s*|【\s*[^】]+\s*】)|$)/;
|
||||
if (executionSectionRegex.test(message)) {
|
||||
return message.replace(executionSectionRegex, optimizedPromptSection);
|
||||
}
|
||||
|
||||
const outputSectionRegex = /(?:###\s*输出设置|【\s*输出设置\s*】)/;
|
||||
if (outputSectionRegex.test(message)) {
|
||||
return message.replace(outputSectionRegex, `${optimizedPromptSection}\n$&`);
|
||||
}
|
||||
|
||||
return `${message.trim()}\n\n${optimizedPromptSection}`;
|
||||
}
|
||||
43
app/(chat)/api/chat/_lib/model-resolver.ts
Normal file
43
app/(chat)/api/chat/_lib/model-resolver.ts
Normal file
@ -0,0 +1,43 @@
|
||||
export function resolveModelDisplayNameByModelId(
|
||||
models: Array<{ model: any; provider?: any }> | undefined,
|
||||
modelId: string | undefined,
|
||||
): string {
|
||||
if (!modelId || !models?.length) return "";
|
||||
const match = models.find(
|
||||
(item) => item.model.modelId === modelId || item.model.id === modelId,
|
||||
);
|
||||
return String(match?.model?.displayName || "");
|
||||
}
|
||||
|
||||
export function resolveModelEntryByRuntimeModelId(
|
||||
models: Array<{ model: any; provider?: any }> | undefined,
|
||||
modelId: string | undefined,
|
||||
fallbackRef?: string,
|
||||
) {
|
||||
const candidates = [modelId, fallbackRef]
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean);
|
||||
if (!candidates.length || !models?.length) return null;
|
||||
|
||||
return (
|
||||
models.find((item) =>
|
||||
candidates.some(
|
||||
(candidate) => item.model.modelId === candidate || item.model.id === candidate,
|
||||
),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveModelRefByRuntimeModelId(
|
||||
models: Array<{ model: any; provider?: any }> | undefined,
|
||||
modelId: string | undefined,
|
||||
fallbackRef?: string,
|
||||
): string {
|
||||
const normalizedModelId = String(modelId || "").trim();
|
||||
if (!normalizedModelId) return fallbackRef || "";
|
||||
|
||||
const match = models?.find(
|
||||
(item) => item.model.modelId === normalizedModelId || item.model.id === normalizedModelId,
|
||||
);
|
||||
return String(match?.model?.id || fallbackRef || normalizedModelId || "");
|
||||
}
|
||||
275
app/(chat)/api/chat/_lib/video-models.ts
Normal file
275
app/(chat)/api/chat/_lib/video-models.ts
Normal file
@ -0,0 +1,275 @@
|
||||
import { after } from "next/server";
|
||||
import {
|
||||
getAiModelById,
|
||||
getEnabledAiModelsWithProvider,
|
||||
updateMessageById,
|
||||
} from "@/lib/db/queries";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
} from "@/lib/ai/model-classification";
|
||||
import type { StoryboardModelCandidate } from "@/lib/video-agent/types";
|
||||
|
||||
export type VideoModeType =
|
||||
| "text-to-video"
|
||||
| "first-frame"
|
||||
| "first-last-frame"
|
||||
| "reference-images";
|
||||
|
||||
export function isVideoProviderType(
|
||||
providerType?: string | null,
|
||||
model?: {
|
||||
modelId?: string | null;
|
||||
capabilities?: unknown;
|
||||
supportsImageGen?: boolean | null;
|
||||
},
|
||||
): boolean {
|
||||
return isVideoModelDescriptor({
|
||||
providerType,
|
||||
modelId: model?.modelId,
|
||||
capabilities: model?.capabilities,
|
||||
supportsImageGen: model?.supportsImageGen,
|
||||
});
|
||||
}
|
||||
|
||||
export function isImageGenerationModelType(
|
||||
providerType?: string | null,
|
||||
model?: {
|
||||
modelId?: string | null;
|
||||
capabilities?: unknown;
|
||||
supportsImageGen?: boolean | null;
|
||||
},
|
||||
): boolean {
|
||||
return isImageGenerationModelDescriptor({
|
||||
providerType,
|
||||
modelId: model?.modelId,
|
||||
capabilities: model?.capabilities,
|
||||
supportsImageGen: model?.supportsImageGen,
|
||||
});
|
||||
}
|
||||
|
||||
export function inferSupportedVideoModesByModelId(modelId: string): VideoModeType[] {
|
||||
const normalized = modelId.toLowerCase();
|
||||
|
||||
if (
|
||||
normalized === "gen4.5" ||
|
||||
normalized === "gen4_turbo" ||
|
||||
normalized === "gen4_aleph" ||
|
||||
normalized === "gen3a_turbo" ||
|
||||
normalized === "veo3" ||
|
||||
normalized === "veo3.1" ||
|
||||
normalized === "veo3.1_fast"
|
||||
) {
|
||||
return ["text-to-video", "first-frame"];
|
||||
}
|
||||
|
||||
if (normalized.includes("lite-t2v")) {
|
||||
return ["text-to-video"];
|
||||
}
|
||||
if (normalized.includes("lite-i2v")) {
|
||||
return ["first-frame", "first-last-frame", "reference-images"];
|
||||
}
|
||||
if (normalized.includes("pro-fast")) {
|
||||
return ["text-to-video", "first-frame"];
|
||||
}
|
||||
if (normalized.includes("-pro-") || normalized.endsWith("-pro")) {
|
||||
return ["text-to-video", "first-frame", "first-last-frame"];
|
||||
}
|
||||
|
||||
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
|
||||
}
|
||||
|
||||
export function getSupportedVideoModesForModel(model: any): VideoModeType[] {
|
||||
const capabilityModes = (((model?.capabilities as any)?.supportedModes ?? []) as string[]).filter(
|
||||
(mode): mode is VideoModeType =>
|
||||
mode === "text-to-video" ||
|
||||
mode === "first-frame" ||
|
||||
mode === "first-last-frame" ||
|
||||
mode === "reference-images",
|
||||
);
|
||||
|
||||
if (capabilityModes.length > 0) {
|
||||
return capabilityModes;
|
||||
}
|
||||
|
||||
const modelId = String(model?.modelId || "");
|
||||
return inferSupportedVideoModesByModelId(modelId);
|
||||
}
|
||||
|
||||
export function scoreVideoSubModelForMode(modelId: string, mode: VideoModeType): number {
|
||||
const normalized = modelId.toLowerCase();
|
||||
const isProFast = normalized.includes("pro-fast");
|
||||
const isLiteT2V = normalized.includes("lite-t2v");
|
||||
const isLiteI2V = normalized.includes("lite-i2v");
|
||||
const isPro = (normalized.includes("-pro-") || normalized.endsWith("-pro")) && !isProFast;
|
||||
const isV15Pro = normalized.includes("seedance-1-5-pro");
|
||||
|
||||
switch (mode) {
|
||||
case "first-last-frame":
|
||||
if (isV15Pro) return 150;
|
||||
if (isPro) return 120;
|
||||
if (isLiteI2V) return 90;
|
||||
if (isProFast) return -1000;
|
||||
if (isLiteT2V) return -1000;
|
||||
return 10;
|
||||
case "first-frame":
|
||||
if (isV15Pro) return 140;
|
||||
if (isProFast) return 130;
|
||||
if (isPro) return 120;
|
||||
if (isLiteI2V) return 100;
|
||||
if (isLiteT2V) return 20;
|
||||
return 10;
|
||||
case "reference-images":
|
||||
if (isV15Pro) return 150;
|
||||
if (isLiteI2V) return 140;
|
||||
if (isPro) return 110;
|
||||
if (isProFast || isLiteT2V) return -1000;
|
||||
return 10;
|
||||
case "text-to-video":
|
||||
default:
|
||||
if (isV15Pro) return 140;
|
||||
if (isLiteT2V) return 130;
|
||||
if (isProFast) return 120;
|
||||
if (isPro) return 100;
|
||||
if (isLiteI2V) return 80;
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectBestVideoSubModelForMode(
|
||||
subModels: Array<{ model: any }>,
|
||||
mode: VideoModeType,
|
||||
): { model: any } | null {
|
||||
const compatible = subModels.filter(({ model }) => {
|
||||
const supportedModes = getSupportedVideoModesForModel(model);
|
||||
return supportedModes.includes(mode);
|
||||
});
|
||||
|
||||
const candidates = compatible.length > 0 ? compatible : subModels;
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
return [...candidates].sort((a, b) => {
|
||||
const scoreA = scoreVideoSubModelForMode(String(a.model?.modelId || ""), mode);
|
||||
const scoreB = scoreVideoSubModelForMode(String(b.model?.modelId || ""), mode);
|
||||
return scoreB - scoreA;
|
||||
})[0];
|
||||
}
|
||||
|
||||
export async function resolveStoryboardModelCandidates(
|
||||
selectedModelRef: string,
|
||||
): Promise<StoryboardModelCandidate[]> {
|
||||
const selectedModel = await getAiModelById({ id: selectedModelRef }).catch(() => undefined);
|
||||
if (!selectedModel) return [];
|
||||
|
||||
const configuredFallbackIds = [
|
||||
selectedModel.fallbackModelId1,
|
||||
selectedModel.fallbackModelId2,
|
||||
].filter((item): item is string => Boolean(String(item || "").trim()));
|
||||
|
||||
const allModels = await getEnabledAiModelsWithProvider(true, true).catch(() => []);
|
||||
const sameProvider = allModels
|
||||
.filter(
|
||||
({ model, provider }: any) =>
|
||||
provider?.id === selectedModel.providerId && model?.id !== selectedModel.id && model?.isEnabled,
|
||||
)
|
||||
.map(({ model }: any) => String(model?.id || "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const candidateIds = Array.from(new Set([selectedModel.id, ...configuredFallbackIds, ...sameProvider]));
|
||||
const byModelRef = new Map<string, any>();
|
||||
allModels
|
||||
.map(({ model }: any) => model)
|
||||
.filter(Boolean)
|
||||
.forEach((model: any) => {
|
||||
byModelRef.set(String(model.id), model);
|
||||
if (model.modelId) {
|
||||
byModelRef.set(String(model.modelId), model);
|
||||
}
|
||||
});
|
||||
|
||||
return candidateIds
|
||||
.map((id) => byModelRef.get(id))
|
||||
.filter(Boolean)
|
||||
.map((model: any) => ({
|
||||
id: String(model.id),
|
||||
modelId: String(model.modelId || "").trim(),
|
||||
displayName: String(model.displayName || model.modelId || "").trim(),
|
||||
}))
|
||||
.filter((item) => item.modelId);
|
||||
}
|
||||
|
||||
export function extractVideoUrlFromText(text: string): string | null {
|
||||
const normalizedText = String(text || "");
|
||||
if (!normalizedText) return null;
|
||||
|
||||
const htmlMatch = normalizedText.match(/<video[^>]+src="([^"]+)"/i);
|
||||
if (htmlMatch?.[1]) return htmlMatch[1];
|
||||
|
||||
const markdownMatch = normalizedText.match(
|
||||
/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i,
|
||||
);
|
||||
if (markdownMatch?.[1]) return markdownMatch[1];
|
||||
|
||||
const plainMatch = normalizedText.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
|
||||
return plainMatch?.[1] || null;
|
||||
}
|
||||
|
||||
export function isAlreadyArchivedVideoUrl(url: string): boolean {
|
||||
return /\/uploads\//i.test(url);
|
||||
}
|
||||
|
||||
export function scheduleVideoArchive(params: {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
text: string;
|
||||
temporaryVideoUrl: string;
|
||||
taskId?: string;
|
||||
metadata?: Record<string, any> | null;
|
||||
}) {
|
||||
const { chatId, messageId, temporaryVideoUrl, taskId, metadata } = params;
|
||||
if (!temporaryVideoUrl || isAlreadyArchivedVideoUrl(temporaryVideoUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delayHoursRaw = Number(process.env.VIDEO_ARCHIVE_DELAY_HOURS || 48);
|
||||
const delayHours = Number.isFinite(delayHoursRaw)
|
||||
? Math.max(1, Math.min(24 * 30, Math.round(delayHoursRaw)))
|
||||
: 48;
|
||||
const archiveAfterAtMs = Date.now() + delayHours * 60 * 60 * 1000;
|
||||
|
||||
after(async () => {
|
||||
try {
|
||||
const nextMetadata = {
|
||||
...(metadata || {}),
|
||||
temporaryVideoUrl,
|
||||
storageStatus: "temporary",
|
||||
archiveMode: "delayed_job",
|
||||
archiveAfterAt: archiveAfterAtMs,
|
||||
archiveDelayHours: delayHours,
|
||||
archiveTaskId: taskId || messageId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await updateMessageById({
|
||||
messageId,
|
||||
chatId,
|
||||
videoStorageStatus: "temporary",
|
||||
temporaryVideoUrl,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: nextMetadata as any,
|
||||
});
|
||||
|
||||
console.log("[Chat API] 🕒 已登记延迟归档视频:", {
|
||||
messageId,
|
||||
archiveAfterAt: new Date(archiveAfterAtMs).toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Chat API] ❌ 登记视频延迟归档失败(保留临时地址):", {
|
||||
messageId,
|
||||
temporaryVideoUrl,
|
||||
error,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user