feat(video): add video generation capability with 4 providers and async task infrastructure

This commit is contained in:
matevip 2026-04-07 19:14:27 +08:00
parent c3eeca7171
commit fe3a7f7b01
33 changed files with 2677 additions and 1 deletions

View File

@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.approval.ApprovalWorkflowService;
@ -143,9 +144,13 @@ public class ToolExecutionExecutor {
return execute(toolCalls, conversationId, agentId, isReplay, "");
}
/** 当前执行的 requesterId传递给 ToolExecutionContext */
private volatile String currentRequesterId;
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
String conversationId, String agentId,
boolean isReplay, String requesterId) {
this.currentRequesterId = requesterId;
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
@ -386,7 +391,16 @@ public class ToolExecutionExecutor {
log.info("[ToolExecutor] Executing tool: {} with args: {}",
toolName, pc.arguments != null && pc.arguments.length() > 200
? pc.arguments.substring(0, 200) + "..." : pc.arguments);
String result = pc.callback.call(pc.arguments);
// 注入工具执行上下文 VideoGenerateTool 等获取 conversationId / username
ToolExecutionContext.set(pc.conversationId, currentRequesterId);
String result;
try {
result = pc.callback.call(pc.arguments);
} finally {
ToolExecutionContext.clear();
}
int rawLen = result != null ? result.length() : 0;
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,

View File

@ -33,4 +33,31 @@ public class SystemSettingsDTO {
// 用于前端回显脱敏后的 API Key
private String serperApiKeyMasked;
private String tavilyApiKeyMasked;
// ===== 视频生成配置 =====
/** 是否启用视频生成能力 */
private Boolean videoEnabled;
/** 首选视频 provider: auto / dashscope / zhipu-cogvideo / fal / kling */
private String videoProvider;
/** 是否启用 provider 级 fallback */
private Boolean videoFallbackEnabled;
// --- 智谱 CogVideo ---
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String zhipuApiKey;
private String zhipuBaseUrl;
private String zhipuApiKeyMasked;
// --- fal.ai ---
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String falApiKey;
private String falApiKeyMasked;
// --- 快手可灵 Kling ---
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String klingAccessKey;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String klingSecretKey;
private String klingAccessKeyMasked;
private String klingSecretKeyMasked;
}

View File

@ -27,6 +27,16 @@ public class SystemSettingService {
private static final String DUCKDUCKGO_ENABLED_KEY = "duckduckgoEnabled";
private static final String SEARXNG_BASE_URL_KEY = "searxngBaseUrl";
// 视频生成配置 keys
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";
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";
private static final String KLING_ACCESS_KEY_KEY = "klingAccessKey";
private static final String KLING_SECRET_KEY_KEY = "klingSecretKey";
private final SystemSettingMapper systemSettingMapper;
public SystemSettingsDTO getSettings() {
@ -48,6 +58,32 @@ public class SystemSettingService {
// API Key 脱敏回显
dto.setSerperApiKeyMasked(maskApiKey(getValue(SERPER_API_KEY_KEY, "")));
dto.setTavilyApiKeyMasked(maskApiKey(getValue(TAVILY_API_KEY_KEY, "")));
// 视频生成配置
dto.setVideoEnabled(Boolean.parseBoolean(getValue(VIDEO_ENABLED_KEY, "false")));
dto.setVideoProvider(getValue(VIDEO_PROVIDER_KEY, "auto"));
dto.setVideoFallbackEnabled(Boolean.parseBoolean(getValue(VIDEO_FALLBACK_ENABLED_KEY, "true")));
dto.setZhipuBaseUrl(getValue(ZHIPU_BASE_URL_KEY, ""));
dto.setZhipuApiKeyMasked(maskApiKey(getValue(ZHIPU_API_KEY_KEY, "")));
dto.setFalApiKeyMasked(maskApiKey(getValue(FAL_API_KEY_KEY, "")));
dto.setKlingAccessKeyMasked(maskApiKey(getValue(KLING_ACCESS_KEY_KEY, "")));
dto.setKlingSecretKeyMasked(maskApiKey(getValue(KLING_SECRET_KEY_KEY, "")));
return dto;
}
/**
* 获取全部配置内部使用包含明文 API Key VideoGenerationService 等后端服务使用
*/
public SystemSettingsDTO getAllSettings() {
SystemSettingsDTO dto = getSettings();
// 补充搜索明文 Key
dto.setSerperApiKey(getValue(SERPER_API_KEY_KEY, ""));
dto.setTavilyApiKey(getValue(TAVILY_API_KEY_KEY, ""));
// 补充视频明文 Key
dto.setZhipuApiKey(getValue(ZHIPU_API_KEY_KEY, ""));
dto.setFalApiKey(getValue(FAL_API_KEY_KEY, ""));
dto.setKlingAccessKey(getValue(KLING_ACCESS_KEY_KEY, ""));
dto.setKlingSecretKey(getValue(KLING_SECRET_KEY_KEY, ""));
return dto;
}
@ -104,6 +140,32 @@ public class SystemSettingService {
if (dto.getSearxngBaseUrl() != null) {
saveValue(SEARXNG_BASE_URL_KEY, dto.getSearxngBaseUrl(), "SearXNG 实例地址");
}
// 视频生成配置
if (dto.getVideoEnabled() != null) {
saveValue(VIDEO_ENABLED_KEY, String.valueOf(dto.getVideoEnabled()), "是否启用视频生成");
}
if (dto.getVideoProvider() != null) {
saveValue(VIDEO_PROVIDER_KEY, dto.getVideoProvider(), "视频生成首选 Provider");
}
if (dto.getVideoFallbackEnabled() != null) {
saveValue(VIDEO_FALLBACK_ENABLED_KEY, String.valueOf(dto.getVideoFallbackEnabled()), "视频 Provider 级 Fallback");
}
if (dto.getZhipuApiKey() != null && !dto.getZhipuApiKey().isBlank()) {
saveValue(ZHIPU_API_KEY_KEY, dto.getZhipuApiKey(), "智谱 CogVideo API Key");
}
if (dto.getZhipuBaseUrl() != null) {
saveValue(ZHIPU_BASE_URL_KEY, dto.getZhipuBaseUrl(), "智谱 API Base URL");
}
if (dto.getFalApiKey() != null && !dto.getFalApiKey().isBlank()) {
saveValue(FAL_API_KEY_KEY, dto.getFalApiKey(), "fal.ai API Key");
}
if (dto.getKlingAccessKey() != null && !dto.getKlingAccessKey().isBlank()) {
saveValue(KLING_ACCESS_KEY_KEY, dto.getKlingAccessKey(), "快手可灵 Access Key");
}
if (dto.getKlingSecretKey() != null && !dto.getKlingSecretKey().isBlank()) {
saveValue(KLING_SECRET_KEY_KEY, dto.getKlingSecretKey(), "快手可灵 Secret Key");
}
return getSettings();
}

View File

@ -0,0 +1,340 @@
package vip.mate.task;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Service;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.task.model.AsyncTaskEntity;
import vip.mate.task.model.AsyncTaskInfo;
import vip.mate.task.repository.AsyncTaskMapper;
import jakarta.annotation.PreDestroy;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.BiConsumer;
import java.util.function.Function;
/**
* 通用异步任务服务 管理长耗时任务的生命周期提交轮询完成回写
* <p>
* 可复用于视频生成图片生成音频生成等异步场景
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AsyncTaskService implements ApplicationRunner {
private final AsyncTaskMapper asyncTaskMapper;
private final ChatStreamTracker streamTracker;
/** 轮询线程池小池2 线程足够) */
private final ScheduledExecutorService pollExecutor =
Executors.newScheduledThreadPool(2, r -> {
Thread t = new Thread(r, "async-task-poll");
t.setDaemon(true);
return t;
});
/** 活跃轮询任务key = taskId */
private final ConcurrentHashMap<String, ScheduledFuture<?>> activePolls = new ConcurrentHashMap<>();
/** 每用户最多并行任务数 */
private static final int MAX_ACTIVE_TASKS_PER_USER = 3;
/** 默认轮询间隔(秒) */
private static final int POLL_INTERVAL_SECONDS = 8;
/** 最大轮询时长(分钟),超时自动标记失败 */
private static final int MAX_POLL_DURATION_MINUTES = 15;
/** 连续轮询失败次数上限,超出自动标记任务失败 */
private static final int MAX_POLL_ERROR_COUNT = 5;
/** 轮询连续错误计数 */
private final ConcurrentHashMap<String, Integer> pollErrorCounts = new ConcurrentHashMap<>();
// ==================== 任务创建 ====================
/**
* 创建一个异步任务记录
*
* @return 创建的任务实体
*/
public AsyncTaskEntity createTask(String taskType, String conversationId,
Long messageId, String providerName,
String providerTaskId, String requestJson,
String createdBy) {
// 并发限制检查
long activeCount = asyncTaskMapper.selectCount(
new LambdaQueryWrapper<AsyncTaskEntity>()
.eq(AsyncTaskEntity::getCreatedBy, createdBy)
.in(AsyncTaskEntity::getStatus, List.of("pending", "running"))
);
if (activeCount >= MAX_ACTIVE_TASKS_PER_USER) {
throw new IllegalStateException("已达到最大并行任务数(" + MAX_ACTIVE_TASKS_PER_USER + "),请等待现有任务完成");
}
AsyncTaskEntity entity = new AsyncTaskEntity();
entity.setTaskId(UUID.randomUUID().toString().replace("-", "").substring(0, 16));
entity.setTaskType(taskType);
entity.setStatus("pending");
entity.setConversationId(conversationId);
entity.setMessageId(messageId);
entity.setProviderName(providerName);
entity.setProviderTaskId(providerTaskId);
entity.setRequestJson(requestJson);
entity.setProgress(0);
entity.setCreatedBy(createdBy);
entity.setCreateTime(LocalDateTime.now());
entity.setUpdateTime(LocalDateTime.now());
asyncTaskMapper.insert(entity);
log.info("[AsyncTask] Created task {} (type={}, provider={}, providerTaskId={})",
entity.getTaskId(), taskType, providerName, providerTaskId);
return entity;
}
// ==================== 轮询管理 ====================
/**
* 启动对某个任务的定期轮询
*
* @param taskId 内部任务 ID
* @param statusChecker 轮询函数providerTaskId 状态
* @param onComplete 完成回调(task, status) void
*/
public void startPolling(String taskId,
Function<String, TaskPollResult> statusChecker,
BiConsumer<AsyncTaskEntity, TaskPollResult> onComplete) {
AsyncTaskEntity task = findByTaskId(taskId);
if (task == null) {
log.warn("[AsyncTask] Cannot start polling: task {} not found", taskId);
return;
}
// 更新状态为 running
updateStatus(taskId, "running", null, null, null);
LocalDateTime deadline = LocalDateTime.now().plusMinutes(MAX_POLL_DURATION_MINUTES);
ScheduledFuture<?> future = pollExecutor.scheduleWithFixedDelay(() -> {
try {
// 超时检查
if (LocalDateTime.now().isAfter(deadline)) {
log.warn("[AsyncTask] Task {} timed out after {} minutes", taskId, MAX_POLL_DURATION_MINUTES);
updateStatus(taskId, "failed", null, null, "任务超时(超过 " + MAX_POLL_DURATION_MINUTES + " 分钟)");
cancelPolling(taskId);
broadcastTaskEvent(task, "async_task_completed", false, null, "任务超时");
return;
}
TaskPollResult result = statusChecker.apply(task.getProviderTaskId());
if (result == null) {
return;
}
// 轮询成功重置错误计数
pollErrorCounts.remove(taskId);
// 更新进度
if (result.progress() != null) {
updateStatus(taskId, "running", result.progress(), null, null);
broadcastProgress(task, result.progress());
}
// 终态处理
if (result.isTerminal()) {
cancelPolling(taskId);
if (result.succeeded()) {
updateStatus(taskId, "succeeded", 100, result.resultJson(), null);
} else {
updateStatus(taskId, "failed", null, null, result.errorMessage());
}
// 刷新任务实体
AsyncTaskEntity freshTask = findByTaskId(taskId);
onComplete.accept(freshTask, result);
}
} catch (Exception e) {
int errorCount = pollErrorCounts.merge(taskId, 1, Integer::sum);
log.error("[AsyncTask] Polling error for task {} ({}/{}): {}",
taskId, errorCount, MAX_POLL_ERROR_COUNT, e.getMessage(), e);
if (errorCount >= MAX_POLL_ERROR_COUNT) {
log.error("[AsyncTask] Task {} exceeded max poll errors, marking as failed", taskId);
updateStatus(taskId, "failed", null, null,
"轮询连续失败 " + errorCount + " 次: " + e.getMessage());
cancelPolling(taskId);
pollErrorCounts.remove(taskId);
broadcastTaskEvent(task, "async_task_completed", false, null,
"轮询异常,任务已标记失败");
}
}
}, 3, POLL_INTERVAL_SECONDS, TimeUnit.SECONDS);
activePolls.put(taskId, future);
log.info("[AsyncTask] Started polling for task {} (interval={}s, timeout={}min)",
taskId, POLL_INTERVAL_SECONDS, MAX_POLL_DURATION_MINUTES);
}
private void cancelPolling(String taskId) {
ScheduledFuture<?> future = activePolls.remove(taskId);
if (future != null) {
future.cancel(false);
}
}
// ==================== 状态更新 ====================
public void updateStatus(String taskId, String status, Integer progress,
String resultJson, String errorMessage) {
LambdaUpdateWrapper<AsyncTaskEntity> wrapper = new LambdaUpdateWrapper<AsyncTaskEntity>()
.eq(AsyncTaskEntity::getTaskId, taskId)
.set(AsyncTaskEntity::getStatus, status)
.set(AsyncTaskEntity::getUpdateTime, LocalDateTime.now());
if (progress != null) {
wrapper.set(AsyncTaskEntity::getProgress, progress);
}
if (resultJson != null) {
wrapper.set(AsyncTaskEntity::getResultJson, resultJson);
}
if (errorMessage != null) {
wrapper.set(AsyncTaskEntity::getErrorMessage, errorMessage);
}
asyncTaskMapper.update(null, wrapper);
}
// ==================== 查询 ====================
public AsyncTaskInfo getTaskInfo(String taskId) {
AsyncTaskEntity entity = findByTaskId(taskId);
if (entity == null) {
return null;
}
return toInfo(entity);
}
public List<AsyncTaskInfo> listActiveTasks(String conversationId) {
List<AsyncTaskEntity> entities = asyncTaskMapper.selectList(
new LambdaQueryWrapper<AsyncTaskEntity>()
.eq(AsyncTaskEntity::getConversationId, conversationId)
.in(AsyncTaskEntity::getStatus, List.of("pending", "running"))
.orderByDesc(AsyncTaskEntity::getCreateTime)
);
return entities.stream().map(this::toInfo).toList();
}
private AsyncTaskEntity findByTaskId(String taskId) {
return asyncTaskMapper.selectOne(
new LambdaQueryWrapper<AsyncTaskEntity>()
.eq(AsyncTaskEntity::getTaskId, taskId)
);
}
private AsyncTaskInfo toInfo(AsyncTaskEntity entity) {
return AsyncTaskInfo.builder()
.taskId(entity.getTaskId())
.taskType(entity.getTaskType())
.status(entity.getStatus())
.progress(entity.getProgress())
.providerName(entity.getProviderName())
.errorMessage(entity.getErrorMessage())
.createTime(entity.getCreateTime())
.build();
}
// ==================== SSE 广播 ====================
private void broadcastProgress(AsyncTaskEntity task, int progress) {
Map<String, Object> data = Map.of(
"taskId", task.getTaskId(),
"taskType", task.getTaskType(),
"progress", progress,
"providerName", Objects.toString(task.getProviderName(), "")
);
streamTracker.broadcastObject(task.getConversationId(), "async_task_progress", data);
}
public void broadcastTaskEvent(AsyncTaskEntity task, String eventName,
boolean success, String videoUrl, 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 (errorMessage != null) data.put("errorMessage", errorMessage);
streamTracker.broadcastObject(task.getConversationId(), eventName, data);
}
// ==================== 启动恢复 ====================
@Override
public void run(ApplicationArguments args) {
List<AsyncTaskEntity> pendingTasks = asyncTaskMapper.selectList(
new LambdaQueryWrapper<AsyncTaskEntity>()
.in(AsyncTaskEntity::getStatus, List.of("pending", "running"))
);
if (!pendingTasks.isEmpty()) {
log.info("[AsyncTask] Found {} unfinished tasks on startup, marking as failed", pendingTasks.size());
for (AsyncTaskEntity task : pendingTasks) {
updateStatus(task.getTaskId(), "failed", null, null,
"服务重启导致任务中断,请重新提交");
}
}
}
// ==================== 生命周期 ====================
@PreDestroy
public void shutdown() {
int count = activePolls.size();
activePolls.values().forEach(f -> f.cancel(false));
activePolls.clear();
pollErrorCounts.clear();
pollExecutor.shutdownNow();
log.info("[AsyncTask] Shutdown complete, cancelled {} active polls", count);
}
// ==================== 轮询结果 ====================
/**
* Provider 轮询返回的结果
*/
public record TaskPollResult(
String state, // pending / running / succeeded / failed
Integer progress, // 0-100, nullable
String videoUrl, // 成功时的视频 URL
String coverImageUrl,// 可选封面图
String resultJson, // 完成时的完整结果 JSON
String errorMessage // 失败时的错误信息
) {
public boolean isTerminal() {
return "succeeded".equals(state) || "failed".equals(state);
}
public boolean succeeded() {
return "succeeded".equals(state);
}
public static TaskPollResult pending(Integer progress) {
return new TaskPollResult("pending", progress, null, null, null, null);
}
public static TaskPollResult running(Integer progress) {
return new TaskPollResult("running", progress, null, null, null, null);
}
public static TaskPollResult succeeded(String videoUrl, String coverImageUrl, String resultJson) {
return new TaskPollResult("succeeded", 100, videoUrl, coverImageUrl, resultJson, null);
}
public static TaskPollResult failed(String errorMessage) {
return new TaskPollResult("failed", null, null, null, null, errorMessage);
}
}
}

View File

@ -0,0 +1,64 @@
package vip.mate.task.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 异步任务实体 通用长耗时任务持久化视频生成图片生成等
*
* @author MateClaw Team
*/
@Data
@TableName("mate_async_task")
public class AsyncTaskEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 对外公开的任务 IDUUID 格式) */
private String taskId;
/** 任务类型video_generation / image_generation 等 */
private String taskType;
/** 任务状态pending / running / succeeded / failed */
private String status;
/** 关联会话 ID用于完成后回写 */
private String conversationId;
/** 关联消息 ID */
private Long messageId;
/** 处理该任务的 provider 名称 */
private String providerName;
/** provider 返回的外部任务 ID */
private String providerTaskId;
/** 序列化的请求参数JSON用于重试 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String requestJson;
/** 序列化的结果JSON完成时填充 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String resultJson;
/** 错误信息 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String errorMessage;
/** 进度0-100 */
private Integer progress;
/** 创建人 */
private String createdBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,29 @@
package vip.mate.task.model;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 异步任务对外 DTO
*
* @author MateClaw Team
*/
@Data
@Builder
public class AsyncTaskInfo {
private String taskId;
private String taskType;
private String status;
private Integer progress;
private String providerName;
private String resultVideoUrl;
private String errorMessage;
private LocalDateTime createTime;
public boolean isTerminal() {
return "succeeded".equals(status) || "failed".equals(status);
}
}

View File

@ -0,0 +1,14 @@
package vip.mate.task.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.task.model.AsyncTaskEntity;
/**
* 异步任务 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface AsyncTaskMapper extends BaseMapper<AsyncTaskEntity> {
}

View File

@ -0,0 +1,35 @@
package vip.mate.tool.builtin;
/**
* 工具执行上下文 通过 ThreadLocal @Tool 方法传递执行环境信息
* <p>
* ToolExecutionExecutor.executeSingleTool() set finally clear
* 视频生成等需要知道 conversationId 的工具从此处获取
*
* @author MateClaw Team
*/
public final class ToolExecutionContext {
private static final ThreadLocal<String> CONVERSATION_ID = new ThreadLocal<>();
private static final ThreadLocal<String> USERNAME = new ThreadLocal<>();
private ToolExecutionContext() {}
public static void set(String conversationId, String username) {
CONVERSATION_ID.set(conversationId);
USERNAME.set(username);
}
public static String conversationId() {
return CONVERSATION_ID.get();
}
public static String username() {
return USERNAME.get();
}
public static void clear() {
CONVERSATION_ID.remove();
USERNAME.remove();
}
}

View File

@ -0,0 +1,185 @@
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.video.*;
import java.util.List;
import java.util.StringJoiner;
/**
* 视频生成工具 Agent 可调用的 @Tool提交异步视频生成任务
* <p>
* 借鉴 OpenClaw video-generate-tool.ts 设计支持 action=generate/list/status
* 以及 session 级重复提交防护
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class VideoGenerateTool {
private final VideoGenerationService videoGenerationService;
private final VideoProviderRegistry providerRegistry;
private final SystemSettingService systemSettingService;
private final AsyncTaskService asyncTaskService;
@Tool(description = "视频生成工具,支持以下 action\n"
+ "- generate默认生成视频。提供 prompt 描述视频内容,可选 aspectRatio/duration/imageUrl/model\n"
+ "- list列出所有可用的视频 Provider 及其支持的模型和能力\n"
+ "- status查看当前会话中正在进行的视频生成任务状态\n"
+ "视频生成是异步过程1-5 分钟),完成后自动显示在对话中。")
public String video_generate(
@ToolParam(description = "操作类型: generate生成视频、list列出可用 Provider、status查看任务状态默认 generate", required = false) String action,
@ToolParam(description = "视频内容描述尽量详细generate 时必填)", required = false) String prompt,
@ToolParam(description = "画面比例: 16:9 / 9:16 / 1:1默认 16:9", required = false) String aspectRatio,
@ToolParam(description = "视频时长(秒),如 5 或 10默认 5", required = false) Integer duration,
@ToolParam(description = "参考图片 URL图生视频模式", required = false) String imageUrl,
@ToolParam(description = "指定模型名称(可选)", required = false) String model,
@ToolParam(description = "查询指定任务 ID 的状态status 模式时使用)", required = false) String taskId
) {
// 路由 action
String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase();
return switch (normalizedAction) {
case "list" -> handleListAction();
case "status" -> handleStatusAction(taskId);
default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model);
};
}
// ==================== action=list ====================
private String handleListAction() {
SystemSettingsDTO config = systemSettingService.getAllSettings();
List<VideoGenerationProvider> providers = providerRegistry.allSorted();
if (providers.isEmpty()) {
return "当前没有注册的视频生成 Provider。";
}
StringJoiner sb = new StringJoiner("\n\n");
sb.add("## 可用的视频生成 Provider\n");
for (VideoGenerationProvider p : providers) {
boolean available = p.isAvailable(config);
VideoProviderCapabilities caps = p.detailedCapabilities();
StringJoiner entry = new StringJoiner("\n");
entry.add("### " + p.label() + " (" + p.id() + ") " + (available ? "[已配置]" : "[未配置]"));
if (caps != null) {
entry.add("- 模式: " + caps.getModes());
if (caps.getModels() != null && !caps.getModels().isEmpty()) {
entry.add("- 模型: " + String.join(", ", caps.getModels()));
}
entry.add("- 画面比例: " + String.join(", ", caps.getAspectRatios()));
entry.add("- 支持时长: " + caps.getSupportedDurations() + "");
}
sb.add(entry.toString());
}
return sb.toString();
}
// ==================== action=status ====================
private String handleStatusAction(String taskId) {
String conversationId = ToolExecutionContext.conversationId();
// 指定 taskId 查询
if (taskId != null && !taskId.isBlank()) {
AsyncTaskInfo info = videoGenerationService.checkTaskStatus(taskId);
if (info == null) {
return "未找到任务 ID: " + taskId;
}
return formatTaskStatus(info);
}
// 查询当前会话的所有活跃任务
if (conversationId == null) {
return "无法获取当前会话信息";
}
List<AsyncTaskInfo> activeTasks = asyncTaskService.listActiveTasks(conversationId);
if (activeTasks.isEmpty()) {
return "当前会话没有进行中的视频生成任务。";
}
StringJoiner sb = new StringJoiner("\n");
sb.add("当前会话有 " + activeTasks.size() + " 个进行中的任务:");
for (AsyncTaskInfo task : activeTasks) {
sb.add("- 任务 " + task.getTaskId() + ": " + formatTaskStatus(task));
}
return sb.toString();
}
// ==================== action=generate ====================
private String handleGenerateAction(String prompt, String aspectRatio, Integer duration,
String imageUrl, String model) {
String conversationId = ToolExecutionContext.conversationId();
String username = ToolExecutionContext.username();
if (conversationId == null || conversationId.isBlank()) {
return "错误:无法获取当前会话信息,请重试";
}
if (prompt == null || prompt.isBlank()) {
return "错误prompt 为必填参数,请描述你想要生成的视频内容";
}
// Session 级重复提交防护借鉴 OpenClaw duplicateGuard
List<AsyncTaskInfo> activeTasks = asyncTaskService.listActiveTasks(conversationId);
long videoTasks = activeTasks.stream()
.filter(t -> "video_generation".equals(t.getTaskType()))
.count();
if (videoTasks > 0) {
AsyncTaskInfo existing = activeTasks.stream()
.filter(t -> "video_generation".equals(t.getTaskType()))
.findFirst().orElse(null);
return "当前会话已有一个视频生成任务正在进行中(任务 ID: "
+ (existing != null ? existing.getTaskId() : "unknown")
+ ")。请等待完成后再提交新任务,或使用 action=status 查看进度。";
}
VideoGenerationRequest request = VideoGenerationRequest.builder()
.prompt(prompt)
.aspectRatio(aspectRatio)
.durationSeconds(duration)
.imageUrl(imageUrl)
.model(model)
.build();
VideoGenerationResult result = videoGenerationService.submitGeneration(
request, conversationId, username != null ? username : "system");
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,18 @@
package vip.mate.tool.video;
/**
* 视频生成能力枚举
*
* @author MateClaw Team
*/
public enum VideoCapability {
/** 文字生成视频 */
GENERATE,
/** 图片生成视频 */
IMAGE_TO_VIDEO,
/** 视频生成视频(风格转换等) */
VIDEO_TO_VIDEO
}

View File

@ -0,0 +1,60 @@
package vip.mate.tool.video;
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;
/**
* 视频文件下载器 provider CDN 下载视频到本地存储
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class VideoFileDownloader {
private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads");
/**
* 下载视频到本地
*
* @param videoUrl provider 返回的视频 URL
* @param conversationId 会话 ID
* @param taskId 任务 ID
* @return 本地文件路径
*/
public Path download(String videoUrl, String conversationId, String taskId) throws IOException {
Path dir = UPLOAD_ROOT.resolve(conversationId);
Files.createDirectories(dir);
String extension = guessExtension(videoUrl);
String fileName = "video_" + taskId + extension;
Path targetFile = dir.resolve(fileName);
log.info("[VideoDownloader] Downloading video from {} to {}", videoUrl, targetFile);
long size = HttpUtil.downloadFile(videoUrl, targetFile.toFile());
log.info("[VideoDownloader] Downloaded {} bytes to {}", size, 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();
if (lower.contains(".mp4")) return ".mp4";
if (lower.contains(".webm")) return ".webm";
if (lower.contains(".mov")) return ".mov";
return ".mp4"; // 默认 mp4
}
}

View File

@ -0,0 +1,61 @@
package vip.mate.tool.video;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.task.AsyncTaskService.TaskPollResult;
import java.util.Set;
/**
* 视频生成提供商接口 所有视频 provider 统一实现此接口
* <p>
* 设计参考 {@link vip.mate.tool.search.SearchProvider}
*
* @author MateClaw Team
*/
public interface VideoGenerationProvider {
/** 提供商唯一 ID如 "dashscope"、"zhipu-cogvideo"、"fal"、"kling" */
String id();
/** 显示名称 */
String label();
/** 是否需要 API Key / Credential */
boolean requiresCredential();
/**
* 自动探测排序优先级升序
* credential provider 优先于不需要 credential
*/
int autoDetectOrder();
/** 该 provider 支持的能力集 */
Set<VideoCapability> capabilities();
/** 细粒度能力声明(支持的 aspectRatio、duration、模型列表等 */
VideoProviderCapabilities detailedCapabilities();
/**
* 判断该 provider 在当前配置下是否可用
* API Key 已配置等
*/
boolean isAvailable(SystemSettingsDTO config);
/**
* 提交视频生成任务异步非阻塞
*
* @param request 统一请求参数
* @param config 系统配置
* @return 提交结果 provider 任务 ID
*/
VideoSubmitResult submit(VideoGenerationRequest request, SystemSettingsDTO config);
/**
* 轮询任务状态
*
* @param providerTaskId provider 返回的任务 ID
* @param config 系统配置
* @return 轮询结果
*/
TaskPollResult checkStatus(String providerTaskId, SystemSettingsDTO config);
}

View File

@ -0,0 +1,41 @@
package vip.mate.tool.video;
import lombok.Builder;
import lombok.Data;
import java.util.Map;
/**
* 视频生成统一请求
*
* @author MateClaw Team
*/
@Data
@Builder
public class VideoGenerationRequest {
/** 视频内容描述 */
private String prompt;
/** 生成模式(由 runtime 自动推断) */
private VideoCapability mode;
/** 指定模型名称可选provider 有默认值) */
private String model;
/** 画面比例16:9 / 9:16 / 1:1 */
@Builder.Default
private String aspectRatio = "16:9";
/** 视频时长(秒) */
private Integer durationSeconds;
/** 参考图片 URLIMAGE_TO_VIDEO 模式) */
private String imageUrl;
/** 参考视频 URLVIDEO_TO_VIDEO 模式) */
private String videoUrl;
/** provider 特有的额外参数 */
private Map<String, Object> extraParams;
}

View File

@ -0,0 +1,47 @@
package vip.mate.tool.video;
import lombok.Builder;
import lombok.Data;
/**
* 视频生成服务提交结果面向 Tool
*
* @author MateClaw Team
*/
@Data
@Builder
public class VideoGenerationResult {
/** 内部任务 ID供 Agent 查询状态用) */
private String taskId;
/** 处理该任务的 provider 名称 */
private String providerName;
/** 状态描述 */
private String status;
/** 面向 Agent 的说明文本 */
private String message;
/** 是否成功提交 */
private boolean submitted;
public static VideoGenerationResult success(String taskId, String providerName) {
return VideoGenerationResult.builder()
.taskId(taskId)
.providerName(providerName)
.status("submitted")
.submitted(true)
.message("视频生成任务已提交(任务 ID: " + taskId + ")。预计 1-5 分钟完成,完成后会自动显示在对话中。")
.build();
}
public static VideoGenerationResult failure(String message) {
return VideoGenerationResult.builder()
.submitted(false)
.status("failed")
.message(message)
.build();
}
}

View File

@ -0,0 +1,231 @@
package vip.mate.tool.video;
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.List;
/**
* 视频生成服务 统一入口处理 provider 选择参数归一化fallback异步提交
* <p>
* 对应 OpenClaw video-generation/runtime.ts
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class VideoGenerationService {
private final SystemSettingService systemSettingService;
private final VideoProviderRegistry providerRegistry;
private final AsyncTaskService asyncTaskService;
private final ConversationService conversationService;
private final VideoFileDownloader fileDownloader;
private final ObjectMapper objectMapper;
private static final String TASK_TYPE = "video_generation";
/**
* 提交视频生成任务异步
*/
public VideoGenerationResult submitGeneration(VideoGenerationRequest request,
String conversationId,
String createdBy) {
SystemSettingsDTO config = systemSettingService.getAllSettings();
// 1. 检查视频功能是否启用
if (!Boolean.TRUE.equals(config.getVideoEnabled())) {
return VideoGenerationResult.failure("视频生成功能未启用,请在系统设置中开启");
}
// 2. 模式推断
if (request.getMode() == null) {
request.setMode(inferMode(request));
}
// 3. 参数归一化
normalizeRequest(request);
// 4. Provider 选择
VideoProviderRegistry.ResolvedProvider resolved =
providerRegistry.resolve(config, request.getMode());
if (resolved == null) {
return VideoGenerationResult.failure(
"没有可用的视频生成 Provider请在系统设置中配置支持 DashScope、智谱、fal.ai、可灵");
}
// 5. 提交 fallback
return submitWithFallback(request, config, resolved, conversationId, createdBy);
}
/**
* 查询任务状态
*/
public AsyncTaskInfo checkTaskStatus(String taskId) {
return asyncTaskService.getTaskInfo(taskId);
}
// ==================== 内部逻辑 ====================
private VideoGenerationResult submitWithFallback(VideoGenerationRequest request,
SystemSettingsDTO config,
VideoProviderRegistry.ResolvedProvider primary,
String conversationId,
String createdBy) {
// 尝试 primary先按 provider 能力归一化参数
normalizeForProvider(request, primary.provider());
VideoSubmitResult submitResult = primary.provider().submit(request, config);
if (submitResult.isAccepted()) {
return createAsyncTask(submitResult, request, conversationId, createdBy, config);
}
// Fallback收集所有尝试的错误信息
List<String> attemptErrors = new java.util.ArrayList<>();
attemptErrors.add(primary.provider().id() + ": " + submitResult.getErrorMessage());
if (Boolean.TRUE.equals(config.getVideoFallbackEnabled())) {
List<VideoGenerationProvider> fallbacks =
providerRegistry.fallbackCandidates(config, request.getMode(), primary.provider().id());
for (VideoGenerationProvider fb : fallbacks) {
log.info("[VideoGen] Trying fallback provider: {}", fb.id());
normalizeForProvider(request, fb);
submitResult = fb.submit(request, config);
if (submitResult.isAccepted()) {
return createAsyncTask(submitResult, request, conversationId, createdBy, config);
}
attemptErrors.add(fb.id() + ": " + submitResult.getErrorMessage());
log.warn("[VideoGen] Fallback provider {} failed: {}", fb.id(), submitResult.getErrorMessage());
}
}
return VideoGenerationResult.failure(
"所有 Provider 均提交失败\n" + String.join("\n", attemptErrors));
}
private VideoGenerationResult createAsyncTask(VideoSubmitResult submitResult,
VideoGenerationRequest 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);
// 获取 provider 引用用于轮询
VideoGenerationProvider provider = providerRegistry.getById(submitResult.getProviderName());
if (provider == null) {
return VideoGenerationResult.failure("Provider 不存在: " + submitResult.getProviderName());
}
// 启动轮询每次轮询重新获取配置避免 API Key 轮换后使用过期凭证
asyncTaskService.startPolling(
task.getTaskId(),
providerTaskId -> provider.checkStatus(providerTaskId, systemSettingService.getAllSettings()),
(completedTask, pollResult) -> handleCompletion(completedTask, pollResult)
);
return VideoGenerationResult.success(task.getTaskId(), submitResult.getProviderName());
} catch (Exception e) {
log.error("[VideoGen] Failed to create async task: {}", e.getMessage(), e);
return VideoGenerationResult.failure("创建任务失败: " + e.getMessage());
}
}
/**
* 任务完成时的回写逻辑下载视频 保存消息 广播 SSE
*/
private void handleCompletion(AsyncTaskEntity task, TaskPollResult result) {
if (result.succeeded()) {
try {
String videoUrl = result.videoUrl();
if (videoUrl == null) {
log.warn("[VideoGen] Task {} succeeded but no video URL", task.getTaskId());
asyncTaskService.broadcastTaskEvent(task, "async_task_completed",
false, null, "视频生成成功但未返回视频 URL");
return;
}
// 下载视频到本地
Path localPath = fileDownloader.download(videoUrl, task.getConversationId(), task.getTaskId());
String servingUrl = fileDownloader.toServingUrl(task.getConversationId(), localPath);
// 保存 assistant 消息 video content part
MessageContentPart videoPart = MessageContentPart.video(null, localPath.getFileName().toString());
videoPart.setFileUrl(servingUrl);
videoPart.setContentType("video/mp4");
conversationService.saveMessage(
task.getConversationId(), "assistant",
"视频已生成完毕",
List.of(videoPart), "completed");
// SSE 广播
asyncTaskService.broadcastTaskEvent(task, "async_task_completed",
true, servingUrl, null);
log.info("[VideoGen] Task {} completed, video saved: {}", task.getTaskId(), servingUrl);
} catch (Exception e) {
log.error("[VideoGen] Completion handling failed for task {}: {}",
task.getTaskId(), e.getMessage(), e);
asyncTaskService.broadcastTaskEvent(task, "async_task_completed",
false, null, "视频下载或保存失败: " + e.getMessage());
}
} else {
// 失败
asyncTaskService.broadcastTaskEvent(task, "async_task_completed",
false, null, result.errorMessage());
log.warn("[VideoGen] Task {} failed: {}", task.getTaskId(), result.errorMessage());
}
}
private VideoCapability inferMode(VideoGenerationRequest request) {
if (request.getVideoUrl() != null && !request.getVideoUrl().isBlank()) {
return VideoCapability.VIDEO_TO_VIDEO;
}
if (request.getImageUrl() != null && !request.getImageUrl().isBlank()) {
return VideoCapability.IMAGE_TO_VIDEO;
}
return VideoCapability.GENERATE;
}
private void normalizeRequest(VideoGenerationRequest request) {
if (request.getAspectRatio() == null || request.getAspectRatio().isBlank()) {
request.setAspectRatio("16:9");
}
if (request.getDurationSeconds() == null) {
request.setDurationSeconds(5);
}
}
/**
* 根据 provider 能力做参数就近归一化借鉴 OpenClaw resolveVideoGenerationOverrides
*/
private void normalizeForProvider(VideoGenerationRequest request, VideoGenerationProvider provider) {
VideoProviderCapabilities caps = provider.detailedCapabilities();
if (caps == null) return;
// aspectRatio 就近匹配
request.setAspectRatio(caps.normalizeAspectRatio(request.getAspectRatio()));
// duration 就近匹配
if (request.getDurationSeconds() != null) {
request.setDurationSeconds(caps.normalizeDuration(request.getDurationSeconds()));
}
}
}

View File

@ -0,0 +1,80 @@
package vip.mate.tool.video;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Set;
/**
* 视频生成 Provider 细粒度能力声明
* <p>
* 参考 OpenClaw VideoGenerationProviderCapabilities 设计
* 每个 Provider 显式声明支持的参数范围Runtime 据此做就近归一化
*
* @author MateClaw Team
*/
@Data
@Builder
public class VideoProviderCapabilities {
/** 支持的生成模式 */
@Builder.Default
private Set<VideoCapability> modes = Set.of(VideoCapability.GENERATE);
/** 支持的画面比例 */
@Builder.Default
private List<String> aspectRatios = List.of("16:9", "9:16", "1:1");
/** 支持的时长(秒),如 [5, 10] 表示只支持 5 秒和 10 秒 */
@Builder.Default
private List<Integer> supportedDurations = List.of(5);
/** 最大时长(秒) */
@Builder.Default
private int maxDurationSeconds = 10;
/** 最大并发视频数 */
@Builder.Default
private int maxVideos = 1;
/** 是否支持音频 */
@Builder.Default
private boolean supportsAudio = false;
/** 默认模型 */
private String defaultModel;
/** 可用模型列表 */
@Builder.Default
private List<String> models = List.of();
/**
* 将请求的 duration 就近匹配到 provider 支持的值
*/
public int normalizeDuration(int requested) {
if (supportedDurations == null || supportedDurations.isEmpty()) {
return Math.min(requested, maxDurationSeconds);
}
int closest = supportedDurations.get(0);
int minDiff = Math.abs(requested - closest);
for (int d : supportedDurations) {
int diff = Math.abs(requested - d);
if (diff < minDiff) {
minDiff = diff;
closest = d;
}
}
return closest;
}
/**
* 将请求的 aspectRatio 就近匹配或回退到默认
*/
public String normalizeAspectRatio(String requested) {
if (aspectRatios.contains(requested)) {
return requested;
}
return aspectRatios.isEmpty() ? "16:9" : aspectRatios.get(0);
}
}

View File

@ -0,0 +1,90 @@
package vip.mate.tool.video;
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 VideoGenerationProvider} 实现提供优先级排序与自动探测
* <p>
* 借鉴 {@link vip.mate.tool.search.SearchProviderRegistry} 的设计模式
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class VideoProviderRegistry {
private final List<VideoGenerationProvider> sortedProviders;
private final Map<String, VideoGenerationProvider> providerMap;
public VideoProviderRegistry(List<VideoGenerationProvider> providers) {
this.sortedProviders = providers.stream()
.sorted(Comparator.comparingInt(VideoGenerationProvider::autoDetectOrder))
.toList();
this.providerMap = providers.stream()
.collect(Collectors.toMap(VideoGenerationProvider::id, Function.identity()));
log.info("注册视频生成提供商 {} 个: {}", sortedProviders.size(),
sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList());
}
/** 按 ID 获取指定 provider */
public VideoGenerationProvider getById(String id) {
return providerMap.get(id);
}
/** 获取按 autoDetectOrder 排序的全部 provider 列表 */
public List<VideoGenerationProvider> allSorted() {
return sortedProviders;
}
/**
* 根据当前配置解析应使用的 provider
*
* @param config 系统配置
* @param requiredCapability 需要的能力 GENERATEIMAGE_TO_VIDEO
* @return 选中的 provider null
*/
public ResolvedProvider resolve(SystemSettingsDTO config, VideoCapability requiredCapability) {
// 1. 用户显式配置的 primary provider
String configuredId = config.getVideoProvider();
if (configuredId != null && !configuredId.isBlank() && !"auto".equals(configuredId)) {
VideoGenerationProvider configured = providerMap.get(configuredId);
if (configured != null && configured.isAvailable(config)
&& configured.capabilities().contains(requiredCapability)) {
return new ResolvedProvider(configured, "configured");
}
}
// 2. 自动探测按优先级遍历找第一个可用且支持该能力的
for (VideoGenerationProvider p : sortedProviders) {
if (p.isAvailable(config) && p.capabilities().contains(requiredCapability)) {
return new ResolvedProvider(p, "auto-detect");
}
}
return null;
}
/**
* 获取所有可用于 fallback provider按优先级排序排除 primary
*/
public List<VideoGenerationProvider> fallbackCandidates(SystemSettingsDTO config,
VideoCapability 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(VideoGenerationProvider provider, String source) {
}
}

View File

@ -0,0 +1,42 @@
package vip.mate.tool.video;
import lombok.Builder;
import lombok.Data;
/**
* Provider 提交视频生成任务的结果
*
* @author MateClaw Team
*/
@Data
@Builder
public class VideoSubmitResult {
/** provider 返回的任务 ID */
private String providerTaskId;
/** 提交的 provider 名称 */
private String providerName;
/** 是否被接受 */
private boolean accepted;
/** 错误信息(仅 accepted=false 时) */
private String errorMessage;
public static VideoSubmitResult success(String providerTaskId, String providerName) {
return VideoSubmitResult.builder()
.providerTaskId(providerTaskId)
.providerName(providerName)
.accepted(true)
.build();
}
public static VideoSubmitResult failure(String providerName, String errorMessage) {
return VideoSubmitResult.builder()
.providerName(providerName)
.accepted(false)
.errorMessage(errorMessage)
.build();
}
}

View File

@ -0,0 +1,180 @@
package vip.mate.tool.video.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 org.springframework.util.StringUtils;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.task.AsyncTaskService.TaskPollResult;
import vip.mate.tool.video.*;
import java.util.List;
import java.util.Set;
/**
* 智谱 CogVideoX Provider 支持 CogVideoX-Flash (免费) CogVideoX (高质量)
* <p>
* API 文档: https://bigmodel.cn/dev/api/video-generation/cogvideox
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CogVideoProvider implements VideoGenerationProvider {
private final ObjectMapper objectMapper;
private static final String DEFAULT_BASE_URL = "https://open.bigmodel.cn";
private static final String DEFAULT_MODEL = "cogvideox-flash";
@Override
public String id() {
return "zhipu-cogvideo";
}
@Override
public String label() {
return "智谱 CogVideoX";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 200;
}
@Override
public Set<VideoCapability> capabilities() {
return Set.of(VideoCapability.GENERATE, VideoCapability.IMAGE_TO_VIDEO);
}
@Override
public VideoProviderCapabilities detailedCapabilities() {
return VideoProviderCapabilities.builder()
.modes(capabilities())
.aspectRatios(List.of("16:9", "9:16", "1:1"))
.supportedDurations(List.of(6))
.maxDurationSeconds(6)
.defaultModel(DEFAULT_MODEL)
.models(List.of("cogvideox-flash", "cogvideox"))
.build();
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
return StringUtils.hasText(config.getZhipuApiKey());
}
@Override
public VideoSubmitResult submit(VideoGenerationRequest request, SystemSettingsDTO config) {
String apiKey = config.getZhipuApiKey();
String baseUrl = StringUtils.hasText(config.getZhipuBaseUrl())
? config.getZhipuBaseUrl() : DEFAULT_BASE_URL;
try {
String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL;
ObjectNode body = objectMapper.createObjectNode();
body.put("model", model);
body.put("prompt", request.getPrompt());
if (request.getMode() == VideoCapability.IMAGE_TO_VIDEO && request.getImageUrl() != null) {
body.put("image_url", request.getImageUrl());
}
// 智谱视频 API 使用 size 参数
if (request.getAspectRatio() != null) {
String size = aspectRatioToSize(request.getAspectRatio());
if (size != null) {
body.put("size", size);
}
}
if (request.getDurationSeconds() != null) {
body.put("duration", request.getDurationSeconds());
}
HttpResponse response = HttpRequest.post(baseUrl + "/api/paas/v4/videos/generations")
.header("Authorization", "Bearer " + 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("id")) {
String taskId = result.get("id").asText();
log.info("[CogVideo] Submitted task: {} (model={})", taskId, model);
return VideoSubmitResult.success(taskId, id());
} else {
String errMsg = result.has("error")
? result.get("error").path("message").asText()
: "HTTP " + response.getStatus();
log.warn("[CogVideo] Submit failed: {}", errMsg);
return VideoSubmitResult.failure(id(), errMsg);
}
} catch (Exception e) {
log.error("[CogVideo] Submit error: {}", e.getMessage(), e);
return VideoSubmitResult.failure(id(), e.getMessage());
}
}
@Override
public TaskPollResult checkStatus(String providerTaskId, SystemSettingsDTO config) {
String apiKey = config.getZhipuApiKey();
String baseUrl = StringUtils.hasText(config.getZhipuBaseUrl())
? config.getZhipuBaseUrl() : DEFAULT_BASE_URL;
try {
HttpResponse response = HttpRequest.get(
baseUrl + "/api/paas/v4/videos/" + providerTaskId)
.header("Authorization", "Bearer " + apiKey)
.timeout(15_000)
.execute();
JsonNode result = objectMapper.readTree(response.body());
String taskStatus = result.path("task_status").asText();
return switch (taskStatus) {
case "SUCCESS" -> {
// 智谱返回 video_result_list 数组
JsonNode videoResults = result.path("video_result_list");
String videoUrl = null;
if (videoResults.isArray() && !videoResults.isEmpty()) {
videoUrl = videoResults.get(0).path("url").asText(null);
}
// 兼容 video_result 字段
if (videoUrl == null) {
videoUrl = result.path("video_result").path("url").asText(null);
}
yield TaskPollResult.succeeded(videoUrl, null, result.toString());
}
case "FAIL" -> TaskPollResult.failed(
result.has("error") ? result.get("error").path("message").asText() : "任务失败");
case "PROCESSING" -> TaskPollResult.running(null);
default -> TaskPollResult.pending(null);
};
} catch (Exception e) {
log.error("[CogVideo] Poll error for task {}: {}", providerTaskId, e.getMessage());
return null;
}
}
private String aspectRatioToSize(String aspectRatio) {
return switch (aspectRatio) {
case "16:9" -> "1920x1080";
case "9:16" -> "1080x1920";
case "1:1" -> "1080x1080";
default -> null;
};
}
}

View File

@ -0,0 +1,222 @@
package vip.mate.tool.video.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.video.*;
import java.util.List;
import java.util.Set;
/**
* DashScope 视频生成 Provider 支持通义万相 Wan 2.5 / Wanx 2.1
* <p>
* 复用已有的 DashScope LLM provider API Key
* API 文档: https://help.aliyun.com/zh/model-studio/developer-reference/video-generation
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DashScopeVideoProvider implements VideoGenerationProvider {
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_T2V_MODEL = "wan2.5-t2v-turbo";
private static final String DEFAULT_I2V_MODEL = "wan2.5-i2v-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<VideoCapability> capabilities() {
return Set.of(VideoCapability.GENERATE, VideoCapability.IMAGE_TO_VIDEO);
}
@Override
public VideoProviderCapabilities detailedCapabilities() {
return VideoProviderCapabilities.builder()
.modes(capabilities())
.aspectRatios(List.of("16:9", "9:16", "1:1"))
.supportedDurations(List.of(5, 10))
.maxDurationSeconds(10)
.defaultModel(DEFAULT_T2V_MODEL)
.models(List.of("wan2.5-t2v-turbo", "wan2.5-i2v-turbo", "wanx2.1-t2v-turbo", "wanx2.1-i2v-turbo"))
.build();
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
try {
return modelProviderService.isProviderConfigured("dashscope");
} catch (Exception e) {
return false;
}
}
@Override
public VideoSubmitResult submit(VideoGenerationRequest request, SystemSettingsDTO config) {
String apiKey = getDashScopeApiKey();
if (apiKey == null) {
return VideoSubmitResult.failure(id(), "DashScope API Key 未配置");
}
try {
String model = resolveModel(request);
ObjectNode body = buildRequestBody(request, model);
HttpResponse response = HttpRequest.post(BASE_URL + "/services/aigc/video-generation/generation")
.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 Video] Submitted task: {} (model={})", taskId, model);
return VideoSubmitResult.success(taskId, id());
} else {
String errMsg = result.has("message") ? result.get("message").asText()
: "HTTP " + response.getStatus();
log.warn("[DashScope Video] Submit failed: {}", errMsg);
return VideoSubmitResult.failure(id(), errMsg);
}
} catch (Exception e) {
log.error("[DashScope Video] Submit error: {}", e.getMessage(), e);
return VideoSubmitResult.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 videoUrl = extractVideoUrl(output);
yield TaskPollResult.succeeded(videoUrl, null, 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 Video] 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 resolveModel(VideoGenerationRequest request) {
if (request.getModel() != null && !request.getModel().isBlank()) {
return request.getModel();
}
return request.getMode() == VideoCapability.IMAGE_TO_VIDEO
? DEFAULT_I2V_MODEL : DEFAULT_T2V_MODEL;
}
private ObjectNode buildRequestBody(VideoGenerationRequest request, String model) {
ObjectNode body = objectMapper.createObjectNode();
body.put("model", model);
ObjectNode input = body.putObject("input");
input.put("prompt", request.getPrompt());
if (request.getMode() == VideoCapability.IMAGE_TO_VIDEO && request.getImageUrl() != null) {
input.put("img_url", request.getImageUrl());
}
ObjectNode parameters = body.putObject("parameters");
if (request.getAspectRatio() != null) {
// DashScope 使用 size 参数 "1280*720"
String size = aspectRatioToSize(request.getAspectRatio());
if (size != null) {
parameters.put("size", size);
}
}
if (request.getDurationSeconds() != null) {
parameters.put("duration", String.valueOf(request.getDurationSeconds()));
}
return body;
}
private String aspectRatioToSize(String aspectRatio) {
return switch (aspectRatio) {
case "16:9" -> "1280*720";
case "9:16" -> "720*1280";
case "1:1" -> "720*720";
default -> null;
};
}
private String extractVideoUrl(JsonNode output) {
JsonNode results = output.path("results");
if (results.isArray() && !results.isEmpty()) {
return results.get(0).path("url").asText(null);
}
// 有些模型返回 video_url
if (output.has("video_url")) {
return output.get("video_url").asText();
}
return null;
}
}

View File

@ -0,0 +1,194 @@
package vip.mate.tool.video.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 org.springframework.util.StringUtils;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.task.AsyncTaskService.TaskPollResult;
import vip.mate.tool.video.*;
import java.util.List;
import java.util.Set;
/**
* fal.ai 视频生成 Provider 通过 fal.ai 中转访问 KlingRunwayLumaMiniMax 等模型
* <p>
* API 文档: https://fal.ai/docs
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FalVideoProvider implements VideoGenerationProvider {
private final ObjectMapper objectMapper;
private static final String BASE_URL = "https://queue.fal.run";
private static final String STATUS_URL = "https://queue.fal.run";
private static final String DEFAULT_MODEL = "fal-ai/kling-video/v1.6/pro/text-to-video";
@Override
public String id() {
return "fal";
}
@Override
public String label() {
return "fal.ai (Kling/Runway/Luma)";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 300;
}
@Override
public Set<VideoCapability> capabilities() {
return Set.of(VideoCapability.GENERATE, VideoCapability.IMAGE_TO_VIDEO);
}
@Override
public VideoProviderCapabilities detailedCapabilities() {
return VideoProviderCapabilities.builder()
.modes(capabilities())
.aspectRatios(List.of("16:9", "9:16", "1:1", "3:4", "4:3"))
.supportedDurations(List.of(5, 10))
.maxDurationSeconds(10)
.defaultModel(DEFAULT_MODEL)
.models(List.of(
"fal-ai/kling-video/v1.6/pro/text-to-video",
"fal-ai/runway-gen3/turbo/text-to-video",
"fal-ai/luma-dream-machine",
"fal-ai/minimax/video-01-live"))
.build();
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
return StringUtils.hasText(config.getFalApiKey());
}
@Override
public VideoSubmitResult submit(VideoGenerationRequest request, SystemSettingsDTO config) {
String apiKey = config.getFalApiKey();
try {
String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL;
ObjectNode body = objectMapper.createObjectNode();
body.put("prompt", request.getPrompt());
if (request.getMode() == VideoCapability.IMAGE_TO_VIDEO && request.getImageUrl() != null) {
body.put("image_url", request.getImageUrl());
}
if (request.getAspectRatio() != null) {
body.put("aspect_ratio", request.getAspectRatio());
}
if (request.getDurationSeconds() != null) {
body.put("duration", String.valueOf(request.getDurationSeconds()));
}
HttpResponse response = HttpRequest.post(BASE_URL + "/" + model)
.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();
// model 编码到 taskId 轮询时需要
String compositeId = model + "|" + requestId;
log.info("[fal.ai] Submitted task: {} (model={})", requestId, model);
return VideoSubmitResult.success(compositeId, id());
} else {
String errMsg = result.has("detail") ? result.get("detail").asText()
: "HTTP " + response.getStatus();
log.warn("[fal.ai] Submit failed: {}", errMsg);
return VideoSubmitResult.failure(id(), errMsg);
}
} catch (Exception e) {
log.error("[fal.ai] Submit error: {}", e.getMessage(), e);
return VideoSubmitResult.failure(id(), e.getMessage());
}
}
@Override
public TaskPollResult checkStatus(String providerTaskId, SystemSettingsDTO config) {
String apiKey = config.getFalApiKey();
// 从复合 ID 中解析 model requestId
String[] parts = providerTaskId.split("\\|", 2);
if (parts.length != 2) {
return TaskPollResult.failed("无效的任务 ID 格式");
}
String model = parts[0];
String requestId = parts[1];
try {
HttpResponse response = HttpRequest.get(
STATUS_URL + "/" + model + "/requests/" + requestId + "/status")
.header("Authorization", "Key " + apiKey)
.timeout(15_000)
.execute();
JsonNode result = objectMapper.readTree(response.body());
String status = result.path("status").asText();
return switch (status) {
case "COMPLETED" -> {
// 完成后需要再获取结果
HttpResponse resultResponse = HttpRequest.get(
STATUS_URL + "/" + model + "/requests/" + requestId)
.header("Authorization", "Key " + apiKey)
.timeout(15_000)
.execute();
JsonNode resultBody = objectMapper.readTree(resultResponse.body());
String videoUrl = extractVideoUrl(resultBody);
yield TaskPollResult.succeeded(videoUrl, null, resultBody.toString());
}
case "FAILED" -> TaskPollResult.failed(
result.has("error") ? result.get("error").asText() : "任务失败");
case "IN_PROGRESS" -> {
Integer progress = result.has("progress")
? (int) (result.get("progress").asDouble() * 100) : null;
yield TaskPollResult.running(progress);
}
default -> TaskPollResult.pending(null);
};
} catch (Exception e) {
log.error("[fal.ai] Poll error for task {}: {}", requestId, e.getMessage());
return null;
}
}
private String extractVideoUrl(JsonNode resultBody) {
// fal.ai 通常返回 video.url video[0].url
JsonNode video = resultBody.path("video");
if (video.has("url")) {
return video.get("url").asText();
}
if (video.isArray() && !video.isEmpty()) {
return video.get(0).path("url").asText(null);
}
// 有些模型用 output.video
JsonNode output = resultBody.path("output");
if (output.has("video")) {
return output.path("video").path("url").asText(null);
}
return null;
}
}

View File

@ -0,0 +1,191 @@
package vip.mate.tool.video.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 io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.task.AsyncTaskService.TaskPollResult;
import vip.mate.tool.video.*;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 快手可灵 Kling 视频生成 Provider
* <p>
* API 文档: https://docs.qingque.cn/d/home/eZQBXy5cfgN4H-YSZjCE1c_5w
* 鉴权方式: JWT (access_key + secret_key 签发短时 token)
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class KlingVideoProvider implements VideoGenerationProvider {
private final ObjectMapper objectMapper;
private static final String BASE_URL = "https://api.klingai.com";
private static final String DEFAULT_MODEL = "kling-v1.6-pro";
@Override
public String id() {
return "kling";
}
@Override
public String label() {
return "快手可灵 Kling";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 250;
}
@Override
public Set<VideoCapability> capabilities() {
return Set.of(VideoCapability.GENERATE, VideoCapability.IMAGE_TO_VIDEO);
}
@Override
public VideoProviderCapabilities detailedCapabilities() {
return VideoProviderCapabilities.builder()
.modes(capabilities())
.aspectRatios(List.of("16:9", "9:16", "1:1"))
.supportedDurations(List.of(5, 10))
.maxDurationSeconds(10)
.defaultModel(DEFAULT_MODEL)
.models(List.of("kling-v1.6-pro", "kling-v2.0-master"))
.build();
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
return StringUtils.hasText(config.getKlingAccessKey())
&& StringUtils.hasText(config.getKlingSecretKey());
}
@Override
public VideoSubmitResult submit(VideoGenerationRequest request, SystemSettingsDTO config) {
try {
String token = generateJwtToken(config.getKlingAccessKey(), config.getKlingSecretKey());
String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL;
String endpoint = request.getMode() == VideoCapability.IMAGE_TO_VIDEO
? "/v1/videos/image2video" : "/v1/videos/text2video";
ObjectNode body = objectMapper.createObjectNode();
body.put("model_name", model);
body.put("prompt", request.getPrompt());
if (request.getMode() == VideoCapability.IMAGE_TO_VIDEO && request.getImageUrl() != null) {
body.put("image", request.getImageUrl());
}
if (request.getAspectRatio() != null) {
body.put("aspect_ratio", request.getAspectRatio());
}
if (request.getDurationSeconds() != null) {
body.put("duration", String.valueOf(request.getDurationSeconds()));
}
HttpResponse response = HttpRequest.post(BASE_URL + endpoint)
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.body(body.toString())
.timeout(30_000)
.execute();
JsonNode result = objectMapper.readTree(response.body());
if (result.path("code").asInt() == 0 && result.has("data")) {
String taskId = result.path("data").path("task_id").asText();
log.info("[Kling] Submitted task: {} (model={})", taskId, model);
// 复合 ID 编码 endpoint 类型
String compositeId = endpoint + "|" + taskId;
return VideoSubmitResult.success(compositeId, id());
} else {
String errMsg = result.has("message") ? result.get("message").asText()
: "HTTP " + response.getStatus();
log.warn("[Kling] Submit failed: {}", errMsg);
return VideoSubmitResult.failure(id(), errMsg);
}
} catch (Exception e) {
log.error("[Kling] Submit error: {}", e.getMessage(), e);
return VideoSubmitResult.failure(id(), e.getMessage());
}
}
@Override
public TaskPollResult checkStatus(String providerTaskId, SystemSettingsDTO config) {
String[] parts = providerTaskId.split("\\|", 2);
if (parts.length != 2) {
return TaskPollResult.failed("无效的任务 ID 格式");
}
String endpoint = parts[0];
String taskId = parts[1];
try {
String token = generateJwtToken(config.getKlingAccessKey(), config.getKlingSecretKey());
HttpResponse response = HttpRequest.get(BASE_URL + endpoint + "/" + taskId)
.header("Authorization", "Bearer " + token)
.timeout(15_000)
.execute();
JsonNode result = objectMapper.readTree(response.body());
JsonNode data = result.path("data");
String taskStatus = data.path("task_status").asText();
return switch (taskStatus) {
case "succeed" -> {
JsonNode works = data.path("task_result").path("videos");
String videoUrl = null;
if (works.isArray() && !works.isEmpty()) {
videoUrl = works.get(0).path("url").asText(null);
}
yield TaskPollResult.succeeded(videoUrl, null, data.toString());
}
case "failed" -> TaskPollResult.failed(
data.has("task_status_msg") ? data.get("task_status_msg").asText() : "任务失败");
case "processing" -> TaskPollResult.running(null);
default -> TaskPollResult.pending(null);
};
} catch (Exception e) {
log.error("[Kling] Poll error for task {}: {}", taskId, e.getMessage());
return null;
}
}
/**
* 使用 access_key + secret_key 签发短时 JWT token
*/
private String generateJwtToken(String accessKey, String secretKey) {
long now = System.currentTimeMillis();
return Jwts.builder()
.header().add("alg", "HS256").add("typ", "JWT").and()
.claims(Map.of(
"iss", accessKey,
"exp", (now / 1000) + 1800, // 30 分钟过期
"nbf", (now / 1000) - 5
))
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)))
.compact();
}
}

View File

@ -372,6 +372,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id)
VALUES (1000000014, 'DelegateAgentTool', 'Agent Delegation', 'Delegate tasks to other Agents for multi-agent collaboration. Call target Agent by name, run in isolated session and return result.', 'builtin', 'delegateAgentTool', '🤝', TRUE, TRUE, NOW(), NOW(), 0);
-- Built-in tool: Video Generation
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
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);
-- 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

@ -378,6 +378,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id)
VALUES (1000000014, 'DelegateAgentTool', 'Agent 委派', '委派任务给其他 Agent 执行,实现多 Agent 协作。支持按名称调用目标 Agent在独立会话中运行并返回结果。', 'builtin', 'delegateAgentTool', '🤝', 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 (1000000015, 'VideoGenerateTool', '视频生成', '使用 AI 生成视频,支持文字生成视频和图片生成视频两种模式。视频生成是异步过程,完成后自动显示在对话中。', 'builtin', 'videoGenerateTool', '🎬', 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

@ -446,3 +446,27 @@ ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_account_id VARCHA
-- 清理 Codex 不支持的 ChatGPT OAuth 模型gpt-4o, o3, o4-mini 在 Codex 模式下不可用)
DELETE FROM mate_model_config WHERE provider = 'openai-chatgpt' AND model_name IN ('gpt-4o', 'o3', 'o4-mini');
-- ==================== 异步任务(视频/图片生成等长耗时操作) ====================
CREATE TABLE IF NOT EXISTS mate_async_task (
id BIGINT NOT NULL PRIMARY KEY,
task_id VARCHAR(64) NOT NULL UNIQUE,
task_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
conversation_id VARCHAR(128),
message_id BIGINT,
provider_name VARCHAR(64),
provider_task_id VARCHAR(128),
request_json TEXT,
result_json TEXT,
error_message VARCHAR(512),
progress INT DEFAULT 0,
created_by VARCHAR(64),
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_async_task_taskid ON mate_async_task(task_id);
CREATE INDEX IF NOT EXISTS idx_async_task_conv ON mate_async_task(conversation_id);
CREATE INDEX IF NOT EXISTS idx_async_task_status ON mate_async_task(status);

View File

@ -618,6 +618,26 @@ 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,
})
}
})
// ===== 发送消息(支持运行中继续发送) =====
const sendMessage = async (content: string, options: SendMessageOptions) => {

View File

@ -32,6 +32,9 @@ export type SSEEventType =
| 'turn_interrupted'
| 'queued_input_accepted'
| 'queued_input_started'
// 异步任务事件
| 'async_task_progress'
| 'async_task_completed'
export interface SSEEvent {
type: SSEEventType

View File

@ -204,6 +204,7 @@ export default {
sections: {
model: 'Model Management',
system: 'System',
video: 'Video Generation',
about: 'About',
},
modelTitle: 'Model Management',
@ -332,6 +333,15 @@ export default {
tavilyBaseUrl: 'Tavily Base URL',
duckduckgoEnabled: 'DuckDuckGo (Keyless)',
searxngBaseUrl: 'SearXNG Base URL',
videoEnabled: 'Enable Video Generation',
videoProvider: 'Preferred Video Provider',
videoFallbackEnabled: 'Provider Fallback',
dashscopeStatus: 'DashScope Status',
zhipuApiKey: 'Zhipu API Key',
zhipuBaseUrl: 'Zhipu API Base URL',
falApiKey: 'fal.ai API Key',
klingAccessKey: 'Kling Access Key',
klingSecretKey: 'Kling Secret Key',
},
hints: {
provider: 'Current implementation applies DashScope model options at runtime.',
@ -347,9 +357,28 @@ 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.',
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.',
dashscopeVideoStatus: 'Reuses the DashScope API Key configured in Model Management. No extra setup needed.',
zhipuApiKey: 'Get from bigmodel.cn. CogVideoX-Flash model is free.',
zhipuBaseUrl: 'Usually no need to change unless using a custom proxy.',
falApiKey: 'Get from fal.ai. One key accesses Kling, Runway, Luma and more.',
klingAccessKey: 'Get from Kuaishou Open Platform for Kling video generation API.',
klingSecretKey: 'Paired with Access Key for JWT authentication.',
},
searchTitle: 'Search Service',
searchDesc: 'Configure the built-in search tool provider and API credentials',
videoTitle: 'Video Generation',
videoDesc: 'Configure AI video generation capabilities, supporting text-to-video and image-to-video',
videoProviderOptions: {
auto: 'Auto Select',
},
videoProviderTags: {
reuseLlmKey: 'Reuses LLM API Key',
freeQuota: 'Free Quota Available',
configuredInModels: 'See Model Settings',
},
actions: {
setDefault: 'Set Default',
saveSystem: 'Save System Settings',

View File

@ -204,6 +204,7 @@ export default {
sections: {
model: '模型管理',
system: '系统设置',
video: '视频生成',
about: '关于',
},
modelTitle: '模型管理',
@ -332,6 +333,16 @@ export default {
tavilyBaseUrl: 'Tavily 接口地址',
duckduckgoEnabled: 'DuckDuckGo免 Key',
searxngBaseUrl: 'SearXNG 地址',
// 视频生成
videoEnabled: '启用视频生成',
videoProvider: '首选视频 Provider',
videoFallbackEnabled: 'Provider 回退',
dashscopeStatus: 'DashScope 状态',
zhipuApiKey: '智谱 API Key',
zhipuBaseUrl: '智谱 API 地址',
falApiKey: 'fal.ai API Key',
klingAccessKey: '可灵 Access Key',
klingSecretKey: '可灵 Secret Key',
},
hints: {
provider: '当前版本会把 DashScope 模型参数真实应用到 Agent 调用链路。',
@ -347,9 +358,29 @@ export default {
tavilyBaseUrl: '通常无需修改,除非使用自定义代理地址。',
duckduckgoEnabled: '免费搜索兜底,无需 API Key。默认开启作为零配置下的搜索降级方案。',
searxngBaseUrl: '自部署 SearXNG 实例地址。Docker 部署时自动配置。',
// 视频生成
videoEnabled: '开启后 Agent 可使用视频生成工具。需至少配置一个视频 Provider 的 API Key。',
videoProvider: '选择首选视频生成 Providerauto 模式自动选择第一个可用的。',
videoFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。',
dashscopeVideoStatus: '复用模型管理中配置的 DashScope API Key无需额外配置。',
zhipuApiKey: '从 bigmodel.cn 获取CogVideoX-Flash 模型免费。',
zhipuBaseUrl: '通常无需修改,除非使用自定义代理地址。',
falApiKey: '从 fal.ai 获取,一个 Key 可访问 Kling、Runway、Luma 等多个模型。',
klingAccessKey: '从快手开放平台获取,用于调用可灵视频生成 API。',
klingSecretKey: '与 Access Key 配对使用,用于 JWT 签名鉴权。',
},
searchTitle: '搜索服务',
searchDesc: '配置内置搜索工具的提供商与 API 凭证',
videoTitle: '视频生成',
videoDesc: '配置 AI 视频生成能力,支持文字生成视频和图片生成视频',
videoProviderOptions: {
auto: '自动选择',
},
videoProviderTags: {
reuseLlmKey: '复用 LLM API Key',
freeQuota: '有免费额度',
configuredInModels: '前往模型设置查看',
},
actions: {
setDefault: '设为默认',
saveSystem: '保存系统设置',

View File

@ -85,6 +85,12 @@ const router = createRouter({
component: () => import('@/views/Settings/System/index.vue'),
meta: { title: 'Settings - System' },
},
{
path: 'video',
name: 'SettingsVideo',
component: () => import('@/views/Settings/Video/index.vue'),
meta: { title: 'Settings - Video' },
},
{
path: 'about',
name: 'SettingsAbout',

View File

@ -502,6 +502,19 @@ export interface SystemSettings {
// Keyless 搜索 provider
duckduckgoEnabled: boolean
searxngBaseUrl: string
// 视频生成配置
videoEnabled?: boolean
videoProvider?: string
videoFallbackEnabled?: boolean
zhipuApiKey?: string
zhipuBaseUrl?: string
zhipuApiKeyMasked?: string
falApiKey?: string
falApiKeyMasked?: string
klingAccessKey?: string
klingSecretKey?: string
klingAccessKeyMasked?: string
klingSecretKeyMasked?: string
}
export interface ProviderModelInfo {

View File

@ -41,6 +41,12 @@ 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: 'video',
path: '/settings/video',
label: t('settings.sections.video'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>',
},
{
id: 'about',
path: '/settings/about',

View File

@ -0,0 +1,307 @@
<template>
<div class="settings-section video-section">
<div class="section-header">
<h2 class="section-title">{{ t('settings.videoTitle') }}</h2>
<p class="section-desc">{{ t('settings.videoDesc') }}</p>
</div>
<div class="settings-card">
<!-- 总开关 -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.videoEnabled') }}</div>
<div class="setting-hint">{{ t('settings.hints.videoEnabled') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.videoEnabled" 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.videoProvider') }}</div>
<div class="setting-hint">{{ t('settings.hints.videoProvider') }}</div>
</div>
<div class="setting-control">
<select v-model="settings.videoProvider" class="form-input" :disabled="!settings.videoEnabled">
<option value="auto">{{ t('settings.videoProviderOptions.auto') }}</option>
<option value="dashscope">DashScope (通义万相)</option>
<option value="zhipu-cogvideo">智谱 CogVideoX</option>
<option value="fal">fal.ai (Kling/Runway/Luma)</option>
<option value="kling">快手可灵 Kling</option>
</select>
</div>
</div>
<!-- Fallback 开关 -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.videoFallbackEnabled') }}</div>
<div class="setting-hint">{{ t('settings.hints.videoFallbackEnabled') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.videoFallbackEnabled" type="checkbox" :disabled="!settings.videoEnabled" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<!-- Provider 配置区块仅在启用时显示 -->
<template v-if="settings.videoEnabled">
<!-- DashScope -->
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">DashScope (通义万相)</span>
<span class="provider-tag">{{ t('settings.videoProviderTags.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.dashscopeVideoStatus') }}</div>
</div>
<div class="setting-control">
<span class="status-tag">{{ t('settings.videoProviderTags.configuredInModels') }}</span>
</div>
</div>
</div>
</div>
<!-- 智谱 CogVideo -->
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">智谱 CogVideoX</span>
<span class="provider-tag tag-free">{{ t('settings.videoProviderTags.freeQuota') }}</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.zhipuApiKey') }}</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 class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.zhipuBaseUrl') }}</div>
<div class="setting-hint">{{ t('settings.hints.zhipuBaseUrl') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="settings.zhipuBaseUrl"
type="text"
class="form-input"
placeholder="https://open.bigmodel.cn"
/>
</div>
</div>
</div>
</div>
<!-- fal.ai -->
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">fal.ai</span>
<span class="provider-tag">Kling / Runway / Luma / MiniMax</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.falApiKey') }}</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>
<!-- 快手可灵 -->
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">快手可灵 Kling</span>
</div>
<div class="settings-card">
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.klingAccessKey') }}</div>
<div class="setting-hint">{{ t('settings.hints.klingAccessKey') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="klingAccessKeyInput"
type="password"
class="form-input"
:placeholder="settings.klingAccessKeyMasked || t('settings.model.apiKeyInput')"
autocomplete="off"
/>
</div>
</div>
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.klingSecretKey') }}</div>
<div class="setting-hint">{{ t('settings.hints.klingSecretKey') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="klingSecretKeyInput"
type="password"
class="form-input"
:placeholder="settings.klingSecretKeyMasked || 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
const zhipuApiKeyInput = ref('')
const falApiKeyInput = ref('')
const klingAccessKeyInput = ref('')
const klingSecretKeyInput = ref('')
const settings = reactive({
videoEnabled: false,
videoProvider: 'auto',
videoFallbackEnabled: true,
zhipuBaseUrl: '',
zhipuApiKeyMasked: '',
falApiKeyMasked: '',
klingAccessKeyMasked: '',
klingSecretKeyMasked: '',
})
onMounted(async () => {
await loadSettings()
})
async function loadSettings() {
const res: any = await settingsApi.get()
const data = res.data || {}
settings.videoEnabled = data.videoEnabled ?? false
settings.videoProvider = data.videoProvider ?? 'auto'
settings.videoFallbackEnabled = data.videoFallbackEnabled ?? true
settings.zhipuBaseUrl = data.zhipuBaseUrl ?? ''
settings.zhipuApiKeyMasked = data.zhipuApiKeyMasked ?? ''
settings.falApiKeyMasked = data.falApiKeyMasked ?? ''
settings.klingAccessKeyMasked = data.klingAccessKeyMasked ?? ''
settings.klingSecretKeyMasked = data.klingSecretKeyMasked ?? ''
//
zhipuApiKeyInput.value = ''
falApiKeyInput.value = ''
klingAccessKeyInput.value = ''
klingSecretKeyInput.value = ''
}
async function onSaveSettings() {
const payload: any = {
videoEnabled: settings.videoEnabled,
videoProvider: settings.videoProvider,
videoFallbackEnabled: settings.videoFallbackEnabled,
zhipuBaseUrl: settings.zhipuBaseUrl,
}
if (zhipuApiKeyInput.value) payload.zhipuApiKey = zhipuApiKeyInput.value
if (falApiKeyInput.value) payload.falApiKey = falApiKeyInput.value
if (klingAccessKeyInput.value) payload.klingAccessKey = klingAccessKeyInput.value
if (klingSecretKeyInput.value) payload.klingSecretKey = klingSecretKeyInput.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.video-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>