refactor: modularize chat route helpers

This commit is contained in:
Your Name 2026-05-26 22:34:12 +08:00
parent 33bfcadf97
commit f2d4f9e698
10 changed files with 3694 additions and 3583 deletions

View 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");
}

View 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();
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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);
});
}

View 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 += `![thumb|||${uploadedUrl}|||${imageSize}](${uploadedUrl})`;
} 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 += `![thumb|||${finalOriginalUrl}|||${parsedImageSize || imageSize}](${finalThumbnailUrl})`;
} 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) => `![thumb|||${url}|||${imageSize}](${url})`)
.join("\n");
}
return normalizedText;
}

View 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}`;
}

View 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 || "");
}

View 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