mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(image): handle data: URLs and broadcast sync image-gen completion
This commit is contained in:
parent
66510c96fa
commit
bbf978ed89
@ -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 编码的图片保存到本地
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user