feat: upgrade agent executor and canvas sync
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-07 15:58:03 +08:00
parent 3badf859b9
commit 864b6d41e3
11 changed files with 3299 additions and 904 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,465 +1,110 @@
import { NextRequest, NextResponse } from "next/server";
import { getSystemSetting } from "@/lib/db/queries";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import {
executeExtendJob,
ExtendServiceError,
} from "@/lib/ai/extend/service";
/**
* 压缩图片生成缩略图(使用 sharp)
*/
async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer> {
try {
const sharp = await import("sharp");
// 创建缩略图(宽度 500px,高度按比例,质量 80%)
const thumbnailBuffer = await sharp.default(buffer)
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 80 }) // JPEG格式
.toBuffer();
console.log(`[Extend] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
return thumbnailBuffer;
} catch (error) {
console.error("[Extend] Failed to create thumbnail:", error);
// 如果失败,返回原图
return buffer;
}
}
/**
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL
*/
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jpeg"): Promise<{ thumbnail: string; original: string }> {
try {
// 解析 base64 数据
const buffer = Buffer.from(base64Data, "base64");
// 生成文件名
const ext = mimeType.split("/")[1] || "jpg";
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `extend-thumb-${timestamp}-${randomStr}.${ext}`;
const originalFilename = `extend-orig-${timestamp}-${randomStr}.${ext}`;
console.log(`[Extend] Original size: ${buffer.length} bytes`);
// 生成缩略图
const thumbnailBuffer = await createThumbnail(buffer, mimeType);
// 转换为 ArrayBuffer
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
// 并行上传缩略图和原图到 S3
console.log(`[Extend] Uploading thumbnail and original to S3...`);
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
uploadFile(originalFilename, originalArrayBuffer),
]);
console.log(`[Extend] Uploaded to S3:`);
console.log(` - Thumbnail: ${thumbnailResult.url}`);
console.log(` - Original: ${originalResult.url}`);
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
} catch (error) {
console.error("[Extend] Failed to upload to S3:", error);
throw new Error(`图片上传到存储桶失败: ${error instanceof Error ? error.message : "未知错误"}。请检查系统设置中的存储配置。`);
}
}
// 设置API路由最大执行时间为3分钟(180秒)
export const maxDuration = 180;
/**
* 将图片上传到S3并返回公网URL(用于API调用)
*/
async function uploadImageToS3ForAPI(imageBase64: string, mimeType: string = "image/jpeg"): Promise<string> {
try {
// 解析 base64 数据
const buffer = Buffer.from(imageBase64, "base64");
// 生成文件名
const ext = mimeType.split("/")[1] || "jpg";
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const filename = `extend-input-${timestamp}-${randomStr}.${ext}`;
console.log(`[Extend API] 上传原图到S3,大小: ${(buffer.length / 1024).toFixed(2)}KB`);
// 转换为 ArrayBuffer
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
// 上传到 S3
const result = await uploadFile(filename, arrayBuffer);
console.log(`[Extend API] ✅ 原图已上传到S3: ${result.url}`);
return result.url;
} catch (error) {
console.error("[Extend API] 上传到S3失败:", error);
throw new Error(`图片上传到存储桶失败: ${error instanceof Error ? error.message : "未知错误"}。请检查系统设置中的存储配置。`);
}
}
/**
* 图像拓展核心实现(使用URL方式,避免Base64长度限制)
* @param imageUrl - 图片的公网URL
* @param apiKey - Dashscope API Key
* @param xScale - X轴放大倍数(默认1.5)
* @param yScale - Y轴放大倍数(默认1.5)
* @returns 拓展后的图片URL
*/
async function extendImage(
imageUrl: string,
apiKey: string,
xScale: number = 1.5,
yScale: number = 1.5
): Promise<string> {
console.log("[Extend API] 开始图像拓展...");
console.log("[Extend API] 使用图片URL:", imageUrl);
// 1. 提交拓展任务(使用URL方式,避免Base64长度限制)
const requestBody = {
model: "image-out-painting",
input: {
image_url: imageUrl,
},
parameters: {
x_scale: xScale,
y_scale: yScale,
best_quality: false,
limit_image_size: true,
add_watermark: false,
},
};
console.log("[Extend API] 提交拓展任务...");
const response = await fetch(
"https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"X-DashScope-Async": "enable",
},
body: JSON.stringify(requestBody),
}
);
const initialResult = await response.json();
if (initialResult.code) {
console.error("[Extend API] API调用失败:", initialResult);
throw new Error(
`图像拓展API错误: ${initialResult.message || JSON.stringify(initialResult)}`
);
}
// 2. 获取任务ID
const taskId = initialResult.output.task_id;
const taskStatusUrl = `https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`;
console.log(`[Extend API] 任务已提交,任务ID: ${taskId}`);
// 3. 等待3秒后开始轮询
console.log("[Extend API] 等待API初始化...");
await new Promise((resolve) => setTimeout(resolve, 3000));
// 4. 轮询获取结果
let attempts = 0;
const maxAttempts = 60; // 最多轮询60次
const pollInterval = 2000; // 每2秒轮询一次
console.log("[Extend API] 开始轮询任务状态...");
while (attempts < maxAttempts) {
const statusResponse = await fetch(taskStatusUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const result = await statusResponse.json();
if (attempts === 0 || attempts % 5 === 0) {
const elapsedTime = attempts * 2 + 3;
console.log(`[Extend API] [${elapsedTime}秒] 任务状态: ${result.output?.task_status || "UNKNOWN"}`);
}
if (result.output?.task_status === "SUCCEEDED") {
const elapsedTime = attempts * 2 + 3;
console.log(`[Extend API] ✅ 任务完成(总用时 ${elapsedTime}秒)`);
return result.output.output_image_url;
} else if (result.output?.task_status === "FAILED") {
console.error("[Extend API] 任务失败:", result.output);
throw new Error(
`图像拓展任务失败: ${result.output.message || result.output.code || "未知错误"}`
);
}
attempts++;
if (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
}
throw new Error(`图像拓展任务超时(等待${maxAttempts * 2 + 3}秒)`);
}
/**
* 图像拓展API路由
* 从数据库读取图像拓展API配置,调用Dashscope API进行图像拓展处理
* 将处理后的图片上传到S3并保存到数据库
*/
export async function POST(request: NextRequest) {
console.log("[Extend API] ===== 收到请求 =====");
try {
// 0. 获取用户会话
console.log("[Extend API] 开始获取用户会话...");
const session = await auth();
console.log("[Extend API] 用户会话获取完成:", session?.user?.id ? "已登录" : "未登录");
if (!session?.user?.id) {
return NextResponse.json(
{ error: "未授权,请先登录" },
{ status: 401 }
);
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
}
const userId = session.user.id;
console.log("[Extend API] 用户ID:", userId);
// 1. 从数据库获取图像拓展API配置
console.log("[Extend API] 开始获取配置...");
const extendConfig = await getSystemSetting({ key: "extend_api_config" });
console.log("[Extend API] 配置获取完成:", extendConfig ? "已找到" : "未找到");
if (!extendConfig || !extendConfig.value) {
return NextResponse.json(
{ error: "图像拓展API配置未设置,请在系统设置中配置" },
{ status: 400 }
);
}
const config = extendConfig.value as {
apiKey?: string;
xScale?: number;
yScale?: number;
};
if (!config.apiKey) {
return NextResponse.json(
{ error: "图像拓展API密钥未配置" },
{ status: 400 }
);
}
// 2. 获取请求体(Base64图片或FormData)
console.log("[Extend API] 开始获取请求体...");
const contentType = request.headers.get("content-type") || "";
console.log("[Extend API] Content-Type:", contentType);
let imageBase64: string;
let prompt = ""; // 用户提示词(可选)
let chatId: string | null = null; // 聊天ID(可选)
let imageUrl: string | null = null;
let imageBase64: string | null = null;
let prompt = "图像拓展处理";
let chatId: string | null = null;
let messageId: string | null = null;
if (contentType.includes("application/json")) {
// JSON格式:支持Base64图片或图片URL
const body = await request.json();
const imageUrl = body.image_url; // 优先使用URL
imageBase64 = body.image_base64; // 如果没有URL,使用Base64
prompt = body.prompt || "图像拓展处理";
imageUrl = body.image_url ? String(body.image_url).trim() : null;
imageBase64 = body.image_base64 ? String(body.image_base64).trim() : null;
prompt = body.prompt || prompt;
chatId = body.chatId || null;
messageId = body.messageId || null;
// 如果提供了URL,直接使用;否则需要Base64
if (imageUrl) {
console.log("[Extend API] 使用图片URL:", imageUrl);
// 直接使用URL,跳过Base64转换和上传步骤
const startTime = Date.now();
const xScale = config.xScale || 1.5;
const yScale = config.yScale || 1.5;
console.log(`[Extend API] 拓展参数: X=${xScale}, Y=${yScale}`);
const extendedImageUrl = await extendImage(imageUrl, config.apiKey, xScale, yScale);
console.log("[Extend API] 拓展后图片URL:", extendedImageUrl);
// 下载拓展后的图片
console.log("[Extend API] 下载拓展后的图片...");
const imageResponse = await fetch(extendedImageUrl);
if (!imageResponse.ok) {
throw new Error(`下载拓展图片失败: ${imageResponse.status} ${imageResponse.statusText}`);
}
const arrayBuffer = await imageResponse.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extendedImageBase64 = buffer.toString("base64");
// 上传到S3
console.log("[Extend API] 上传到S3...");
const { thumbnail, original } = await uploadBase64ToS3(extendedImageBase64, "image/jpeg");
// 保存到数据库(相册和生图记录)
console.log("[Extend API] 保存到数据库...");
let savedImage;
try {
[savedImage] = await db
.insert(generatedImage)
.values({
userId,
chatId: chatId || null,
messageId: messageId || null,
thumbnailUrl: thumbnail,
originalUrl: original,
prompt: prompt || "图像拓展处理",
modelId: "extend-1.0",
imageSize: "2K",
})
.returning();
console.log(`[Extend API] ✅ 图片已保存到相册和生图记录,ID: ${savedImage.id}`);
} catch (dbError) {
console.error("[Extend API] ❌ 保存到数据库失败:", dbError);
}
const processingTime = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`[Extend API] ✅ 图像拓展完成,处理时间: ${processingTime}秒`);
// 返回结果
return NextResponse.json({
success: true,
original_url: original,
thumbnail_url: thumbnail,
base64: extendedImageBase64,
processing_time: parseFloat(processingTime),
});
}
if (!imageBase64) {
if (!imageUrl && !imageBase64) {
return NextResponse.json(
{ error: "缺少image_base64或image_url参数" },
{ status: 400 }
);
}
} else if (contentType.includes("multipart/form-data")) {
// FormData格式:文件上传
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "缺少file参数" },
{ status: 400 }
{ status: 400 },
);
}
// 将文件转换为Base64
const result = await executeExtendJob({
userId: session.user.id,
imageUrl,
imageBase64,
prompt,
chatId,
messageId,
xScale: body.xScale ? Number(body.xScale) : undefined,
yScale: body.yScale ? Number(body.yScale) : undefined,
});
return NextResponse.json({
success: true,
original_url: result.original_url,
thumbnail_url: result.thumbnail_url,
base64: result.image_base64,
processing_time: result.processing_time,
});
}
if (contentType.includes("multipart/form-data")) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
imageBase64 = buffer.toString("base64");
prompt = (formData.get("prompt") as string) || "图像拓展处理";
chatId = (formData.get("chatId") as string) || null;
messageId = (formData.get("messageId") as string) || null;
} else {
const result = await executeExtendJob({
userId: session.user.id,
imageBase64: buffer.toString("base64"),
prompt: String(formData.get("prompt") || prompt),
chatId: String(formData.get("chatId") || "") || null,
messageId: String(formData.get("messageId") || "") || null,
});
return NextResponse.json({
success: true,
original_url: result.original_url,
thumbnail_url: result.thumbnail_url,
base64: result.image_base64,
processing_time: result.processing_time,
});
}
return NextResponse.json(
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
{ status: 400 },
);
} catch (error) {
if (error instanceof ExtendServiceError) {
return NextResponse.json(
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
{ status: 400 }
{
error: error.message,
detail: error.detail || error.message,
},
{ status: error.status },
);
}
console.log(`[Extend API] 图片Base64长度: ${imageBase64.length} 字符`);
// 3. 先将图片上传到S3获取公网URL(使用URL方式避免Base64长度限制)
console.log("[Extend API] 上传原图到S3获取公网URL...");
const inputImageUrl = await uploadImageToS3ForAPI(imageBase64, "image/jpeg");
console.log("[Extend API] 原图URL:", inputImageUrl);
// 4. 调用图像拓展API(使用URL方式)
const startTime = Date.now();
const xScale = config.xScale || 1.5;
const yScale = config.yScale || 1.5;
console.log(`[Extend API] 拓展参数: X=${xScale}, Y=${yScale}`);
const extendedImageUrl = await extendImage(inputImageUrl, config.apiKey, xScale, yScale);
console.log("[Extend API] 拓展后图片URL:", extendedImageUrl);
// 4. 下载拓展后的图片
console.log("[Extend API] 下载拓展后的图片...");
const imageResponse = await fetch(extendedImageUrl);
if (!imageResponse.ok) {
throw new Error(`下载拓展图片失败: ${imageResponse.status} ${imageResponse.statusText}`);
}
const arrayBuffer = await imageResponse.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extendedImageBase64 = buffer.toString("base64");
// 5. 上传到S3
console.log("[Extend API] 上传到S3...");
const { thumbnail, original } = await uploadBase64ToS3(extendedImageBase64, "image/jpeg");
// 6. 保存到数据库(相册和生图记录)
console.log("[Extend API] 保存到数据库...");
let savedImage;
try {
[savedImage] = await db
.insert(generatedImage)
.values({
userId,
chatId: chatId || null,
messageId: messageId || null,
thumbnailUrl: thumbnail,
originalUrl: original,
prompt: prompt || "图像拓展处理",
modelId: "extend-1.0",
imageSize: "2K",
})
.returning();
console.log(`[Extend API] ✅ 图片已保存到相册和生图记录,ID: ${savedImage.id}`);
} catch (dbError) {
console.error("[Extend API] ❌ 保存到数据库失败:", dbError);
// 数据库保存失败时仍然返回成功,但记录错误(图片已上传到S3,用户可以访问)
}
const processingTime = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`[Extend API] ✅ 图像拓展完成,处理时间: ${processingTime}秒`);
// 7. 返回结果
return NextResponse.json({
success: true,
original_url: original,
thumbnail_url: thumbnail,
base64: extendedImageBase64,
processing_time: parseFloat(processingTime),
});
} catch (error) {
console.error("[Extend API] 错误:", error);
return NextResponse.json(
{
error: "图像拓展失败",
detail: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
{ status: 500 },
);
}
}

View File

@ -1,225 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { upsertMediaAsset } from "@/lib/db/queries";
import {
executeResizeJob,
ResizeServiceError,
} from "@/lib/ai/resize/service";
/**
* 压缩图片生成缩略图(使用 sharp)
*/
async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer> {
try {
const sharp = await import("sharp");
// 创建缩略图(宽度 500px,高度按比例,质量 80%)
const thumbnailBuffer = await sharp.default(buffer)
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.png({ quality: 80 }) // PNG格式保持透明背景
.toBuffer();
console.log(`[Resize] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
return thumbnailBuffer;
} catch (error) {
console.error("[Resize] Failed to create thumbnail:", error);
// 如果失败,返回原图
return buffer;
}
}
/**
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL
*/
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/png"): Promise<{ thumbnail: string; original: string }> {
try {
// 解析 base64 数据
const buffer = Buffer.from(base64Data, "base64");
// 生成文件名
const ext = mimeType.split("/")[1] || "png";
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `resize-thumb-${timestamp}-${randomStr}.${ext}`;
const originalFilename = `resize-orig-${timestamp}-${randomStr}.${ext}`;
console.log(`[Resize] Original size: ${buffer.length} bytes`);
// 生成缩略图
const thumbnailBuffer = await createThumbnail(buffer, mimeType);
// 转换为 ArrayBuffer
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
// 并行上传缩略图和原图到 S3
console.log(`[Resize] Uploading thumbnail and original to S3...`);
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
uploadFile(originalFilename, originalArrayBuffer),
]);
console.log(`[Resize] Uploaded to S3:`);
console.log(` - Thumbnail: ${thumbnailResult.url}`);
console.log(` - Original: ${originalResult.url}`);
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
} catch (error) {
console.error("[Resize] Failed to upload to S3:", error);
throw new Error(`图片上传到存储桶失败: ${error instanceof Error ? error.message : "未知错误"}。请检查系统设置中的存储配置。`);
}
}
/**
* 尺寸调整API路由
* 接收前端处理后的Base64图片,上传到S3并保存到数据库
*/
export async function POST(request: NextRequest) {
try {
// 0. 获取用户会话
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "未授权,请先登录" },
{ status: 401 }
);
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
}
const userId = session.user.id;
// 1. 获取请求体
const contentType = request.headers.get("content-type") || "";
let imageBase64: string;
let prompt = "尺寸调整处理";
let chatId: string | null = null;
let messageId: string | null = null;
let canvasWidth: number;
let canvasHeight: number;
let mimeType = "image/png";
if (contentType.includes("application/json")) {
const body = await request.json();
imageBase64 = body.image_base64;
prompt = body.prompt || "尺寸调整处理";
chatId = body.chatId || null;
messageId = body.messageId || null;
canvasWidth = body.canvasWidth;
canvasHeight = body.canvasHeight;
mimeType = body.mimeType || "image/png";
if (!imageBase64) {
return NextResponse.json(
{ error: "缺少image_base64参数" },
{ status: 400 }
);
}
if (!canvasWidth || !canvasHeight) {
return NextResponse.json(
{ error: "缺少canvasWidth或canvasHeight参数" },
{ status: 400 }
);
}
} else {
if (!contentType.includes("application/json")) {
return NextResponse.json(
{ error: "不支持的Content-Type,请使用application/json" },
{ status: 400 }
{ status: 400 },
);
}
console.log(`[Resize API] 图片Base64长度: ${imageBase64.length} 字符`);
console.log(`[Resize API] 画布尺寸: ${canvasWidth}x${canvasHeight}`);
const body = await request.json();
const imageBase64 = String(body.image_base64 || "").trim();
const canvasWidth = Number(body.canvasWidth);
const canvasHeight = Number(body.canvasHeight);
// 2. 上传处理后的图片到S3存储桶
let thumbnailUrl: string;
let originalUrl: string;
try {
const uploadResult = await uploadBase64ToS3(imageBase64, mimeType);
thumbnailUrl = uploadResult.thumbnail;
originalUrl = uploadResult.original;
console.log(`[Resize] ✅ Images uploaded to S3 successfully`);
} catch (uploadError) {
console.error("[Resize] ❌ Failed to upload to S3:", uploadError);
return NextResponse.json(
{
error: "图片上传到存储桶失败",
detail: uploadError instanceof Error ? uploadError.message : "未知错误"
},
{ status: 500 }
);
if (!imageBase64) {
return NextResponse.json({ error: "缺少image_base64参数" }, { status: 400 });
}
// 3. 保存图片记录到数据库(相册和生图记录)
try {
const [savedImage] = await db.insert(generatedImage).values({
userId,
chatId: chatId || null,
messageId: messageId || null,
thumbnailUrl,
originalUrl,
imageSize: `${canvasWidth}x${canvasHeight}`, // 使用实际尺寸
prompt: prompt || "尺寸调整处理",
modelId: "resize-1.0", // 标记为尺寸调整处理
}).returning();
console.log(`[Resize] ✅ 图片已保存到相册和生图记录,ID: ${savedImage.id}`);
const result = await executeResizeJob({
userId: session.user.id,
imageBuffer: Buffer.from(imageBase64, "base64"),
prompt: body.prompt || "尺寸调整处理",
chatId: body.chatId || null,
messageId: body.messageId || null,
canvasWidth,
canvasHeight,
mimeType: body.mimeType || "image/png",
transparentBackground: body.transparentBackground === true,
backgroundColor: body.backgroundColor || undefined,
});
try {
await upsertMediaAsset({
userId,
chatId,
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl,
previewUrl: originalUrl,
thumbnailUrl,
displayName: (prompt || "尺寸调整处理").slice(0, 80),
prompt: prompt || "尺寸调整处理",
modelId: "resize-1.0",
sourceMessageId: messageId,
sourceTable: "GeneratedImage",
sourceRowId: savedImage.id,
metadata: {
imageSize: `${canvasWidth}x${canvasHeight}`,
canvasWidth,
canvasHeight,
},
});
} catch (assetError) {
console.error("[Resize] ⚠️ MediaAsset 注册失败:", assetError);
}
} catch (dbError) {
console.error("[Resize] ❌ 保存到数据库失败:", dbError);
// 数据库保存失败时仍然返回成功,但记录错误(图片已上传到S3,用户可以访问)
}
// 4. 返回结果(包含S3 URL和Base64,前端可以选择使用)
return NextResponse.json({
success: true,
image_base64: imageBase64, // 保留Base64用于前端立即显示
thumbnail_url: thumbnailUrl, // S3缩略图URL
original_url: originalUrl, // S3原图URL
canvas_width: canvasWidth,
canvas_height: canvasHeight,
...result,
});
} catch (error) {
if (error instanceof ResizeServiceError) {
return NextResponse.json(
{
error: error.message,
detail: error.detail || error.message,
},
{ status: error.status },
);
}
console.error("[Resize API] 处理错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
{ status: 500 }
{ status: 500 },
);
}
}

View File

@ -82,6 +82,21 @@ interface TagPreviewInfo {
};
}
type CanvasMediaStreamPayload = {
messageId?: string;
imageUrls?: string[];
videoUrls?: string[];
prompt?: string;
sourceImageUrls?: string[];
};
type ImageGenerationStatusStreamPayload = {
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
providerType?: string;
modelName?: string;
imageCount?: number;
};
interface CanvasChatPanelProps {
chatId: string;
initialMessages?: ChatMessage[];
@ -1665,6 +1680,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
unitCost: number;
imageCount: number;
} | null>(null);
const [streamImageGenerationState, setStreamImageGenerationState] =
useState<ImageGenerationStatusStreamPayload | null>(null);
const imageParamsRef = useRef(imageParams);
const videoParamsRef = useRef(videoParams);
const deepThinkingEnabledRef = useRef(deepThinkingEnabled);
@ -2723,22 +2740,27 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
currentModel.isMultimodal &&
!VIDEO_PROVIDER_TYPES.has(currentModel.providerType)
);
const isAgentModel = !isDetailGenerationMode && (selectedModelId === "auto" || isConversationModel);
const agentSelectableModels = useMemo(
() => [SMART_AUTO_MODEL, ...conversationOnlyModels],
[SMART_AUTO_MODEL, conversationOnlyModels]
);
const selectableModels = useMemo(() => {
if (isDetailGenerationMode) {
return [SMART_AUTO_MODEL];
}
if (isConversationModel) {
return conversationOnlyModels;
}
if (isVideoModel) {
return videoOnlyModels;
}
return [SMART_AUTO_MODEL, ...imageOnlyModels];
if (isAgentModel) {
return agentSelectableModels;
}
return imageOnlyModels;
}, [
SMART_AUTO_MODEL,
conversationOnlyModels,
agentSelectableModels,
imageOnlyModels,
isConversationModel,
isAgentModel,
isDetailGenerationMode,
isVideoModel,
videoOnlyModels,
@ -2751,8 +2773,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
currentModel ||
SMART_AUTO_MODEL;
const agentDisplayName =
modelsData?.agentModel?.displayName || selectDisplayModel?.displayName || "Agent模式";
const agentAvatarUrl = modelsData?.agentModel?.avatarUrl || selectDisplayModel?.avatarUrl || null;
currentModel?.displayName || selectDisplayModel?.displayName || "Agent模式";
const agentAvatarUrl = currentModel?.avatarUrl || selectDisplayModel?.avatarUrl || null;
const videoFrameSlots = useMemo(() => {
if (!isVideoModel || !videoParams) return [] as string[];
if (videoParams.mode === "first-last-frame") return ["首帧", "尾帧"] as string[];
@ -2901,28 +2923,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
() => imageOnlyModels[0]?.id || null,
[imageOnlyModels]
);
const firstConversationModelId = useMemo(
() => conversationOnlyModels[0]?.id || null,
[conversationOnlyModels]
);
const firstVideoModelId = useMemo(
() => videoOnlyModels[0]?.id || null,
[videoOnlyModels]
);
const activeComposerMode = useMemo<"agent" | "chat" | "image" | "video" | "detail">(() => {
const activeComposerMode = useMemo<"agent" | "image" | "video" | "detail">(() => {
if (isDetailGenerationMode) return "detail";
if (selectedModelId === "auto") return "agent";
if (isVideoModel) return "video";
if (isConversationModel) return "chat";
if (isAgentModel) return "agent";
return "image";
}, [isConversationModel, isDetailGenerationMode, isVideoModel, selectedModelId]);
}, [isAgentModel, isDetailGenerationMode, isVideoModel]);
const activeModeLabel =
activeComposerMode === "video"
? "视频"
: activeComposerMode === "detail"
? "AI详情"
: activeComposerMode === "chat"
? "对话"
: activeComposerMode === "image"
? "生图"
: "Agent";
@ -3025,10 +3040,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}, [currentModel, isConversationModel, isDetailGenerationMode, isVideoModel]);
const handleSelectGenerationMode = useCallback(
(mode: "agent" | "chat" | "image" | "video" | "detail") => {
(mode: "agent" | "image" | "video" | "detail") => {
if (mode === "agent") {
currentModelIdRef.current = "auto";
setSelectedModelId("auto");
const preferredAgentModelId =
allModels.find(
(model) =>
model.id === lastConversationModelId &&
model.isMultimodal &&
!VIDEO_PROVIDER_TYPES.has(model.providerType)
)?.id || "auto";
currentModelIdRef.current = preferredAgentModelId;
setSelectedModelId(preferredAgentModelId);
setIsDetailGenerationMode(false);
return;
}
@ -3045,25 +3067,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
return;
}
if (mode === "chat") {
const preferredConversationModelId =
allModels.find(
(model) =>
model.id === lastConversationModelId &&
model.isMultimodal &&
!VIDEO_PROVIDER_TYPES.has(model.providerType)
)?.id || firstConversationModelId;
if (!preferredConversationModelId) {
toast.error("当前没有可用的多模态模型");
return;
}
currentModelIdRef.current = preferredConversationModelId;
setSelectedModelId(preferredConversationModelId);
setIsDetailGenerationMode(false);
return;
}
if (mode === "image") {
const preferredImageModelId =
allModels.find(
@ -3100,7 +3103,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
[
aiDetailGenerationEnabled,
allModels,
firstConversationModelId,
firstImageModelId,
firstVideoModelId,
lastConversationModelId,
@ -3264,6 +3266,159 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// 合并 initialMessages 和历史消息
const mergedInitialMessages = historyData?.messages ?? (activeChatId === chatId ? initialMessages : []);
const appendStreamMediaToCanvas = useCallback(
async (payload: CanvasMediaStreamPayload) => {
const imageUrls = Array.isArray(payload.imageUrls)
? payload.imageUrls.filter((url): url is string => typeof url === "string" && /^https?:\/\//i.test(url))
: [];
const videoUrls = Array.isArray(payload.videoUrls)
? payload.videoUrls.filter((url): url is string => typeof url === "string" && /^https?:\/\//i.test(url))
: [];
if (imageUrls.length === 0 && videoUrls.length === 0) {
return;
}
const latestNodes = useCanvasStore.getState().nodes;
const existingImageUrlKeys = new Set(
latestNodes
.filter((node) => node.type === "image" && (node.data as any)?.src)
.map((node) => normalizeImageIdentityUrl(String((node.data as any)?.src || "")))
.filter(Boolean)
);
const existingMessageImageKeys = new Set(
latestNodes
.filter((node) => node.type === "image")
.map((node) => {
const data = ((node.data as any) || {}) as Record<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
const sourceImageKey = normalizeImageIdentityUrl(
String(metadata.sourceImageKey || data.src || "").trim()
);
if (!sourceMessageId || !sourceImageKey) return "";
return `${sourceMessageId}::${sourceImageKey}`;
})
.filter(Boolean)
);
const existingVideoUrlKeys = new Set(
latestNodes
.filter((node) => node.type === "video" && (node.data as any)?.src)
.map((node) => normalizeMediaUrl(String((node.data as any)?.src || "")))
.filter(Boolean)
);
const sourceImageUrls = Array.isArray(payload.sourceImageUrls)
? payload.sourceImageUrls.filter((url): url is string => typeof url === "string" && /^https?:\/\//i.test(url))
: [];
const messageId = String(payload.messageId || "").trim();
const generationPrompt = String(payload.prompt || "").trim() || "对话生成";
let addedImageCount = 0;
let addedVideoCount = 0;
for (let index = 0; index < imageUrls.length; index += 1) {
const url = imageUrls[index]!;
const sourceImageKey = normalizeImageIdentityUrl(url);
if (!sourceImageKey || existingImageUrlKeys.has(sourceImageKey)) {
continue;
}
const messageImageKey = messageId ? `${messageId}::${sourceImageKey}` : "";
if (messageImageKey && existingMessageImageKeys.has(messageImageKey)) {
continue;
}
existingImageUrlKeys.add(sourceImageKey);
if (messageImageKey) {
existingMessageImageKeys.add(messageImageKey);
}
const dimensions = await getImageDimensions(url);
const currentNodes = useCanvasStore.getState().nodes;
const existingNodes = currentNodes.length + addedImageCount + addedVideoCount;
const spacing = 20;
const x = 100 + (existingNodes % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing);
const compareSourceUrlRaw =
sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] ||
sourceImageUrls[0] ||
"";
const compareSourceUrl = String(compareSourceUrlRaw || "").trim();
const normalizedCompareSource = normalizeImageIdentityUrl(compareSourceUrl);
const canUseCompareSource =
Boolean(normalizedCompareSource) &&
normalizedCompareSource !== sourceImageKey;
const sourceNodeId = canUseCompareSource
? useCanvasStore
.getState()
.nodes.find((node) => {
if (node.type !== "image") return false;
const src = String(((node.data as any) || {}).src || "").trim();
return normalizeImageIdentityUrl(src) === normalizedCompareSource;
})?.id || ""
: "";
const nodeId = addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: "completed",
title: "图片",
prompt: generationPrompt,
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt,
sourceMessageId: messageId || undefined,
sourceImageKey,
compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined,
},
},
connections: sourceNodeId ? [sourceNodeId] : [],
});
void autoNameGeneratedImageNode(nodeId, url);
addedImageCount += 1;
}
for (let index = 0; index < videoUrls.length; index += 1) {
const url = videoUrls[index]!;
const key = normalizeMediaUrl(url);
if (!key || existingVideoUrlKeys.has(key)) {
continue;
}
existingVideoUrlKeys.add(key);
const currentNodes = useCanvasStore.getState().nodes;
const existingNodes = currentNodes.length + addedImageCount + addedVideoCount;
const x = 120 + (existingNodes % 2) * 420;
const y = 120 + Math.floor(existingNodes / 2) * 260;
addNode({
type: "video",
position: { x, y },
size: { width: 380, height: 220 },
data: {
src: url,
status: "completed",
prompt: generationPrompt,
},
connections: [],
});
addedVideoCount += 1;
}
if (addedImageCount > 0 || addedVideoCount > 0) {
const notice = [
addedImageCount > 0 ? `${addedImageCount} 张图片` : "",
addedVideoCount > 0 ? `${addedVideoCount} 条视频` : "",
]
.filter(Boolean)
.join("、");
toast.success(`已将 ${notice} 添加到画布`);
}
},
[addNode, autoNameGeneratedImageNode]
);
const {
messages,
@ -3332,12 +3487,79 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
},
}),
onData: (dataPart: any) => {
if (dataPart.type === "data-imageGenerationStatus") {
try {
const payload: ImageGenerationStatusStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
if (payload.phase === "completed") {
setStreamImageGenerationState(null);
return;
}
setStreamImageGenerationState({
phase: payload.phase || "processing",
providerType: payload.providerType,
modelName: payload.modelName,
imageCount: Number(payload.imageCount || 0) || undefined,
});
} catch (e) {
console.error("[CanvasChat] Failed to parse data-imageGenerationStatus:", e);
}
return;
}
if (dataPart.type === "data-canvasMedia") {
try {
const payload: CanvasMediaStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
void appendStreamMediaToCanvas(payload);
} catch (e) {
console.error("[CanvasChat] Failed to parse data-canvasMedia:", e);
}
return;
}
// 处理后端返回的自定义消息
if (dataPart.type === "data-appendMessage") {
try {
const messageData = typeof dataPart.data === 'string'
? JSON.parse(dataPart.data)
: dataPart.data;
const hasImageGenStartMarker = Array.isArray(messageData.parts)
? messageData.parts.some(
(part: any) =>
part?.type === "text" &&
typeof part?.text === "string" &&
part.text.includes("<!-- IMAGE_GENERATION_START -->")
)
: false;
const hasImageGenEndMarker = Array.isArray(messageData.parts)
? messageData.parts.some(
(part: any) =>
part?.type === "text" &&
typeof part?.text === "string" &&
part.text.includes("<!-- IMAGE_GENERATION_END -->")
)
: false;
if (hasImageGenStartMarker) {
setStreamImageGenerationState((prev) => ({
phase: "processing",
providerType:
prev?.providerType ||
(activeComposerMode === "agent" ? "alibaba-image" : currentModel?.providerType),
modelName:
prev?.modelName ||
(activeComposerMode === "agent"
? "智能处理"
: currentModel?.displayName || undefined),
imageCount:
prev?.imageCount || Math.max(1, Number(imageParamsRef.current?.imageCount || 1)),
}));
}
if (hasImageGenEndMarker) {
setStreamImageGenerationState(null);
}
const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : [];
const normalizedAttachments = Array.isArray(messageData.attachments)
? messageData.attachments
@ -5950,7 +6172,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
]);
const ensureImagePointsBeforeSend = useCallback(() => {
if (isVideoModel || activeComposerMode === "chat") return true;
if (isVideoModel || activeComposerMode === "agent") return true;
const pointsEnabled = membershipSummary?.pointsSystemConfig?.enabled === true;
const blockOnInsufficient =
@ -6169,14 +6391,15 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
return;
}
sendGuardRef.current = {
inFlight: true,
signature: sendSignature,
timestamp: Date.now(),
};
sendGuardRef.current = {
inFlight: true,
signature: sendSignature,
timestamp: Date.now(),
};
setStreamImageGenerationState(null);
// 使用与 multimodal-input 相同的格式发送消息
try {
// 使用与 multimodal-input 相同的格式发送消息
try {
sendMessage({
role: "user",
parts: parts,
@ -6633,10 +6856,27 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_END -->")
)
);
const shouldShowImageLoading = hasGenerationStart && !hasGenerationEnd && !!currentModel;
const shouldShowImageLoading =
((hasGenerationStart && !hasGenerationEnd) || Boolean(streamImageGenerationState)) &&
!!currentModel;
const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0;
const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter;
const loadingImageCount = imageParams.imageCount || 1;
const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
const loadingProviderType =
streamImageGenerationState?.providerType ||
(activeComposerMode === "agent" ? "alibaba-image" : currentModel?.providerType);
const loadingModelName =
streamImageGenerationState?.modelName ||
(activeComposerMode === "agent"
? "智能处理"
: currentModel?.displayName || undefined);
const loadingPhase =
streamImageGenerationState?.phase === "queued" ||
streamImageGenerationState?.phase === "processing" ||
streamImageGenerationState?.phase === "finalizing" ||
streamImageGenerationState?.phase === "failed"
? streamImageGenerationState.phase
: "processing";
const renderableVisibleMessages = useMemo(() => {
return visibleMessages.reduce<ChatMessage[]>((acc, message) => {
const rawParts = (message.parts || []) as any[];
@ -7474,8 +7714,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<ImageGenerationLoading
imageCount={loadingImageCount}
maxImages={6}
providerType={currentModel?.providerType}
modelName={currentModel?.displayName}
providerType={loadingProviderType}
modelName={loadingModelName}
phase={loadingPhase}
/>
)}
@ -8498,8 +8739,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
? "border border-blue-500 bg-blue-50 text-blue-600 hover:bg-blue-100"
: activeComposerMode === "detail"
? "border border-violet-500 bg-violet-50 text-violet-700 hover:bg-violet-100"
: activeComposerMode === "chat"
? "border border-sky-500 bg-sky-50 text-sky-700 hover:bg-sky-100"
: activeComposerMode === "image"
? "border border-purple-500 bg-purple-50 text-purple-700 hover:bg-purple-100"
: "border border-[#9eb3ff] bg-white text-zinc-700 hover:bg-zinc-50"
@ -8510,8 +8749,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<Video className="mr-1 h-4 w-4" />
) : activeComposerMode === "detail" ? (
<LayoutGrid className="mr-1 h-4 w-4" />
) : activeComposerMode === "chat" ? (
<MessageCircle className="mr-1 h-4 w-4" />
) : activeComposerMode === "image" ? (
<ImagePlus className="mr-1 h-4 w-4" />
) : (
@ -8538,17 +8775,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<span className="flex-1">Agent</span>
{activeComposerMode === "agent" ? <Check className="h-4 w-4 text-[#3b82f6]" /> : null}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("chat")}
className={cn(
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium text-[#2e3238]",
activeComposerMode === "chat" ? "bg-white/80" : "hover:bg-white/70"
)}
>
<MessageCircle className="h-4 w-4" />
<span className="flex-1">对话模式</span>
{activeComposerMode === "chat" ? <Check className="h-4 w-4 text-[#3b82f6]" /> : null}
</DropdownMenuItem>
{aiDetailGenerationEnabled ? (
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("detail")}
@ -8612,10 +8838,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
/>
</div>
</div>
) : activeComposerMode !== "chat" ? (
) : (
<div className="shrink-0">
<ImageParamsBar
modelName={currentModel?.displayName}
modelName={activeComposerMode === "agent" ? agentDisplayName : currentModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline={true}
@ -8624,7 +8850,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
hideAdvancedOptions={activeComposerMode === "image"}
/>
</div>
) : null}
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
@ -8671,7 +8897,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</Button>
) : null}
{activeComposerMode !== "agent" ? (
{activeComposerMode !== "detail" ? (
<Select
value={modelSelectValue}
onValueChange={(value) => {

View File

@ -3,6 +3,11 @@ import { generateText } from "ai";
import sharp from "sharp";
import type { AnalysisResult } from "@/lib/ai/smart-analyzer";
import type { RouteResult } from "@/lib/ai/model-router";
import {
getEnabledAgentToolDefinitionsForContext,
type AgentProjectToolDefinition,
type AgentProjectToolName,
} from "@/lib/ai/tools-registry";
import {
getAiModelAgentCodingModelId,
isAiModelMultimodal,
@ -47,12 +52,90 @@ export interface AgentVideoPlan {
recommendedVideoParams?: AgentRecommendedVideoParams;
}
export type AgentTargetResolutionMode =
| "explicit_selection"
| "current_upload"
| "text_reference"
| "working_memory"
| "latest_result"
| "clarification"
| "none";
export type AgentPreferredCapability =
| "respond"
| "analyze"
| "generic_edit"
| "remove_background"
| "enhance"
| "extend"
| "resize"
| "box_cutout"
| "generate_image"
| "generate_video"
| "publish_canvas";
export type AgentPlanNextAction =
| "respond"
| "clarify"
| "analyze_image"
| "generate_image"
| "edit_image"
| "generate_video";
export type AgentPlanResponseMode =
| "direct_answer"
| "tool_then_answer"
| "tool_then_execute"
| "clarify";
export interface AgentPlannedToolCall {
name: AgentProjectToolName;
reason?: string;
args?: Record<string, unknown>;
}
export interface AgentExecutionPlan {
goal: string;
nextAction: AgentPlanNextAction;
intent: AnalysisResult["intent"];
subIntent?: string;
targetSource: AgentTargetResolutionMode;
preferredCapability: AgentPreferredCapability;
responseMode: AgentPlanResponseMode;
shouldUseHistoryImage: boolean;
shouldUseStructuredReply: boolean;
confidence: number;
toolCalls: AgentPlannedToolCall[];
}
export interface AgentPreparedExecution {
mode: AgentPlanNextAction;
routeIntent: AnalysisResult["intent"];
routeSubIntent?: string;
targetSource: AgentTargetResolutionMode;
preferredCapability: AgentPreferredCapability;
shouldUseStructuredReply: boolean;
shouldOptimizePrompt: boolean;
shouldApplyImagePipeline: boolean;
shouldApplyVideoPipeline: boolean;
shouldStayConversational: boolean;
shouldPublishToCanvas: boolean;
toolCalls: AgentPlannedToolCall[];
availableTools: AgentProjectToolName[];
}
export interface AutoAgentInput {
text: string;
hasImage: boolean;
imageUrls?: string[];
assetIds?: string[];
imageFromHistory?: boolean;
targetResolutionMode?: AgentTargetResolutionMode;
mediaSource?: "current_message" | "canvas_selection" | "history" | "none";
hasCanvasSelection?: boolean;
selectedImageCount?: number;
historyImageCount?: number;
currentUploadImageCount?: number;
historyMessages?: HistoryMessage[];
context?: string;
preferredModelId?: string;
@ -60,6 +143,8 @@ export interface AutoAgentInput {
export interface AutoAgentResult {
analysis?: AnalysisResult;
plan?: AgentExecutionPlan;
execution?: AgentPreparedExecution;
friendlyMessage?: string;
optimizedPrompt?: string;
recommendedImageParams?: AgentRecommendedImageParams;
@ -73,10 +158,19 @@ const AutoAgentState = Annotation.Root({
imageUrls: Annotation<string[]>,
assetIds: Annotation<string[]>,
imageFromHistory: Annotation<boolean>,
targetResolutionMode: Annotation<AgentTargetResolutionMode>,
mediaSource: Annotation<"current_message" | "canvas_selection" | "history" | "none">,
hasCanvasSelection: Annotation<boolean>,
selectedImageCount: Annotation<number>,
historyImageCount: Annotation<number>,
currentUploadImageCount: Annotation<number>,
historyMessages: Annotation<HistoryMessage[]>,
context: Annotation<string | null>,
preferredModelId: Annotation<string | null>,
analysis: Annotation<AnalysisResult | null>,
availableTools: Annotation<AgentProjectToolDefinition[]>,
plan: Annotation<AgentExecutionPlan | null>,
execution: Annotation<AgentPreparedExecution | null>,
friendlyMessage: Annotation<string | null>,
optimizedPrompt: Annotation<string | null>,
recommendedImageParams: Annotation<AgentRecommendedImageParams | null>,
@ -92,6 +186,244 @@ function compactText(text: string, max = 48) {
return `${normalized.slice(0, max)}...`;
}
function createPlanGoalSummary(text: string, hasImage: boolean): string {
const compact = compactText(text || "", 72);
if (compact) return compact;
return hasImage ? "基于当前图片继续处理" : "继续当前对话";
}
function describeTargetSource(mode: AgentTargetResolutionMode): string {
switch (mode) {
case "explicit_selection":
return "画布选中图";
case "current_upload":
return "当前上传图";
case "text_reference":
return "用户文本指定图";
case "working_memory":
return "会话工作记忆图";
case "latest_result":
return "最近生成结果图";
case "clarification":
return "目标待澄清";
case "none":
default:
return "无明确图像目标";
}
}
function inferPreferredCapability(
analysis: AnalysisResult,
toolCalls?: AgentPlannedToolCall[],
): AgentPreferredCapability {
const firstTool = toolCalls?.[0]?.name;
if (firstTool === "removeBackground") return "remove_background";
if (firstTool === "enhanceImage") return "enhance";
if (firstTool === "extendImage") return "extend";
if (firstTool === "resizeImage") return "resize";
if (firstTool === "boxCutout") return "box_cutout";
if (firstTool === "publishToCanvas") return "publish_canvas";
if (firstTool === "generateVideo") return "generate_video";
if (firstTool === "generateImage") return "generate_image";
if (firstTool === "analyzeImage") return "analyze";
if (analysis.intent === "video_gen") return "generate_video";
if (analysis.intent === "image_gen") return "generate_image";
if (analysis.intent === "vision") return "analyze";
if (analysis.intent === "chat") return "respond";
switch (analysis.subIntent) {
case "remove_background":
return "remove_background";
case "enhance":
return "enhance";
case "extend":
return "extend";
case "resize":
return "resize";
default:
return "generic_edit";
}
}
function defaultToolForAction(action: AgentPlanNextAction): AgentProjectToolName {
switch (action) {
case "analyze_image":
return "analyzeImage";
case "edit_image":
return "editImage";
case "generate_image":
return "generateImage";
case "generate_video":
return "generateVideo";
case "clarify":
case "respond":
default:
return "respondDirect";
}
}
function mapIntentToAction(intent: AnalysisResult["intent"]): AgentPlanNextAction {
switch (intent) {
case "vision":
return "analyze_image";
case "image_edit":
return "edit_image";
case "image_gen":
return "generate_image";
case "video_gen":
return "generate_video";
case "chat":
default:
return "respond";
}
}
function mapActionToIntent(
action: AgentPlanNextAction,
fallbackIntent: AnalysisResult["intent"],
): AnalysisResult["intent"] {
switch (action) {
case "analyze_image":
return "vision";
case "edit_image":
return "image_edit";
case "generate_image":
return "image_gen";
case "generate_video":
return "video_gen";
case "clarify":
case "respond":
return fallbackIntent === "vision" ? "vision" : "chat";
default:
return fallbackIntent;
}
}
function defaultResponseMode(action: AgentPlanNextAction): AgentPlanResponseMode {
switch (action) {
case "clarify":
return "clarify";
case "respond":
return "direct_answer";
case "analyze_image":
return "tool_then_answer";
case "edit_image":
case "generate_image":
case "generate_video":
return "tool_then_execute";
default:
return "direct_answer";
}
}
function shouldUseStructuredReply(action: AgentPlanNextAction): boolean {
return action === "edit_image" || action === "generate_image" || action === "generate_video";
}
function sanitizePlannedToolCalls(
rawToolCalls: unknown,
availableTools: AgentProjectToolDefinition[],
fallbackAction: AgentPlanNextAction,
): AgentPlannedToolCall[] {
const availableNames = new Set(availableTools.map((tool) => tool.name));
const list = Array.isArray(rawToolCalls) ? rawToolCalls : [];
const sanitized = list
.map((item) => {
if (!item || typeof item !== "object") return null;
const rawName = String((item as { name?: unknown }).name || "").trim() as AgentProjectToolName;
if (!availableNames.has(rawName)) return null;
const args = (item as { args?: unknown }).args;
return {
name: rawName,
reason: String((item as { reason?: unknown }).reason || "").trim() || undefined,
args: args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : undefined,
} satisfies AgentPlannedToolCall;
})
.filter(Boolean) as AgentPlannedToolCall[];
if (sanitized.length > 0) return sanitized;
const fallbackTool = defaultToolForAction(fallbackAction);
if (availableNames.has(fallbackTool)) {
return [{ name: fallbackTool }];
}
if (availableNames.has("respondDirect")) {
return [{ name: "respondDirect" }];
}
return [];
}
function buildFallbackPlan(params: {
analysis: AnalysisResult;
text: string;
hasImage: boolean;
imageFromHistory: boolean;
targetResolutionMode: AgentTargetResolutionMode;
availableTools: AgentProjectToolDefinition[];
}): AgentExecutionPlan {
const baseAction = params.analysis.needsClarification
? "clarify"
: mapIntentToAction(params.analysis.intent);
const toolCalls = sanitizePlannedToolCalls([], params.availableTools, baseAction);
return {
goal: createPlanGoalSummary(params.text, params.hasImage),
nextAction: baseAction,
intent: mapActionToIntent(baseAction, params.analysis.intent),
subIntent: baseAction === "edit_image" ? params.analysis.subIntent || "enhance" : undefined,
targetSource: params.targetResolutionMode,
preferredCapability: inferPreferredCapability(params.analysis, toolCalls),
responseMode: defaultResponseMode(baseAction),
shouldUseHistoryImage: params.imageFromHistory,
shouldUseStructuredReply: shouldUseStructuredReply(baseAction),
confidence: Math.max(params.analysis.confidence || 0.6, 0.68),
toolCalls,
};
}
function alignAnalysisWithPlan(
analysis: AnalysisResult,
plan: AgentExecutionPlan,
): AnalysisResult {
const intent = plan.intent || mapActionToIntent(plan.nextAction, analysis.intent);
return {
...analysis,
intent,
subIntent: intent === "image_edit" ? plan.subIntent || analysis.subIntent : undefined,
needsClarification: plan.nextAction === "clarify",
clarifyingQuestions:
plan.nextAction === "clarify"
? analysis.clarifyingQuestions?.length
? analysis.clarifyingQuestions
: ["我先确认一下你希望我下一步执行什么。"]
: [],
};
}
function prepareExecutionFromPlan(params: {
plan: AgentExecutionPlan;
availableTools: AgentProjectToolDefinition[];
}): AgentPreparedExecution {
const availableToolNames = params.availableTools.map((tool) => tool.name);
const mode = params.plan.nextAction;
return {
mode,
routeIntent: params.plan.intent,
routeSubIntent: params.plan.subIntent,
targetSource: params.plan.targetSource,
preferredCapability: params.plan.preferredCapability,
shouldUseStructuredReply: params.plan.shouldUseStructuredReply,
shouldOptimizePrompt:
mode === "edit_image" || mode === "generate_image" || mode === "generate_video",
shouldApplyImagePipeline: mode === "edit_image" || mode === "generate_image",
shouldApplyVideoPipeline: mode === "generate_video",
shouldStayConversational: mode === "respond" || mode === "analyze_image" || mode === "clarify",
shouldPublishToCanvas: params.plan.toolCalls.some((tool) => tool.name === "publishToCanvas"),
toolCalls: params.plan.toolCalls,
availableTools: availableToolNames,
};
}
function getEnvInt(name: string, fallback: number, min: number, max: number): number {
const raw = process.env[name];
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
@ -760,6 +1092,244 @@ function resolveAgentCodingModelOverride(
return getAiModelAgentCodingModelId(configuredAgentModel?.model || {}) || null;
}
function sanitizeAgentPlan(
raw: unknown,
fallbackPlan: AgentExecutionPlan,
availableTools: AgentProjectToolDefinition[],
): AgentExecutionPlan {
if (!raw || typeof raw !== "object") {
return fallbackPlan;
}
const payload = raw as Record<string, unknown>;
const rawAction = String(payload.nextAction || "").trim() as AgentPlanNextAction;
const nextAction: AgentPlanNextAction = [
"respond",
"clarify",
"analyze_image",
"generate_image",
"edit_image",
"generate_video",
].includes(rawAction)
? rawAction
: fallbackPlan.nextAction;
const fallbackIntent = fallbackPlan.intent;
const rawIntent = String(payload.intent || "").trim() as AnalysisResult["intent"];
const intent: AnalysisResult["intent"] = [
"chat",
"vision",
"image_edit",
"image_gen",
"video_gen",
].includes(rawIntent)
? rawIntent
: mapActionToIntent(nextAction, fallbackIntent);
const rawResponseMode = String(payload.responseMode || "").trim() as AgentPlanResponseMode;
const responseMode: AgentPlanResponseMode = [
"direct_answer",
"tool_then_answer",
"tool_then_execute",
"clarify",
].includes(rawResponseMode)
? rawResponseMode
: defaultResponseMode(nextAction);
const confidenceNumber = Number(payload.confidence);
const confidence = Number.isFinite(confidenceNumber)
? Math.max(0.3, Math.min(0.99, confidenceNumber))
: fallbackPlan.confidence;
const goal =
typeof payload.goal === "string" && payload.goal.trim()
? payload.goal.trim().slice(0, 160)
: fallbackPlan.goal;
const rawSubIntent =
typeof payload.subIntent === "string" && payload.subIntent.trim()
? payload.subIntent.trim().slice(0, 48)
: undefined;
const rawTargetSource = String(payload.targetSource || "").trim() as AgentTargetResolutionMode;
const targetSource: AgentTargetResolutionMode = [
"explicit_selection",
"current_upload",
"text_reference",
"working_memory",
"latest_result",
"clarification",
"none",
].includes(rawTargetSource)
? rawTargetSource
: fallbackPlan.targetSource;
const sanitizedToolCalls = sanitizePlannedToolCalls(payload.toolCalls, availableTools, nextAction);
const rawCapability = String(payload.preferredCapability || "").trim() as AgentPreferredCapability;
const derivedAnalysis: AnalysisResult = {
intent,
subIntent: intent === "image_edit" ? rawSubIntent || fallbackPlan.subIntent : undefined,
confidence,
};
const preferredCapability: AgentPreferredCapability = [
"respond",
"analyze",
"generic_edit",
"remove_background",
"enhance",
"extend",
"resize",
"box_cutout",
"generate_image",
"generate_video",
"publish_canvas",
].includes(rawCapability)
? rawCapability
: inferPreferredCapability(derivedAnalysis, sanitizedToolCalls);
return {
goal,
nextAction,
intent,
subIntent: intent === "image_edit" ? rawSubIntent || fallbackPlan.subIntent : undefined,
targetSource,
preferredCapability,
responseMode,
shouldUseHistoryImage:
typeof payload.shouldUseHistoryImage === "boolean"
? payload.shouldUseHistoryImage
: fallbackPlan.shouldUseHistoryImage,
shouldUseStructuredReply:
typeof payload.shouldUseStructuredReply === "boolean"
? payload.shouldUseStructuredReply
: shouldUseStructuredReply(nextAction),
confidence,
toolCalls: sanitizedToolCalls,
};
}
async function buildExecutionPlanWithAgent(
state: AutoAgentStateType,
analysis: AnalysisResult,
availableTools: AgentProjectToolDefinition[],
): Promise<AgentExecutionPlan> {
const fallbackPlan = buildFallbackPlan({
analysis,
text: state.text,
hasImage: state.hasImage,
imageFromHistory: state.imageFromHistory,
targetResolutionMode: state.targetResolutionMode,
availableTools,
});
try {
const {
getEnabledAiModelsWithProvider,
getAgentAiModelWithProvider,
} = await import("@/lib/db/queries");
const { getLanguageModelById } = await import("./dynamic-provider");
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const primaryAgentModel = resolvePrimaryAgentModel({
preferredModelId: state.preferredModelId,
configuredAgentModel: agentModel,
allModels,
});
const plannerModelConfig = pickAgentReasoningModel(primaryAgentModel, allModels);
if (!plannerModelConfig) {
return fallbackPlan;
}
const plannerModel = await getLanguageModelById(
plannerModelConfig.model.id,
resolveAgentCodingModelOverride(plannerModelConfig.model.id, agentModel),
);
if (!plannerModel) {
return fallbackPlan;
}
const toolCatalog = availableTools
.map((tool) => `- ${tool.name}: ${tool.title}|${tool.description}`)
.join("\n");
const historyContext = (state.historyMessages || [])
.slice(-6)
.map((item) => `${item.role}: ${compactText(item.content || "", 120)}`)
.join("\n");
const { text } = await generateText({
model: plannerModel,
system: `你是一个多模态 Agent Planner。
你的职责不是直接回答,而是决定当前回合下一步该走哪条能力路径。
只输出 JSON,不要输出解释,不要输出 markdown。
可选 nextAction:
- respond:纯对话、策略讨论、解释说明、普通问答
- clarify:信息不足,需要先澄清
- analyze_image:用户要你看图、识图、评估、分析、给建议
- edit_image:基于当前图/历史图做修改、抠图、增强、换背景、调色、尺寸调整、局部重绘
- generate_image:生成新图
- generate_video:生成视频
规则:
1. 用户只是聊天、问图、讨论方案时,绝不能走生图/生视频。
2. 只有用户明确要求“生成/修改/做视频”等执行任务时,才走执行型 action。
3. 如果用户上传了图片但只是问“这是什么/适不适合/帮我分析一下”,应选择 analyze_image 或 respond。
4. 优先保持一次只选择一个主工具;toolCalls 只从提供的工具列表里选。
5. shouldUseStructuredReply 只在 edit_image / generate_image / generate_video 时设为 true。
6. targetSource 要忠实反映当前目标图来源:explicit_selection/current_upload/text_reference/working_memory/latest_result/clarification/none。
7. preferredCapability 要尽量具体,例如 remove_background / enhance / extend / resize / box_cutout / publish_canvas。
输出格式:
{
"goal": "一句话概括当前回合目标",
"nextAction": "respond|clarify|analyze_image|edit_image|generate_image|generate_video",
"intent": "chat|vision|image_edit|image_gen|video_gen",
"subIntent": "可选,仅 image_edit 时填写",
"targetSource": "explicit_selection|current_upload|text_reference|working_memory|latest_result|clarification|none",
"preferredCapability": "respond|analyze|generic_edit|remove_background|enhance|extend|resize|box_cutout|generate_image|generate_video|publish_canvas",
"responseMode": "direct_answer|tool_then_answer|tool_then_execute|clarify",
"shouldUseHistoryImage": true,
"shouldUseStructuredReply": false,
"confidence": 0.92,
"toolCalls": [
{
"name": "analyzeImage",
"reason": "为什么选这个工具",
"args": {}
}
]
}`,
prompt: [
`用户输入:${state.text || "(空)"}`,
`有当前或历史图片:${state.hasImage ? "是" : "否"}`,
`图片来自历史:${state.imageFromHistory ? "是" : "否"}`,
`目标图来源:${state.targetResolutionMode}(${describeTargetSource(state.targetResolutionMode)})`,
`媒体输入来源:${state.mediaSource}`,
`当前上传图数量:${state.currentUploadImageCount}`,
`历史图数量:${state.historyImageCount}`,
`画布选中图数量:${state.selectedImageCount}`,
`基础分析:intent=${analysis.intent}; subIntent=${analysis.subIntent || "none"}; confidence=${analysis.confidence}`,
state.context ? `画布上下文:${compactText(state.context, 600)}` : "",
historyContext ? `最近对话:\n${historyContext}` : "",
`可用工具:\n${toolCatalog}`,
]
.filter(Boolean)
.join("\n\n"),
temperature: 0.1,
maxTokens: 420,
abortSignal: AbortSignal.timeout(AGENT_OPERATION_TIMEOUT_MS),
} as any);
const parsed = parseJsonBlock<Record<string, unknown>>(text);
const planned = sanitizeAgentPlan(parsed, fallbackPlan, availableTools);
console.log(
`[Auto Agent] Planner model: ${plannerModelConfig.model.displayName} (${plannerModelConfig.provider.type})`,
);
console.log("[Auto Agent] Execution plan:", planned);
return planned;
} catch (error) {
console.error("[Auto Agent] Planner failed, fallback to heuristic plan:", error);
return fallbackPlan;
}
}
async function generateAgentConversation(
analysis: AnalysisResult,
state: AutoAgentStateType
@ -1191,38 +1761,72 @@ const analyzeNode = async (state: AutoAgentStateType) => {
preferredModelId: state.preferredModelId ?? undefined,
});
const fallbackQuestion = analysis.needsClarification
? analysis.clarifyingQuestions?.[0]
: undefined;
return { analysis };
};
const strategicMessage = await buildStrategicFriendlyMessage(analysis, state);
const planNode = async (state: AutoAgentStateType) => {
if (!state.analysis) return {};
const availableTools = await getEnabledAgentToolDefinitionsForContext({
hasImage: state.hasImage,
hasCanvasSelection: state.hasCanvasSelection || state.selectedImageCount > 0,
hasHistoryImage: state.historyImageCount > 0 || state.imageFromHistory,
hasCurrentUpload: state.currentUploadImageCount > 0,
});
const plan = await buildExecutionPlanWithAgent(state, state.analysis, availableTools);
const alignedAnalysis = alignAnalysisWithPlan(state.analysis, plan);
return {
analysis,
friendlyMessage: strategicMessage || analysis.conversationalReply || fallbackQuestion || null,
availableTools,
plan,
analysis: alignedAnalysis,
};
};
const prepareExecutionNode = async (state: AutoAgentStateType) => {
if (!state.analysis || !state.plan) {
return {};
}
const execution = prepareExecutionFromPlan({
plan: state.plan,
availableTools: state.availableTools || [],
});
const fallbackQuestion = state.analysis.needsClarification
? state.analysis.clarifyingQuestions?.[0]
: undefined;
let friendlyMessage =
state.analysis.conversationalReply || fallbackQuestion || null;
if (state.plan.nextAction === "clarify") {
friendlyMessage =
friendlyMessage || "我先确认一下你希望我下一步执行什么。";
} else if (execution.shouldUseStructuredReply) {
const strategicMessage = await buildStrategicFriendlyMessage(state.analysis, state);
friendlyMessage =
strategicMessage ||
state.analysis.conversationalReply ||
friendlyMessage;
}
return {
execution,
friendlyMessage,
};
};
const shouldOptimize = (state: AutoAgentStateType) => {
if (!state.analysis) return "route";
if (!state.analysis || !state.execution) return "route";
if (state.analysis.needsClarification) return "route";
if (state.analysis.intent === "video_gen") {
return "optimize";
}
if (state.analysis.intent === "image_gen") {
return "optimize";
}
if (state.analysis.intent === "image_edit" && state.analysis.subIntent) {
return "optimize";
}
return "route";
return state.execution.shouldOptimizePrompt ? "optimize" : "route";
};
const optimizeNode = async (state: AutoAgentStateType) => {
if (!state.analysis) {
if (!state.analysis || !state.execution) {
return {};
}
if (state.analysis.intent === "video_gen") {
if (state.execution.mode === "generate_video") {
const videoPlan = await recommendVideoPlanWithAgent(state);
const storyboardPreview = videoPlan.storyboard
.slice(0, 4)
@ -1250,11 +1854,11 @@ const optimizeNode = async (state: AutoAgentStateType) => {
videoPlan,
};
}
if (state.analysis.intent === "image_edit" && !state.analysis.subIntent) {
if (state.execution.mode === "edit_image" && !state.analysis.subIntent) {
const recommendedImageParams = heuristicImageParams(state.text, state.analysis);
return { recommendedImageParams };
}
if (state.analysis.intent !== "image_edit" && state.analysis.intent !== "image_gen") {
if (!state.execution.shouldApplyImagePipeline) {
return {};
}
const { optimizePrompt, optimizePromptWithLLM } = await import("./prompt-optimizer");
@ -1297,11 +1901,14 @@ const optimizeNode = async (state: AutoAgentStateType) => {
const routeNode = async (state: AutoAgentStateType) => {
const { routeToModel } = await import("./model-router");
const route = await routeToModel({
intent: state.analysis?.intent || "chat",
subIntent: state.analysis?.subIntent,
let route = await routeToModel({
intent: state.execution?.routeIntent || state.analysis?.intent || "chat",
subIntent: state.execution?.routeSubIntent || state.analysis?.subIntent,
hasImage: state.hasImage,
});
if (state.execution?.shouldApplyImagePipeline) {
route = await refineRouteWithAgent(state, route);
}
return { route };
};
@ -1310,10 +1917,14 @@ let compiledGraph: ReturnType<typeof buildGraph> | null = null;
function buildGraph() {
return new StateGraph(AutoAgentState)
.addNode("analyze", analyzeNode)
.addNode("plan", planNode)
.addNode("prepareExecution", prepareExecutionNode)
.addNode("optimize", optimizeNode)
.addNode("routeModel", routeNode)
.addEdge(START, "analyze")
.addConditionalEdges("analyze", shouldOptimize, {
.addEdge("analyze", "plan")
.addEdge("plan", "prepareExecution")
.addConditionalEdges("prepareExecution", shouldOptimize, {
optimize: "optimize",
route: "routeModel",
})
@ -1337,10 +1948,19 @@ export async function runAutoAgentGraph(params: AutoAgentInput): Promise<AutoAge
imageUrls: params.imageUrls ?? [],
assetIds: params.assetIds ?? [],
imageFromHistory: params.imageFromHistory ?? false,
targetResolutionMode: params.targetResolutionMode ?? "none",
mediaSource: params.mediaSource ?? "none",
hasCanvasSelection: params.hasCanvasSelection ?? false,
selectedImageCount: params.selectedImageCount ?? 0,
historyImageCount: params.historyImageCount ?? 0,
currentUploadImageCount: params.currentUploadImageCount ?? 0,
historyMessages: params.historyMessages ?? [],
context: params.context ?? null,
preferredModelId: params.preferredModelId ?? null,
analysis: null,
availableTools: [],
plan: null,
execution: null,
friendlyMessage: null,
optimizedPrompt: null,
recommendedImageParams: null,
@ -1350,6 +1970,8 @@ export async function runAutoAgentGraph(params: AutoAgentInput): Promise<AutoAge
return {
analysis: result.analysis ?? undefined,
plan: result.plan ?? undefined,
execution: result.execution ?? undefined,
friendlyMessage: result.friendlyMessage ?? undefined,
optimizedPrompt: result.optimizedPrompt ?? undefined,
recommendedImageParams: result.recommendedImageParams ?? undefined,

342
lib/ai/extend/service.ts Normal file
View File

@ -0,0 +1,342 @@
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service";
type ExtendConfig = {
apiKey?: string;
xScale?: number;
yScale?: number;
};
export interface ExecuteExtendJobParams {
userId: string;
imageUrl?: string | null;
imageBase64?: string | null;
prompt?: string;
chatId?: string | null;
messageId?: string | null;
xScale?: number;
yScale?: number;
skipGallerySave?: boolean;
sourceImageUrl?: string | null;
}
export interface ExecuteExtendJobResult {
image_base64: string;
thumbnail_url: string;
original_url: string;
processing_time: number;
mediaAssetId?: string | null;
generatedImageId?: string | null;
}
export class ExtendServiceError extends Error {
status: number;
detail?: string;
constructor(message: string, status = 500, detail?: string) {
super(message);
this.name = "ExtendServiceError";
this.status = status;
this.detail = detail;
}
}
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
try {
const sharp = await import("sharp");
return await sharp.default(buffer)
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 80 })
.toBuffer();
} catch (error) {
console.error("[Extend Service] Failed to create thumbnail:", error);
return buffer;
}
}
async function uploadBufferPairToS3(buffer: Buffer): Promise<{ thumbnail: string; original: string }> {
try {
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `extend-thumb-${timestamp}-${randomStr}.jpg`;
const originalFilename = `extend-orig-${timestamp}-${randomStr}.jpg`;
const thumbnailBuffer = await createThumbnail(buffer);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength,
) as ArrayBuffer;
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
uploadFile(originalFilename, originalArrayBuffer),
]);
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
} catch (error) {
console.error("[Extend Service] Failed to upload to S3:", error);
throw new ExtendServiceError(
"图片上传到存储桶失败",
500,
error instanceof Error ? error.message : "未知错误",
);
}
}
async function uploadImageToS3ForAPI(imageBase64: string): Promise<string> {
try {
const buffer = Buffer.from(imageBase64, "base64");
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const filename = `extend-input-${timestamp}-${randomStr}.jpg`;
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
const result = await uploadFile(filename, arrayBuffer);
return result.url;
} catch (error) {
console.error("[Extend Service] Failed to upload input to S3:", error);
throw new ExtendServiceError(
"图片上传到存储桶失败",
500,
error instanceof Error ? error.message : "未知错误",
);
}
}
async function extendImage(
imageUrl: string,
apiKey: string,
xScale: number,
yScale: number,
): Promise<string> {
const requestBody = {
model: "image-out-painting",
input: {
image_url: imageUrl,
},
parameters: {
x_scale: xScale,
y_scale: yScale,
best_quality: false,
limit_image_size: true,
add_watermark: false,
},
};
const response = await fetch(
"https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"X-DashScope-Async": "enable",
},
body: JSON.stringify(requestBody),
},
);
const initialResult = await response.json();
if (initialResult.code) {
throw new ExtendServiceError(
"图像拓展API错误",
500,
initialResult.message || JSON.stringify(initialResult),
);
}
const taskId = initialResult.output.task_id;
const taskStatusUrl = `https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`;
await new Promise((resolve) => setTimeout(resolve, 3000));
for (let attempt = 0; attempt < 60; attempt += 1) {
const statusResponse = await fetch(taskStatusUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const result = await statusResponse.json();
if (result.output?.task_status === "SUCCEEDED") {
return result.output.output_image_url;
}
if (result.output?.task_status === "FAILED") {
throw new ExtendServiceError(
"图像拓展任务失败",
500,
result.output.message || result.output.code || "未知错误",
);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
throw new ExtendServiceError("图像拓展任务超时", 500);
}
export function inferExtendOptionsFromText(text: string): Pick<ExecuteExtendJobParams, "xScale" | "yScale"> {
const normalizedText = String(text || "").toLowerCase();
const scaleMatch = normalizedText.match(/([1-4](?:\.\d+)?)\s*倍/);
const scale = scaleMatch ? Number(scaleMatch[1]) : undefined;
return {
xScale: scale,
yScale: scale,
};
}
async function getValidatedExtendConfig(): Promise<ExtendConfig> {
const extendConfig = await getSystemSetting({ key: "extend_api_config" });
if (!extendConfig || !extendConfig.value) {
throw new ExtendServiceError("图像拓展API配置未设置,请在系统设置中配置", 400);
}
const config = extendConfig.value as ExtendConfig;
if (!config.apiKey) {
throw new ExtendServiceError("图像拓展API密钥未配置", 400);
}
return config;
}
export async function executeExtendJob(
params: ExecuteExtendJobParams,
): Promise<ExecuteExtendJobResult> {
const {
userId,
imageUrl = null,
imageBase64 = null,
prompt = "图像拓展处理",
chatId = null,
messageId = null,
skipGallerySave = false,
sourceImageUrl = imageUrl || null,
} = params;
if (!imageUrl && !imageBase64) {
throw new ExtendServiceError("缺少 image_url 或 image_base64 参数", 400);
}
const config = await getValidatedExtendConfig();
const xScale = Math.max(1.1, Math.min(Number(params.xScale || config.xScale || 1.5), 4));
const yScale = Math.max(1.1, Math.min(Number(params.yScale || config.yScale || 1.5), 4));
const inputImageUrl = imageUrl || (imageBase64 ? await uploadImageToS3ForAPI(imageBase64) : "");
if (!inputImageUrl) {
throw new ExtendServiceError("无法获取输入图片URL", 400);
}
const startTime = Date.now();
const extendedImageUrl = await extendImage(inputImageUrl, config.apiKey!, xScale, yScale);
const imageResponse = await fetch(extendedImageUrl);
if (!imageResponse.ok) {
throw new ExtendServiceError(
"下载拓展图片失败",
500,
`${imageResponse.status} ${imageResponse.statusText}`,
);
}
const buffer = Buffer.from(await imageResponse.arrayBuffer());
const imageBase64Result = buffer.toString("base64");
const uploadResult = await uploadBufferPairToS3(buffer);
let mediaAssetId: string | null = null;
let generatedImageId: string | null = null;
if (!skipGallerySave) {
try {
const [savedImage] = await db
.insert(generatedImage)
.values({
userId,
chatId,
messageId,
thumbnailUrl: uploadResult.thumbnail,
originalUrl: uploadResult.original,
prompt,
modelId: "extend-1.0",
imageSize: "2K",
})
.returning();
generatedImageId = savedImage?.id || null;
const sourceAssets = sourceImageUrl
? await getMediaAssetsByUserAndUrls({
userId,
urls: [sourceImageUrl],
})
: [];
const media = await upsertMediaAsset({
userId,
chatId,
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl: uploadResult.original,
previewUrl: uploadResult.original,
thumbnailUrl: uploadResult.thumbnail,
displayName: prompt.slice(0, 80),
prompt,
modelId: "extend-1.0",
sourceMessageId: messageId,
sourceTable: "GeneratedImage",
sourceRowId: generatedImageId,
primaryParentAssetId: sourceAssets[0]?.id ?? null,
metadata: {
xScale,
yScale,
imageSize: "2K",
},
});
mediaAssetId = media?.id || null;
if (media?.id && sourceAssets.length > 0) {
for (const sourceAsset of sourceAssets) {
await createMediaAssetRelation({
userId,
chatId,
fromAssetId: sourceAsset.id,
toAssetId: media.id,
relationType: "edited_from",
sourceMessageId: messageId,
metadata: {
prompt,
tool: "extend",
xScale,
yScale,
},
});
}
}
} catch (error) {
console.error("[Extend Service] Failed to save result:", error);
throw new ExtendServiceError(
"图像拓展成功但保存结果失败",
500,
error instanceof Error ? error.message : "未知错误",
);
}
}
return {
image_base64: imageBase64Result,
thumbnail_url: uploadResult.thumbnail,
original_url: uploadResult.original,
processing_time: Number(((Date.now() - startTime) / 1000).toFixed(2)),
mediaAssetId,
generatedImageId,
};
}

353
lib/ai/resize/service.ts Normal file
View File

@ -0,0 +1,353 @@
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
upsertMediaAsset,
} from "@/lib/db/queries";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
export interface ResizeCanvasOptions {
canvasWidth: number;
canvasHeight: number;
backgroundColor?: string;
transparentBackground?: boolean;
mimeType?: string;
}
export interface ExecuteResizeJobParams extends ResizeCanvasOptions {
userId: string;
imageUrl?: string | null;
imageBuffer?: Buffer | null;
prompt?: string;
chatId?: string | null;
messageId?: string | null;
skipGallerySave?: boolean;
sourceImageUrl?: string | null;
}
export interface ExecuteResizeJobResult {
image_base64: string;
thumbnail_url: string;
original_url: string;
canvas_width: number;
canvas_height: number;
mediaAssetId?: string | null;
generatedImageId?: string | null;
mimeType: string;
}
export class ResizeServiceError extends Error {
status: number;
detail?: string;
constructor(message: string, status = 500, detail?: string) {
super(message);
this.name = "ResizeServiceError";
this.status = status;
this.detail = detail;
}
}
const COMMON_COLOR_MAP: Record<string, string> = {
白: "#FFFFFF",
白色: "#FFFFFF",
黑: "#000000",
黑色: "#000000",
灰: "#D1D5DB",
灰色: "#D1D5DB",
浅灰: "#E5E7EB",
深灰: "#6B7280",
红: "#EF4444",
红色: "#EF4444",
蓝: "#3B82F6",
蓝色: "#3B82F6",
绿色: "#22C55E",
绿: "#22C55E",
黄色: "#FACC15",
黄: "#FACC15",
};
function normalizeHexColor(color?: string | null): string | undefined {
const raw = String(color || "").trim();
if (!raw) return undefined;
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(raw)) {
return raw.length === 4
? `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`
: raw;
}
return COMMON_COLOR_MAP[raw];
}
function normalizeMimeType(
mimeType: string | undefined,
transparentBackground: boolean,
): "image/png" | "image/jpeg" | "image/webp" {
if (transparentBackground) return "image/png";
const normalized = String(mimeType || "").toLowerCase().trim();
if (normalized === "image/png" || normalized === "image/jpeg" || normalized === "image/webp") {
return normalized;
}
return "image/jpeg";
}
async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer> {
try {
const sharp = await import("sharp");
const pipeline = sharp.default(buffer)
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
});
if (mimeType === "image/png") return await pipeline.png({ quality: 80 }).toBuffer();
if (mimeType === "image/webp") return await pipeline.webp({ quality: 80 }).toBuffer();
return await pipeline.jpeg({ quality: 80 }).toBuffer();
} catch (error) {
console.error("[Resize Service] Failed to create thumbnail:", error);
return buffer;
}
}
async function uploadBufferPairToS3(
buffer: Buffer,
mimeType: string,
): Promise<{ thumbnail: string; original: string }> {
try {
const ext = mimeType.split("/")[1] || "png";
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `resize-thumb-${timestamp}-${randomStr}.${ext}`;
const originalFilename = `resize-orig-${timestamp}-${randomStr}.${ext}`;
const thumbnailBuffer = await createThumbnail(buffer, mimeType);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength,
) as ArrayBuffer;
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
uploadFile(originalFilename, originalArrayBuffer),
]);
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
} catch (error) {
console.error("[Resize Service] Failed to upload to S3:", error);
throw new ResizeServiceError(
"图片上传到存储桶失败",
500,
error instanceof Error ? error.message : "未知错误",
);
}
}
async function renderResizedImage(params: {
imageBuffer: Buffer;
canvasWidth: number;
canvasHeight: number;
transparentBackground: boolean;
backgroundColor?: string;
mimeType: "image/png" | "image/jpeg" | "image/webp";
}): Promise<Buffer> {
const sharp = await import("sharp");
const background = params.transparentBackground
? { r: 255, g: 255, b: 255, alpha: 0 }
: params.backgroundColor
? params.backgroundColor
: "#FFFFFF";
let pipeline = sharp.default(params.imageBuffer)
.rotate()
.resize({
width: params.canvasWidth,
height: params.canvasHeight,
fit: "contain",
background,
withoutEnlargement: false,
});
if (params.mimeType === "image/png") {
pipeline = pipeline.png({ quality: 95 });
} else if (params.mimeType === "image/webp") {
pipeline = pipeline.webp({ quality: 95 });
} else {
pipeline = pipeline.jpeg({ quality: 95 });
}
return pipeline.toBuffer();
}
export function inferResizeOptionsFromText(
text: string,
): Partial<ResizeCanvasOptions> {
const normalizedText = String(text || "").toLowerCase();
const dimensionMatch = normalizedText.match(/(\d{2,5})\s*[x×*]\s*(\d{2,5})/i);
if (dimensionMatch) {
return {
canvasWidth: Number(dimensionMatch[1]),
canvasHeight: Number(dimensionMatch[2]),
transparentBackground: /透明/.test(normalizedText),
backgroundColor: normalizeHexColor(
normalizedText.match(/(白色|白底|黑色|黑底|灰色|灰底|红色|蓝色|绿色|黄色)/)?.[1] || "",
),
};
}
const ratioMap: Record<string, { width: number; height: number }> = {
"1:1": { width: 1080, height: 1080 },
"4:5": { width: 1080, height: 1350 },
"3:4": { width: 1080, height: 1440 },
"4:3": { width: 1440, height: 1080 },
"3:2": { width: 1440, height: 960 },
"2:3": { width: 960, height: 1440 },
"16:9": { width: 1600, height: 900 },
"9:16": { width: 900, height: 1600 },
};
const ratioMatch = normalizedText.match(/\b(1:1|4:5|3:4|4:3|3:2|2:3|16:9|9:16)\b/);
const ratioSize = ratioMatch ? ratioMap[ratioMatch[1]] : undefined;
return {
canvasWidth: ratioSize?.width,
canvasHeight: ratioSize?.height,
transparentBackground: /透明/.test(normalizedText),
backgroundColor: normalizeHexColor(
normalizedText.match(/(白色|白底|黑色|黑底|灰色|灰底|红色|蓝色|绿色|黄色)/)?.[1] || "",
),
};
}
export async function executeResizeJob(
params: ExecuteResizeJobParams,
): Promise<ExecuteResizeJobResult> {
const {
userId,
imageUrl = null,
imageBuffer = null,
prompt = "尺寸调整处理",
chatId = null,
messageId = null,
canvasWidth,
canvasHeight,
skipGallerySave = false,
sourceImageUrl = imageUrl || null,
} = params;
if (!canvasWidth || !canvasHeight) {
throw new ResizeServiceError("缺少 canvasWidth 或 canvasHeight 参数", 400);
}
const transparentBackground = params.transparentBackground === true;
const mimeType = normalizeMimeType(params.mimeType, transparentBackground);
const backgroundColor = normalizeHexColor(params.backgroundColor) || "#FFFFFF";
const sourceBuffer =
imageBuffer || (imageUrl ? await fetchImageBufferFromUrl(imageUrl) : null);
if (!sourceBuffer) {
throw new ResizeServiceError("缺少可处理的图片输入", 400);
}
const outputBuffer = await renderResizedImage({
imageBuffer: sourceBuffer,
canvasWidth,
canvasHeight,
transparentBackground,
backgroundColor,
mimeType,
});
const imageBase64 = outputBuffer.toString("base64");
const uploadResult = await uploadBufferPairToS3(outputBuffer, mimeType);
let mediaAssetId: string | null = null;
let generatedImageId: string | null = null;
if (!skipGallerySave) {
try {
const [savedImage] = await db
.insert(generatedImage)
.values({
userId,
chatId,
messageId,
thumbnailUrl: uploadResult.thumbnail,
originalUrl: uploadResult.original,
imageSize: `${canvasWidth}x${canvasHeight}`,
prompt,
modelId: "resize-1.0",
})
.returning();
generatedImageId = savedImage?.id || null;
const sourceAssets = sourceImageUrl
? await getMediaAssetsByUserAndUrls({
userId,
urls: [sourceImageUrl],
})
: [];
const media = await upsertMediaAsset({
userId,
chatId,
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl: uploadResult.original,
previewUrl: uploadResult.original,
thumbnailUrl: uploadResult.thumbnail,
displayName: prompt.slice(0, 80),
prompt,
modelId: "resize-1.0",
sourceMessageId: messageId,
sourceTable: "GeneratedImage",
sourceRowId: generatedImageId,
primaryParentAssetId: sourceAssets[0]?.id ?? null,
metadata: {
imageSize: `${canvasWidth}x${canvasHeight}`,
canvasWidth,
canvasHeight,
transparentBackground,
backgroundColor: transparentBackground ? undefined : backgroundColor,
},
});
mediaAssetId = media?.id || null;
if (media?.id && sourceAssets.length > 0) {
for (const sourceAsset of sourceAssets) {
await createMediaAssetRelation({
userId,
chatId,
fromAssetId: sourceAsset.id,
toAssetId: media.id,
relationType: "edited_from",
sourceMessageId: messageId,
metadata: {
prompt,
tool: "resize",
},
});
}
}
} catch (error) {
console.error("[Resize Service] Failed to save result:", error);
throw new ResizeServiceError(
"尺寸调整成功但保存结果失败",
500,
error instanceof Error ? error.message : "未知错误",
);
}
}
return {
image_base64: imageBase64,
thumbnail_url: uploadResult.thumbnail,
original_url: uploadResult.original,
canvas_width: canvasWidth,
canvas_height: canvasHeight,
mediaAssetId,
generatedImageId,
mimeType,
};
}

View File

@ -39,6 +39,8 @@ export type ExecuteRmbgJobResult = {
original_url: string;
processing_time: number;
provider: RmbgProvider;
mediaAssetId?: string | null;
generatedImageId?: string | null;
};
export class RmbgServiceError extends Error {
@ -540,6 +542,7 @@ export async function executeRmbgJob(
if (!skipGallerySave) {
let saved = false;
let saveError: unknown = null;
let mediaAssetId: string | null = null;
let savedImage:
| {
id: string;
@ -606,6 +609,7 @@ export async function executeRmbgJob(
imageSize: "2K",
},
});
mediaAssetId = media?.id || null;
if (media) {
for (const sourceAsset of sourceAssets) {
@ -626,6 +630,16 @@ export async function executeRmbgJob(
} catch (assetError) {
console.error("[RMBG] ⚠️ MediaAsset 注册失败:", assetError);
}
return {
image_base64: resultBase64,
thumbnail_url: thumbnailUrl,
original_url: originalUrl,
processing_time: processingTime,
provider,
mediaAssetId,
generatedImageId: savedImage.id,
};
}
} else {
console.log("[RMBG] Skip gallery save by request flag");
@ -637,5 +651,7 @@ export async function executeRmbgJob(
original_url: originalUrl,
processing_time: processingTime,
provider,
mediaAssetId: null,
generatedImageId: null,
};
}

View File

@ -41,6 +41,39 @@ function isVideoGenerationRequest(text: string): boolean {
);
}
function normalizeCompactText(text: string): string {
return text.toLowerCase().replace(/\s+/g, "").trim();
}
function isExplicitImageGenerationRequest(text: string): boolean {
const normalizedText = text.toLowerCase();
return /重新生成|再来一张|生成新图|新建图片|创作一张|参考.*生成|按这个风格生成|以这张图为灵感生成|仿照.*生成|出一张/.test(
normalizedText,
);
}
function isLikelyImageEditRequest(text: string): boolean {
const normalizedText = text.toLowerCase();
return /抠图|去背景|透明背景|去背|扣图|移除背景|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
normalizedText,
);
}
function isLikelyVisionRequest(text: string): boolean {
const normalizedText = text.toLowerCase();
return /这是什么|是什么|图里|图片里|画面里|照片里|有什么|有啥|看起来像|像什么|帮我看|看看|看下|识别|分析|描述|介绍|解读|讲讲|解释|评价|评估|适合|优缺点|问题|构图|什么风格|风格是什么|材质|元素|内容|文案|品牌感|商业化|电商|卖点|哪里不好|哪里有问题|能看出/.test(
normalizedText,
);
}
function isLikelyGeneralChatTurn(text: string): boolean {
const compactText = normalizeCompactText(text);
if (!compactText) return false;
return /^(你好|您好|hello|hi|嗨|哈喽|在吗|谢谢|感谢|收到|好的|ok|okay|算了|不用了|明白了|了解了)$/.test(
compactText,
);
}
function applyDirectExecutionPolicy(
result: AnalysisResult,
params: {
@ -70,24 +103,72 @@ function applyDirectExecutionPolicy(
params.hasImage || Boolean(params.imageFromHistory) || (params.imageUrls?.length || 0) > 0;
if (!hasUsableImage) return result;
const compactText = params.text.replace(/\s+/g, "").trim();
const baseResult = normalizeIntentForImageContext(result, params.text, hasUsableImage);
const compactText = normalizeCompactText(params.text);
const hasAnyRequestText = compactText.length > 0;
const keepClarification = Boolean(result.needsClarification);
const normalizedIntent = result.intent === "chat" || result.intent === "vision" ? "image_edit" : result.intent;
const explicitEditRequest = hasAnyRequestText && isLikelyImageEditRequest(params.text);
const explicitImageGenerationRequest = hasAnyRequestText && isExplicitImageGenerationRequest(params.text);
const likelyVisionRequest = hasAnyRequestText && isLikelyVisionRequest(params.text);
const likelyGeneralChatTurn = hasAnyRequestText && isLikelyGeneralChatTurn(params.text);
if (!hasAnyRequestText) {
return {
...baseResult,
intent: "image_edit",
subIntent: baseResult.subIntent || inferDefaultImageEditSubIntent(params.text),
confidence: Math.max(baseResult.confidence, 0.72),
needsClarification: keepClarification,
clarifyingQuestions: keepClarification ? result.clarifyingQuestions || [] : [],
conversationalReply:
baseResult.conversationalReply || "收到图片,我先按保真优化策略直接执行生成。",
};
}
if (!explicitEditRequest && !explicitImageGenerationRequest) {
if (likelyVisionRequest || baseResult.intent === "vision") {
return {
...baseResult,
intent: "vision",
subIntent: undefined,
confidence: Math.max(baseResult.confidence, 0.78),
needsClarification: false,
clarifyingQuestions: [],
conversationalReply:
baseResult.conversationalReply || "收到,我先看看这张图,再直接告诉你我的判断。",
};
}
if (likelyGeneralChatTurn || baseResult.intent === "chat") {
return {
...baseResult,
intent: "chat",
subIntent: undefined,
confidence: Math.max(baseResult.confidence, 0.72),
needsClarification: keepClarification,
clarifyingQuestions: keepClarification ? result.clarifyingQuestions || [] : [],
conversationalReply:
baseResult.conversationalReply || "收到,我们先围绕这张图继续聊。",
};
}
}
const normalizedIntent =
explicitEditRequest && baseResult.intent !== "image_gen" ? "image_edit" : baseResult.intent;
const normalizedSubIntent =
normalizedIntent === "image_edit"
? result.subIntent || inferDefaultImageEditSubIntent(params.text)
: result.subIntent;
? baseResult.subIntent || inferDefaultImageEditSubIntent(params.text)
: baseResult.subIntent;
return {
...result,
...baseResult,
intent: normalizedIntent,
subIntent: normalizedSubIntent,
confidence: Math.max(result.confidence, 0.72),
confidence: Math.max(baseResult.confidence, 0.72),
needsClarification: keepClarification,
clarifyingQuestions: keepClarification ? result.clarifyingQuestions || [] : [],
conversationalReply:
result.conversationalReply ||
baseResult.conversationalReply ||
(hasAnyRequestText ? "收到,我会按你的描述直接执行生成。" : "收到图片,我先按保真优化策略直接执行生成。"),
};
}
@ -102,10 +183,7 @@ function normalizeIntentForImageContext(
}
const normalizedText = text.toLowerCase();
const explicitGenerate =
/重新生成|再来一张|生成新图|新建图片|创作一张|参考.*生成|按这个风格生成|以这张图为灵感生成/.test(
normalizedText
);
const explicitGenerate = isExplicitImageGenerationRequest(normalizedText);
const editSignal =
/改|调整|优化|去掉|换成|改成|保留|继续|再.*一点|修|抠|替换|增强|清晰|重绘/.test(normalizedText);
@ -122,11 +200,11 @@ function normalizeIntentForImageContext(
}
// 系统提示词 - 针对图片编辑场景优化,生成自然对话
const ANALYZER_SYSTEM_PROMPT = `你是一个友好的AI图片编辑助手。用户会给你发送图片和编辑请求,你需要:
const ANALYZER_SYSTEM_PROMPT = `你是一个友好的AI多模态Agent。用户可能会让你聊天、看图分析、修图、生图或生视频,你需要:
1. 理解用户想要对图片做什么
2. 用自然、友好的语气回复用户,告诉他你理解了他的需求并会帮他处理
3. 同时输出结构化的意图分析结果
4. 默认直接执行,不要反问用户;只有在完全无图且需求为空时才允许澄清
4. 只有用户明确要求执行图片/视频任务时,才输出 image_edit / image_gen / video_gen;如果用户是在问图、聊图、要建议或分析,就输出 vision 或 chat;只有“只发图没说话”时,才允许默认按保真优化执行
## 意图分类
@ -204,6 +282,22 @@ const ANALYZER_SYSTEM_PROMPT = `你是一个友好的AI图片编辑助手。用
"needsClarification": false
}
用户发了图片并问:"图片里是什么?"
{
"intent": "vision",
"confidence": 0.92,
"conversationalReply": "我先看一下这张图,然后直接告诉你内容和判断~",
"needsClarification": false
}
用户发了图片并问:"这个 logo 适合做品牌吗?"
{
"intent": "chat",
"confidence": 0.86,
"conversationalReply": "可以,我先结合这张图聊聊它的品牌感和适用场景~",
"needsClarification": false
}
连续对话场景 - 用户说"换成蓝色"(系统自动使用历史图片):
{
"intent": "image_edit",
@ -2009,7 +2103,7 @@ export function fallbackKeywordAnalysis(
}
// 风格迁移
if (/风格|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格/.test(lowerText)) {
if (/风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|转换为.*风格/.test(lowerText)) {
const styleMatch = text.match(/(?:转换为|变成|改成|转成)(.+?)(?:风格|$)/);
const style = styleMatch ? styleMatch[1].trim() : "新";
return {
@ -2063,7 +2157,23 @@ export function fallbackKeywordAnalysis(
};
}
// 有图但意图不明确
if (isLikelyGeneralChatTurn(text)) {
return {
intent: "chat",
confidence: 0.78,
conversationalReply: "收到,我们先继续聊这张图~",
};
}
if (text.trim()) {
return {
intent: "vision",
confidence: 0.72,
conversationalReply: "收到,我先看看这张图,再根据你的问题继续分析~",
};
}
// 有图但没文本
return {
intent: "image_edit",
subIntent: inferDefaultImageEditSubIntent(text),

View File

@ -1,34 +1,220 @@
import { getAllToolConfigs } from "@/lib/db/queries";
export type ToolRegistry = {
getWeather: boolean;
createDocument: boolean;
updateDocument: boolean;
requestSuggestions: boolean;
generateVideo: boolean;
};
export type RuntimeToolName =
| "getWeather"
| "createDocument"
| "updateDocument"
| "requestSuggestions"
| "generateVideo";
export type AgentProjectToolName =
| RuntimeToolName
| "respondDirect"
| "analyzeImage"
| "generateImage"
| "editImage"
| "removeBackground"
| "enhanceImage"
| "extendImage"
| "resizeImage"
| "boxCutout"
| "publishToCanvas"
| "useSelectedCanvasImage"
| "useLatestResultImage"
| "useOriginalUploadImage";
export type ToolRegistry = Record<RuntimeToolName, boolean>;
export interface AgentProjectToolDefinition {
name: AgentProjectToolName;
title: string;
description: string;
category: "conversation" | "image" | "video" | "document" | "utility" | "canvas";
runtimeTool?: RuntimeToolName;
requiresImage?: boolean;
requiresCanvasSelection?: boolean;
requiresHistoryImage?: boolean;
requiresCurrentUpload?: boolean;
producesMedia?: boolean;
}
export interface AgentToolContext {
hasImage?: boolean;
hasCanvasSelection?: boolean;
hasHistoryImage?: boolean;
hasCurrentUpload?: boolean;
}
const RUNTIME_TOOL_DEFINITIONS: Array<AgentProjectToolDefinition & { runtimeTool: RuntimeToolName }> = [
{
name: "getWeather",
title: "天气查询",
description: "查询天气并在对话中直接回答用户。",
category: "utility",
runtimeTool: "getWeather",
},
{
name: "createDocument",
title: "创建文档",
description: "按用户要求创建文档、草稿或结构化内容。",
category: "document",
runtimeTool: "createDocument",
},
{
name: "updateDocument",
title: "更新文档",
description: "继续修改已有文档、方案或草稿内容。",
category: "document",
runtimeTool: "updateDocument",
},
{
name: "requestSuggestions",
title: "内容建议",
description: "生成补充建议、优化意见或下一步选项。",
category: "document",
runtimeTool: "requestSuggestions",
},
{
name: "generateVideo",
title: "视频生成",
description: "根据文本或参考图生成视频。",
category: "video",
runtimeTool: "generateVideo",
producesMedia: true,
},
];
const INTERNAL_AGENT_TOOLS: AgentProjectToolDefinition[] = [
{
name: "respondDirect",
title: "直接回复",
description: "仅对话、解释、建议或策略讨论,不执行生图生视频。",
category: "conversation",
},
{
name: "analyzeImage",
title: "图片分析",
description: "对当前图或历史图做识别、风格分析、质量评估、品牌/营销建议。",
category: "image",
requiresImage: true,
},
{
name: "generateImage",
title: "图片生成",
description: "根据文本或参考风格生成新图片。",
category: "image",
producesMedia: true,
},
{
name: "editImage",
title: "图片编辑",
description: "基于当前图进行抠图、换背景、增强、调色、尺寸调整、局部重绘等。",
category: "image",
requiresImage: true,
producesMedia: true,
},
{
name: "removeBackground",
title: "抠图去背",
description: "提取主体、去除背景,适合商品图、人像、元素裁切。",
category: "image",
requiresImage: true,
producesMedia: true,
},
{
name: "enhanceImage",
title: "清晰增强",
description: "提升清晰度、细节和质感,适合低清图修复与放大前预处理。",
category: "image",
requiresImage: true,
producesMedia: true,
},
{
name: "extendImage",
title: "扩图补边",
description: "向四周延展画面,适合改比例、补全背景或生成更大构图。",
category: "image",
requiresImage: true,
producesMedia: true,
},
{
name: "resizeImage",
title: "尺寸调整",
description: "调整画布尺寸、背景色与构图位置,适合电商主图、社媒比例适配。",
category: "image",
requiresImage: true,
producesMedia: true,
},
{
name: "boxCutout",
title: "框选抠图",
description: "对目标图中的局部区域做框选裁切,再提取被框选主体。",
category: "image",
requiresImage: true,
producesMedia: true,
},
{
name: "publishToCanvas",
title: "发布到画布",
description: "把当前结果图继续发送到无限画布或作为新的画布结果节点。",
category: "canvas",
producesMedia: false,
},
{
name: "useSelectedCanvasImage",
title: "使用选中图",
description: "将当前画布里选中的图片作为本轮分析或编辑目标。",
category: "canvas",
requiresImage: true,
requiresCanvasSelection: true,
},
{
name: "useLatestResultImage",
title: "使用最新结果图",
description: "将上一轮生成结果作为连续对话和继续编辑的目标图。",
category: "canvas",
requiresImage: true,
requiresHistoryImage: true,
},
{
name: "useOriginalUploadImage",
title: "使用原上传图",
description: "将最近上传的原图作为分析、修图或对比基准。",
category: "canvas",
requiresImage: true,
requiresCurrentUpload: true,
},
];
const DEFAULT_RUNTIME_TOOLS = RUNTIME_TOOL_DEFINITIONS.map((tool) => tool.runtimeTool);
function getAllAgentToolDefinitions(): AgentProjectToolDefinition[] {
return [...INTERNAL_AGENT_TOOLS, ...RUNTIME_TOOL_DEFINITIONS];
}
function matchesAgentToolContext(
tool: AgentProjectToolDefinition,
context?: AgentToolContext,
) {
if (!context) return true;
if (tool.requiresImage && !context.hasImage) return false;
if (tool.requiresCanvasSelection && !context.hasCanvasSelection) return false;
if (tool.requiresHistoryImage && !context.hasHistoryImage) return false;
if (tool.requiresCurrentUpload && !context.hasCurrentUpload) return false;
return true;
}
/**
* 获取启用的工具列表
* 获取启用的运行时工具列表
* 从数据库读取工具配置,如果工具未配置则默认启用
*/
export async function getEnabledTools(): Promise<string[]> {
export async function getEnabledTools(): Promise<RuntimeToolName[]> {
try {
const configs = await getAllToolConfigs();
const enabledTools: string[] = [];
const enabledTools: RuntimeToolName[] = [];
// 默认所有工具都启用
const defaultTools = [
"getWeather",
"createDocument",
"updateDocument",
"requestSuggestions",
"generateVideo",
];
for (const toolName of defaultTools) {
for (const toolName of DEFAULT_RUNTIME_TOOLS) {
const config = configs.find((c) => c.toolName === toolName);
// 如果未配置或配置为启用,则添加到启用列表
if (!config || config.isEnabled) {
enabledTools.push(toolName);
}
@ -37,14 +223,20 @@ export async function getEnabledTools(): Promise<string[]> {
return enabledTools;
} catch (error) {
console.error("Failed to get enabled tools, using defaults:", error);
// 出错时返回所有默认工具
return [
"getWeather",
"createDocument",
"updateDocument",
"requestSuggestions",
"generateVideo",
];
return [...DEFAULT_RUNTIME_TOOLS];
}
}
export async function getEnabledAgentToolDefinitions(): Promise<AgentProjectToolDefinition[]> {
return getEnabledAgentToolDefinitionsForContext();
}
export async function getEnabledAgentToolDefinitionsForContext(
context?: AgentToolContext,
): Promise<AgentProjectToolDefinition[]> {
const runtimeTools = await getEnabledTools();
return getAllAgentToolDefinitions().filter((tool) => {
if (!tool.runtimeTool) return true;
return runtimeTools.includes(tool.runtimeTool);
}).filter((tool) => matchesAgentToolContext(tool, context));
}

File diff suppressed because one or more lines are too long