feat(image,tts): add image generation with 4 providers and TTS with 3 providers

This commit is contained in:
matevip 2026-04-07 22:14:31 +08:00
parent fe3a7f7b01
commit 43acd2bf94
38 changed files with 3525 additions and 19 deletions

1
mateclaw-server/nul Normal file
View File

@ -0,0 +1 @@
/bin/sh: wmic: command not found

View File

@ -60,4 +60,26 @@ public class SystemSettingsDTO {
private String klingSecretKey;
private String klingAccessKeyMasked;
private String klingSecretKeyMasked;
// ===== 图片生成配置 =====
/** 是否启用图片生成能力 */
private Boolean imageEnabled;
/** 首选图片 provider: auto / dashscope / openai / fal / zhipu-cogview */
private String imageProvider;
/** 是否启用 provider 级 fallback */
private Boolean imageFallbackEnabled;
// ===== TTS 语音合成配置 =====
/** 是否启用 TTS */
private Boolean ttsEnabled;
/** 首选 TTS provider: auto / edge-tts / openai / dashscope */
private String ttsProvider;
/** 是否启用 provider 级 fallback */
private Boolean ttsFallbackEnabled;
/** 自动 TTS 模式: off / always */
private String ttsAutoMode;
/** 默认语音 */
private String ttsDefaultVoice;
/** 默认语速 0.5-2.0 */
private Double ttsSpeed;
}

View File

@ -31,6 +31,19 @@ public class SystemSettingService {
private static final String VIDEO_ENABLED_KEY = "videoEnabled";
private static final String VIDEO_PROVIDER_KEY = "videoProvider";
private static final String VIDEO_FALLBACK_ENABLED_KEY = "videoFallbackEnabled";
// 图片生成配置 keys
private static final String IMAGE_ENABLED_KEY = "imageEnabled";
private static final String IMAGE_PROVIDER_KEY = "imageProvider";
private static final String IMAGE_FALLBACK_ENABLED_KEY = "imageFallbackEnabled";
// TTS 配置 keys
private static final String TTS_ENABLED_KEY = "ttsEnabled";
private static final String TTS_PROVIDER_KEY = "ttsProvider";
private static final String TTS_FALLBACK_ENABLED_KEY = "ttsFallbackEnabled";
private static final String TTS_AUTO_MODE_KEY = "ttsAutoMode";
private static final String TTS_DEFAULT_VOICE_KEY = "ttsDefaultVoice";
private static final String TTS_SPEED_KEY = "ttsSpeed";
private static final String ZHIPU_API_KEY_KEY = "zhipuApiKey";
private static final String ZHIPU_BASE_URL_KEY = "zhipuBaseUrl";
private static final String FAL_API_KEY_KEY = "falApiKey";
@ -68,6 +81,20 @@ public class SystemSettingService {
dto.setFalApiKeyMasked(maskApiKey(getValue(FAL_API_KEY_KEY, "")));
dto.setKlingAccessKeyMasked(maskApiKey(getValue(KLING_ACCESS_KEY_KEY, "")));
dto.setKlingSecretKeyMasked(maskApiKey(getValue(KLING_SECRET_KEY_KEY, "")));
// 图片生成配置
dto.setImageEnabled(Boolean.parseBoolean(getValue(IMAGE_ENABLED_KEY, "false")));
dto.setImageProvider(getValue(IMAGE_PROVIDER_KEY, "auto"));
dto.setImageFallbackEnabled(Boolean.parseBoolean(getValue(IMAGE_FALLBACK_ENABLED_KEY, "true")));
// TTS 配置
dto.setTtsEnabled(Boolean.parseBoolean(getValue(TTS_ENABLED_KEY, "false")));
dto.setTtsProvider(getValue(TTS_PROVIDER_KEY, "auto"));
dto.setTtsFallbackEnabled(Boolean.parseBoolean(getValue(TTS_FALLBACK_ENABLED_KEY, "true")));
dto.setTtsAutoMode(getValue(TTS_AUTO_MODE_KEY, "off"));
dto.setTtsDefaultVoice(getValue(TTS_DEFAULT_VOICE_KEY, ""));
String speedStr = getValue(TTS_SPEED_KEY, "1.0");
try { dto.setTtsSpeed(Double.parseDouble(speedStr)); } catch (NumberFormatException e) { dto.setTtsSpeed(1.0); }
return dto;
}
@ -166,6 +193,37 @@ public class SystemSettingService {
if (dto.getKlingSecretKey() != null && !dto.getKlingSecretKey().isBlank()) {
saveValue(KLING_SECRET_KEY_KEY, dto.getKlingSecretKey(), "快手可灵 Secret Key");
}
// 图片生成配置
if (dto.getImageEnabled() != null) {
saveValue(IMAGE_ENABLED_KEY, String.valueOf(dto.getImageEnabled()), "是否启用图片生成");
}
if (dto.getImageProvider() != null) {
saveValue(IMAGE_PROVIDER_KEY, dto.getImageProvider(), "图片生成首选 Provider");
}
if (dto.getImageFallbackEnabled() != null) {
saveValue(IMAGE_FALLBACK_ENABLED_KEY, String.valueOf(dto.getImageFallbackEnabled()), "图片 Provider 级 Fallback");
}
// TTS 配置
if (dto.getTtsEnabled() != null) {
saveValue(TTS_ENABLED_KEY, String.valueOf(dto.getTtsEnabled()), "是否启用 TTS 语音合成");
}
if (dto.getTtsProvider() != null) {
saveValue(TTS_PROVIDER_KEY, dto.getTtsProvider(), "TTS 首选 Provider");
}
if (dto.getTtsFallbackEnabled() != null) {
saveValue(TTS_FALLBACK_ENABLED_KEY, String.valueOf(dto.getTtsFallbackEnabled()), "TTS Provider 级 Fallback");
}
if (dto.getTtsAutoMode() != null) {
saveValue(TTS_AUTO_MODE_KEY, dto.getTtsAutoMode(), "TTS 自动模式off/always");
}
if (dto.getTtsDefaultVoice() != null) {
saveValue(TTS_DEFAULT_VOICE_KEY, dto.getTtsDefaultVoice(), "TTS 默认语音");
}
if (dto.getTtsSpeed() != null) {
saveValue(TTS_SPEED_KEY, String.valueOf(dto.getTtsSpeed()), "TTS 默认语速");
}
return getSettings();
}

View File

@ -262,11 +262,17 @@ public class AsyncTaskService implements ApplicationRunner {
public void broadcastTaskEvent(AsyncTaskEntity task, String eventName,
boolean success, String videoUrl, String errorMessage) {
broadcastTaskEvent(task, eventName, success, videoUrl, null, errorMessage);
}
public void broadcastTaskEvent(AsyncTaskEntity task, String eventName,
boolean success, String videoUrl, String imageUrl, 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 (errorMessage != null) data.put("errorMessage", errorMessage);
streamTracker.broadcastObject(task.getConversationId(), eventName, data);
}
@ -310,6 +316,7 @@ public class AsyncTaskService implements ApplicationRunner {
Integer progress, // 0-100, nullable
String videoUrl, // 成功时的视频 URL
String coverImageUrl,// 可选封面图
String imageUrl, // 成功时的图片 URL图片生成场景
String resultJson, // 完成时的完整结果 JSON
String errorMessage // 失败时的错误信息
) {
@ -322,19 +329,23 @@ public class AsyncTaskService implements ApplicationRunner {
}
public static TaskPollResult pending(Integer progress) {
return new TaskPollResult("pending", progress, null, null, null, null);
return new TaskPollResult("pending", progress, null, null, null, null, null);
}
public static TaskPollResult running(Integer progress) {
return new TaskPollResult("running", progress, null, null, null, null);
return new TaskPollResult("running", progress, null, null, null, null, null);
}
public static TaskPollResult succeeded(String videoUrl, String coverImageUrl, String resultJson) {
return new TaskPollResult("succeeded", 100, videoUrl, coverImageUrl, resultJson, null);
return new TaskPollResult("succeeded", 100, videoUrl, coverImageUrl, null, resultJson, null);
}
public static TaskPollResult imageSucceeded(String imageUrl, String resultJson) {
return new TaskPollResult("succeeded", 100, null, null, imageUrl, resultJson, null);
}
public static TaskPollResult failed(String errorMessage) {
return new TaskPollResult("failed", null, null, null, null, errorMessage);
return new TaskPollResult("failed", null, null, null, null, null, errorMessage);
}
}
}

View File

@ -0,0 +1,171 @@
package vip.mate.tool.builtin;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.system.service.SystemSettingService;
import vip.mate.task.AsyncTaskService;
import vip.mate.task.model.AsyncTaskInfo;
import vip.mate.tool.image.*;
import java.util.List;
import java.util.StringJoiner;
/**
* 图片生成工具 Agent 可调用的 @Tool提交图片生成任务
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ImageGenerateTool {
private final ImageGenerationService imageGenerationService;
private final ImageProviderRegistry providerRegistry;
private final SystemSettingService systemSettingService;
private final AsyncTaskService asyncTaskService;
@Tool(description = "图片生成工具,支持以下 action\n"
+ "- generate默认生成图片。提供 prompt 描述图片内容,可选 size/aspectRatio/model/count\n"
+ "- list列出所有可用的图片 Provider 及其支持的模型和能力\n"
+ "- status查看当前会话中正在进行的图片生成任务状态\n"
+ "部分 Provider 是异步生成30秒-2分钟完成后自动显示在对话中。")
public String image_generate(
@ToolParam(description = "操作类型: generate生成图片、list列出可用 Provider、status查看任务状态默认 generate", required = false) String action,
@ToolParam(description = "图片内容描述尽量详细generate 时必填)", required = false) String prompt,
@ToolParam(description = "图片尺寸: 1024x1024 / 1024x1792 / 1792x1024", required = false) String size,
@ToolParam(description = "画面比例: 1:1 / 16:9 / 9:16默认 1:1", required = false) String aspectRatio,
@ToolParam(description = "生成数量1-4默认 1", required = false) Integer count,
@ToolParam(description = "指定模型名称(可选)", required = false) String model,
@ToolParam(description = "查询指定任务 ID 的状态status 模式时使用)", required = false) String taskId
) {
String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase();
return switch (normalizedAction) {
case "list" -> handleListAction();
case "status" -> handleStatusAction(taskId);
default -> handleGenerateAction(prompt, size, aspectRatio, count, model);
};
}
// ==================== action=list ====================
private String handleListAction() {
SystemSettingsDTO config = systemSettingService.getAllSettings();
List<ImageGenerationProvider> providers = providerRegistry.allSorted();
if (providers.isEmpty()) {
return "当前没有注册的图片生成 Provider。";
}
StringJoiner sb = new StringJoiner("\n\n");
sb.add("## 可用的图片生成 Provider\n");
for (ImageGenerationProvider p : providers) {
boolean available = p.isAvailable(config);
ImageProviderCapabilities caps = p.detailedCapabilities();
StringJoiner entry = new StringJoiner("\n");
entry.add("### " + p.label() + " (" + p.id() + ") " + (available ? "[已配置]" : "[未配置]"));
if (caps != null) {
if (caps.getModels() != null && !caps.getModels().isEmpty()) {
entry.add("- 模型: " + String.join(", ", caps.getModels()));
}
entry.add("- 支持尺寸: " + String.join(", ", caps.getSupportedSizes()));
entry.add("- 最大数量: " + caps.getMaxCount());
}
sb.add(entry.toString());
}
return sb.toString();
}
// ==================== action=status ====================
private String handleStatusAction(String taskId) {
String conversationId = ToolExecutionContext.conversationId();
if (taskId != null && !taskId.isBlank()) {
AsyncTaskInfo info = imageGenerationService.checkTaskStatus(taskId);
if (info == null) {
return "未找到任务 ID: " + taskId;
}
return formatTaskStatus(info);
}
if (conversationId == null) {
return "无法获取当前会话信息";
}
List<AsyncTaskInfo> activeTasks = asyncTaskService.listActiveTasks(conversationId);
List<AsyncTaskInfo> imageTasks = activeTasks.stream()
.filter(t -> "image_generation".equals(t.getTaskType()))
.toList();
if (imageTasks.isEmpty()) {
return "当前会话没有进行中的图片生成任务。";
}
StringJoiner sb = new StringJoiner("\n");
sb.add("当前会话有 " + imageTasks.size() + " 个进行中的任务:");
for (AsyncTaskInfo task : imageTasks) {
sb.add("- 任务 " + task.getTaskId() + ": " + formatTaskStatus(task));
}
return sb.toString();
}
// ==================== action=generate ====================
private String handleGenerateAction(String prompt, String size, String aspectRatio,
Integer count, String model) {
String conversationId = ToolExecutionContext.conversationId();
String username = ToolExecutionContext.username();
if (conversationId == null || conversationId.isBlank()) {
return "错误:无法获取当前会话信息,请重试";
}
if (prompt == null || prompt.isBlank()) {
return "错误prompt 为必填参数,请描述你想要生成的图片内容";
}
ImageGenerationRequest request = ImageGenerationRequest.builder()
.prompt(prompt)
.size(size)
.aspectRatio(aspectRatio != null ? aspectRatio : "1:1")
.count(count != null ? count : 1)
.model(model)
.build();
ImageGenerationResult result = imageGenerationService.submitGeneration(
request, conversationId, username != null ? username : "system");
if (result.isCompleted()) {
// 同步模式图片已生成
return result.getMessage();
} else if (result.isSubmitted()) {
// 异步模式已提交
return result.getMessage();
} else {
return "图片生成失败:" + result.getMessage();
}
}
// ==================== 辅助方法 ====================
private String formatTaskStatus(AsyncTaskInfo info) {
return switch (info.getStatus()) {
case "pending" -> "排队中,请稍候...";
case "running" -> {
String progressStr = info.getProgress() != null && info.getProgress() > 0
? "(进度: " + info.getProgress() + "%" : "";
yield "生成中" + progressStr + "" + info.getProviderName() + "";
}
case "succeeded" -> "已完成,图片已显示在对话中";
case "failed" -> "失败:" + (info.getErrorMessage() != null ? info.getErrorMessage() : "未知错误");
default -> "状态: " + info.getStatus();
};
}
}

View File

@ -0,0 +1,15 @@
package vip.mate.tool.image;
/**
* 图片生成能力枚举
*
* @author MateClaw Team
*/
public enum ImageCapability {
/** 文字生成图片 */
TEXT_TO_IMAGE,
/** 图片编辑 / 风格转换 */
IMAGE_EDIT
}

View File

@ -0,0 +1,74 @@
package vip.mate.tool.image;
import cn.hutool.http.HttpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Base64;
/**
* 图片文件下载器 provider CDN 下载图片到本地存储或解码 Base64 图片
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class ImageFileDownloader {
private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads");
/**
* URL 下载图片到本地
*/
public Path download(String imageUrl, String conversationId, String taskId, int index) throws IOException {
Path dir = UPLOAD_ROOT.resolve(conversationId);
Files.createDirectories(dir);
String extension = guessExtension(imageUrl);
String fileName = "image_" + taskId + "_" + index + extension;
Path targetFile = dir.resolve(fileName);
log.info("[ImageDownloader] Downloading image from {} to {}", imageUrl, targetFile);
long size = HttpUtil.downloadFile(imageUrl, targetFile.toFile());
log.info("[ImageDownloader] Downloaded {} bytes to {}", size, targetFile);
return targetFile;
}
/**
* Base64 编码的图片保存到本地
*/
public Path saveBase64(String base64Data, String conversationId, String taskId, int index) throws IOException {
Path dir = UPLOAD_ROOT.resolve(conversationId);
Files.createDirectories(dir);
String fileName = "image_" + taskId + "_" + index + ".png";
Path targetFile = dir.resolve(fileName);
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
Files.write(targetFile, imageBytes);
log.info("[ImageDownloader] Saved base64 image ({} bytes) to {}", imageBytes.length, targetFile);
return targetFile;
}
/**
* 构造文件的 API 访问 URL
*/
public String toServingUrl(String conversationId, Path localPath) {
return "/api/v1/chat/files/" + conversationId + "/" + localPath.getFileName().toString();
}
private String guessExtension(String url) {
String lower = url.toLowerCase().split("\\?")[0];
if (lower.endsWith(".png")) return ".png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return ".jpg";
if (lower.endsWith(".webp")) return ".webp";
if (lower.endsWith(".gif")) return ".gif";
return ".png";
}
}

View File

@ -0,0 +1,65 @@
package vip.mate.tool.image;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.task.AsyncTaskService.TaskPollResult;
import java.util.Set;
/**
* 图片生成提供商接口 所有图片 provider 统一实现此接口
* <p>
* 设计参考 {@link vip.mate.tool.video.VideoGenerationProvider}
* 与视频不同图片 Provider 分同步和异步两种模式
*
* @author MateClaw Team
*/
public interface ImageGenerationProvider {
/** 提供商唯一 ID如 "dashscope"、"openai"、"fal"、"zhipu-cogview" */
String id();
/** 显示名称 */
String label();
/** 是否需要 API Key / Credential */
boolean requiresCredential();
/**
* 自动探测排序优先级升序
*/
int autoDetectOrder();
/** 该 provider 支持的能力集 */
Set<ImageCapability> capabilities();
/** 细粒度能力声明(支持的 size、aspectRatio、模型列表等 */
ImageProviderCapabilities detailedCapabilities();
/**
* 判断该 provider 在当前配置下是否可用
*/
boolean isAvailable(SystemSettingsDTO config);
/**
* 提交图片生成任务
* <p>
* 同步 Provider 在此方法内完成生成并返回 imageUrlsasync=false
* 异步 Provider 返回 providerTaskIdasync=true需后续轮询
*
* @param request 统一请求参数
* @param config 系统配置
* @return 提交结果
*/
ImageSubmitResult submit(ImageGenerationRequest request, SystemSettingsDTO config);
/**
* 轮询任务状态仅异步 Provider 需要实现
*
* @param providerTaskId provider 返回的任务 ID
* @param config 系统配置
* @return 轮询结果同步 Provider 可返回 null
*/
default TaskPollResult checkStatus(String providerTaskId, SystemSettingsDTO config) {
return null;
}
}

View File

@ -0,0 +1,43 @@
package vip.mate.tool.image;
import lombok.Builder;
import lombok.Data;
import java.util.Map;
/**
* 图片生成统一请求
*
* @author MateClaw Team
*/
@Data
@Builder
public class ImageGenerationRequest {
/** 图片内容描述 */
private String prompt;
/** 生成模式(由 runtime 自动推断) */
private ImageCapability mode;
/** 指定模型名称可选provider 有默认值) */
private String model;
/** 图片尺寸1024x1024 / 1024x1792 / 1792x1024 等 */
@Builder.Default
private String size = "1024x1024";
/** 画面比例1:1 / 16:9 / 9:16 */
@Builder.Default
private String aspectRatio = "1:1";
/** 生成数量 */
@Builder.Default
private Integer count = 1;
/** 参考图片 URLIMAGE_EDIT 模式) */
private String referenceImageUrl;
/** provider 特有的额外参数 */
private Map<String, Object> extraParams;
}

View File

@ -0,0 +1,62 @@
package vip.mate.tool.image;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* 图片生成服务提交结果面向 Tool
*
* @author MateClaw Team
*/
@Data
@Builder
public class ImageGenerationResult {
/** 内部任务 ID异步模式供 Agent 查询状态用) */
private String taskId;
/** 处理该任务的 provider 名称 */
private String providerName;
/** 是否成功提交(异步)或生成完成(同步) */
private boolean submitted;
/** 同步模式:图片是否已生成完毕 */
private boolean completed;
/** 同步模式:生成的图片本地 serving URL 列表 */
private List<String> imageUrls;
/** 面向 Agent 的说明文本 */
private String message;
public static ImageGenerationResult asyncSuccess(String taskId, String providerName) {
return ImageGenerationResult.builder()
.taskId(taskId)
.providerName(providerName)
.submitted(true)
.completed(false)
.message("图片生成任务已提交(任务 ID: " + taskId + ")。预计 30 秒 - 2 分钟完成,完成后会自动显示在对话中。")
.build();
}
public static ImageGenerationResult syncSuccess(String providerName, List<String> imageUrls) {
return ImageGenerationResult.builder()
.providerName(providerName)
.submitted(true)
.completed(true)
.imageUrls(imageUrls)
.message("图片已生成完毕,共 " + imageUrls.size() + " 张。")
.build();
}
public static ImageGenerationResult failure(String message) {
return ImageGenerationResult.builder()
.submitted(false)
.completed(false)
.message(message)
.build();
}
}

View File

@ -0,0 +1,262 @@
package vip.mate.tool.image;
import com.fasterxml.jackson.databind.ObjectMapper;
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.AsyncTaskService.TaskPollResult;
import vip.mate.task.model.AsyncTaskEntity;
import vip.mate.task.model.AsyncTaskInfo;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* 图片生成服务 统一入口处理 provider 选择参数归一化fallback同步/异步提交
* <p>
* VideoGenerationService 结构一致额外处理同步模式部分 Provider 直接返回图片 URL
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ImageGenerationService {
private final SystemSettingService systemSettingService;
private final ImageProviderRegistry providerRegistry;
private final AsyncTaskService asyncTaskService;
private final ConversationService conversationService;
private final ImageFileDownloader fileDownloader;
private final ObjectMapper objectMapper;
private static final String TASK_TYPE = "image_generation";
/**
* 提交图片生成任务
*/
public ImageGenerationResult submitGeneration(ImageGenerationRequest request,
String conversationId,
String createdBy) {
SystemSettingsDTO config = systemSettingService.getAllSettings();
// 1. 检查图片功能是否启用
if (!Boolean.TRUE.equals(config.getImageEnabled())) {
return ImageGenerationResult.failure("图片生成功能未启用,请在系统设置中开启");
}
// 2. 模式推断
if (request.getMode() == null) {
request.setMode(inferMode(request));
}
// 3. Provider 选择
ImageProviderRegistry.ResolvedProvider resolved =
providerRegistry.resolve(config, request.getMode());
if (resolved == null) {
return ImageGenerationResult.failure(
"没有可用的图片生成 Provider请在系统设置中配置支持 DashScope、OpenAI、fal.ai、智谱");
}
// 4. 提交 fallback
return submitWithFallback(request, config, resolved, conversationId, createdBy);
}
/**
* 查询任务状态
*/
public AsyncTaskInfo checkTaskStatus(String taskId) {
return asyncTaskService.getTaskInfo(taskId);
}
// ==================== 内部逻辑 ====================
private ImageGenerationResult submitWithFallback(ImageGenerationRequest request,
SystemSettingsDTO config,
ImageProviderRegistry.ResolvedProvider primary,
String conversationId,
String createdBy) {
// 尝试 primary
normalizeForProvider(request, primary.provider());
ImageSubmitResult submitResult = primary.provider().submit(request, config);
if (submitResult.isAccepted()) {
return handleSubmitResult(submitResult, request, conversationId, createdBy, config);
}
// Fallback
List<String> attemptErrors = new ArrayList<>();
attemptErrors.add(primary.provider().id() + ": " + submitResult.getErrorMessage());
if (Boolean.TRUE.equals(config.getImageFallbackEnabled())) {
List<ImageGenerationProvider> fallbacks =
providerRegistry.fallbackCandidates(config, request.getMode(), primary.provider().id());
for (ImageGenerationProvider fb : fallbacks) {
log.info("[ImageGen] Trying fallback provider: {}", fb.id());
normalizeForProvider(request, fb);
submitResult = fb.submit(request, config);
if (submitResult.isAccepted()) {
return handleSubmitResult(submitResult, request, conversationId, createdBy, config);
}
attemptErrors.add(fb.id() + ": " + submitResult.getErrorMessage());
log.warn("[ImageGen] Fallback provider {} failed: {}", fb.id(), submitResult.getErrorMessage());
}
}
return ImageGenerationResult.failure(
"所有 Provider 均提交失败\n" + String.join("\n", attemptErrors));
}
private ImageGenerationResult handleSubmitResult(ImageSubmitResult submitResult,
ImageGenerationRequest request,
String conversationId,
String createdBy,
SystemSettingsDTO config) {
if (submitResult.isAsync()) {
// 异步模式创建任务 + 启动轮询
return createAsyncTask(submitResult, request, conversationId, createdBy, config);
} else {
// 同步模式直接下载图片保存消息
return handleSyncCompletion(submitResult, conversationId, createdBy);
}
}
private ImageGenerationResult createAsyncTask(ImageSubmitResult submitResult,
ImageGenerationRequest request,
String conversationId,
String createdBy,
SystemSettingsDTO config) {
try {
String requestJson = objectMapper.writeValueAsString(request);
AsyncTaskEntity task = asyncTaskService.createTask(
TASK_TYPE, conversationId, null,
submitResult.getProviderName(),
submitResult.getProviderTaskId(),
requestJson, createdBy);
ImageGenerationProvider provider = providerRegistry.getById(submitResult.getProviderName());
if (provider == null) {
return ImageGenerationResult.failure("Provider 不存在: " + submitResult.getProviderName());
}
asyncTaskService.startPolling(
task.getTaskId(),
providerTaskId -> provider.checkStatus(providerTaskId, systemSettingService.getAllSettings()),
(completedTask, pollResult) -> handleAsyncCompletion(completedTask, pollResult)
);
return ImageGenerationResult.asyncSuccess(task.getTaskId(), submitResult.getProviderName());
} catch (Exception e) {
log.error("[ImageGen] Failed to create async task: {}", e.getMessage(), e);
return ImageGenerationResult.failure("创建任务失败: " + e.getMessage());
}
}
/**
* 同步 Provider 完成后下载图片 保存消息
*/
private ImageGenerationResult handleSyncCompletion(ImageSubmitResult submitResult,
String conversationId,
String createdBy) {
try {
List<String> imageUrls = submitResult.getImageUrls();
List<String> servingUrls = new ArrayList<>();
String taskId = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16);
List<MessageContentPart> contentParts = new ArrayList<>();
for (int i = 0; i < imageUrls.size(); i++) {
Path localPath = fileDownloader.download(imageUrls.get(i), conversationId, taskId, i);
String servingUrl = fileDownloader.toServingUrl(conversationId, localPath);
servingUrls.add(servingUrl);
MessageContentPart imagePart = MessageContentPart.image(null, servingUrl);
imagePart.setFileName(localPath.getFileName().toString());
imagePart.setContentType("image/png");
contentParts.add(imagePart);
}
// 保存 assistant 消息
conversationService.saveMessage(
conversationId, "assistant",
"图片已生成完毕",
contentParts, "completed");
log.info("[ImageGen] Sync generation completed, {} image(s) saved for conversation {}",
servingUrls.size(), conversationId);
return ImageGenerationResult.syncSuccess(submitResult.getProviderName(), servingUrls);
} catch (Exception e) {
log.error("[ImageGen] Sync completion handling failed: {}", e.getMessage(), e);
return ImageGenerationResult.failure("图片下载或保存失败: " + e.getMessage());
}
}
/**
* 异步任务完成时的回写逻辑下载图片 保存消息 广播 SSE
*/
private void handleAsyncCompletion(AsyncTaskEntity task, TaskPollResult result) {
if (result.succeeded()) {
try {
String imageUrl = result.imageUrl();
if (imageUrl == null) {
log.warn("[ImageGen] Task {} succeeded but no image URL", task.getTaskId());
asyncTaskService.broadcastTaskEvent(task, "async_task_completed",
false, null, null, "图片生成成功但未返回图片 URL");
return;
}
// 下载图片到本地
Path localPath = fileDownloader.download(imageUrl, task.getConversationId(), task.getTaskId(), 0);
String servingUrl = fileDownloader.toServingUrl(task.getConversationId(), localPath);
// 保存 assistant 消息
MessageContentPart imagePart = MessageContentPart.image(null, servingUrl);
imagePart.setFileName(localPath.getFileName().toString());
imagePart.setContentType("image/png");
conversationService.saveMessage(
task.getConversationId(), "assistant",
"图片已生成完毕",
List.of(imagePart), "completed");
// SSE 广播使用 imageUrl 字段
asyncTaskService.broadcastTaskEvent(task, "async_task_completed",
true, null, servingUrl, null);
log.info("[ImageGen] Task {} completed, image saved: {}", task.getTaskId(), servingUrl);
} catch (Exception e) {
log.error("[ImageGen] Completion handling failed for task {}: {}",
task.getTaskId(), e.getMessage(), e);
asyncTaskService.broadcastTaskEvent(task, "async_task_completed",
false, null, null, "图片下载或保存失败: " + e.getMessage());
}
} else {
asyncTaskService.broadcastTaskEvent(task, "async_task_completed",
false, null, null, result.errorMessage());
log.warn("[ImageGen] Task {} failed: {}", task.getTaskId(), result.errorMessage());
}
}
private ImageCapability inferMode(ImageGenerationRequest request) {
if (request.getReferenceImageUrl() != null && !request.getReferenceImageUrl().isBlank()) {
return ImageCapability.IMAGE_EDIT;
}
return ImageCapability.TEXT_TO_IMAGE;
}
private void normalizeForProvider(ImageGenerationRequest request, ImageGenerationProvider provider) {
ImageProviderCapabilities caps = provider.detailedCapabilities();
if (caps == null) return;
request.setSize(caps.normalizeSize(request.getSize()));
request.setAspectRatio(caps.normalizeAspectRatio(request.getAspectRatio()));
if (request.getCount() != null) {
request.setCount(caps.normalizeCount(request.getCount()));
}
}
}

View File

@ -0,0 +1,90 @@
package vip.mate.tool.image;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Set;
/**
* 图片生成 Provider 细粒度能力声明
*
* @author MateClaw Team
*/
@Data
@Builder
public class ImageProviderCapabilities {
/** 支持的生成模式 */
@Builder.Default
private Set<ImageCapability> modes = Set.of(ImageCapability.TEXT_TO_IMAGE);
/** 支持的图片尺寸,如 ["1024x1024", "1024x1792"] */
@Builder.Default
private List<String> supportedSizes = List.of("1024x1024");
/** 支持的画面比例 */
@Builder.Default
private List<String> aspectRatios = List.of("1:1", "16:9", "9:16");
/** 最大生成数量 */
@Builder.Default
private int maxCount = 1;
/** 默认模型 */
private String defaultModel;
/** 可用模型列表 */
@Builder.Default
private List<String> models = List.of();
/**
* 将请求的 size 就近匹配到 provider 支持的值
*/
public String normalizeSize(String requested) {
if (requested == null || requested.isBlank()) {
return supportedSizes.isEmpty() ? "1024x1024" : supportedSizes.get(0);
}
if (supportedSizes.contains(requested)) {
return requested;
}
// 就近匹配解析面积找最接近的
long reqArea = parseArea(requested);
String closest = supportedSizes.get(0);
long minDiff = Math.abs(reqArea - parseArea(closest));
for (String s : supportedSizes) {
long diff = Math.abs(reqArea - parseArea(s));
if (diff < minDiff) {
minDiff = diff;
closest = s;
}
}
return closest;
}
/**
* 将请求的 aspectRatio 就近匹配或回退到默认
*/
public String normalizeAspectRatio(String requested) {
if (aspectRatios.contains(requested)) {
return requested;
}
return aspectRatios.isEmpty() ? "1:1" : aspectRatios.get(0);
}
/**
* 将请求的 count 限制在 provider 支持范围内
*/
public int normalizeCount(int requested) {
return Math.min(Math.max(requested, 1), maxCount);
}
private long parseArea(String size) {
try {
String[] parts = size.toLowerCase().split("x");
return Long.parseLong(parts[0].trim()) * Long.parseLong(parts[1].trim());
} catch (Exception e) {
return 1024L * 1024L;
}
}
}

View File

@ -0,0 +1,84 @@
package vip.mate.tool.image;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 图片生成提供商注册表 收集所有 {@link ImageGenerationProvider} 实现提供优先级排序与自动探测
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class ImageProviderRegistry {
private final List<ImageGenerationProvider> sortedProviders;
private final Map<String, ImageGenerationProvider> providerMap;
public ImageProviderRegistry(List<ImageGenerationProvider> providers) {
this.sortedProviders = providers.stream()
.sorted(Comparator.comparingInt(ImageGenerationProvider::autoDetectOrder))
.toList();
this.providerMap = providers.stream()
.collect(Collectors.toMap(ImageGenerationProvider::id, Function.identity()));
log.info("注册图片生成提供商 {} 个: {}", sortedProviders.size(),
sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList());
}
/** 按 ID 获取指定 provider */
public ImageGenerationProvider getById(String id) {
return providerMap.get(id);
}
/** 获取按 autoDetectOrder 排序的全部 provider 列表 */
public List<ImageGenerationProvider> allSorted() {
return sortedProviders;
}
/**
* 根据当前配置解析应使用的 provider
*/
public ResolvedProvider resolve(SystemSettingsDTO config, ImageCapability requiredCapability) {
// 1. 用户显式配置的 primary provider
String configuredId = config.getImageProvider();
if (configuredId != null && !configuredId.isBlank() && !"auto".equals(configuredId)) {
ImageGenerationProvider configured = providerMap.get(configuredId);
if (configured != null && configured.isAvailable(config)
&& configured.capabilities().contains(requiredCapability)) {
return new ResolvedProvider(configured, "configured");
}
}
// 2. 自动探测按优先级遍历找第一个可用且支持该能力的
for (ImageGenerationProvider p : sortedProviders) {
if (p.isAvailable(config) && p.capabilities().contains(requiredCapability)) {
return new ResolvedProvider(p, "auto-detect");
}
}
return null;
}
/**
* 获取所有可用于 fallback provider按优先级排序排除 primary
*/
public List<ImageGenerationProvider> fallbackCandidates(SystemSettingsDTO config,
ImageCapability requiredCapability,
String excludeId) {
return sortedProviders.stream()
.filter(p -> !p.id().equals(excludeId))
.filter(p -> p.isAvailable(config))
.filter(p -> p.capabilities().contains(requiredCapability))
.toList();
}
public record ResolvedProvider(ImageGenerationProvider provider, String source) {
}
}

View File

@ -0,0 +1,64 @@
package vip.mate.tool.image;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* Provider 提交图片生成任务的结果
* <p>
* 与视频不同图片 Provider 分同步和异步两种
* - 同步async=falsesubmit 时已完成生成imageUrls 直接包含结果
* - 异步async=true返回 providerTaskId需后续轮询
*
* @author MateClaw Team
*/
@Data
@Builder
public class ImageSubmitResult {
/** 是否被接受 */
private boolean accepted;
/** true=异步需轮询, false=同步已完成 */
private boolean async;
/** 提交的 provider 名称 */
private String providerName;
/** 异步模式provider 返回的任务 ID */
private String providerTaskId;
/** 同步模式:直接返回的图片 URL 列表 */
private List<String> imageUrls;
/** 错误信息(仅 accepted=false 时) */
private String errorMessage;
public static ImageSubmitResult syncSuccess(String providerName, List<String> imageUrls) {
return ImageSubmitResult.builder()
.accepted(true)
.async(false)
.providerName(providerName)
.imageUrls(imageUrls)
.build();
}
public static ImageSubmitResult asyncSuccess(String providerTaskId, String providerName) {
return ImageSubmitResult.builder()
.accepted(true)
.async(true)
.providerName(providerName)
.providerTaskId(providerTaskId)
.build();
}
public static ImageSubmitResult failure(String providerName, String errorMessage) {
return ImageSubmitResult.builder()
.providerName(providerName)
.accepted(false)
.errorMessage(errorMessage)
.build();
}
}

View File

@ -0,0 +1,196 @@
package vip.mate.tool.image.provider;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.fasterxml.jackson.databind.JsonNode;
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.Component;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.task.AsyncTaskService.TaskPollResult;
import vip.mate.tool.image.*;
import java.util.List;
import java.util.Set;
/**
* DashScope 图片生成 Provider 支持通义万相 Wanx 系列
* <p>
* 异步模式提交后返回 taskId需轮询获取结果
* 复用已有的 DashScope LLM provider API Key
* API 文档: https://help.aliyun.com/zh/model-studio/developer-reference/text-to-image
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DashScopeImageProvider implements ImageGenerationProvider {
private final ModelProviderService modelProviderService;
private final ObjectMapper objectMapper;
private static final String BASE_URL = "https://dashscope.aliyuncs.com/api/v1";
private static final String DEFAULT_MODEL = "wanx2.1-t2i-turbo";
@Override
public String id() {
return "dashscope";
}
@Override
public String label() {
return "DashScope (通义万相)";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 100;
}
@Override
public Set<ImageCapability> capabilities() {
return Set.of(ImageCapability.TEXT_TO_IMAGE);
}
@Override
public ImageProviderCapabilities detailedCapabilities() {
return ImageProviderCapabilities.builder()
.modes(capabilities())
.supportedSizes(List.of("1024x1024", "720x1280", "1280x720"))
.aspectRatios(List.of("1:1", "16:9", "9:16"))
.maxCount(4)
.defaultModel(DEFAULT_MODEL)
.models(List.of("wanx2.1-t2i-turbo", "wanx-v1"))
.build();
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
try {
return modelProviderService.isProviderConfigured("dashscope");
} catch (Exception e) {
return false;
}
}
@Override
public ImageSubmitResult submit(ImageGenerationRequest request, SystemSettingsDTO config) {
String apiKey = getDashScopeApiKey();
if (apiKey == null) {
return ImageSubmitResult.failure(id(), "DashScope API Key 未配置");
}
try {
String model = request.getModel() != null && !request.getModel().isBlank()
? request.getModel() : DEFAULT_MODEL;
ObjectNode body = objectMapper.createObjectNode();
body.put("model", model);
ObjectNode input = body.putObject("input");
input.put("prompt", request.getPrompt());
ObjectNode parameters = body.putObject("parameters");
String size = aspectRatioToSize(request.getAspectRatio());
if (size != null) {
parameters.put("size", size);
}
int count = request.getCount() != null ? Math.min(request.getCount(), 4) : 1;
parameters.put("n", count);
HttpResponse response = HttpRequest.post(BASE_URL + "/services/aigc/text2image/image-synthesis")
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("X-DashScope-Async", "enable")
.body(body.toString())
.timeout(30_000)
.execute();
JsonNode result = objectMapper.readTree(response.body());
if (response.getStatus() == 200 && result.has("output")) {
String taskId = result.path("output").path("task_id").asText();
log.info("[DashScope Image] Submitted task: {} (model={})", taskId, model);
return ImageSubmitResult.asyncSuccess(taskId, id());
} else {
String errMsg = result.has("message") ? result.get("message").asText()
: "HTTP " + response.getStatus();
log.warn("[DashScope Image] Submit failed: {}", errMsg);
return ImageSubmitResult.failure(id(), errMsg);
}
} catch (Exception e) {
log.error("[DashScope Image] Submit error: {}", e.getMessage(), e);
return ImageSubmitResult.failure(id(), e.getMessage());
}
}
@Override
public TaskPollResult checkStatus(String providerTaskId, SystemSettingsDTO config) {
String apiKey = getDashScopeApiKey();
if (apiKey == null) {
return TaskPollResult.failed("DashScope API Key 未配置");
}
try {
HttpResponse response = HttpRequest.get(BASE_URL + "/tasks/" + providerTaskId)
.header("Authorization", "Bearer " + apiKey)
.timeout(15_000)
.execute();
JsonNode result = objectMapper.readTree(response.body());
JsonNode output = result.path("output");
String taskStatus = output.path("task_status").asText();
return switch (taskStatus) {
case "SUCCEEDED" -> {
String imageUrl = extractImageUrl(output);
yield TaskPollResult.imageSucceeded(imageUrl, output.toString());
}
case "FAILED" -> {
String errMsg = output.has("message") ? output.get("message").asText() : "任务失败";
yield TaskPollResult.failed(errMsg);
}
case "RUNNING" -> TaskPollResult.running(null);
default -> TaskPollResult.pending(null);
};
} catch (Exception e) {
log.error("[DashScope Image] Poll error for task {}: {}", providerTaskId, e.getMessage());
return null;
}
}
private String getDashScopeApiKey() {
try {
var providerEntity = modelProviderService.getProviderConfig("dashscope");
return providerEntity.getApiKey();
} catch (Exception e) {
return null;
}
}
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()) {
return results.get(0).path("url").asText(null);
}
return null;
}
}

View File

@ -0,0 +1,205 @@
package vip.mate.tool.image.provider;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.fasterxml.jackson.databind.JsonNode;
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.Component;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.task.AsyncTaskService.TaskPollResult;
import vip.mate.tool.image.*;
import java.util.List;
import java.util.Set;
/**
* fal.ai 图片生成 Provider 支持 Flux 系列模型
* <p>
* 异步队列模式提交到 queue轮询获取结果
* API 文档: https://fal.ai/models/fal-ai/flux/dev/api
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FalImageProvider implements ImageGenerationProvider {
private final ObjectMapper objectMapper;
private static final String DEFAULT_MODEL = "fal-ai/flux/dev";
private static final String QUEUE_BASE = "https://queue.fal.run";
@Override
public String id() {
return "fal";
}
@Override
public String label() {
return "fal.ai (Flux)";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 300;
}
@Override
public Set<ImageCapability> capabilities() {
return Set.of(ImageCapability.TEXT_TO_IMAGE);
}
@Override
public ImageProviderCapabilities detailedCapabilities() {
return ImageProviderCapabilities.builder()
.modes(capabilities())
.supportedSizes(List.of("1024x1024", "1024x1536", "1536x1024"))
.aspectRatios(List.of("1:1", "16:9", "9:16", "4:3", "3:4"))
.maxCount(4)
.defaultModel(DEFAULT_MODEL)
.models(List.of("fal-ai/flux/dev", "fal-ai/flux/schnell", "fal-ai/flux-pro/v1.1"))
.build();
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
return config.getFalApiKey() != null && !config.getFalApiKey().isBlank();
}
@Override
public ImageSubmitResult submit(ImageGenerationRequest request, SystemSettingsDTO config) {
String apiKey = config.getFalApiKey();
if (apiKey == null || apiKey.isBlank()) {
return ImageSubmitResult.failure(id(), "fal.ai API Key 未配置");
}
try {
String model = request.getModel() != null && !request.getModel().isBlank()
? request.getModel() : DEFAULT_MODEL;
ObjectNode body = objectMapper.createObjectNode();
body.put("prompt", request.getPrompt());
// fal.ai 使用 image_size 对象或字符串
String size = normalizeSize(request.getSize(), request.getAspectRatio());
ObjectNode imageSize = body.putObject("image_size");
String[] parts = size.split("x");
imageSize.put("width", Integer.parseInt(parts[0]));
imageSize.put("height", Integer.parseInt(parts[1]));
int count = request.getCount() != null ? Math.min(request.getCount(), 4) : 1;
body.put("num_images", count);
String url = QUEUE_BASE + "/" + model;
HttpResponse response = HttpRequest.post(url)
.header("Authorization", "Key " + apiKey)
.header("Content-Type", "application/json")
.body(body.toString())
.timeout(30_000)
.execute();
JsonNode result = objectMapper.readTree(response.body());
if (response.getStatus() == 200 && result.has("request_id")) {
String requestId = result.get("request_id").asText();
// 复合 taskId: model|requestId
String compositeId = model + "|" + requestId;
log.info("[Fal Image] Submitted task: {} (model={})", requestId, model);
return ImageSubmitResult.asyncSuccess(compositeId, id());
} else {
String errMsg = result.has("detail") ? result.get("detail").asText()
: "HTTP " + response.getStatus();
log.warn("[Fal Image] Submit failed: {}", errMsg);
return ImageSubmitResult.failure(id(), errMsg);
}
} catch (Exception e) {
log.error("[Fal Image] Submit error: {}", e.getMessage(), e);
return ImageSubmitResult.failure(id(), e.getMessage());
}
}
@Override
public TaskPollResult checkStatus(String providerTaskId, SystemSettingsDTO config) {
String apiKey = config.getFalApiKey();
if (apiKey == null || apiKey.isBlank()) {
return TaskPollResult.failed("fal.ai API Key 未配置");
}
try {
// 解析复合 ID: model|requestId
String[] parts = providerTaskId.split("\\|", 2);
if (parts.length != 2) {
return TaskPollResult.failed("Invalid fal task ID format");
}
String model = parts[0];
String requestId = parts[1];
// 先检查状态
String statusUrl = QUEUE_BASE + "/" + model + "/requests/" + requestId + "/status";
HttpResponse statusResp = HttpRequest.get(statusUrl)
.header("Authorization", "Key " + apiKey)
.timeout(15_000)
.execute();
JsonNode statusResult = objectMapper.readTree(statusResp.body());
String status = statusResult.has("status") ? statusResult.get("status").asText() : "UNKNOWN";
return switch (status) {
case "COMPLETED" -> {
// 获取结果
String resultUrl = QUEUE_BASE + "/" + model + "/requests/" + requestId;
HttpResponse resultResp = HttpRequest.get(resultUrl)
.header("Authorization", "Key " + apiKey)
.timeout(15_000)
.execute();
JsonNode resultData = objectMapper.readTree(resultResp.body());
String imageUrl = extractImageUrl(resultData);
yield TaskPollResult.imageSucceeded(imageUrl, resultData.toString());
}
case "FAILED" -> {
String errMsg = statusResult.has("error") ? statusResult.get("error").asText() : "任务失败";
yield TaskPollResult.failed(errMsg);
}
case "IN_PROGRESS" -> TaskPollResult.running(null);
default -> TaskPollResult.pending(null);
};
} catch (Exception e) {
log.error("[Fal Image] Poll error for task {}: {}", providerTaskId, e.getMessage());
return null;
}
}
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()) {
return images.get(0).path("url").asText(null);
}
return null;
}
}

View File

@ -0,0 +1,173 @@
package vip.mate.tool.image.provider;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.fasterxml.jackson.databind.JsonNode;
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.Component;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.tool.image.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* OpenAI 图片生成 Provider 支持 DALL-E 3 / DALL-E 2 / gpt-image-1
* <p>
* 同步模式直接返回图片 URL
* 复用已有的 OpenAI LLM provider API Key
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OpenAiImageProvider implements ImageGenerationProvider {
private final ModelProviderService modelProviderService;
private final ObjectMapper objectMapper;
private static final String DEFAULT_MODEL = "dall-e-3";
@Override
public String id() {
return "openai";
}
@Override
public String label() {
return "OpenAI (DALL-E)";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 200;
}
@Override
public Set<ImageCapability> capabilities() {
return Set.of(ImageCapability.TEXT_TO_IMAGE);
}
@Override
public ImageProviderCapabilities detailedCapabilities() {
return ImageProviderCapabilities.builder()
.modes(capabilities())
.supportedSizes(List.of("1024x1024", "1024x1792", "1792x1024"))
.aspectRatios(List.of("1:1", "9:16", "16:9"))
.maxCount(1) // DALL-E 3 只支持 n=1
.defaultModel(DEFAULT_MODEL)
.models(List.of("dall-e-3", "dall-e-2", "gpt-image-1"))
.build();
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
try {
return modelProviderService.isProviderConfigured("openai");
} catch (Exception e) {
return false;
}
}
@Override
public ImageSubmitResult submit(ImageGenerationRequest request, SystemSettingsDTO config) {
String apiKey = getOpenAiApiKey();
String baseUrl = getOpenAiBaseUrl();
if (apiKey == null) {
return ImageSubmitResult.failure(id(), "OpenAI API Key 未配置");
}
try {
String model = request.getModel() != null && !request.getModel().isBlank()
? request.getModel() : DEFAULT_MODEL;
ObjectNode body = objectMapper.createObjectNode();
body.put("model", model);
body.put("prompt", request.getPrompt());
body.put("size", normalizeSize(request.getSize(), request.getAspectRatio()));
body.put("n", 1);
body.put("response_format", "url");
String url = (baseUrl != null ? baseUrl : "https://api.openai.com") + "/v1/images/generations";
HttpResponse response = HttpRequest.post(url)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(body.toString())
.timeout(60_000)
.execute();
JsonNode result = objectMapper.readTree(response.body());
if (response.getStatus() == 200 && result.has("data")) {
List<String> imageUrls = new ArrayList<>();
for (JsonNode item : result.get("data")) {
String imageUrl = item.has("url") ? item.get("url").asText() : null;
if (imageUrl != null) {
imageUrls.add(imageUrl);
}
}
if (imageUrls.isEmpty()) {
return ImageSubmitResult.failure(id(), "API 返回成功但未包含图片 URL");
}
log.info("[OpenAI Image] Generated {} image(s) (model={})", imageUrls.size(), model);
return ImageSubmitResult.syncSuccess(id(), imageUrls);
} else {
String errMsg = result.has("error")
? result.path("error").path("message").asText("Unknown error")
: "HTTP " + response.getStatus();
log.warn("[OpenAI Image] Submit failed: {}", errMsg);
return ImageSubmitResult.failure(id(), errMsg);
}
} catch (Exception e) {
log.error("[OpenAI Image] Submit error: {}", e.getMessage(), e);
return ImageSubmitResult.failure(id(), e.getMessage());
}
}
private String getOpenAiApiKey() {
try {
var providerEntity = modelProviderService.getProviderConfig("openai");
return providerEntity.getApiKey();
} catch (Exception e) {
return null;
}
}
private String getOpenAiBaseUrl() {
try {
var providerEntity = modelProviderService.getProviderConfig("openai");
return providerEntity.getBaseUrl();
} catch (Exception e) {
return null;
}
}
private String normalizeSize(String size, String aspectRatio) {
// 优先使用 size
if (size != null && !size.isBlank()) {
List<String> supported = List.of("1024x1024", "1024x1792", "1792x1024");
if (supported.contains(size)) return size;
}
// 根据 aspectRatio 推断
if (aspectRatio != null) {
return switch (aspectRatio) {
case "9:16" -> "1024x1792";
case "16:9" -> "1792x1024";
default -> "1024x1024";
};
}
return "1024x1024";
}
}

View File

@ -0,0 +1,147 @@
package vip.mate.tool.image.provider;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.fasterxml.jackson.databind.JsonNode;
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.Component;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.tool.image.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 智谱 CogView 图片生成 Provider 支持 CogView-4 / CogView-3-Flash
* <p>
* 同步模式直接返回图片 URL
* CogView-3-Flash 模型免费
* API 文档: https://open.bigmodel.cn/dev/api/image-generate/cogview
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ZhipuImageProvider implements ImageGenerationProvider {
private final ObjectMapper objectMapper;
private static final String DEFAULT_MODEL = "cogview-3-flash";
@Override
public String id() {
return "zhipu-cogview";
}
@Override
public String label() {
return "智谱 CogView";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 150;
}
@Override
public Set<ImageCapability> capabilities() {
return Set.of(ImageCapability.TEXT_TO_IMAGE);
}
@Override
public ImageProviderCapabilities detailedCapabilities() {
return ImageProviderCapabilities.builder()
.modes(capabilities())
.supportedSizes(List.of("1024x1024", "768x1344", "1344x768",
"864x1152", "1152x864", "1440x720", "720x1440"))
.aspectRatios(List.of("1:1", "16:9", "9:16", "4:3", "3:4"))
.maxCount(1)
.defaultModel(DEFAULT_MODEL)
.models(List.of("cogview-4", "cogview-3-flash", "cogview-3-plus"))
.build();
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
return config.getZhipuApiKey() != null && !config.getZhipuApiKey().isBlank();
}
@Override
public ImageSubmitResult submit(ImageGenerationRequest request, SystemSettingsDTO config) {
String apiKey = config.getZhipuApiKey();
if (apiKey == null || apiKey.isBlank()) {
return ImageSubmitResult.failure(id(), "智谱 API Key 未配置");
}
try {
String model = request.getModel() != null && !request.getModel().isBlank()
? request.getModel() : DEFAULT_MODEL;
String baseUrl = config.getZhipuBaseUrl() != null && !config.getZhipuBaseUrl().isBlank()
? config.getZhipuBaseUrl() : "https://open.bigmodel.cn";
ObjectNode body = objectMapper.createObjectNode();
body.put("model", model);
body.put("prompt", request.getPrompt());
String size = aspectRatioToSize(request.getAspectRatio());
if (size != null) {
body.put("size", size);
}
HttpResponse response = HttpRequest.post(baseUrl + "/api/paas/v4/images/generations")
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(body.toString())
.timeout(60_000)
.execute();
JsonNode result = objectMapper.readTree(response.body());
if (response.getStatus() == 200 && result.has("data")) {
List<String> imageUrls = new ArrayList<>();
for (JsonNode item : result.get("data")) {
String imageUrl = item.has("url") ? item.get("url").asText() : null;
if (imageUrl != null) {
imageUrls.add(imageUrl);
}
}
if (imageUrls.isEmpty()) {
return ImageSubmitResult.failure(id(), "API 返回成功但未包含图片 URL");
}
log.info("[Zhipu Image] Generated {} image(s) (model={})", imageUrls.size(), model);
return ImageSubmitResult.syncSuccess(id(), imageUrls);
} else {
String errMsg = result.has("error")
? result.path("error").path("message").asText("Unknown error")
: "HTTP " + response.getStatus();
log.warn("[Zhipu Image] Submit failed: {}", errMsg);
return ImageSubmitResult.failure(id(), errMsg);
}
} catch (Exception e) {
log.error("[Zhipu Image] Submit error: {}", e.getMessage(), e);
return ImageSubmitResult.failure(id(), e.getMessage());
}
}
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

@ -0,0 +1,54 @@
package vip.mate.tts;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* TTS 语音合成 REST 端点
*
* @author MateClaw Team
*/
@RestController
@RequestMapping("/api/v1/tts")
@RequiredArgsConstructor
public class TtsController {
private final TtsService ttsService;
/**
* 合成语音 前端"朗读"按钮调用
*/
@PostMapping("/synthesize")
public ResponseEntity<Map<String, Object>> synthesize(@RequestBody SynthesizeRequest req) {
Map<String, Object> result = ttsService.synthesize(
req.getConversationId(),
req.getText(),
req.getVoice(),
req.getSpeed(),
req.getFormat()
);
return ResponseEntity.ok(result);
}
/**
* 列出所有可用语音
*/
@GetMapping("/voices")
public ResponseEntity<List<Map<String, Object>>> listVoices() {
return ResponseEntity.ok(ttsService.listVoices());
}
@Data
public static class SynthesizeRequest {
private String conversationId;
private String text;
private String voice;
private Double speed;
private String format;
}
}

View File

@ -0,0 +1,43 @@
package vip.mate.tts;
import vip.mate.system.model.SystemSettingsDTO;
import java.util.List;
/**
* TTS 语音合成提供商接口
*
* @author MateClaw Team
*/
public interface TtsProvider {
/** 提供商唯一 ID如 "edge-tts"、"openai"、"dashscope" */
String id();
/** 显示名称 */
String label();
/** 是否需要 API Key */
boolean requiresCredential();
/** 自动探测排序优先级(升序),免费 Provider 优先 */
int autoDetectOrder();
/** 判断该 provider 在当前配置下是否可用 */
boolean isAvailable(SystemSettingsDTO config);
/** 可用语音列表 */
List<String> availableVoices();
/** 默认语音 */
String defaultVoice();
/**
* 合成语音
*
* @param request 请求参数
* @param config 系统配置
* @return 合成结果含音频字节
*/
TtsResult synthesize(TtsRequest request, SystemSettingsDTO config);
}

View File

@ -0,0 +1,73 @@
package vip.mate.tts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* TTS 提供商注册表
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class TtsProviderRegistry {
private final List<TtsProvider> sortedProviders;
private final Map<String, TtsProvider> providerMap;
public TtsProviderRegistry(List<TtsProvider> providers) {
this.sortedProviders = providers.stream()
.sorted(Comparator.comparingInt(TtsProvider::autoDetectOrder))
.toList();
this.providerMap = providers.stream()
.collect(Collectors.toMap(TtsProvider::id, Function.identity()));
log.info("注册 TTS 提供商 {} 个: {}", sortedProviders.size(),
sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList());
}
public TtsProvider getById(String id) {
return providerMap.get(id);
}
public List<TtsProvider> allSorted() {
return sortedProviders;
}
/**
* 根据当前配置解析应使用的 provider
*/
public TtsProvider resolve(SystemSettingsDTO config) {
String configuredId = config.getTtsProvider();
if (configuredId != null && !configuredId.isBlank() && !"auto".equals(configuredId)) {
TtsProvider configured = providerMap.get(configuredId);
if (configured != null && configured.isAvailable(config)) {
return configured;
}
}
// 自动探测
for (TtsProvider p : sortedProviders) {
if (p.isAvailable(config)) {
return p;
}
}
return null;
}
/**
* 获取可用于 fallback provider 列表
*/
public List<TtsProvider> fallbackCandidates(SystemSettingsDTO config, String excludeId) {
return sortedProviders.stream()
.filter(p -> !p.id().equals(excludeId))
.filter(p -> p.isAvailable(config))
.toList();
}
}

View File

@ -0,0 +1,31 @@
package vip.mate.tts;
import lombok.Builder;
import lombok.Data;
/**
* TTS 语音合成统一请求
*
* @author MateClaw Team
*/
@Data
@Builder
public class TtsRequest {
/** 待合成的文本 */
private String text;
/** 语音 ID可选使用 Provider 默认) */
private String voice;
/** 模型名称(可选) */
private String model;
/** 语速 0.5-2.0,默认 1.0 */
@Builder.Default
private Double speed = 1.0;
/** 输出格式mp3 / ogg / wav默认 mp3 */
@Builder.Default
private String format = "mp3";
}

View File

@ -0,0 +1,45 @@
package vip.mate.tts;
import lombok.Builder;
import lombok.Data;
/**
* TTS 语音合成结果
*
* @author MateClaw Team
*/
@Data
@Builder
public class TtsResult {
/** 是否合成成功 */
private boolean success;
/** 音频字节数据 */
private byte[] audioData;
/** MIME 类型,如 audio/mpeg, audio/ogg */
private String contentType;
/** 音频格式mp3, ogg, wav */
private String format;
/** 错误信息(仅 success=false 时) */
private String errorMessage;
public static TtsResult success(byte[] audioData, String contentType, String format) {
return TtsResult.builder()
.success(true)
.audioData(audioData)
.contentType(contentType)
.format(format)
.build();
}
public static TtsResult failure(String errorMessage) {
return TtsResult.builder()
.success(false)
.errorMessage(errorMessage)
.build();
}
}

View File

@ -0,0 +1,206 @@
package vip.mate.tts;
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 java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* TTS 语音合成服务 核心编排处理 provider 选择文本预处理fallback文件保存
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TtsService {
private final SystemSettingService systemSettingService;
private final TtsProviderRegistry providerRegistry;
private final ChatStreamTracker streamTracker;
private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads");
private static final int MAX_TEXT_LENGTH = 4096;
/** 用于自动 TTS 的异步线程池 */
private final ExecutorService ttsExecutor = Executors.newFixedThreadPool(2, r -> {
Thread t = new Thread(r, "tts-worker");
t.setDaemon(true);
return t;
});
/**
* 合成语音并保存为文件
*
* @return { success, audioUrl, contentType, providerName }
*/
public Map<String, Object> synthesize(String conversationId, String text,
String voice, Double speed, String format) {
SystemSettingsDTO config = systemSettingService.getAllSettings();
if (!Boolean.TRUE.equals(config.getTtsEnabled())) {
return Map.of("success", false, "error", "TTS 功能未启用,请在系统设置中开启");
}
// 文本预处理
String cleanText = preprocessText(text);
if (cleanText.isBlank()) {
return Map.of("success", false, "error", "待合成的文本为空");
}
// 构建请求
TtsRequest request = TtsRequest.builder()
.text(cleanText)
.voice(voice)
.speed(speed != null ? speed : config.getTtsSpeed())
.format(format != null ? format : "mp3")
.build();
// Provider 选择 + fallback
TtsResult result = synthesizeWithFallback(request, config);
if (!result.isSuccess()) {
return Map.of("success", false, "error", result.getErrorMessage());
}
// 保存文件
try {
String fileId = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
Path filePath = saveAudioFile(conversationId, fileId, result.getAudioData(), result.getFormat());
String audioUrl = "/api/v1/chat/files/" + conversationId + "/" + filePath.getFileName();
return Map.of(
"success", true,
"audioUrl", audioUrl,
"contentType", result.getContentType(),
"format", result.getFormat()
);
} catch (IOException e) {
log.error("[TTS] Failed to save audio file: {}", e.getMessage(), e);
return Map.of("success", false, "error", "音频文件保存失败: " + e.getMessage());
}
}
/**
* 自动 TTS消息完成后异步触发通过 SSE 广播结果
*/
public void autoSynthesize(String conversationId, String text) {
ttsExecutor.submit(() -> {
try {
Map<String, Object> result = synthesize(conversationId, text, null, null, null);
if (Boolean.TRUE.equals(result.get("success"))) {
Map<String, Object> event = new HashMap<>();
event.put("audioUrl", result.get("audioUrl"));
event.put("contentType", result.get("contentType"));
streamTracker.broadcastObject(conversationId, "tts_ready", event);
log.info("[TTS] Auto-synthesized for conversation {}", conversationId);
}
} catch (Exception e) {
log.error("[TTS] Auto-synthesize failed: {}", e.getMessage(), e);
}
});
}
/**
* 获取当前配置是否开启自动 TTS
*/
public boolean isAutoModeEnabled() {
SystemSettingsDTO config = systemSettingService.getSettings();
return Boolean.TRUE.equals(config.getTtsEnabled())
&& "always".equals(config.getTtsAutoMode());
}
/**
* 列出所有可用的语音
*/
public List<Map<String, Object>> listVoices() {
SystemSettingsDTO config = systemSettingService.getAllSettings();
List<Map<String, Object>> voices = new ArrayList<>();
for (TtsProvider provider : providerRegistry.allSorted()) {
boolean available = provider.isAvailable(config);
for (String voice : provider.availableVoices()) {
Map<String, Object> info = new LinkedHashMap<>();
info.put("voice", voice);
info.put("provider", provider.id());
info.put("providerLabel", provider.label());
info.put("available", available);
info.put("isDefault", voice.equals(provider.defaultVoice()));
voices.add(info);
}
}
return voices;
}
// ==================== 内部逻辑 ====================
private TtsResult synthesizeWithFallback(TtsRequest request, SystemSettingsDTO config) {
TtsProvider primary = providerRegistry.resolve(config);
if (primary == null) {
return TtsResult.failure("没有可用的 TTS Provider请检查配置");
}
TtsResult result = primary.synthesize(request, config);
if (result.isSuccess()) {
return result;
}
// Fallback
List<String> errors = new ArrayList<>();
errors.add(primary.id() + ": " + result.getErrorMessage());
if (Boolean.TRUE.equals(config.getTtsFallbackEnabled())) {
for (TtsProvider fb : providerRegistry.fallbackCandidates(config, primary.id())) {
log.info("[TTS] Trying fallback provider: {}", fb.id());
result = fb.synthesize(request, config);
if (result.isSuccess()) {
return result;
}
errors.add(fb.id() + ": " + result.getErrorMessage());
}
}
return TtsResult.failure("所有 TTS Provider 均失败\n" + String.join("\n", errors));
}
private String preprocessText(String text) {
if (text == null) return "";
// 去除 Markdown 格式
String clean = text
.replaceAll("```[\\s\\S]*?```", "") // 代码块
.replaceAll("`[^`]+`", "") // 行内代码
.replaceAll("!?\\[([^\\]]*)\\]\\([^)]+\\)", "$1") // 链接/图片
.replaceAll("[*_~]{1,3}", "") // 加粗/斜体/删除线
.replaceAll("^#{1,6}\\s+", "") // 标题
.replaceAll("^[\\-*+]\\s+", "") // 列表
.replaceAll("^>\\s+", "") // 引用
.replaceAll("\\|[^|]+\\|", "") // 表格
.replaceAll("\n{3,}", "\n\n") // 多余空行
.trim();
// 截断
if (clean.length() > MAX_TEXT_LENGTH) {
clean = clean.substring(0, MAX_TEXT_LENGTH);
}
return clean;
}
private Path saveAudioFile(String conversationId, String fileId, byte[] data, String format)
throws IOException {
Path dir = UPLOAD_ROOT.resolve(conversationId);
Files.createDirectories(dir);
String fileName = "tts_" + fileId + "." + format;
Path filePath = dir.resolve(fileName);
Files.write(filePath, data);
return filePath;
}
}

View File

@ -0,0 +1,133 @@
package vip.mate.tts.provider;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
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.Component;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.tts.TtsProvider;
import vip.mate.tts.TtsRequest;
import vip.mate.tts.TtsResult;
import java.util.List;
/**
* DashScope TTS Provider 使用 CosyVoiceOpenAI 兼容接口
* <p>
* 同步模式直接返回音频流
* 复用已有的 DashScope LLM provider API Key
* API 文档: https://help.aliyun.com/zh/model-studio/developer-reference/cosyvoice-openai-compatible
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DashScopeTtsProvider implements TtsProvider {
private final ModelProviderService modelProviderService;
private final ObjectMapper objectMapper;
private static final String BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
private static final String DEFAULT_MODEL = "cosyvoice-v2";
private static final String DEFAULT_VOICE = "longxiaochun";
@Override
public String id() {
return "dashscope";
}
@Override
public String label() {
return "DashScope (CosyVoice)";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 150;
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
try {
return modelProviderService.isProviderConfigured("dashscope");
} catch (Exception e) {
return false;
}
}
@Override
public List<String> availableVoices() {
return List.of(
"longxiaochun", "longxiaoxia", "longlaotie", "longshu",
"longhua", "longshuo", "longjielidou", "longmiao",
"longyue", "longfei", "longtong", "longxiang"
);
}
@Override
public String defaultVoice() {
return DEFAULT_VOICE;
}
@Override
public TtsResult synthesize(TtsRequest request, SystemSettingsDTO config) {
String apiKey = getDashScopeApiKey();
if (apiKey == null) {
return TtsResult.failure("DashScope API Key 未配置");
}
try {
String model = request.getModel() != null && !request.getModel().isBlank()
? request.getModel() : DEFAULT_MODEL;
String voice = request.getVoice() != null && !request.getVoice().isBlank()
? request.getVoice() : DEFAULT_VOICE;
ObjectNode body = objectMapper.createObjectNode();
body.put("model", model);
body.put("input", request.getText());
body.put("voice", voice);
body.put("response_format", "mp3");
if (request.getSpeed() != null && request.getSpeed() != 1.0) {
body.put("speed", request.getSpeed());
}
HttpResponse response = HttpRequest.post(BASE_URL + "/audio/speech")
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(body.toString())
.timeout(60_000)
.execute();
if (response.getStatus() == 200) {
byte[] audioData = response.bodyBytes();
log.info("[DashScope TTS] Synthesized {} bytes (model={}, voice={})", audioData.length, model, voice);
return TtsResult.success(audioData, "audio/mpeg", "mp3");
} else {
String errBody = response.body();
log.warn("[DashScope TTS] Failed: HTTP {} - {}", response.getStatus(), errBody);
return TtsResult.failure("DashScope TTS 失败: HTTP " + response.getStatus());
}
} catch (Exception e) {
log.error("[DashScope TTS] Error: {}", e.getMessage(), e);
return TtsResult.failure("DashScope TTS 异常: " + e.getMessage());
}
}
private String getDashScopeApiKey() {
try {
return modelProviderService.getProviderConfig("dashscope").getApiKey();
} catch (Exception e) {
return null;
}
}
}

View File

@ -0,0 +1,241 @@
package vip.mate.tts.provider;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.tts.TtsProvider;
import vip.mate.tts.TtsRequest;
import vip.mate.tts.TtsResult;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.TimeUnit;
/**
* Microsoft Edge TTS Provider 免费无需 API Key
* <p>
* 使用 Edge 浏览器内置的 TTS WebSocket 协议
* 自动根据文本语言选择中文或英文语音
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class EdgeTtsProvider implements TtsProvider {
private static final String WS_URL = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud";
private static final String TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4";
private static final String DEFAULT_VOICE_ZH = "zh-CN-XiaoxiaoNeural";
private static final String DEFAULT_VOICE_EN = "en-US-MichelleNeural";
private static final String OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3";
@Override
public String id() {
return "edge-tts";
}
@Override
public String label() {
return "Edge TTS (免费)";
}
@Override
public boolean requiresCredential() {
return false;
}
@Override
public int autoDetectOrder() {
return 100;
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
return true; // 始终可用无需 Key
}
@Override
public List<String> availableVoices() {
return List.of(
"zh-CN-XiaoxiaoNeural", "zh-CN-YunxiNeural", "zh-CN-YunjianNeural",
"zh-CN-XiaoyiNeural", "zh-CN-YunyangNeural",
"en-US-MichelleNeural", "en-US-GuyNeural", "en-US-JennyNeural",
"en-US-AriaNeural", "en-US-DavisNeural",
"ja-JP-NanamiNeural", "ko-KR-SunHiNeural"
);
}
@Override
public String defaultVoice() {
return DEFAULT_VOICE_ZH;
}
@Override
public TtsResult synthesize(TtsRequest request, SystemSettingsDTO config) {
String voice = resolveVoice(request.getVoice(), request.getText(), config);
String rate = speedToRate(request.getSpeed());
try {
byte[] audioData = synthesizeViaWebSocket(request.getText(), voice, rate);
if (audioData == null || audioData.length == 0) {
return TtsResult.failure("Edge TTS 未返回音频数据");
}
log.info("[Edge TTS] Synthesized {} bytes (voice={})", audioData.length, voice);
return TtsResult.success(audioData, "audio/mpeg", "mp3");
} catch (Exception e) {
log.error("[Edge TTS] Synthesis error: {}", e.getMessage(), e);
return TtsResult.failure("Edge TTS 合成失败: " + e.getMessage());
}
}
private byte[] synthesizeViaWebSocket(String text, String voice, String rate) throws Exception {
String requestId = UUID.randomUUID().toString().replace("-", "");
String wsUrl = WS_URL + "?TrustedClientToken=" + TRUSTED_CLIENT_TOKEN
+ "&ConnectionId=" + requestId;
ByteArrayOutputStream audioBuffer = new ByteArrayOutputStream();
CompletableFuture<byte[]> resultFuture = new CompletableFuture<>();
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
WebSocket ws = client.newWebSocketBuilder()
.header("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.buildAsync(URI.create(wsUrl), new WebSocket.Listener() {
private final StringBuilder textBuffer = new StringBuilder();
@Override
public void onOpen(WebSocket webSocket) {
webSocket.request(Long.MAX_VALUE);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
textBuffer.append(data);
if (last) {
String msg = textBuffer.toString();
textBuffer.setLength(0);
if (msg.contains("turn.end")) {
resultFuture.complete(audioBuffer.toByteArray());
}
}
webSocket.request(1);
return null;
}
@Override
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
// 二进制帧 2 字节是 header 长度大端跳过 header
byte[] bytes = new byte[data.remaining()];
data.get(bytes);
// 查找 "Path:audio\r\n" 后的音频数据
int headerEnd = findHeaderEnd(bytes);
if (headerEnd >= 0 && headerEnd < bytes.length) {
audioBuffer.write(bytes, headerEnd, bytes.length - headerEnd);
}
webSocket.request(1);
return null;
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!resultFuture.isDone()) {
resultFuture.complete(audioBuffer.toByteArray());
}
return null;
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (!resultFuture.isDone()) {
resultFuture.completeExceptionally(error);
}
}
}).join();
// 发送配置消息
String configMsg = "Content-Type:application/json; charset=utf-8\r\n"
+ "Path:speech.config\r\n\r\n"
+ "{\"context\":{\"synthesis\":{\"audio\":{\"metadataoptions\":{"
+ "\"sentenceBoundaryEnabled\":false,\"wordBoundaryEnabled\":false},"
+ "\"outputFormat\":\"" + OUTPUT_FORMAT + "\"}}}}\r\n";
ws.sendText(configMsg, true);
// 发送 SSML
String escapedText = escapeXml(text);
String ssml = "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"
+ "<voice name='" + voice + "'>"
+ "<prosody pitch='+0Hz' rate='" + rate + "' volume='+0%'>"
+ escapedText
+ "</prosody></voice></speak>";
String ssmlMsg = "X-RequestId:" + requestId + "\r\n"
+ "Content-Type:application/ssml+xml\r\n"
+ "Path:ssml\r\n\r\n" + ssml;
ws.sendText(ssmlMsg, true);
// 等待结果最多 60
return resultFuture.get(60, TimeUnit.SECONDS);
}
private int findHeaderEnd(byte[] data) {
// 二进制帧格式2 字节 header 长度大端 + header + 音频数据
if (data.length < 2) return -1;
int headerLen = ((data[0] & 0xFF) << 8) | (data[1] & 0xFF);
return 2 + headerLen;
}
private String resolveVoice(String requestedVoice, String text, SystemSettingsDTO config) {
if (requestedVoice != null && !requestedVoice.isBlank()) {
return requestedVoice;
}
String configVoice = config.getTtsDefaultVoice();
if (configVoice != null && !configVoice.isBlank()) {
return configVoice;
}
// 自动语言检测CJK 字符比例 > 30% 使用中文语音
return isCjkDominant(text) ? DEFAULT_VOICE_ZH : DEFAULT_VOICE_EN;
}
private boolean isCjkDominant(String text) {
if (text == null || text.isEmpty()) return true;
int cjkCount = 0;
int totalAlphaNum = 0;
for (char c : text.toCharArray()) {
if (Character.isLetterOrDigit(c)) {
totalAlphaNum++;
if (Character.UnicodeScript.of(c) == Character.UnicodeScript.HAN
|| Character.UnicodeScript.of(c) == Character.UnicodeScript.HIRAGANA
|| Character.UnicodeScript.of(c) == Character.UnicodeScript.KATAKANA
|| Character.UnicodeScript.of(c) == Character.UnicodeScript.HANGUL) {
cjkCount++;
}
}
}
return totalAlphaNum == 0 || (double) cjkCount / totalAlphaNum > 0.3;
}
private String speedToRate(Double speed) {
if (speed == null || speed == 1.0) return "+0%";
int percent = (int) ((speed - 1.0) * 100);
return (percent >= 0 ? "+" : "") + percent + "%";
}
private String escapeXml(String text) {
return text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;");
}
}

View File

@ -0,0 +1,138 @@
package vip.mate.tts.provider;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
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.Component;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.tts.TtsProvider;
import vip.mate.tts.TtsRequest;
import vip.mate.tts.TtsResult;
import java.util.List;
/**
* OpenAI TTS Provider 支持 tts-1 / tts-1-hd / gpt-4o-mini-tts
* <p>
* 同步模式直接返回音频流
* 复用已有的 OpenAI LLM provider API Key
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OpenAiTtsProvider implements TtsProvider {
private final ModelProviderService modelProviderService;
private final ObjectMapper objectMapper;
private static final String DEFAULT_MODEL = "tts-1";
private static final String DEFAULT_VOICE = "alloy";
@Override
public String id() {
return "openai";
}
@Override
public String label() {
return "OpenAI TTS";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 200;
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
try {
return modelProviderService.isProviderConfigured("openai");
} catch (Exception e) {
return false;
}
}
@Override
public List<String> availableVoices() {
return List.of("alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer");
}
@Override
public String defaultVoice() {
return DEFAULT_VOICE;
}
@Override
public TtsResult synthesize(TtsRequest request, SystemSettingsDTO config) {
String apiKey = getApiKey();
String baseUrl = getBaseUrl();
if (apiKey == null) {
return TtsResult.failure("OpenAI API Key 未配置");
}
try {
String model = request.getModel() != null && !request.getModel().isBlank()
? request.getModel() : DEFAULT_MODEL;
String voice = request.getVoice() != null && !request.getVoice().isBlank()
? request.getVoice() : DEFAULT_VOICE;
ObjectNode body = objectMapper.createObjectNode();
body.put("model", model);
body.put("input", request.getText());
body.put("voice", voice);
body.put("response_format", "mp3");
if (request.getSpeed() != null && request.getSpeed() != 1.0) {
body.put("speed", request.getSpeed());
}
String url = (baseUrl != null ? baseUrl : "https://api.openai.com") + "/v1/audio/speech";
HttpResponse response = HttpRequest.post(url)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(body.toString())
.timeout(60_000)
.execute();
if (response.getStatus() == 200) {
byte[] audioData = response.bodyBytes();
log.info("[OpenAI TTS] Synthesized {} bytes (model={}, voice={})", audioData.length, model, voice);
return TtsResult.success(audioData, "audio/mpeg", "mp3");
} else {
String errBody = response.body();
log.warn("[OpenAI TTS] Failed: HTTP {} - {}", response.getStatus(), errBody);
return TtsResult.failure("OpenAI TTS 失败: HTTP " + response.getStatus());
}
} catch (Exception e) {
log.error("[OpenAI TTS] Error: {}", e.getMessage(), e);
return TtsResult.failure("OpenAI TTS 异常: " + e.getMessage());
}
}
private String getApiKey() {
try {
return modelProviderService.getProviderConfig("openai").getApiKey();
} catch (Exception e) {
return null;
}
}
private String getBaseUrl() {
try {
return modelProviderService.getProviderConfig("openai").getBaseUrl();
} catch (Exception e) {
return null;
}
}
}

View File

@ -377,6 +377,10 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id)
VALUES (1000000015, 'VideoGenerateTool', 'Video Generation', 'Generate videos using AI. Supports text-to-video and image-to-video modes. Video generation is asynchronous and will appear in conversation when complete.', 'builtin', 'videoGenerateTool', '🎬', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000016, 'ImageGenerateTool', 'Image Generation', 'Generate images using AI. Supports text-to-image mode with multiple providers: DashScope, OpenAI DALL-E, fal.ai Flux, Zhipu CogView. Auto-fallback between providers.', 'builtin', 'imageGenerateTool', '🎨', TRUE, TRUE, NOW(), NOW(), 0);
-- Example MCP Server: Filesystem (see MateClaw docs mcpServers.filesystem)
MERGE INTO mate_mcp_server (
id, name, description, transport, url, headers_json, command, args_json, env_json, cwd,

View File

@ -383,6 +383,10 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id)
VALUES (1000000015, 'VideoGenerateTool', '视频生成', '使用 AI 生成视频,支持文字生成视频和图片生成视频两种模式。视频生成是异步过程,完成后自动显示在对话中。', 'builtin', 'videoGenerateTool', '🎬', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000016, 'ImageGenerateTool', '图片生成', '使用 AI 生成图片,支持文字生成图片。支持 DashScope 通义万相、OpenAI DALL-E、fal.ai Flux、智谱 CogView 等多个 Provider自动回退。', 'builtin', 'imageGenerateTool', '🎨', TRUE, TRUE, NOW(), NOW(), 0);
-- 示例 MCP ServerFilesystem参考 MateClaw 文档中的 mcpServers.filesystem
MERGE INTO mate_mcp_server (
id, name, description, transport, url, headers_json, command, args_json, env_json, cwd,

View File

@ -275,6 +275,29 @@
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
<!-- 朗读 TTS assistant -->
<button
v-if="role === 'assistant' && !isGenerating"
class="action-btn"
:class="{ 'tts-playing': ttsState === 'playing' }"
type="button"
:title="ttsState === 'playing' ? $t('chat.ttsStop') : $t('chat.ttsPlay')"
:disabled="ttsState === 'loading'"
@click="handleTts"
>
<svg v-if="ttsState === 'loading'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="tts-loading-icon">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
<svg v-else-if="ttsState === 'playing'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
</svg>
</button>
<!-- 重新生成 assistant -->
<button
v-if="role === 'assistant' && !isGenerating"
@ -300,6 +323,7 @@ import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
import { http } from '@/api'
import TypingCursor from './TypingCursor.vue'
import type { Message, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types'
import type { ChatErrorInfo } from '@/types/chatError'
@ -467,8 +491,62 @@ function copyMessage() {
}).catch(() => {})
}
// --- TTS ---
const ttsState = ref<'idle' | 'loading' | 'playing'>('idle')
let ttsAudio: HTMLAudioElement | null = null
async function handleTts() {
if (ttsState.value === 'playing') {
//
ttsAudio?.pause()
ttsAudio = null
ttsState.value = 'idle'
return
}
const text = displayContent.value || props.message.content || ''
if (!text) return
const conversationId = props.message.conversationId
if (!conversationId) return
ttsState.value = 'loading'
try {
const res: any = await http.post('/tts/synthesize', {
conversationId,
text,
})
if (res.data?.success && res.data?.audioUrl) {
// fetch blob
const audioRes = await fetch(res.data.audioUrl, {
headers: { Authorization: `Bearer ${localStorage.getItem('token') || ''}` },
})
const blob = await audioRes.blob()
const blobUrl = URL.createObjectURL(blob)
ttsAudio = new Audio(blobUrl)
ttsAudio.onended = () => {
ttsState.value = 'idle'
URL.revokeObjectURL(blobUrl)
ttsAudio = null
}
ttsAudio.onerror = () => {
ttsState.value = 'idle'
URL.revokeObjectURL(blobUrl)
ttsAudio = null
}
ttsState.value = 'playing'
await ttsAudio.play()
} else {
ttsState.value = 'idle'
}
} catch {
ttsState.value = 'idle'
}
}
onBeforeUnmount(() => {
if (copyTimer) clearTimeout(copyTimer)
if (ttsAudio) { ttsAudio.pause(); ttsAudio = null }
revokeAll()
})
@ -1185,6 +1263,16 @@ watch(isGenerating, (generating) => {
.action-btn.copied {
color: #10b981;
}
.action-btn.tts-playing {
color: var(--mc-primary);
}
.tts-loading-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.action-time {
font-size: 11px;

View File

@ -14,6 +14,7 @@ import { useStream } from './useStream'
import { useMessageQueue } from './useMessageQueue'
import type { Message, MessageContentPart, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData } from '@/types'
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
import { http } from '@/api'
export interface UseChatOptions {
/** API 基础 URL */
@ -242,6 +243,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
setMessageStatus(currentAssistantId.value, data.status || 'completed')
// 关键修复:不在这里清除 currentAssistantId
}
// === 自动 TTSmessage_complete 且 status=completed 时触发 ===
if (data.status === 'completed' && data.hasContent && currentAssistantId.value) {
const msg = getMessage(currentAssistantId.value)
if (msg?.content && streamConversationId) {
triggerAutoTts(streamConversationId, msg.content)
}
}
})
stream.on('done', (data) => {
@ -618,23 +627,76 @@ export function useChat(options: UseChatOptions): UseChatReturn {
phaseInfo.value = null
})
// ===== 异步任务完成事件(视频生成等) =====
// ===== 异步任务完成事件(视频生成、图片生成等) =====
stream.on('async_task_completed', (data) => {
console.log('[useChat] Async task completed:', data)
if (data.success && data.videoUrl && streamConversationId) {
// 在消息列表中追加一条包含视频的 assistant 消息
addMessage({
role: 'assistant',
content: '',
contentParts: [{
type: 'video',
fileUrl: data.videoUrl,
fileName: `video_${data.taskId}.mp4`,
contentType: 'video/mp4',
}] as MessageContentPart[],
status: 'completed',
conversationId: streamConversationId,
})
if (data.success && streamConversationId) {
if (data.videoUrl) {
// 视频生成完成
addMessage({
role: 'assistant',
content: '',
contentParts: [{
type: 'video',
fileUrl: data.videoUrl,
fileName: `video_${data.taskId}.mp4`,
contentType: 'video/mp4',
}] as MessageContentPart[],
status: 'completed',
conversationId: streamConversationId,
})
} else if (data.imageUrl) {
// 图片生成完成
addMessage({
role: 'assistant',
content: '',
contentParts: [{
type: 'image',
fileUrl: data.imageUrl,
fileName: `image_${data.taskId}.png`,
contentType: 'image/png',
}] as MessageContentPart[],
status: 'completed',
conversationId: streamConversationId,
})
}
}
})
// ===== TTS 自动朗读 =====
let ttsAutoModeCache: string | null = null
let ttsCacheExpiry = 0
async function triggerAutoTts(conversationId: string, text: string) {
try {
// 缓存 settings 5 分钟,避免每条消息都请求
const now = Date.now()
if (!ttsAutoModeCache || now > ttsCacheExpiry) {
const res: any = await http.get('/system-settings')
ttsAutoModeCache = res.data?.ttsAutoMode || 'off'
ttsCacheExpiry = now + 5 * 60 * 1000
}
if (ttsAutoModeCache !== 'always') return
// 调用后端合成,后端会通过 SSE tts_ready 广播
http.post('/tts/synthesize', { conversationId, text }).catch(() => {})
} catch {
// 静默失败
}
}
// ===== TTS 自动朗读:监听 tts_ready 事件 =====
stream.on('tts_ready', (data) => {
if (data.audioUrl) {
const token = localStorage.getItem('token') || ''
fetch(data.audioUrl, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
const url = URL.createObjectURL(blob)
const audio = new Audio(url)
audio.onended = () => URL.revokeObjectURL(url)
audio.play().catch(() => URL.revokeObjectURL(url))
})
.catch(() => {})
}
})

View File

@ -35,6 +35,8 @@ export type SSEEventType =
// 异步任务事件
| 'async_task_progress'
| 'async_task_completed'
// TTS 事件
| 'tts_ready'
export interface SSEEvent {
type: SSEEventType

View File

@ -87,6 +87,8 @@ export default {
copy: 'Copy',
copied: 'Copied',
regenerate: 'Regenerate',
ttsPlay: 'Read Aloud',
ttsStop: 'Stop Reading',
conversations: 'Conversations',
newChat: 'New Chat',
loadingAgents: 'Loading agents...',
@ -204,6 +206,8 @@ export default {
sections: {
model: 'Model Management',
system: 'System',
image: 'Image Generation',
tts: 'Text-to-Speech',
video: 'Video Generation',
about: 'About',
},
@ -333,6 +337,15 @@ export default {
tavilyBaseUrl: 'Tavily Base URL',
duckduckgoEnabled: 'DuckDuckGo (Keyless)',
searxngBaseUrl: 'SearXNG Base URL',
ttsEnabled: 'Enable Text-to-Speech',
ttsProvider: 'Preferred TTS Provider',
ttsFallbackEnabled: 'Provider Fallback',
ttsAutoMode: 'Auto Read Aloud',
ttsSpeed: 'Speed',
imageEnabled: 'Enable Image Generation',
imageProvider: 'Preferred Image Provider',
imageFallbackEnabled: 'Provider Fallback',
openaiStatus: 'OpenAI Status',
videoEnabled: 'Enable Video Generation',
videoProvider: 'Preferred Video Provider',
videoFallbackEnabled: 'Provider Fallback',
@ -357,6 +370,21 @@ export default {
tavilyBaseUrl: 'Usually no need to change unless using a custom proxy.',
duckduckgoEnabled: 'Free keyless search fallback. No API key required. Enabled by default as a zero-config fallback.',
searxngBaseUrl: 'Self-hosted SearXNG instance URL. Auto-configured when deploying via Docker.',
ttsEnabled: 'Enable to use TTS via Read Aloud button or auto mode. Edge TTS is free, no API key needed.',
ttsProvider: 'Select preferred TTS provider. Auto mode prioritizes free Edge TTS.',
ttsFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.',
ttsAutoMode: 'off = manual read aloud only, always = auto-play voice for every AI reply.',
ttsSpeed: 'Speech playback speed. 1.0 is normal speed.',
edgeTtsInfo: 'Microsoft Edge built-in TTS. Free to use, supports Chinese, English, Japanese and more. Auto-detects language.',
dashscopeTtsInfo: 'Reuses DashScope API Key from Model Management. Uses CosyVoice model with multiple Chinese voices.',
openaiTtsInfo: 'Reuses OpenAI API Key from Model Management. Supports alloy, echo, nova and other voice styles.',
imageEnabled: 'Enable to let Agent use the image generation tool. Requires at least one configured image provider.',
imageProvider: 'Select preferred image provider. Auto mode picks the first available one.',
imageFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.',
dashscopeImageStatus: 'Reuses the DashScope API Key configured in Model Management. No extra setup needed.',
openaiImageStatus: 'Reuses the OpenAI API Key configured in Model Management. No extra setup needed.',
zhipuImageApiKey: 'Get from bigmodel.cn. CogView-3-Flash model is free. Shared with video generation.',
falImageApiKey: 'Get from fal.ai for Flux image generation models. Shared with video generation.',
videoEnabled: 'Enable to let Agent use the video generation tool. Requires at least one configured video provider.',
videoProvider: 'Select preferred video provider. Auto mode picks the first available one.',
videoFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.',
@ -369,6 +397,31 @@ export default {
},
searchTitle: 'Search Service',
searchDesc: 'Configure the built-in search tool provider and API credentials',
ttsTitle: 'Text-to-Speech',
ttsDesc: 'Configure TTS with Edge TTS (free), OpenAI TTS, and DashScope CosyVoice',
ttsProviderOptions: {
auto: 'Auto Select',
},
ttsProviderTags: {
free: 'Free',
noKeyNeeded: 'No API Key Needed',
reuseLlmKey: 'Reuses LLM API Key',
},
ttsAutoModeOptions: {
off: 'Off (Manual only)',
always: 'Always Auto Read',
},
imageTitle: 'Image Generation',
imageDesc: 'Configure AI image generation with DashScope, OpenAI DALL-E, fal.ai Flux, and Zhipu CogView',
imageProviderOptions: {
auto: 'Auto Select',
},
imageProviderTags: {
reuseLlmKey: 'Reuses LLM API Key',
freeQuota: 'Free Quota Available',
configuredInModels: 'See Model Settings',
sharedWithVideo: 'Shared with Video',
},
videoTitle: 'Video Generation',
videoDesc: 'Configure AI video generation capabilities, supporting text-to-video and image-to-video',
videoProviderOptions: {

View File

@ -87,6 +87,8 @@ export default {
copy: '复制',
copied: '已复制',
regenerate: '重新生成',
ttsPlay: '朗读',
ttsStop: '停止朗读',
conversations: '会话列表',
newChat: '新对话',
loadingAgents: '加载 Agent 中...',
@ -204,6 +206,8 @@ export default {
sections: {
model: '模型管理',
system: '系统设置',
image: '图片生成',
tts: '语音合成',
video: '视频生成',
about: '关于',
},
@ -333,6 +337,17 @@ export default {
tavilyBaseUrl: 'Tavily 接口地址',
duckduckgoEnabled: 'DuckDuckGo免 Key',
searxngBaseUrl: 'SearXNG 地址',
// TTS 语音合成
ttsEnabled: '启用语音合成',
ttsProvider: '首选 TTS Provider',
ttsFallbackEnabled: 'Provider 回退',
ttsAutoMode: '自动朗读模式',
ttsSpeed: '语速',
// 图片生成
imageEnabled: '启用图片生成',
imageProvider: '首选图片 Provider',
imageFallbackEnabled: 'Provider 回退',
openaiStatus: 'OpenAI 状态',
// 视频生成
videoEnabled: '启用视频生成',
videoProvider: '首选视频 Provider',
@ -358,6 +373,23 @@ export default {
tavilyBaseUrl: '通常无需修改,除非使用自定义代理地址。',
duckduckgoEnabled: '免费搜索兜底,无需 API Key。默认开启作为零配置下的搜索降级方案。',
searxngBaseUrl: '自部署 SearXNG 实例地址。Docker 部署时自动配置。',
// TTS 语音合成
ttsEnabled: '开启后可通过消息朗读按钮或自动模式使用语音合成。Edge TTS 免费无需 Key。',
ttsProvider: '选择首选 TTS Providerauto 模式优先使用免费的 Edge TTS。',
ttsFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。',
ttsAutoMode: 'off = 仅手动朗读always = 每条 AI 回复自动播放语音。',
ttsSpeed: '语音播放速度1.0 为正常速度。',
edgeTtsInfo: '微软 Edge 内置 TTS 服务,免费使用,支持中文、英文、日文等多语言。自动根据文本语言切换语音。',
dashscopeTtsInfo: '复用模型管理中的 DashScope API Key。使用 CosyVoice 模型,支持多种中文语音。',
openaiTtsInfo: '复用模型管理中的 OpenAI API Key。支持 alloy、echo、nova 等多种语音风格。',
// 图片生成
imageEnabled: '开启后 Agent 可使用图片生成工具。需至少配置一个图片 Provider 的 API Key。',
imageProvider: '选择首选图片生成 Providerauto 模式自动选择第一个可用的。',
imageFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。',
dashscopeImageStatus: '复用模型管理中配置的 DashScope API Key无需额外配置。',
openaiImageStatus: '复用模型管理中配置的 OpenAI API Key无需额外配置。',
zhipuImageApiKey: '从 bigmodel.cn 获取。CogView-3-Flash 模型免费。与视频生成共用同一 Key。',
falImageApiKey: '从 fal.ai 获取,支持 Flux 系列图片生成模型。与视频生成共用同一 Key。',
// 视频生成
videoEnabled: '开启后 Agent 可使用视频生成工具。需至少配置一个视频 Provider 的 API Key。',
videoProvider: '选择首选视频生成 Providerauto 模式自动选择第一个可用的。',
@ -371,6 +403,31 @@ export default {
},
searchTitle: '搜索服务',
searchDesc: '配置内置搜索工具的提供商与 API 凭证',
ttsTitle: '语音合成',
ttsDesc: '配置 TTS 语音合成,支持 Edge TTS免费、OpenAI TTS、DashScope CosyVoice',
ttsProviderOptions: {
auto: '自动选择',
},
ttsProviderTags: {
free: '免费',
noKeyNeeded: '无需 API Key',
reuseLlmKey: '复用 LLM API Key',
},
ttsAutoModeOptions: {
off: '关闭(仅手动朗读)',
always: '始终自动朗读',
},
imageTitle: '图片生成',
imageDesc: '配置 AI 图片生成能力,支持 DashScope、OpenAI DALL-E、fal.ai Flux、智谱 CogView',
imageProviderOptions: {
auto: '自动选择',
},
imageProviderTags: {
reuseLlmKey: '复用 LLM API Key',
freeQuota: '有免费额度',
configuredInModels: '前往模型设置查看',
sharedWithVideo: '与视频共用',
},
videoTitle: '视频生成',
videoDesc: '配置 AI 视频生成能力,支持文字生成视频和图片生成视频',
videoProviderOptions: {

View File

@ -85,6 +85,18 @@ const router = createRouter({
component: () => import('@/views/Settings/System/index.vue'),
meta: { title: 'Settings - System' },
},
{
path: 'image',
name: 'SettingsImage',
component: () => import('@/views/Settings/Image/index.vue'),
meta: { title: 'Settings - Image' },
},
{
path: 'tts',
name: 'SettingsTts',
component: () => import('@/views/Settings/Tts/index.vue'),
meta: { title: 'Settings - TTS' },
},
{
path: 'video',
name: 'SettingsVideo',

View File

@ -0,0 +1,262 @@
<template>
<div class="settings-section image-section">
<div class="section-header">
<h2 class="section-title">{{ t('settings.imageTitle') }}</h2>
<p class="section-desc">{{ t('settings.imageDesc') }}</p>
</div>
<div class="settings-card">
<!-- 总开关 -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.imageEnabled') }}</div>
<div class="setting-hint">{{ t('settings.hints.imageEnabled') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.imageEnabled" type="checkbox" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<!-- 首选 Provider -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.imageProvider') }}</div>
<div class="setting-hint">{{ t('settings.hints.imageProvider') }}</div>
</div>
<div class="setting-control">
<select v-model="settings.imageProvider" class="form-input" :disabled="!settings.imageEnabled">
<option value="auto">{{ t('settings.imageProviderOptions.auto') }}</option>
<option value="dashscope">DashScope (通义万相)</option>
<option value="zhipu-cogview">智谱 CogView</option>
<option value="openai">OpenAI (DALL-E)</option>
<option value="fal">fal.ai (Flux)</option>
</select>
</div>
</div>
<!-- Fallback 开关 -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.imageFallbackEnabled') }}</div>
<div class="setting-hint">{{ t('settings.hints.imageFallbackEnabled') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.imageFallbackEnabled" type="checkbox" :disabled="!settings.imageEnabled" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<!-- Provider 配置区块仅在启用时显示 -->
<template v-if="settings.imageEnabled">
<!-- DashScope -->
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">DashScope (通义万相)</span>
<span class="provider-tag">{{ t('settings.imageProviderTags.reuseLlmKey') }}</span>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.dashscopeStatus') }}</div>
<div class="setting-hint">{{ t('settings.hints.dashscopeImageStatus') }}</div>
</div>
<div class="setting-control">
<span class="status-tag">{{ t('settings.imageProviderTags.configuredInModels') }}</span>
</div>
</div>
</div>
</div>
<!-- OpenAI -->
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">OpenAI (DALL-E)</span>
<span class="provider-tag">{{ t('settings.imageProviderTags.reuseLlmKey') }}</span>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.openaiStatus') }}</div>
<div class="setting-hint">{{ t('settings.hints.openaiImageStatus') }}</div>
</div>
<div class="setting-control">
<span class="status-tag">{{ t('settings.imageProviderTags.configuredInModels') }}</span>
</div>
</div>
</div>
</div>
<!-- 智谱 CogView -->
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">智谱 CogView</span>
<span class="provider-tag tag-free">{{ t('settings.imageProviderTags.freeQuota') }}</span>
<span class="provider-tag">{{ t('settings.imageProviderTags.sharedWithVideo') }}</span>
</div>
<div class="settings-card">
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.zhipuApiKey') }}</div>
<div class="setting-hint">{{ t('settings.hints.zhipuImageApiKey') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="zhipuApiKeyInput"
type="password"
class="form-input"
:placeholder="settings.zhipuApiKeyMasked || t('settings.model.apiKeyInput')"
autocomplete="off"
/>
</div>
</div>
</div>
</div>
<!-- fal.ai -->
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">fal.ai (Flux)</span>
<span class="provider-tag">{{ t('settings.imageProviderTags.sharedWithVideo') }}</span>
</div>
<div class="settings-card">
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.falApiKey') }}</div>
<div class="setting-hint">{{ t('settings.hints.falImageApiKey') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="falApiKeyInput"
type="password"
class="form-input"
:placeholder="settings.falApiKeyMasked || t('settings.model.apiKeyInput')"
autocomplete="off"
/>
</div>
</div>
</div>
</div>
</template>
<div class="save-bar">
<button class="btn-secondary" @click="loadSettings">{{ t('common.reset') }}</button>
<button class="btn-primary" @click="onSaveSettings">{{ t('settings.actions.saveSystem') }}</button>
</div>
<div v-if="savedTip" class="save-tip">{{ savedTip }}</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { settingsApi } from '@/api'
const { t } = useI18n()
const savedTip = ref('')
// API Key Key
const zhipuApiKeyInput = ref('')
const falApiKeyInput = ref('')
const settings = reactive({
imageEnabled: false,
imageProvider: 'auto',
imageFallbackEnabled: true,
zhipuApiKeyMasked: '',
falApiKeyMasked: '',
})
onMounted(async () => {
await loadSettings()
})
async function loadSettings() {
const res: any = await settingsApi.get()
const data = res.data || {}
settings.imageEnabled = data.imageEnabled ?? false
settings.imageProvider = data.imageProvider ?? 'auto'
settings.imageFallbackEnabled = data.imageFallbackEnabled ?? true
settings.zhipuApiKeyMasked = data.zhipuApiKeyMasked ?? ''
settings.falApiKeyMasked = data.falApiKeyMasked ?? ''
//
zhipuApiKeyInput.value = ''
falApiKeyInput.value = ''
}
async function onSaveSettings() {
const payload: any = {
imageEnabled: settings.imageEnabled,
imageProvider: settings.imageProvider,
imageFallbackEnabled: settings.imageFallbackEnabled,
}
// API Key
if (zhipuApiKeyInput.value) payload.zhipuApiKey = zhipuApiKeyInput.value
if (falApiKeyInput.value) payload.falApiKey = falApiKeyInput.value
await settingsApi.update(payload)
await loadSettings()
showSavedTip(t('settings.messages.saveSuccess'))
}
function showSavedTip(message: string) {
savedTip.value = message
window.setTimeout(() => { savedTip.value = '' }, 2500)
}
</script>
<style scoped>
.settings-section { width: 100%; }
.settings-section.image-section { max-width: none; }
.section-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
.section-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
.settings-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124, 63, 30, 0.04); width: 100%; }
.setting-item { display: flex; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--mc-border-light); }
.setting-item:last-child { border-bottom: none; }
.setting-item-vertical { flex-direction: column; gap: 10px; }
.setting-info { flex: 1; }
.setting-label { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin-bottom: 4px; }
.setting-hint { font-size: 13px; color: var(--mc-text-secondary); }
.setting-control { width: 220px; display: flex; align-items: center; justify-content: flex-end; }
.setting-control-full { width: 100%; }
.form-input { width: 100%; border: 1px solid var(--mc-border); border-radius: 10px; padding: 10px 12px; font-size: 14px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
.form-input:focus { outline: none; border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1); }
.form-input:disabled { opacity: 0.5; cursor: not-allowed; }
.toggle-switch { position: relative; display: inline-flex; width: 44px; height: 24px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; inset: 0; cursor: pointer; background: var(--mc-border); border-radius: 999px; transition: 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: 0.2s; }
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(20px); }
.toggle-switch input:disabled + .toggle-slider { opacity: 0.5; cursor: not-allowed; }
.provider-section { margin-top: 24px; }
.provider-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.provider-name { font-size: 16px; font-weight: 600; color: var(--mc-text-primary); }
.provider-tag { font-size: 12px; padding: 2px 8px; border-radius: 6px; background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
.provider-tag.tag-free { background: #e8f5e9; color: #2e7d32; }
.status-tag { font-size: 13px; color: var(--mc-text-secondary); }
.save-bar { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.btn-primary, .btn-secondary { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; }
.btn-primary { background: var(--mc-primary); color: white; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-secondary { background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.save-tip { position: fixed; right: 24px; bottom: 24px; background: var(--mc-text-primary); color: var(--mc-text-inverse); padding: 10px 14px; border-radius: 10px; box-shadow: 0 10px 30px rgba(124, 63, 30, 0.22); }
@media (max-width: 900px) {
.setting-item { flex-direction: column; }
.setting-control { width: 100%; justify-content: flex-start; }
}
</style>

View File

@ -41,6 +41,18 @@ const sections = computed(() => [
label: t('settings.sections.system'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.09a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .66.26 1.3.73 1.77.47.47 1.11.73 1.77.73H21a2 2 0 1 1 0 4h-.09c-.66 0-1.3.26-1.77.73-.47.47-.73 1.11-.73 1.77z"/></svg>',
},
{
id: 'image',
path: '/settings/image',
label: t('settings.sections.image'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
},
{
id: 'tts',
path: '/settings/tts',
label: t('settings.sections.tts'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>',
},
{
id: 'video',
path: '/settings/video',

View File

@ -0,0 +1,243 @@
<template>
<div class="settings-section tts-section">
<div class="section-header">
<h2 class="section-title">{{ t('settings.ttsTitle') }}</h2>
<p class="section-desc">{{ t('settings.ttsDesc') }}</p>
</div>
<div class="settings-card">
<!-- 总开关 -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.ttsEnabled') }}</div>
<div class="setting-hint">{{ t('settings.hints.ttsEnabled') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.ttsEnabled" type="checkbox" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<!-- 首选 Provider -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.ttsProvider') }}</div>
<div class="setting-hint">{{ t('settings.hints.ttsProvider') }}</div>
</div>
<div class="setting-control">
<select v-model="settings.ttsProvider" class="form-input" :disabled="!settings.ttsEnabled">
<option value="auto">{{ t('settings.ttsProviderOptions.auto') }}</option>
<option value="edge-tts">Edge TTS ({{ t('settings.ttsProviderTags.free') }})</option>
<option value="dashscope">DashScope (CosyVoice)</option>
<option value="openai">OpenAI TTS</option>
</select>
</div>
</div>
<!-- Fallback 开关 -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.ttsFallbackEnabled') }}</div>
<div class="setting-hint">{{ t('settings.hints.ttsFallbackEnabled') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.ttsFallbackEnabled" type="checkbox" :disabled="!settings.ttsEnabled" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<!-- 自动模式 -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.ttsAutoMode') }}</div>
<div class="setting-hint">{{ t('settings.hints.ttsAutoMode') }}</div>
</div>
<div class="setting-control">
<select v-model="settings.ttsAutoMode" class="form-input" :disabled="!settings.ttsEnabled">
<option value="off">{{ t('settings.ttsAutoModeOptions.off') }}</option>
<option value="always">{{ t('settings.ttsAutoModeOptions.always') }}</option>
</select>
</div>
</div>
<!-- 语速 -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.ttsSpeed') }}</div>
<div class="setting-hint">{{ t('settings.hints.ttsSpeed') }}</div>
</div>
<div class="setting-control speed-control">
<input
v-model.number="settings.ttsSpeed"
type="range"
min="0.5"
max="2.0"
step="0.1"
:disabled="!settings.ttsEnabled"
class="speed-slider"
/>
<span class="speed-value">{{ settings.ttsSpeed?.toFixed(1) }}x</span>
</div>
</div>
</div>
<!-- Provider 说明 -->
<template v-if="settings.ttsEnabled">
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">Edge TTS</span>
<span class="provider-tag tag-free">{{ t('settings.ttsProviderTags.free') }}</span>
<span class="provider-tag">{{ t('settings.ttsProviderTags.noKeyNeeded') }}</span>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-hint">{{ t('settings.hints.edgeTtsInfo') }}</div>
</div>
</div>
</div>
</div>
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">DashScope (CosyVoice)</span>
<span class="provider-tag">{{ t('settings.ttsProviderTags.reuseLlmKey') }}</span>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-hint">{{ t('settings.hints.dashscopeTtsInfo') }}</div>
</div>
</div>
</div>
</div>
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">OpenAI TTS</span>
<span class="provider-tag">{{ t('settings.ttsProviderTags.reuseLlmKey') }}</span>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-hint">{{ t('settings.hints.openaiTtsInfo') }}</div>
</div>
</div>
</div>
</div>
</template>
<div class="save-bar">
<button class="btn-secondary" @click="loadSettings">{{ t('common.reset') }}</button>
<button class="btn-primary" @click="onSaveSettings">{{ t('settings.actions.saveSystem') }}</button>
</div>
<div v-if="savedTip" class="save-tip">{{ savedTip }}</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { settingsApi } from '@/api'
const { t } = useI18n()
const savedTip = ref('')
const settings = reactive({
ttsEnabled: false,
ttsProvider: 'auto',
ttsFallbackEnabled: true,
ttsAutoMode: 'off',
ttsDefaultVoice: '',
ttsSpeed: 1.0,
})
onMounted(async () => {
await loadSettings()
})
async function loadSettings() {
const res: any = await settingsApi.get()
const data = res.data || {}
settings.ttsEnabled = data.ttsEnabled ?? false
settings.ttsProvider = data.ttsProvider ?? 'auto'
settings.ttsFallbackEnabled = data.ttsFallbackEnabled ?? true
settings.ttsAutoMode = data.ttsAutoMode ?? 'off'
settings.ttsDefaultVoice = data.ttsDefaultVoice ?? ''
settings.ttsSpeed = data.ttsSpeed ?? 1.0
}
async function onSaveSettings() {
await settingsApi.update({
ttsEnabled: settings.ttsEnabled,
ttsProvider: settings.ttsProvider,
ttsFallbackEnabled: settings.ttsFallbackEnabled,
ttsAutoMode: settings.ttsAutoMode,
ttsDefaultVoice: settings.ttsDefaultVoice,
ttsSpeed: settings.ttsSpeed,
})
await loadSettings()
showSavedTip(t('settings.messages.saveSuccess'))
}
function showSavedTip(message: string) {
savedTip.value = message
window.setTimeout(() => { savedTip.value = '' }, 2500)
}
</script>
<style scoped>
.settings-section { width: 100%; }
.settings-section.tts-section { max-width: none; }
.section-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
.section-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
.settings-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124, 63, 30, 0.04); width: 100%; }
.setting-item { display: flex; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--mc-border-light); }
.setting-item:last-child { border-bottom: none; }
.setting-info { flex: 1; }
.setting-label { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin-bottom: 4px; }
.setting-hint { font-size: 13px; color: var(--mc-text-secondary); }
.setting-control { width: 220px; display: flex; align-items: center; justify-content: flex-end; }
.form-input { width: 100%; border: 1px solid var(--mc-border); border-radius: 10px; padding: 10px 12px; font-size: 14px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
.form-input:focus { outline: none; border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1); }
.form-input:disabled { opacity: 0.5; cursor: not-allowed; }
.toggle-switch { position: relative; display: inline-flex; width: 44px; height: 24px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; inset: 0; cursor: pointer; background: var(--mc-border); border-radius: 999px; transition: 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: 0.2s; }
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(20px); }
.toggle-switch input:disabled + .toggle-slider { opacity: 0.5; cursor: not-allowed; }
.speed-control { gap: 10px; }
.speed-slider { width: 140px; accent-color: var(--mc-primary); }
.speed-value { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); min-width: 36px; text-align: right; }
.provider-section { margin-top: 24px; }
.provider-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.provider-name { font-size: 16px; font-weight: 600; color: var(--mc-text-primary); }
.provider-tag { font-size: 12px; padding: 2px 8px; border-radius: 6px; background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
.provider-tag.tag-free { background: #e8f5e9; color: #2e7d32; }
.save-bar { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.btn-primary, .btn-secondary { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; }
.btn-primary { background: var(--mc-primary); color: white; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-secondary { background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.save-tip { position: fixed; right: 24px; bottom: 24px; background: var(--mc-text-primary); color: var(--mc-text-inverse); padding: 10px 14px; border-radius: 10px; box-shadow: 0 10px 30px rgba(124, 63, 30, 0.22); }
@media (max-width: 900px) {
.setting-item { flex-direction: column; }
.setting-control { width: 100%; justify-content: flex-start; }
}
</style>