mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
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
192 lines
8.8 KiB
Java
192 lines
8.8 KiB
Java
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();
|
||
};
|
||
}
|
||
}
|