mateclaw/mateclaw-server/src/main/java/vip/mate/tool/builtin/VideoGenerateTool.java
matevip 4011050ceb feat(cron): channel delivery via ChatOrigin + Spring AI ToolContext
Replaces the prior ThreadLocal context plumbing with explicit Spring AI
ToolContext threading carried by an immutable ChatOrigin value object,
so a cron created from inside WeChat (or any IM channel) delivers its
results back to the originating channel.

Architecture
- ChatOrigin / ChannelTarget value objects + per-entry-point factories
  (ChannelChatOriginFactory in vip.mate.channel, CronChatOriginFactory
  in vip.mate.cron — symmetric, no cyclic deps).
- LocaleAwareToolCallback now forwards call(String, ToolContext) and
  getToolMetadata so the decorator chain cannot silently drop the origin.
- AgentService 6-method overhaul + ChatOriginHolder bridge into
  StateGraph buildInitialState which writes CHAT_ORIGIN; ActionNode +
  StepExecutionNode forward it to ToolExecutionExecutor.
- ToolExecutionExecutor builds ToolContext per call; 8/8 tools migrated
  (CronJobTool, WorkspacePathGuard, Video/Image/Browser/ReadFile/Music,
  DelegateAgentTool with parent-origin inheritance).
- CronJobRunner + CronJobLifecycleService 3-segment REQUIRES_NEW model
  (T1 startRun / no-tx runAgent / T2 finishRunAndPublish); ArchUnit
  pins CronJobRunner as @Transactional-free.
- CronResultDelivery Strategy + AbstractCronResultDelivery Template
  with SQL CAS idempotency on mate_cron_job_run.delivery_status —
  replaces the prior process-local Caffeine TTL, cluster-safe.
- CronJobCompletedEvent + @Async @TransactionalEventListener(AFTER_COMMIT);
  cronDeliveryExecutor (core=2, max=4, queue=1000, AbortPolicy + audit).
- CronRunStaleCleanup @Scheduled(5min) sweeps PENDING-15min and
  status='running'-30min in one query each.
- CronJobRunner.wrapWithDeliveryGuard prepends a system note for
  channel-bound crons to suppress hallucinated 'install CLI to send
  WeChat' suggestions.
- ApprovalWorkflowService Memento: persist ChatOrigin snapshot on
  create, restore on replay so cross-restart approvals keep channel
  binding; ChannelMessageRouter + ChatController web-replay both prefer
  the Memento and fall back to fresh-build.
- ChannelManager.sendToChannel 4-arg DeliveryOptions overload;
  ChannelAdapter#proactiveSend default 4-arg pass-through; Slack
  overrides for thread_ts and Telegram overrides for message_thread_id.
- CronJobs UI: read-only 'last delivery' badge driven by
  CronJobMapper.selectListWithDeliveryStatus subquery.

Schema migrations V57/V58/V59 (V56 was already taken by an unrelated
provider migration — Flyway processes versions in order regardless of
gaps):
- V57: mate_cron_job_run delivery_status / target / error + composite
       index (delivery_status, started_at) covering the cleanup sweep.
- V58: mate_cron_job channel_id (indexed) + delivery_config TEXT (JSON
       via MyBatis Plus JacksonTypeHandler).
- V59: mate_tool_approval chat_origin TEXT (Memento).
All idempotent in both H2 (IF NOT EXISTS) and MySQL (INFORMATION_SCHEMA
guard + PREPARE).

ArchUnit guards (test scope, archunit-junit5 1.3.0):
- every concrete vip.mate.* ToolCallback must override
  call(String, ToolContext) — pins the decorator-forward fix.
- CronJobRunner must NOT carry @Transactional on the class or any
  method — pins the 3-segment lifecycle rule.

Tests: 32 new unit tests + 21 regression tests in touched areas, all
53 green:
- ChatOriginTest (6) — value-object invariants + JSON round-trip.
- LocaleAwareToolCallbackToolContextTest (2) — decorator forward.
- DeliveryConfigTest (4) — Jackson round-trip + forward-compat.
- ToolCallbackToolContextForwardArchTest (2) — both ArchUnit guards.
- CronJobRunnerDeliveryGuardTest (3) — channel-cron prefix injection.
- AbstractCronResultDeliveryTest (4) — claim CAS + concurrent CAS.
- ChannelCronResultDeliveryTest (6) — supports / doDeliver / errors.
- ApprovalReplayContinuityTest (5) — Memento round-trip + corrupt
  payload fallback + unknown-field tolerance.

Refs: #25, #16
2026-04-28 21:43:58 +08:00

192 lines
8.8 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.tool.builtin;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
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;
@vip.mate.tool.ConcurrencyUnsafe("creates async tasks and persists generated artifacts; provider rate limits also forbid parallel calls")
@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,
// RFC-063r §2.5: ToolContext is auto-injected by Spring AI MethodToolCallback
// and explicitly skipped by JsonSchemaGenerator — never visible to the LLM.
@Nullable ToolContext ctx
) {
// 路由 action
String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase();
return switch (normalizedAction) {
case "list" -> handleListAction();
case "status" -> handleStatusAction(taskId, ctx);
default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model, ctx);
};
}
// ==================== 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, @Nullable ToolContext ctx) {
String conversationId = ToolExecutionContext.conversationId(ctx);
// 指定 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, @Nullable ToolContext ctx) {
String conversationId = ToolExecutionContext.conversationId(ctx);
String username = ToolExecutionContext.username(ctx);
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();
};
}
}