174 lines
5.7 KiB
TypeScript
174 lines
5.7 KiB
TypeScript
import { createLogger } from "@/lib/observability/logger";
|
|
import {
|
|
analyzeOptimizedPromptMerge,
|
|
buildOptimizedPromptPreview,
|
|
} from "@/lib/ai/runtime/optimized-prompt";
|
|
import type { ChatMessage } from "@/lib/types";
|
|
|
|
const chatApiLogger = createLogger("chat.messagePreparation");
|
|
|
|
|
|
/**
|
|
* 将图片 URL 转换为 base64 格式
|
|
* 用于将 S3 URL 的图片转换为 base64 发送给 Gemini API
|
|
*/
|
|
export async function convertImageUrlToBase64(url: string): Promise<string> {
|
|
try {
|
|
chatApiLogger.info("[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}`;
|
|
|
|
chatApiLogger.info("[Image Convert] Converted to base64, size:", base64.length);
|
|
return dataUrl;
|
|
} catch (error) {
|
|
chatApiLogger.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();
|
|
chatApiLogger.info("[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}`;
|
|
}
|