feat(generative): unified async pipeline + live SSE delivery for music/video/image

This commit is contained in:
matevip 2026-05-01 20:15:20 +08:00
parent 1f368336de
commit e3ab06d57c
19 changed files with 755 additions and 185 deletions

View File

@ -1374,8 +1374,13 @@ public class ChatController {
* 注册 SseEmitter 的完整生命周期回调
*/
private void registerEmitterCallbacks(SseEmitter emitter, String conversationId) {
emitter.onCompletion(() ->
log.debug("SSE emitter completed: conversationId={}", conversationId));
emitter.onCompletion(() -> {
log.debug("SSE emitter completed: conversationId={}", conversationId);
// Detach immediately so a subsequent broadcast (heartbeat / async_task_*)
// doesn't waste a send call on the zombie emitter and emit
// "Removing dead subscriber ... ResponseBodyEmitter has already completed".
streamTracker.detach(conversationId, emitter);
});
emitter.onTimeout(() -> {
log.debug("SSE emitter timeout: conversationId={}", conversationId);
streamTracker.detach(conversationId, emitter);

View File

@ -246,33 +246,73 @@ public class ChatStreamTracker {
}
/**
* 广播事件到所有订阅者并缓存到 buffer
* 注意"done" 事件即使在流已完成状态下也会被发送确保客户端能收到完成信号
* 广播事件到所有订阅者并缓存到 buffer.
* <p>
* Two event categories survive {@code state.done=true}:
* <ul>
* <li>{@code "done"} the lifecycle marker itself. If a client missed
* this on a broken pipe and reconnects within the 5-minute retention
* window, replay surfaces it so the UI exits "生成中" state.</li>
* <li>{@code "async_task_*"} task lifecycle events from
* {@code AsyncTaskService} (image/video/music generation). These
* routinely fire <em>after</em> the agent's reasoning turn finishes
* (long-running upstream calls). Without this carve-out the events
* are silently dropped and the UI never sees the audio/error.</li>
* </ul>
* For all other events, the prior {@code state==null || state.done}
* early-return remains.
*/
public void broadcast(String conversationId, String eventName, String jsonData) {
RunState state = runs.get(conversationId);
// 特殊处理 "done" 事件即使流已完成仍然尝试发送给所有订阅者
// 并且**也必须入 buffer**这样如果客户端在生成期间 SSE 断了
// (broken pipe / 浏览器 tab throttle / 网络抖动)刷新页面重连
// 时仍能从 buffer 回放 done 事件UI 不再永远卡在"生成中"
// 之前的设计 done 不入 buffer配合 complete() 立即 runs.remove()
// 一起使得 SSE 中途断开 = done 永远丢是这次故障的根源
if ("done".equals(eventName)) {
if (state != null) {
SseEvent doneEvent = new SseEvent(eventName, jsonData);
synchronized (state.lock) {
state.buffer.add(doneEvent);
Iterator<SseEmitter> it = state.subscribers.iterator();
while (it.hasNext()) {
SseEmitter emitter = it.next();
try {
emitter.send(SseEmitter.event().name(eventName).data(jsonData));
boolean isDone = "done".equals(eventName);
boolean isAsyncTask = eventName != null && eventName.startsWith("async_task_");
boolean isHeartbeat = "heartbeat".equals(eventName);
if (isDone || isAsyncTask) {
if (state == null) return;
SseEvent ev = new SseEvent(eventName, jsonData);
synchronized (state.lock) {
state.buffer.add(ev);
if (state.buffer.size() > MAX_BUFFER_SIZE) {
trimBuffer(state.buffer);
}
Iterator<SseEmitter> it = state.subscribers.iterator();
while (it.hasNext()) {
SseEmitter emitter = it.next();
try {
emitter.send(SseEmitter.event().name(eventName).data(jsonData));
if (isDone) {
log.debug("Sent final 'done' event to subscriber for {}", conversationId);
} catch (IOException | IllegalStateException e) {
log.debug("Removing dead subscriber for {} while sending done event: {}", conversationId, e.getMessage());
it.remove();
}
} catch (IOException | IllegalStateException e) {
log.debug("Removing dead subscriber for {} while sending {} event: {}",
conversationId, eventName, e.getMessage());
it.remove();
}
}
}
// done events do not flow through eventRelays; async_task_* should
// also short-circuit since relays exist for delta-style streaming
// events, not lifecycle markers.
return;
}
// Heartbeat is ephemeral keep-alive must reach subscribers even when
// state.done=true (e.g. a reconnected emitter waiting for late
// async_task_* events). Skip the buffer (heartbeats are not replayable).
if (isHeartbeat) {
if (state == null) return;
synchronized (state.lock) {
Iterator<SseEmitter> it = state.subscribers.iterator();
while (it.hasNext()) {
SseEmitter emitter = it.next();
try {
emitter.send(SseEmitter.event().name(eventName).data(jsonData));
} catch (IOException | IllegalStateException e) {
log.debug("Removing dead subscriber for {} while sending heartbeat: {}",
conversationId, e.getMessage());
it.remove();
}
}
}
@ -382,18 +422,26 @@ public class ChatStreamTracker {
return false;
}
}
// 流已完成buffer 已回放完毕 done event不需要订阅后续事件
// Stream complete: buffer replayed (including the `done` event itself).
// We DO NOT auto-complete the emitter here keep it subscribed so any
// late-arriving async_task_* events (image/video/music generation that
// outlasts the agent's reasoning turn) reach the client live. Idle
// emitters are pruned naturally when:
// - the next broadcast hits a broken pipe and removes the dead subscriber
// - cleanupStaleRuns() removes the RunState after DONE_RETENTION_MS (5 min)
// - the frontend explicitly disconnects (component unmount / navigation)
// Without this, async_task_completed fired after `done` would be silently
// dropped, leaving the chat UI stuck on the "正在生成中" placeholder.
state.subscribers.add(emitter);
if (state.done) {
log.info("[SSE] Replayed {} buffered events to reconnecting client for completed stream: {}",
log.info("[SSE] Replayed {} buffered events; emitter stays subscribed for late async events: {}",
state.buffer.size(), conversationId);
try {
emitter.complete();
} catch (Exception ignored) {
// emitter 已被 servlet 容器关掉了无需处理
}
// Restart heartbeat so the proxy/Tomcat 60s idle timeout doesn't
// close the reconnected emitter before the async_task_* event fires.
// The scheduler self-stops once subscribers go empty (see startHeartbeat).
startHeartbeat(conversationId);
return true;
}
state.subscribers.add(emitter);
}
log.info("[SSE] Client reconnected for conversation={}, replaying {} buffered events",
conversationId, state.buffer.size());
@ -527,7 +575,16 @@ public class ChatStreamTracker {
state.heartbeatFuture = heartbeatScheduler.scheduleAtFixedRate(() -> {
try {
RunState s = runs.get(conversationId);
if (s == null || s.done) {
if (s == null) {
stopHeartbeat(conversationId);
return;
}
// Continue heartbeating post-done as long as someone is still listening
// (reconnected emitter waiting for late async_task_* events). Stop only
// when the run is done AND the subscribers list is empty otherwise the
// 60s idle proxy timeout drops the reconnected emitter and async events
// never reach the client live.
if (s.done && s.subscribers.isEmpty()) {
stopHeartbeat(conversationId);
return;
}

View File

@ -61,6 +61,7 @@ public class SystemSettingService {
private static final String KLING_SECRET_KEY_KEY = "klingSecretKey";
private static final String RUNWAY_API_KEY_KEY = "runwayApiKey";
private static final String MINIMAX_API_KEY_KEY = "minimaxApiKey";
private static final String MINIMAX_REGION_KEY = "minimaxRegion";
private final SystemSettingMapper systemSettingMapper;
@ -107,6 +108,7 @@ public class SystemSettingService {
dto.setKlingSecretKeyMasked(maskApiKey(getValue(KLING_SECRET_KEY_KEY, "")));
dto.setRunwayApiKeyMasked(maskApiKey(getValue(RUNWAY_API_KEY_KEY, "")));
dto.setMinimaxApiKeyMasked(maskApiKey(getValue(MINIMAX_API_KEY_KEY, "")));
dto.setMinimaxRegion(getValue(MINIMAX_REGION_KEY, "global"));
// 图片生成配置
dto.setImageEnabled(Boolean.parseBoolean(getValue(IMAGE_ENABLED_KEY, "false")));
@ -237,6 +239,9 @@ public class SystemSettingService {
if (dto.getMinimaxApiKey() != null && !dto.getMinimaxApiKey().isBlank()) {
saveValue(MINIMAX_API_KEY_KEY, dto.getMinimaxApiKey(), "MiniMax API Key");
}
if (dto.getMinimaxRegion() != null && !dto.getMinimaxRegion().isBlank()) {
saveValue(MINIMAX_REGION_KEY, dto.getMinimaxRegion(), "MiniMax API 区域 (global / cn)");
}
// 图片生成配置
if (dto.getImageEnabled() != null) {

View File

@ -34,9 +34,12 @@ public class AsyncTaskService implements ApplicationRunner {
private final AsyncTaskMapper asyncTaskMapper;
private final ChatStreamTracker streamTracker;
/** 轮询线程池小池2 线程足够) */
/** Polling thread pool. Bumped from 2 to 8 in P0 image+video+future generative
* tasks all share this pool, and per-task work (poll HTTP + DB write + file
* download in completion callbacks) is non-trivial; 2 threads saturate
* immediately under any concurrent load. */
private final ScheduledExecutorService pollExecutor =
Executors.newScheduledThreadPool(2, r -> {
Executors.newScheduledThreadPool(8, r -> {
Thread t = new Thread(r, "async-task-poll");
t.setDaemon(true);
return t;
@ -267,12 +270,26 @@ public class AsyncTaskService implements ApplicationRunner {
public void broadcastTaskEvent(AsyncTaskEntity task, String eventName,
boolean success, String videoUrl, String imageUrl, String errorMessage) {
Map<String, Object> extra = new HashMap<>();
if (videoUrl != null) extra.put("videoUrl", videoUrl);
if (imageUrl != null) extra.put("imageUrl", imageUrl);
broadcastTaskEventWithData(task, eventName, success, extra, errorMessage);
}
/**
* Generic task-event broadcaster. Use this for any media kind where the URL
* field name varies (audioUrl, modelUrl, ...) pass it via {@code extraData}.
* Named distinctly from {@link #broadcastTaskEvent} to avoid overload
* ambiguity when callers pass {@code null} for the 4th argument.
*/
public void broadcastTaskEventWithData(AsyncTaskEntity task, String eventName,
boolean success, Map<String, Object> extraData,
String errorMessage) {
Map<String, Object> data = new HashMap<>();
data.put("taskId", task.getTaskId());
data.put("taskType", task.getTaskType());
data.put("success", success);
if (videoUrl != null) data.put("videoUrl", videoUrl);
if (imageUrl != null) data.put("imageUrl", imageUrl);
if (extraData != null) data.putAll(extraData);
if (errorMessage != null) data.put("errorMessage", errorMessage);
streamTracker.broadcastObject(task.getConversationId(), eventName, data);
}

View File

@ -38,7 +38,9 @@ public class ImageGenerationResult {
.providerName(providerName)
.submitted(true)
.completed(false)
.message("图片生成任务已提交(任务 ID: " + taskId + ")。预计 30 秒 - 2 分钟完成,完成后会自动显示在对话中。")
// Format MUST keep `taskId=...` so the frontend reconnect detector
// (useChat.ts TASK_ID_PATTERN) can extract it from the tool result.
.message("图片生成任务已提交taskId=" + taskId + ", provider=" + providerName + ")。预计 30 秒 - 2 分钟完成,完成后会自动显示在对话中。")
.build();
}

View File

@ -253,8 +253,10 @@ public class ImageGenerationService {
ImageProviderCapabilities caps = provider.detailedCapabilities();
if (caps == null) return;
request.setSize(caps.normalizeSize(request.getSize()));
request.setAspectRatio(caps.normalizeAspectRatio(request.getAspectRatio()));
// Resolve aspect ratio first so size normalization can preserve orientation.
String aspectRatio = caps.normalizeAspectRatio(request.getAspectRatio());
request.setAspectRatio(aspectRatio);
request.setSize(caps.normalizeSize(request.getSize(), aspectRatio));
if (request.getCount() != null) {
request.setCount(caps.normalizeCount(request.getCount()));
}

View File

@ -39,20 +39,50 @@ public class ImageProviderCapabilities {
private List<String> models = List.of();
/**
* 将请求的 size 就近匹配到 provider 支持的值
* Match the requested size against supported sizes by area only.
* Orientation-blind prefer {@link #normalizeSize(String, String)} when an
* aspect ratio is available so portrait/landscape intent is preserved.
*/
public String normalizeSize(String requested) {
if (requested == null || requested.isBlank()) {
return supportedSizes.isEmpty() ? "1024x1024" : supportedSizes.get(0);
return normalizeSize(requested, null);
}
/**
* Match the requested size against supported sizes, preserving orientation.
* <p>Resolution order:
* <ol>
* <li>If {@code requestedSize} is already in {@code supportedSizes}, return it.</li>
* <li>If {@code requestedAspectRatio} is given, narrow {@code supportedSizes}
* to those whose orientation matches (portrait / landscape / square),
* then pick by closest area.</li>
* <li>Otherwise pick by closest area across all supported sizes.</li>
* </ol>
*/
public String normalizeSize(String requestedSize, String requestedAspectRatio) {
if (supportedSizes.isEmpty()) {
return "1024x1024";
}
if (supportedSizes.contains(requested)) {
return requested;
if (requestedSize != null && supportedSizes.contains(requestedSize)) {
return requestedSize;
}
// 就近匹配解析面积找最接近的
long reqArea = parseArea(requested);
String closest = supportedSizes.get(0);
Orientation targetOrientation = orientationFor(requestedAspectRatio);
List<String> candidates = supportedSizes;
if (targetOrientation != null) {
List<String> matching = supportedSizes.stream()
.filter(s -> orientationOf(s) == targetOrientation)
.toList();
if (!matching.isEmpty()) {
candidates = matching;
}
}
long reqArea = (requestedSize == null || requestedSize.isBlank())
? 1024L * 1024L
: parseArea(requestedSize);
String closest = candidates.get(0);
long minDiff = Math.abs(reqArea - parseArea(closest));
for (String s : supportedSizes) {
for (String s : candidates) {
long diff = Math.abs(reqArea - parseArea(s));
if (diff < minDiff) {
minDiff = diff;
@ -62,6 +92,34 @@ public class ImageProviderCapabilities {
return closest;
}
private enum Orientation { PORTRAIT, LANDSCAPE, SQUARE }
private static Orientation orientationFor(String aspectRatio) {
if (aspectRatio == null || aspectRatio.isBlank()) return null;
String[] parts = aspectRatio.split(":");
if (parts.length != 2) return null;
try {
double w = Double.parseDouble(parts[0].trim());
double h = Double.parseDouble(parts[1].trim());
if (w == h) return Orientation.SQUARE;
return w > h ? Orientation.LANDSCAPE : Orientation.PORTRAIT;
} catch (NumberFormatException e) {
return null;
}
}
private static Orientation orientationOf(String size) {
try {
String[] parts = size.toLowerCase().split("x");
long w = Long.parseLong(parts[0].trim());
long h = Long.parseLong(parts[1].trim());
if (w == h) return Orientation.SQUARE;
return w > h ? Orientation.LANDSCAPE : Orientation.PORTRAIT;
} catch (Exception e) {
return Orientation.SQUARE;
}
}
/**
* 将请求的 aspectRatio 就近匹配或回退到默认
*/

View File

@ -100,9 +100,11 @@ public class DashScopeImageProvider implements ImageGenerationProvider {
input.put("prompt", request.getPrompt());
ObjectNode parameters = body.putObject("parameters");
String size = aspectRatioToSize(request.getAspectRatio());
if (size != null) {
parameters.put("size", size);
// request.size already normalized by ImageGenerationService to one of supportedSizes.
// DashScope API uses '*' separator instead of 'x'.
String size = request.getSize();
if (size != null && !size.isBlank()) {
parameters.put("size", size.replace("x", "*"));
}
int count = request.getCount() != null ? Math.min(request.getCount(), 4) : 1;
parameters.put("n", count);
@ -177,15 +179,6 @@ public class DashScopeImageProvider implements ImageGenerationProvider {
}
}
private String aspectRatioToSize(String aspectRatio) {
if (aspectRatio == null) return "1024*1024";
return switch (aspectRatio) {
case "16:9" -> "1280*720";
case "9:16" -> "720*1280";
default -> "1024*1024";
};
}
private String extractImageUrl(JsonNode output) {
JsonNode results = output.path("results");
if (results.isArray() && !results.isEmpty()) {

View File

@ -89,8 +89,9 @@ public class FalImageProvider implements ImageGenerationProvider {
ObjectNode body = objectMapper.createObjectNode();
body.put("prompt", request.getPrompt());
// fal.ai 使用 image_size 对象或字符串
String size = normalizeSize(request.getSize(), request.getAspectRatio());
// request.size already normalized by ImageGenerationService to one of supportedSizes.
// fal.ai expects image_size as a {width, height} object.
String size = request.getSize();
ObjectNode imageSize = body.putObject("image_size");
String[] parts = size.split("x");
imageSize.put("width", Integer.parseInt(parts[0]));
@ -179,22 +180,6 @@ public class FalImageProvider implements ImageGenerationProvider {
}
}
private String normalizeSize(String size, String aspectRatio) {
if (size != null && !size.isBlank()) {
return size;
}
if (aspectRatio != null) {
return switch (aspectRatio) {
case "16:9" -> "1536x1024";
case "9:16" -> "1024x1536";
case "4:3" -> "1024x768";
case "3:4" -> "768x1024";
default -> "1024x1024";
};
}
return "1024x1024";
}
private String extractImageUrl(JsonNode result) {
JsonNode images = result.path("images");
if (images.isArray() && !images.isEmpty()) {

View File

@ -94,8 +94,9 @@ public class ZhipuImageProvider implements ImageGenerationProvider {
body.put("model", model);
body.put("prompt", request.getPrompt());
String size = aspectRatioToSize(request.getAspectRatio());
if (size != null) {
// request.size already normalized by ImageGenerationService to one of supportedSizes.
String size = request.getSize();
if (size != null && !size.isBlank()) {
body.put("size", size);
}
@ -134,14 +135,4 @@ public class ZhipuImageProvider implements ImageGenerationProvider {
}
}
private String aspectRatioToSize(String aspectRatio) {
if (aspectRatio == null) return "1024x1024";
return switch (aspectRatio) {
case "16:9" -> "1344x768";
case "9:16" -> "768x1344";
case "4:3" -> "1152x864";
case "3:4" -> "864x1152";
default -> "1024x1024";
};
}
}

View File

@ -21,7 +21,7 @@ public class MusicGenerateTool {
private final MusicGenerationService musicGenerationService;
@Tool(description = "生成音乐或歌曲。支持文字描述生成音乐、歌词谱曲、纯音乐等模式。支持 Google Lyria 和 MiniMax Music 等 Provider。")
@Tool(description = "生成音乐或歌曲。支持文字描述生成音乐、歌词谱曲、纯音乐等模式。支持 Google Lyria 和 MiniMax Music 等 Provider。任务异步执行(约 1-3 分钟),工具立即返回任务 ID前端会在生成完成时自动接收 SSE 事件并把音频推到对话中。无需用户手动刷新。")
public String music_generate(
@ToolParam(description = "音乐风格/场景描述,如:'轻快的钢琴爵士乐'、'史诗电影配乐'、'欢快的流行歌曲'") String prompt,
@ToolParam(description = "歌词文本(可选,不填则由 AI 生成或生成纯音乐)") String lyrics,
@ -33,6 +33,7 @@ public class MusicGenerateTool {
if (conversationId == null) {
return "无法获取会话 ID";
}
String username = ToolExecutionContext.username(ctx);
MusicGenerationRequest request = MusicGenerationRequest.builder()
.prompt(prompt)
@ -40,15 +41,13 @@ public class MusicGenerateTool {
.instrumental(instrumental != null ? instrumental : false)
.build();
Map<String, Object> result = musicGenerationService.generate(conversationId, request);
Map<String, Object> result = musicGenerationService.submitGeneration(
conversationId, request, username);
if (Boolean.TRUE.equals(result.get("success"))) {
StringBuilder sb = new StringBuilder("音乐生成完成!\n");
sb.append("播放链接: ").append(result.get("audioUrl"));
if (result.containsKey("lyrics") && result.get("lyrics") != null) {
sb.append("\n\n歌词:\n").append(result.get("lyrics"));
}
return sb.toString();
return "音乐生成任务已提交taskId=" + result.get("taskId")
+ ", provider=" + result.get("providerName")
+ ")。生成需要约 1-2 分钟,完成后会自动推送到对话。";
} else {
return "音乐生成失败: " + result.get("error");
}

View File

@ -1,19 +1,42 @@
package vip.mate.tool.music;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.system.service.SystemSettingService;
import vip.mate.task.AsyncTaskService;
import vip.mate.task.model.AsyncTaskEntity;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import jakarta.annotation.PreDestroy;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 音乐生成服务
* Music generation service.
* <p>
* Music providers (MiniMax, Lyria) are synchronous from the upstream API
* perspective a single HTTP call blocks for ~120s and returns the audio
* bytes. Running that on the chat SSE thread freezes the conversation's
* event stream for two minutes; instead we register an
* {@link AsyncTaskEntity} for state + SSE bookkeeping and dispatch the actual
* provider call to a dedicated virtual-thread worker. The chat thread returns
* with a taskId immediately.
*
* @author MateClaw Team
*/
@Slf4j
@Service
@ -22,47 +45,138 @@ public class MusicGenerationService {
private final SystemSettingService systemSettingService;
private final MusicProviderRegistry providerRegistry;
private final AsyncTaskService asyncTaskService;
private final ConversationService conversationService;
private final ObjectMapper objectMapper;
private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads");
private static final String TASK_TYPE = "music_generation";
public Map<String, Object> generate(String conversationId, MusicGenerationRequest request) {
/** Dedicated virtual-thread worker. Music generation blocks on a single
* upstream HTTP call (~120s) keeping it off the polling pool and the
* chat SSE thread is the whole point of P0b. */
private final ExecutorService musicWorker = Executors.newThreadPerTaskExecutor(
Thread.ofVirtual().name("music-gen-", 0).factory());
/**
* Submit a music generation request. Returns immediately with a taskId;
* actual generation runs on {@link #musicWorker} and completion is
* broadcast via {@link AsyncTaskService#broadcastTaskEvent}.
*/
public Map<String, Object> submitGeneration(String conversationId,
MusicGenerationRequest request,
String createdBy) {
SystemSettingsDTO config = systemSettingService.getAllSettings();
if (!Boolean.TRUE.equals(config.getMusicEnabled())) {
return Map.of("success", false, "error", "音乐生成功能未启用");
}
MusicGenerationResult result = generateWithFallback(request, config);
if (!result.isSuccess()) {
return Map.of("success", false, "error", result.getErrorMessage());
MusicGenerationProvider primary = providerRegistry.resolve(config);
if (primary == null) {
return Map.of("success", false, "error", "没有可用的音乐 Provider");
}
AsyncTaskEntity task;
try {
String fileId = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
Path dir = UPLOAD_ROOT.resolve(conversationId);
Files.createDirectories(dir);
String fileName = "music_" + fileId + "." + result.getFormat();
Path filePath = dir.resolve(fileName);
Files.write(filePath, result.getAudioData());
String requestJson = objectMapper.writeValueAsString(request);
String localProviderTaskId = "music_" + UUID.randomUUID().toString()
.replace("-", "").substring(0, 12);
task = asyncTaskService.createTask(
TASK_TYPE, conversationId, null,
primary.id(),
localProviderTaskId,
requestJson, createdBy);
} catch (Exception e) {
log.error("[Music] Failed to create async task: {}", e.getMessage(), e);
return Map.of("success", false, "error", "创建任务失败: " + e.getMessage());
}
String audioUrl = "/api/v1/chat/files/" + conversationId + "/" + fileName;
AsyncTaskEntity finalTask = task;
musicWorker.execute(() -> doGenerate(finalTask, request, config, conversationId));
Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("audioUrl", audioUrl);
response.put("contentType", result.getContentType());
response.put("format", result.getFormat());
if (result.getLyrics() != null) {
response.put("lyrics", result.getLyrics());
Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("taskId", task.getTaskId());
response.put("providerName", primary.id());
return response;
}
// ==================== Worker thread ====================
private void doGenerate(AsyncTaskEntity task, MusicGenerationRequest request,
SystemSettingsDTO config, String conversationId) {
try {
asyncTaskService.updateStatus(task.getTaskId(), "running", null, null, null);
MusicGenerationResult result = generateWithFallback(request, config);
if (!result.isSuccess()) {
asyncTaskService.updateStatus(task.getTaskId(), "failed", null, null,
result.getErrorMessage());
asyncTaskService.broadcastTaskEventWithData(task, "async_task_completed",
false, Map.of(), result.getErrorMessage());
return;
}
return response;
} catch (IOException e) {
log.error("[Music] Failed to save audio: {}", e.getMessage());
return Map.of("success", false, "error", "音频文件保存失败");
String audioUrl = persistAudio(conversationId, task.getTaskId(), result);
saveAssistantMessage(conversationId, audioUrl, result);
ObjectNode resultJson = objectMapper.createObjectNode();
resultJson.put("audioUrl", audioUrl);
resultJson.put("format", result.getFormat());
if (result.getLyrics() != null) {
resultJson.put("lyrics", result.getLyrics());
}
asyncTaskService.updateStatus(task.getTaskId(), "succeeded", 100,
resultJson.toString(), null);
Map<String, Object> extra = new LinkedHashMap<>();
extra.put("audioUrl", audioUrl);
extra.put("format", result.getFormat());
if (result.getLyrics() != null) {
extra.put("lyrics", result.getLyrics());
}
asyncTaskService.broadcastTaskEventWithData(task, "async_task_completed",
true, extra, null);
log.info("[Music] Task {} succeeded, audio at {}", task.getTaskId(), audioUrl);
} catch (Exception e) {
log.error("[Music] Task {} worker failed: {}", task.getTaskId(), e.getMessage(), e);
asyncTaskService.updateStatus(task.getTaskId(), "failed", null, null,
"音乐生成异常: " + e.getMessage());
asyncTaskService.broadcastTaskEventWithData(task, "async_task_completed",
false, Map.of(), "音乐生成异常: " + e.getMessage());
}
}
private MusicGenerationResult generateWithFallback(MusicGenerationRequest request, SystemSettingsDTO config) {
private String persistAudio(String conversationId, String taskId,
MusicGenerationResult result) throws IOException {
Path dir = UPLOAD_ROOT.resolve(conversationId);
Files.createDirectories(dir);
String fileName = "music_" + taskId + "." + result.getFormat();
Path filePath = dir.resolve(fileName);
Files.write(filePath, result.getAudioData());
return "/api/v1/chat/files/" + conversationId + "/" + fileName;
}
private void saveAssistantMessage(String conversationId, String audioUrl,
MusicGenerationResult result) {
MessageContentPart audioPart = MessageContentPart.audio(null,
audioUrl.substring(audioUrl.lastIndexOf('/') + 1));
audioPart.setFileUrl(audioUrl);
audioPart.setContentType(result.getContentType());
StringBuilder content = new StringBuilder("音乐生成完成");
if (result.getLyrics() != null && !result.getLyrics().isBlank()) {
content.append("\n\n歌词:\n").append(result.getLyrics());
}
conversationService.saveMessage(conversationId, "assistant",
content.toString(), List.of(audioPart), "completed");
}
private MusicGenerationResult generateWithFallback(MusicGenerationRequest request,
SystemSettingsDTO config) {
MusicGenerationProvider primary = providerRegistry.resolve(config);
if (primary == null) return MusicGenerationResult.failure("没有可用的音乐 Provider");
@ -74,6 +188,7 @@ public class MusicGenerationService {
if (Boolean.TRUE.equals(config.getMusicFallbackEnabled())) {
for (MusicGenerationProvider fb : providerRegistry.fallbackCandidates(config, primary.id())) {
log.info("[Music] Trying fallback provider: {}", fb.id());
result = fb.generate(request, config);
if (result.isSuccess()) return result;
errors.add(fb.id() + ": " + result.getErrorMessage());
@ -82,4 +197,9 @@ public class MusicGenerationService {
return MusicGenerationResult.failure("所有音乐 Provider 均失败\n" + String.join("\n", errors));
}
@PreDestroy
public void shutdown() {
musicWorker.shutdown();
}
}

View File

@ -7,19 +7,38 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.tool.music.MusicGenerationProvider;
import vip.mate.tool.music.MusicGenerationRequest;
import vip.mate.tool.music.MusicGenerationResult;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
/**
* MiniMax 音乐生成 Provider music-2.5+
* MiniMax 音乐生成 Provider music-2.5+.
* <p>
* 复用视频生成中的 MiniMax API Key
* Credential resolution order (first match wins):
* <ol>
* <li>{@code mate_model_provider} entry with id {@code minimax-cn} China region
* (api.minimaxi.com). The base URL declared for the LLM endpoint typically
* ends in {@code /anthropic}; we strip that and append the music path.</li>
* <li>{@code mate_model_provider} entry with id {@code minimax} international
* region (api.minimax.io).</li>
* <li>Legacy {@code mate_system_setting.minimaxApiKey} only kept for
* backwards compatibility with the pre-RFC-202605 single-key setup; defaults
* to the international endpoint.</li>
* </ol>
* The China and international MiniMax APIs use different keys and different hosts;
* sourcing credentials from the unified LLM provider table avoids the prior bug
* where users had to configure the same key in two places (and the music provider
* silently used the wrong region).
*/
@Slf4j
@Component
@ -27,9 +46,21 @@ import java.util.List;
public class MiniMaxMusicProvider implements MusicGenerationProvider {
private final ObjectMapper objectMapper;
private final ModelProviderService modelProviderService;
private static final String BASE_URL = "https://api.minimax.io";
private static final String DEFAULT_BASE_URL = "https://api.minimax.io";
private static final String DEFAULT_MODEL = "music-2.5+";
private static final String[] LLM_PROVIDER_IDS = {"minimax-cn", "minimax"};
/** Cache TTL credentials rarely change at runtime; 60s is generous and
* invalidated immediately on {@link ModelConfigChangedEvent}. Without this
* every music task hit {@code SELECT mate_model_provider WHERE provider_id=?}
* 4-6 times (registry probe + worker re-resolve + per-call resolve). */
private static final long CACHE_TTL_NANOS = 60L * 1_000_000_000L;
private final AtomicReference<CachedCredentials> cache = new AtomicReference<>();
private record CachedCredentials(Credentials creds, long expiresAtNanos) {}
@Override public String id() { return "minimax"; }
@Override public String label() { return "MiniMax Music"; }
@ -40,13 +71,78 @@ public class MiniMaxMusicProvider implements MusicGenerationProvider {
@Override
public boolean isAvailable(SystemSettingsDTO config) {
return StringUtils.hasText(config.getMinimaxApiKey());
return resolveCredentials(config) != null;
}
private record Credentials(String apiKey, String baseUrl) {}
private Credentials resolveCredentials(SystemSettingsDTO config) {
CachedCredentials hit = cache.get();
long now = System.nanoTime();
if (hit != null && now < hit.expiresAtNanos()) {
return hit.creds();
}
Credentials fresh = resolveCredentialsUncached(config);
cache.set(new CachedCredentials(fresh, now + CACHE_TTL_NANOS));
return fresh;
}
private Credentials resolveCredentialsUncached(SystemSettingsDTO config) {
for (String providerId : LLM_PROVIDER_IDS) {
try {
if (!modelProviderService.isProviderConfigured(providerId)) {
continue;
}
ModelProviderEntity p = modelProviderService.getProviderConfig(providerId);
if (StringUtils.hasText(p.getApiKey())) {
return new Credentials(p.getApiKey(), normalizeBaseUrl(p.getBaseUrl()));
}
} catch (Exception ignore) {
// try next id
}
}
String legacyKey = config.getMinimaxApiKey();
if (StringUtils.hasText(legacyKey)) {
return new Credentials(legacyKey, DEFAULT_BASE_URL);
}
return null;
}
/** Drop the credential cache when any provider config changes keeps the
* 60s TTL safe under the user-edits-key-mid-task scenario. */
@EventListener
public void onModelConfigChanged(ModelConfigChangedEvent event) {
cache.set(null);
}
/**
* Reduce any configured base URL to its scheme+host origin (e.g.
* {@code https://api.minimaxi.com/anthropic} {@code https://api.minimaxi.com}).
* The music API lives at {@code /v1/music_generation} regardless of which
* upstream path the LLM client uses.
*/
private static String normalizeBaseUrl(String baseUrl) {
if (!StringUtils.hasText(baseUrl)) return DEFAULT_BASE_URL;
try {
java.net.URI uri = java.net.URI.create(baseUrl.trim());
if (uri.getScheme() == null || uri.getAuthority() == null) {
return DEFAULT_BASE_URL;
}
return uri.getScheme() + "://" + uri.getAuthority();
} catch (IllegalArgumentException e) {
return DEFAULT_BASE_URL;
}
}
@Override
public MusicGenerationResult generate(MusicGenerationRequest request, SystemSettingsDTO config) {
try {
String apiKey = config.getMinimaxApiKey();
Credentials creds = resolveCredentials(config);
if (creds == null) {
return MusicGenerationResult.failure("MiniMax 凭据未配置(在「模型与凭据」中配置 minimax-cn 或 minimax");
}
String apiKey = creds.apiKey();
String baseUrl = creds.baseUrl();
String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL;
ObjectNode body = objectMapper.createObjectNode();
@ -75,11 +171,11 @@ public class MiniMaxMusicProvider implements MusicGenerationProvider {
audioSetting.put("bitrate", 256000);
audioSetting.put("format", "mp3");
HttpResponse response = HttpRequest.post(BASE_URL + "/v1/music_generation")
HttpResponse response = HttpRequest.post(baseUrl + "/v1/music_generation")
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(body.toString())
.timeout(120_000)
.timeout(300_000)
.execute();
JsonNode result = objectMapper.readTree(response.body());
@ -91,27 +187,42 @@ public class MiniMaxMusicProvider implements MusicGenerationProvider {
return MusicGenerationResult.failure(errMsg);
}
// 提取音频 URL 或内联数据
String audioUrl = result.has("audio_url") ? result.get("audio_url").asText(null)
: result.path("data").path("audio_url").asText(null);
String lyrics = result.has("lyrics") ? result.get("lyrics").asText(null)
: result.path("data").path("lyrics").asText(null);
// Response shape varies between regions:
// - Some responses put the CDN URL in {audio_url} or {data.audio_url}.
// - Others put it in {audio} or {data.audio} (the field nominally for
// inline hex/base64 binary). Earlier versions of this code treated
// {data.audio} as inline data unconditionally and base64-decoded a
// URL, throwing "Illegal base64 character 3a" on the ':' in https://.
// Fix: resolve a single audioCandidate, then route to URL download or
// binary decode based on whether it parses as http(s) URL.
String audioCandidate = firstNonBlank(
nullableText(result, "audio"),
nullableText(result.path("data"), "audio"));
String audioUrl = firstNonBlank(
nullableText(result, "audio_url"),
nullableText(result.path("data"), "audio_url"));
if (audioUrl == null && isLikelyRemoteUrl(audioCandidate)) {
audioUrl = audioCandidate;
}
String inlineAudio = isLikelyRemoteUrl(audioCandidate) ? null : audioCandidate;
String lyrics = firstNonBlank(
nullableText(result, "lyrics"),
nullableText(result.path("data"), "lyrics"));
if (StringUtils.hasText(audioUrl)) {
// 下载音频
if (audioUrl != null) {
byte[] audioData = HttpRequest.get(audioUrl).timeout(30_000).execute().bodyBytes();
log.info("[MiniMax Music] Generated {} bytes audio (model={})", audioData.length, model);
log.info("[MiniMax Music] Generated {} bytes audio via URL (model={})", audioData.length, model);
return MusicGenerationResult.successWithLyrics(audioData, "audio/mpeg", "mp3", lyrics);
}
// 尝试 inline audio
String inlineAudio = result.has("audio") ? result.get("audio").asText(null)
: result.path("data").path("audio").asText(null);
if (StringUtils.hasText(inlineAudio)) {
byte[] audioData = decodeAudio(inlineAudio);
log.info("[MiniMax Music] Generated {} bytes audio inline (model={})", audioData.length, model);
return MusicGenerationResult.successWithLyrics(audioData, "audio/mpeg", "mp3", lyrics);
}
log.warn("[MiniMax Music] Response missing audio output. Body keys: {}",
iteratorToString(result.fieldNames()));
return MusicGenerationResult.failure("MiniMax 未返回音频数据");
} catch (Exception e) {
@ -120,12 +231,43 @@ public class MiniMaxMusicProvider implements MusicGenerationProvider {
}
}
private byte[] decodeAudio(String data) {
// MiniMax 可能返回 hex base64
if (data.matches("^[0-9a-fA-F]+$") && data.length() % 2 == 0) {
return hexToBytes(data);
private static String nullableText(JsonNode node, String field) {
if (node == null || !node.has(field)) return null;
JsonNode v = node.get(field);
if (v == null || v.isNull()) return null;
String s = v.asText(null);
return (s == null || s.isBlank()) ? null : s.trim();
}
private static String firstNonBlank(String... values) {
for (String v : values) {
if (v != null && !v.isBlank()) return v;
}
return java.util.Base64.getDecoder().decode(data);
return null;
}
private static boolean isLikelyRemoteUrl(String value) {
if (value == null || value.isBlank()) return false;
String trimmed = value.trim();
return trimmed.regionMatches(true, 0, "http://", 0, 7)
|| trimmed.regionMatches(true, 0, "https://", 0, 8);
}
private static String iteratorToString(java.util.Iterator<String> it) {
StringBuilder sb = new StringBuilder("[");
while (it.hasNext()) {
sb.append(it.next());
if (it.hasNext()) sb.append(',');
}
return sb.append(']').toString();
}
private byte[] decodeAudio(String data) {
String trimmed = data.trim();
if (trimmed.matches("^[0-9a-fA-F]+$") && trimmed.length() % 2 == 0) {
return hexToBytes(trimmed);
}
return java.util.Base64.getDecoder().decode(trimmed);
}
private byte[] hexToBytes(String hex) {

View File

@ -33,7 +33,9 @@ public class VideoGenerationResult {
.providerName(providerName)
.status("submitted")
.submitted(true)
.message("视频生成任务已提交(任务 ID: " + taskId + ")。预计 1-5 分钟完成,完成后会自动显示在对话中。")
// Format MUST keep `taskId=...` so the frontend reconnect detector
// (useChat.ts TASK_ID_PATTERN) can extract it from the tool result.
.message("视频生成任务已提交taskId=" + taskId + ", provider=" + providerName + ")。预计 1-5 分钟完成,完成后会自动显示在对话中。")
.build();
}

View File

@ -73,6 +73,11 @@ tool.edit_file.error.edit_exception=\u7f16\u8f91\u6587\u4ef6\u5f02\u5e38: {0}
tool.shell.error.timeout=\u547d\u4ee4\u6267\u884c\u8d85\u65f6\uff08{0}\u79d2\uff09\uff0c\u5df2\u5f3a\u5236\u7ec8\u6b62
tool.shell.error.exception=\u6267\u884c\u5f02\u5e38: {0}
# --- Generative tools ---
tool.image_generate.desc=\u751f\u6210\u56fe\u7247\u3002\u6839\u636e\u6587\u5b57\u63cf\u8ff0\u521b\u4f5c\u56fe\u50cf\uff0c\u652f\u6301 DashScope\u3001OpenAI\u3001fal.ai\u3001\u667a\u8c31 CogView \u7b49 Provider\u3002\u4efb\u52a1\u5f02\u6b65\u6267\u884c\uff0c\u5b8c\u6210\u540e\u524d\u7aef\u4f1a\u81ea\u52a8\u63a5\u6536 SSE \u4e8b\u4ef6 async_task_completed \u5e76\u628a\u56fe\u7247\u63a8\u5230\u5bf9\u8bdd\u4e2d\uff0c\u65e0\u9700\u624b\u52a8\u5237\u65b0\u3002
tool.video_generate.desc=\u751f\u6210\u89c6\u9891\u3002\u652f\u6301\u6587\u751f\u89c6\u9891\u3001\u56fe\u751f\u89c6\u9891\u7b49\u6a21\u5f0f\uff0c\u652f\u6301 DashScope\u3001\u667a\u8c31 CogVideo\u3001Kling\u3001Runway\u3001MiniMax \u3001fal.ai \u7b49 Provider\u3002\u4efb\u52a1\u5f02\u6b65\u6267\u884c\uff08\u7ea6 1-5 \u5206\u949f\uff09\uff0c\u5b8c\u6210\u540e\u524d\u7aef\u4f1a\u81ea\u52a8\u63a5\u6536 SSE \u4e8b\u4ef6 async_task_completed \u5e76\u628a\u89c6\u9891\u63a8\u5230\u5bf9\u8bdd\u4e2d\uff0c\u65e0\u9700\u624b\u52a8\u5237\u65b0\u3002
tool.music_generate.desc=\u751f\u6210\u97f3\u4e50\u6216\u6b4c\u66f2\u3002\u652f\u6301\u6587\u5b57\u63cf\u8ff0\u751f\u6210\u97f3\u4e50\u3001\u6b4c\u8bcd\u8c31\u66f2\u3001\u7eaf\u97f3\u4e50\u7b49\u6a21\u5f0f\uff0c\u652f\u6301 Google Lyria \u548c MiniMax Music \u7b49 Provider\u3002\u4efb\u52a1\u5f02\u6b65\u6267\u884c\uff08\u7ea6 1-3 \u5206\u949f\uff09\uff0c\u5b8c\u6210\u540e\u524d\u7aef\u4f1a\u81ea\u52a8\u63a5\u6536 SSE \u4e8b\u4ef6 async_task_completed \u5e76\u628a\u97f3\u9891\u63a8\u5230\u5bf9\u8bdd\u4e2d\uff0c\u65e0\u9700\u624b\u52a8\u5237\u65b0\u3002
# --- Guard Rules ---
guard.SHELL_RM_RF_ROOT.name=\u9012\u5f52\u5f3a\u5236\u5220\u9664\u6839\u76ee\u5f55
guard.SHELL_RM_RF_ROOT.fix=\u8bf7\u6307\u5b9a\u5177\u4f53\u76ee\u5f55\u8def\u5f84\u800c\u975e\u6839\u76ee\u5f55

View File

@ -73,6 +73,11 @@ tool.edit_file.error.edit_exception=Edit file exception: {0}
tool.shell.error.timeout=Command timed out ({0} seconds), forcefully terminated
tool.shell.error.exception=Execution exception: {0}
# --- Generative tools ---
tool.image_generate.desc=Generate an image from a text description. Supports DashScope, OpenAI, fal.ai, Zhipu CogView, etc. Runs asynchronously; the client receives the image automatically via the SSE async_task_completed event without a manual refresh.
tool.video_generate.desc=Generate a video. Supports text-to-video and image-to-video modes via DashScope, Zhipu CogVideo, Kling, Runway, MiniMax, fal.ai, etc. Runs asynchronously (1-5 minutes); the client receives the video automatically via the SSE async_task_completed event without a manual refresh.
tool.music_generate.desc=Generate music or a song. Supports text-to-music, lyrics composition, and instrumental modes via Google Lyria and MiniMax Music. Runs asynchronously (1-3 minutes); the client receives the audio automatically via the SSE async_task_completed event without a manual refresh.
# --- Guard Rules ---
guard.SHELL_RM_RF_ROOT.name=Recursive force delete root directory
guard.SHELL_RM_RF_ROOT.fix=Specify a concrete directory path instead of root

View File

@ -196,6 +196,18 @@
/>
<span class="message-attachment-video__name">{{ attachment.name }}</span>
</div>
<div
v-for="attachment in audioAttachments"
:key="'audio-' + attachment.storedName"
class="message-attachment-audio"
>
<audio
:src="getDisplayUrl(attachment)"
controls
preload="metadata"
/>
<span class="message-attachment-audio__name">{{ attachment.name }}</span>
</div>
<button
v-for="attachment in fileAttachments"
:key="attachment.storedName"
@ -295,7 +307,7 @@ import type { ChatErrorInfo } from '@/types/chatError'
const { renderMarkdown } = useMarkdownRenderer()
const { t } = useI18n()
const { getToolLabel } = useToolLabel()
const { blobUrls, loadAllImages, loadAllVideos, downloadFile, openImage, getDisplayUrl, revokeAll } = useAuthenticatedAttachment()
const { blobUrls, loadAllImages, loadAllVideos, loadAllAudios, downloadFile, openImage, getDisplayUrl, revokeAll } = useAuthenticatedAttachment()
interface Props {
message: Message
@ -545,20 +557,65 @@ onBeforeUnmount(() => {
})
// --- ---
const attachments = computed(() => props.message.attachments || [])
// MessageContentPart media (image/audio/video produced by generation tools) live
// in `contentParts` rather than `attachments`. Synthesize virtual attachment
// entries so the existing render + auth-blob loader works for them too.
//
// Dedup against `props.message.attachments` by URL user-uploaded images often
// land in BOTH lists (the upload endpoint registers them as ChatAttachment AND
// the message persistence echoes them back as a `type: 'image'` MessageContentPart).
// Without this guard each user image shows twice in the bubble.
const mediaPartAttachments = computed<ChatAttachment[]>(() => {
const parts = (props.message as any).contentParts as Array<any> | undefined
if (!parts || !parts.length) return []
const existingUrls = new Set(
(props.message.attachments || []).map(a => a.url).filter(Boolean)
)
const out: ChatAttachment[] = []
const seen = new Set<string>()
for (const p of parts) {
if (!p || !p.fileUrl) continue
if (p.type !== 'image' && p.type !== 'audio' && p.type !== 'video') continue
if (existingUrls.has(p.fileUrl) || seen.has(p.fileUrl)) continue
seen.add(p.fileUrl)
const fileName = p.fileName || p.fileUrl.split('/').pop() || `${p.type}-${out.length}`
const ct = p.contentType
|| (p.type === 'image' ? 'image/png' : p.type === 'audio' ? 'audio/mpeg' : 'video/mp4')
out.push({
name: fileName,
size: 0,
url: p.fileUrl,
storedName: fileName,
path: p.fileUrl,
contentType: ct,
})
}
return out
})
const attachments = computed(() => [
...(props.message.attachments || []),
...mediaPartAttachments.value,
])
const imageAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('image/')))
const videoAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('video/')))
const audioAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('audio/')))
const fileAttachments = computed(() => attachments.value.filter(a =>
!a.contentType?.startsWith('image/') && !a.contentType?.startsWith('video/')
!a.contentType?.startsWith('image/')
&& !a.contentType?.startsWith('video/')
&& !a.contentType?.startsWith('audio/')
))
// / blob URLwatch +
// // blob URLwatch +
watch(imageAttachments, (atts) => {
if (atts.length > 0) loadAllImages(atts)
}, { immediate: true })
watch(videoAttachments, (atts) => {
if (atts.length > 0) loadAllVideos(atts)
}, { immediate: true })
watch(audioAttachments, (atts) => {
if (atts.length > 0) loadAllAudios(atts)
}, { immediate: true })
// --- ---
const formattedTime = computed(() => {
@ -1374,6 +1431,27 @@ watch(isGenerating, (generating) => {
white-space: nowrap;
}
.message-attachment-audio {
border-radius: 12px;
overflow: hidden;
}
.message-attachment-audio audio {
width: 100%;
max-width: 400px;
display: block;
}
.message-attachment-audio__name {
display: block;
margin-top: 4px;
font-size: 12px;
opacity: 0.76;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-attachment {
display: flex;
align-items: center;

View File

@ -255,6 +255,38 @@ export function useChat(options: UseChatOptions): UseChatReturn {
headers: streamHeaders,
})
// ===== Async-task lifecycle bridge =====
// Generative tools (music / video / image) return a taskId synchronously and
// finish asynchronously via `async_task_completed`. If the upstream provider
// is slow (MiniMax music ~2-3 min) the agent's reasoning turn finishes long
// before the audio is ready, the SSE stream emits `done`, and any later
// `async_task_completed` event lands on a closed emitter. We track which
// taskIds are still pending here, and when `done` fires with non-empty set
// we re-attach to the same conversation's stream so buffered + future async
// events can flow through. ChatStreamTracker.attach was extended (RFC P0)
// to keep the new emitter subscribed even when state.done=true.
const pendingAsyncTaskIds = new Set<string>()
// 16-hex taskId emitted by AsyncTaskService.createTask. Tool result text
// varies — `taskId=xxx` is the canonical form (music/video/image), but
// earlier video/image versions used 中文「任务 ID: xxx」 and old strings may
// still flow through if the LLM cached them. Match both defensively.
const TASK_ID_PATTERNS: RegExp[] = [
/taskId[=:"\s]+([a-f0-9]{16})/i,
/任务\s*ID[=:"\s]+([a-f0-9]{16})/i,
/task[_\s]*id[=:"\s]+([a-f0-9]{16})/i,
]
const ASYNC_TOOL_NAMES = new Set(['music_generate', 'video_generate', 'image_generate'])
let reconnectingForAsyncTasks = false
function extractTaskId(result: unknown): string | null {
if (typeof result !== 'string') return null
for (const re of TASK_ID_PATTERNS) {
const m = result.match(re)
if (m) return m[1]
}
return null
}
// ===== SSE event handlers =====
stream.on('content_delta', (data) => {
@ -449,6 +481,28 @@ export function useChat(options: UseChatOptions): UseChatReturn {
persisted: data.persisted,
messageCount: data.messageCount,
})
// Re-attach SSE if any generative task is still in flight, so the eventual
// async_task_completed event reaches us live (otherwise the user has to
// refresh). Skip if we're already in a reconnect cycle, or for non-completed
// terminal statuses where reconnect is misleading.
const reconnectableStatus = !data.status
|| data.status === 'completed'
|| data.status === 'idle'
if (reconnectableStatus
&& !reconnectingForAsyncTasks
&& pendingAsyncTaskIds.size > 0
&& streamConversationId) {
const targetConv = streamConversationId
reconnectingForAsyncTasks = true
// Defer one tick so the current 'done' handler chain finishes before
// disconnect() fires inside connect().
setTimeout(() => {
stream.connect({ conversationId: targetConv, reconnect: true })
.catch(() => { /* swallow — handled by stream.error event */ })
.finally(() => { reconnectingForAsyncTasks = false })
}, 50)
}
})
let errorFired = false
@ -579,6 +633,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
flushSegmentsToMessage()
}
// Track async generative tools whose taskId is in the result string.
if (data.success !== false && ASYNC_TOOL_NAMES.has(data.toolName)) {
const taskId = extractTaskId(data.result)
if (taskId) {
pendingAsyncTaskIds.add(taskId)
}
}
})
// ===== Browser action events =====
@ -1080,50 +1142,78 @@ export function useChat(options: UseChatOptions): UseChatReturn {
phaseInfo.value = null
})
// ===== Async task completion events (video generation, image generation, etc.) =====
// ===== Async task completion events (video / image / music generation) =====
stream.on('async_task_completed', (data) => {
if (isStaleEvent(data)) return
if (data.success && streamConversationId) {
let mediaPart: MessageContentPart | null = null
if (data.videoUrl) {
mediaPart = {
type: 'video',
fileUrl: data.videoUrl,
fileName: `video_${data.taskId}.mp4`,
contentType: 'video/mp4',
} as MessageContentPart
} else if (data.imageUrl) {
mediaPart = {
type: 'image',
fileUrl: data.imageUrl,
fileName: `image_${data.taskId}.png`,
contentType: 'image/png',
} as MessageContentPart
}
if (!streamConversationId) return
if (!mediaPart) return
// Mark this taskId resolved so done-after-pending reconnect logic
// doesn't keep the SSE alive longer than needed.
if (data.taskId) pendingAsyncTaskIds.delete(data.taskId)
// Prefer appending to the current assistant message (avoids image appearing above the text reply)
if (currentAssistantId.value) {
const msg = getMessage(currentAssistantId.value)
if (msg) {
const existingParts = (msg as any).contentParts || []
updateMessage(currentAssistantId.value, {
contentParts: [...existingParts, mediaPart],
} as any)
return
}
}
// Fallback: agent already finished — create a standalone message
// Failure path — surface error so user knows the task is over.
if (!data.success) {
const taskLabel = data.taskType === 'music_generation' ? '音乐'
: data.taskType === 'video_generation' ? '视频'
: data.taskType === 'image_generation' ? '图片'
: '任务'
addMessage({
role: 'assistant',
content: '',
contentParts: [mediaPart],
content: `${taskLabel}生成失败: ${data.errorMessage || '未知错误'}`,
contentParts: [],
status: 'completed',
conversationId: streamConversationId,
})
return
}
let mediaPart: MessageContentPart | null = null
if (data.videoUrl) {
mediaPart = {
type: 'video',
fileUrl: data.videoUrl,
fileName: `video_${data.taskId}.mp4`,
contentType: 'video/mp4',
} as MessageContentPart
} else if (data.imageUrl) {
mediaPart = {
type: 'image',
fileUrl: data.imageUrl,
fileName: `image_${data.taskId}.png`,
contentType: 'image/png',
} as MessageContentPart
} else if (data.audioUrl) {
const fmt = (data.format || 'mp3') as string
mediaPart = {
type: 'audio',
fileUrl: data.audioUrl,
fileName: `music_${data.taskId}.${fmt}`,
contentType: fmt === 'wav' ? 'audio/wav' : 'audio/mpeg',
} as MessageContentPart
}
if (!mediaPart) return
// Prefer appending to the current assistant message (avoids media appearing above the text reply)
if (currentAssistantId.value) {
const msg = getMessage(currentAssistantId.value)
if (msg) {
const existingParts = (msg as any).contentParts || []
updateMessage(currentAssistantId.value, {
contentParts: [...existingParts, mediaPart],
} as any)
return
}
}
// Fallback: agent already finished — create a standalone message
addMessage({
role: 'assistant',
content: '',
contentParts: [mediaPart],
status: 'completed',
conversationId: streamConversationId,
})
})
// ===== Auto TTS =====

View File

@ -71,6 +71,19 @@ export function useAuthenticatedAttachment() {
}
}
/**
* blob URL<audio :src> Authorization
*/
async function loadAllAudios(attachments: ChatAttachment[]) {
const audioAtts = attachments.filter(a => a.contentType?.startsWith('audio/'))
for (const att of audioAtts) {
const key = att.storedName || att.url
if (!att.previewUrl && att.url && !blobUrls.value[key]) {
await loadBlobUrl(att.url, key)
}
}
}
/**
* fetch blob <a download>
*/
@ -140,6 +153,7 @@ export function useAuthenticatedAttachment() {
loadBlobUrl,
loadAllImages,
loadAllVideos,
loadAllAudios,
downloadFile,
openImage,
getDisplayUrl,