fix(image): handle data: URLs and broadcast sync image-gen completion

This commit is contained in:
matevip 2026-05-05 13:06:53 +08:00
parent 66510c96fa
commit bbf978ed89
2 changed files with 89 additions and 1 deletions

View File

@ -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}.
*
* <p>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:[<mediatype>][;base64],<data>} 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:<mediatype>?(;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 编码的图片保存到本地
*/

View File

@ -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<String, Object> 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);