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