From bbf978ed892a37d2be167077eeb9a02808acd4ed Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 5 May 2026 13:06:53 +0800 Subject: [PATCH] fix(image): handle data: URLs and broadcast sync image-gen completion --- .../mate/tool/image/ImageFileDownloader.java | 69 ++++++++++++++++++- .../tool/image/ImageGenerationService.java | 21 ++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java index 02964ef1..b008e4d1 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java @@ -5,6 +5,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -22,12 +24,27 @@ public class ImageFileDownloader { private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); /** - * 从 URL 下载图片到本地 + * Persist an image referenced by either a {@code data:} URL (inline base64 / + * percent-encoded payload) or an http(s) CDN URL. Returns the local path + * the caller can convert to a serving URL via {@link #toServingUrl}. + * + *

Without the {@code data:} branch, callers ended up handing the raw URL + * to {@code HttpUtil.downloadFile}, which interprets it relative to the + * working directory and produces nonsense like + * {@code file:/cwd/http:/data:image/...} before failing — the image never + * lands on disk and the assistant message renders empty. */ public Path download(String imageUrl, String conversationId, String taskId, int index) throws IOException { + if (imageUrl == null) { + throw new IOException("imageUrl is null"); + } Path dir = UPLOAD_ROOT.resolve(conversationId); Files.createDirectories(dir); + if (imageUrl.startsWith("data:")) { + return saveDataUrl(imageUrl, dir, taskId, index); + } + String extension = guessExtension(imageUrl); String fileName = "image_" + taskId + "_" + index + extension; Path targetFile = dir.resolve(fileName); @@ -39,6 +56,56 @@ public class ImageFileDownloader { return targetFile; } + /** + * Decode a {@code data:[][;base64],} URL onto disk. + * Handles both the base64-encoded and percent-encoded body forms defined + * by RFC 2397, and picks the file extension from the media type so the + * stored asset is openable by name. + */ + private Path saveDataUrl(String dataUrl, Path dir, String taskId, int index) throws IOException { + int comma = dataUrl.indexOf(','); + if (comma < 0) { + throw new IOException("Malformed data URL: missing comma"); + } + // Header layout: "data:?(;base64)?" (we already saw "data:") + String header = dataUrl.substring("data:".length(), comma); + String body = dataUrl.substring(comma + 1); + + boolean isBase64 = header.toLowerCase().contains(";base64"); + String mime = isBase64 + ? header.substring(0, header.toLowerCase().indexOf(";base64")) + : (header.indexOf(';') >= 0 ? header.substring(0, header.indexOf(';')) : header); + + byte[] bytes; + try { + bytes = isBase64 + ? Base64.getDecoder().decode(body) + : URLDecoder.decode(body, StandardCharsets.UTF_8).getBytes(StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid base64 payload in data URL: " + e.getMessage(), e); + } + + String extension = extensionForMime(mime); + String fileName = "image_" + taskId + "_" + index + extension; + Path targetFile = dir.resolve(fileName); + Files.write(targetFile, bytes); + log.info("[ImageDownloader] Saved data URL ({} bytes, mime={}) to {}", + bytes.length, mime.isBlank() ? "image/png" : mime, targetFile); + return targetFile; + } + + private static String extensionForMime(String mime) { + if (mime == null) return ".png"; + return switch (mime.toLowerCase().trim()) { + case "image/jpeg", "image/jpg" -> ".jpg"; + case "image/webp" -> ".webp"; + case "image/gif" -> ".gif"; + case "image/bmp" -> ".bmp"; + case "image/svg+xml" -> ".svg"; + default -> ".png"; + }; + } + /** * 将 Base64 编码的图片保存到本地 */ diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageGenerationService.java b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageGenerationService.java index 282c1154..44751781 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageGenerationService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageGenerationService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import vip.mate.channel.web.ChatStreamTracker; import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.service.SystemSettingService; import vip.mate.task.AsyncTaskService; @@ -15,7 +16,9 @@ import vip.mate.workspace.conversation.model.MessageContentPart; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * 图片生成服务 — 统一入口,处理 provider 选择、参数归一化、fallback、同步/异步提交 @@ -35,6 +38,7 @@ public class ImageGenerationService { private final ConversationService conversationService; private final ImageFileDownloader fileDownloader; private final ObjectMapper objectMapper; + private final ChatStreamTracker streamTracker; private static final String TASK_TYPE = "image_generation"; @@ -186,6 +190,23 @@ public class ImageGenerationService { "图片已生成完毕", contentParts, "completed"); + // Broadcast async_task_completed so the chat window renders the image + // inline immediately. Without this, the message is in DB but the SSE + // stream never tells the frontend a new assistant turn arrived, so the + // user's "loading…" spinner stays until they refresh and the + // conversation re-fetches from DB. Mirror the async path's payload + // shape (taskId / taskType / success / imageUrl) so the existing + // frontend handler treats it identically. + for (String servingUrl : servingUrls) { + Map data = new HashMap<>(); + data.put("taskId", taskId); + data.put("taskType", TASK_TYPE); + data.put("success", true); + data.put("imageUrl", servingUrl); + data.put("providerName", submitResult.getProviderName()); + streamTracker.broadcastObject(conversationId, "async_task_completed", data); + } + log.info("[ImageGen] Sync generation completed, {} image(s) saved for conversation {}", servingUrls.size(), conversationId);