contradictions = queryService.listContradictions(agentId);
+ if (contradictions.isEmpty()) return "No unresolved contradictions.";
+
+ return contradictions.stream()
+ .map(c -> String.format("- Contradiction #%d: factA=%d vs factB=%d — %s",
+ c.getId(), c.getFactAId(), c.getFactBId(),
+ c.getDescription() != null ? c.getDescription() : ""))
+ .collect(Collectors.joining("\n"));
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleEventListener.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleEventListener.java
new file mode 100644
index 00000000..72c397bf
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleEventListener.java
@@ -0,0 +1,42 @@
+package vip.mate.memory.lifecycle;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import vip.mate.memory.MemoryProperties;
+import vip.mate.memory.event.ConversationCompletedEvent;
+
+/**
+ * Dispatches ConversationCompletedEvent to MemoryManager.onSessionEnd unconditionally.
+ *
+ * Contract: every successfully-persisted conversation end must reach all memory
+ * providers, regardless of whether summarization / nudge preconditions held.
+ *
+ *
This is a separate listener from PostConversationMemoryListener on purpose —
+ * that one has four early returns tied to summarize/nudge heuristics. Those are fine
+ * for summarize/nudge business logic, but none of them are appropriate gates for
+ * provider-level session-end signals (rfc-037 §3.7, decision D10).
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class MemoryLifecycleEventListener {
+
+ private final MemoryLifecycleMediator mediator;
+ private final MemoryProperties props;
+
+ @Async
+ @EventListener
+ public void onConversationCompleted(ConversationCompletedEvent event) {
+ if (!props.isLifecycleMediatorEnabled()) return;
+ try {
+ mediator.onSessionEnd(event.agentId(), event.conversationId());
+ } catch (Exception e) {
+ log.debug("[Memory] onSessionEnd dispatch failed (non-fatal): {}", e.getMessage());
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java
new file mode 100644
index 00000000..eb5e1e30
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java
@@ -0,0 +1,84 @@
+package vip.mate.memory.lifecycle;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Component;
+import vip.mate.memory.spi.MemoryManager;
+
+/**
+ * Mediator between the Agent entry-layer (AgentService) and MemoryManager.
+ * Agent code only calls this class; it hides the details of when/how
+ * providers are invoked across a turn's lifecycle.
+ *
+ *
Non-goals:
+ *
+ * Does NOT call MemoryRecallTracker.trackRecalls — AgentService already
+ * owns that call; duplicating here would double recall_count / daily_count
+ * and pollute Dream scoring (rfc-037 F4).
+ * Does NOT prefetch next-turn recall keyed on current-turn query —
+ * query-conditioned providers cannot reuse stale queries (rfc-037 F2).
+ *
+ *
+ * Thread-safety: all public methods are reentrant; per-turn state lives
+ * in {@link TurnContext}.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class MemoryLifecycleMediator {
+
+ private final MemoryManager memoryManager;
+ private final ApplicationEventPublisher events;
+
+ /**
+ * Called BEFORE the LLM is invoked for a turn.
+ * Returns the memory-context block to inject, or "" if none.
+ *
+ *
Latency contract: synchronous. BuiltinMemoryProvider returns "" so the
+ * only cost today is iteration overhead (<5ms).
+ */
+ public String beforeLlmCall(TurnContext ctx) {
+ try {
+ String context = memoryManager.prefetchAll(ctx.agentId(), ctx.userQuery());
+ events.publishEvent(new TurnStartedEvent(ctx));
+ log.debug("[Memory] beforeLlmCall: agent={}, contextLen={}", ctx.agentId(),
+ context != null ? context.length() : 0);
+ return context;
+ } catch (Exception e) {
+ log.debug("[Memory] beforeLlmCall failed (non-fatal): {}", e.getMessage());
+ return "";
+ }
+ }
+
+ /**
+ * Called AFTER the LLM finishes a turn successfully.
+ * Non-blocking: MemoryManager.syncAll dispatches to provider.syncTurn(),
+ * each provider is responsible for being async internally.
+ */
+ public void afterLlmCall(TurnContext ctx, String assistantReply) {
+ try {
+ memoryManager.syncAll(ctx.agentId(), ctx.conversationId(),
+ ctx.userQuery(), assistantReply);
+ events.publishEvent(new TurnCompletedEvent(ctx, assistantReply));
+ log.debug("[Memory] afterLlmCall: agent={}, conv={}, replyLen={}", ctx.agentId(),
+ ctx.conversationId(), assistantReply != null ? assistantReply.length() : 0);
+ } catch (Exception e) {
+ log.debug("[Memory] afterLlmCall failed (non-fatal): {}", e.getMessage());
+ }
+ }
+
+ /**
+ * Called when a conversation ends (from MemoryLifecycleEventListener).
+ */
+ public void onSessionEnd(Long agentId, String conversationId) {
+ try {
+ memoryManager.onSessionEnd(agentId, conversationId);
+ log.debug("[Memory] onSessionEnd: agent={}, conv={}", agentId, conversationId);
+ } catch (Exception e) {
+ log.debug("[Memory] onSessionEnd dispatch failed (non-fatal): {}", e.getMessage());
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnCompletedEvent.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnCompletedEvent.java
new file mode 100644
index 00000000..690bea8c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnCompletedEvent.java
@@ -0,0 +1,10 @@
+package vip.mate.memory.lifecycle;
+
+/**
+ * Published after syncAll completes for a turn.
+ *
+ * @param context the turn context
+ * @param assistantReply the LLM response text
+ * @author MateClaw Team
+ */
+public record TurnCompletedEvent(TurnContext context, String assistantReply) {}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java
new file mode 100644
index 00000000..d7b6f4c2
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java
@@ -0,0 +1,19 @@
+package vip.mate.memory.lifecycle;
+
+/**
+ * Minimal turn-scoped context; built once per turn at AgentService level.
+ *
+ * @param agentId the agent ID
+ * @param conversationId the conversation ID
+ * @param sessionId session ID (may equal conversationId in Phase 1)
+ * @param turnNumber turn sequence number within the conversation
+ * @param userQuery the current user message
+ * @author MateClaw Team
+ */
+public record TurnContext(
+ Long agentId,
+ String conversationId,
+ String sessionId,
+ int turnNumber,
+ String userQuery
+) {}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnStartedEvent.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnStartedEvent.java
new file mode 100644
index 00000000..c92faadb
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnStartedEvent.java
@@ -0,0 +1,9 @@
+package vip.mate.memory.lifecycle;
+
+/**
+ * Published after prefetchAll completes, before the LLM call.
+ *
+ * @param context the turn context
+ * @author MateClaw Team
+ */
+public record TurnStartedEvent(TurnContext context) {}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/model/DreamReportEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/model/DreamReportEntity.java
new file mode 100644
index 00000000..b912bd87
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/model/DreamReportEntity.java
@@ -0,0 +1,62 @@
+package vip.mate.memory.model;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Dream report entity — persists each dream consolidation run.
+ *
+ * @author MateClaw Team
+ */
+@Data
+@TableName("mate_dream_report")
+public class DreamReportEntity {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ private Long agentId;
+
+ /** NIGHTLY | FOCUSED */
+ private String mode;
+
+ /** Topic hint for FOCUSED mode; null for NIGHTLY */
+ private String topic;
+
+ /** cron | user | api */
+ private String triggerSource;
+
+ /** userId or "system" */
+ private String triggeredBy;
+
+ private LocalDateTime startedAt;
+
+ private LocalDateTime finishedAt;
+
+ private Integer candidateCount;
+
+ private Integer promotedCount;
+
+ private Integer rejectedCount;
+
+ /** Diff between old and new MEMORY.md */
+ private String memoryDiff;
+
+ /** LLM explanation (first 500 chars) */
+ private String llmReason;
+
+ /** SUCCESS | FAILED | SKIPPED */
+ private String status;
+
+ private String errorMessage;
+
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private LocalDateTime updateTime;
+
+ private Integer deleted;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java
index c5bf87b7..3902e95e 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java
@@ -50,12 +50,17 @@ public class MemoryRecallEntity {
/** 是否已提升到 MEMORY.md */
private Boolean promoted;
+ /** Times this candidate was reviewed but not promoted (Dream v2, Phase 1 write-only) */
+ private Integer reviewCount;
+
+ /** Last time this candidate was reviewed during a dream run */
+ private LocalDateTime lastReviewedAt;
+
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
- @TableLogic
private Integer deleted;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java
new file mode 100644
index 00000000..df388dfe
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java
@@ -0,0 +1,33 @@
+package vip.mate.memory.model;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Morning card seen state — tracks per (user, agent) whether the card was dismissed.
+ *
+ * @author MateClaw Team
+ */
+@Data
+@TableName("mate_morning_card_seen")
+public class MorningCardSeenEntity {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Long userId;
+
+ private Long agentId;
+
+ private LocalDateTime lastSeenAt;
+
+ private Long lastReportId;
+
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private LocalDateTime updateTime;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/repository/DreamReportMapper.java b/mateclaw-server/src/main/java/vip/mate/memory/repository/DreamReportMapper.java
new file mode 100644
index 00000000..aad533c4
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/repository/DreamReportMapper.java
@@ -0,0 +1,14 @@
+package vip.mate.memory.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import vip.mate.memory.model.DreamReportEntity;
+
+/**
+ * Mapper for mate_dream_report table.
+ *
+ * @author MateClaw Team
+ */
+@Mapper
+public interface DreamReportMapper extends BaseMapper {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/repository/MorningCardSeenMapper.java b/mateclaw-server/src/main/java/vip/mate/memory/repository/MorningCardSeenMapper.java
new file mode 100644
index 00000000..92c645b2
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/repository/MorningCardSeenMapper.java
@@ -0,0 +1,9 @@
+package vip.mate.memory.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import vip.mate.memory.model.MorningCardSeenEntity;
+
+@Mapper
+public interface MorningCardSeenMapper extends BaseMapper {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java
index c10ba4f8..58a34fb1 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java
@@ -52,7 +52,7 @@ public class DreamingScheduler {
continue;
}
try {
- emergenceService.consolidate(agent.getId());
+ emergenceService.consolidate(agent.getId(), vip.mate.memory.service.DreamMode.NIGHTLY, null);
success++;
} catch (Exception e) {
failed++;
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/DreamMode.java b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamMode.java
new file mode 100644
index 00000000..06f8ac0b
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamMode.java
@@ -0,0 +1,11 @@
+package vip.mate.memory.service;
+
+/**
+ * Dream consolidation modes. Phase 1: NIGHTLY + FOCUSED only.
+ *
+ * @author MateClaw Team
+ */
+public enum DreamMode {
+ NIGHTLY,
+ FOCUSED
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/DreamReport.java b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamReport.java
new file mode 100644
index 00000000..38bac953
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamReport.java
@@ -0,0 +1,29 @@
+package vip.mate.memory.service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * Structured dream consolidation result returned by consolidate().
+ *
+ * @author MateClaw Team
+ */
+public record DreamReport(
+ Long id,
+ Long agentId,
+ DreamMode mode,
+ String topic,
+ String triggerSource,
+ String triggeredBy,
+ LocalDateTime startedAt,
+ LocalDateTime finishedAt,
+ int candidateCount,
+ int promotedCount,
+ int rejectedCount,
+ String memoryDiff,
+ String llmReason,
+ DreamStatus status,
+ String errorMessage,
+ List promoted,
+ List rejected
+) {}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/DreamStatus.java b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamStatus.java
new file mode 100644
index 00000000..5196507b
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamStatus.java
@@ -0,0 +1,12 @@
+package vip.mate.memory.service;
+
+/**
+ * Dream consolidation result status.
+ *
+ * @author MateClaw Team
+ */
+public enum DreamStatus {
+ SUCCESS,
+ FAILED,
+ SKIPPED
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java
index aff44dcf..bfa545e8 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java
@@ -9,25 +9,31 @@ import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
+import vip.mate.memory.event.DreamCompletedEvent;
+import vip.mate.memory.event.DreamFailedEvent;
+import vip.mate.memory.event.MemoryWriteEvent;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.memory.MemoryProperties;
+import vip.mate.memory.model.DreamReportEntity;
import vip.mate.memory.model.MemoryRecallEntity;
+import vip.mate.memory.repository.DreamReportMapper;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
-import java.util.Comparator;
-import java.util.List;
+import java.time.LocalDateTime;
+import java.util.*;
import java.util.stream.Collectors;
/**
- * 记忆整合服务
+ * Memory emergence (dream) service.
*
- * 读取近 N 天的 daily notes,提炼反复出现的模式和重要信息,
- * 合并到 MEMORY.md 中。
+ * Reads daily notes + scored recall candidates, invokes LLM to consolidate
+ * recurring patterns into MEMORY.md, and produces a structured DreamReport.
*
* @author MateClaw Team
*/
@@ -42,19 +48,36 @@ public class MemoryEmergenceService {
private final MemoryProperties properties;
private final ObjectMapper objectMapper;
private final MemoryRecallService recallService;
+ private final DreamReportMapper dreamReportMapper;
+ private final vip.mate.memory.archive.MemoryArchiveService archiveService;
+ private final ApplicationEventPublisher eventPublisher;
+ private final vip.mate.memory.fact.contradiction.ContradictionDetector contradictionDetector;
/**
- * 执行记忆整合:将 daily notes 中的重复模式提炼到 MEMORY.md
+ * Legacy signature — delegates to NIGHTLY mode for backward compatibility.
+ */
+ public DreamReport consolidate(Long agentId) {
+ return consolidate(agentId, DreamMode.NIGHTLY, null);
+ }
+
+ /**
+ * Execute memory consolidation with the specified mode and optional topic.
*
* @param agentId Agent ID
+ * @param mode NIGHTLY or FOCUSED
+ * @param topic topic hint for FOCUSED mode (null for NIGHTLY)
+ * @return structured DreamReport (never null)
*/
- public void consolidate(Long agentId) {
+ public DreamReport consolidate(Long agentId, DreamMode mode, String topic) {
+ LocalDateTime startedAt = LocalDateTime.now();
+ String triggerSource = mode == DreamMode.NIGHTLY ? "cron" : "user";
+
if (!properties.isEmergenceEnabled()) {
log.debug("[Memory] Emergence is disabled, skipping for agent={}", agentId);
- return;
+ return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "emergence disabled");
}
- // 1. 列出所有 memory/*.md 文件
+ // 1. Load daily notes
List allFiles = workspaceFileService.listFiles(agentId);
List dailyFilenames = allFiles.stream()
.map(WorkspaceFileEntity::getFilename)
@@ -65,13 +88,11 @@ public class MemoryEmergenceService {
if (dailyFilenames.isEmpty()) {
log.info("[Memory] No daily notes found for agent={}, skipping emergence", agentId);
- return;
+ return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "no daily notes");
}
- // 2. 批量读取 daily notes 内容(避免 N+1 查询)
StringBuilder dailyNotesBuilder = new StringBuilder();
for (String filename : dailyFilenames) {
- // TODO: 未来可优化为 IN 批量查询,当前 listFiles() 会清除 content 字段
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
if (file != null && file.getContent() != null && !file.getContent().isBlank()) {
dailyNotesBuilder.append("### ").append(filename).append("\n");
@@ -82,41 +103,28 @@ public class MemoryEmergenceService {
if (dailyNotes.isEmpty()) {
log.info("[Memory] All daily notes are empty for agent={}, skipping emergence", agentId);
- return;
+ return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "all daily notes empty");
}
- // 3. 读取现有 MEMORY.md
- String memoryContent = readFileContentSafe(agentId, "MEMORY.md");
+ // 2. Read existing MEMORY.md (for diff later)
+ String oldMemoryContent = readFileContentSafe(agentId, "MEMORY.md");
- // 4. 计算召回评分(必须在 resetDailyCounts 之前,否则 velocity 信号被清零)
+ // 3. Score candidates (must happen before resetDailyCounts)
List scoredCandidates = recallService.computeScores(agentId);
boolean hasScoredCandidates = !scoredCandidates.isEmpty();
- // 5. 评分快照完成后再重置 dailyCount,为下一轮积累
+ // 4. Reset daily counts for next accumulation cycle
recallService.resetDailyCounts(agentId);
+ // 5. Build prompt based on mode
String systemPrompt = PromptLoader.loadPrompt("memory/emergence-system");
- String userPrompt;
+ String userPrompt = buildUserPrompt(mode, topic, oldMemoryContent, scoredCandidates,
+ hasScoredCandidates, dailyNotes);
- if (hasScoredCandidates) {
- // 使用评分增强的 prompt
- String candidatesText = formatScoredCandidates(scoredCandidates);
- String userTemplate = PromptLoader.loadPrompt("memory/emergence-scored-user");
- userPrompt = userTemplate
- .replace("{memory}", memoryContent)
- .replace("{scored_candidates}", candidatesText)
- .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange()))
- .replace("{daily_notes}", dailyNotes);
- log.info("[Memory] Emergence with {} scored candidates for agent={}", scoredCandidates.size(), agentId);
- } else {
- // 冷启动:回退到原有纯 LLM 逻辑
- String userTemplate = PromptLoader.loadPrompt("memory/emergence-user");
- userPrompt = userTemplate
- .replace("{memory}", memoryContent)
- .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange()))
- .replace("{daily_notes}", dailyNotes);
- }
+ log.info("[Memory] Emergence {} with {} candidates for agent={}, topic={}",
+ mode, scoredCandidates.size(), agentId, topic);
+ // 6. Call LLM
String llmResponse;
try {
ChatModel chatModel = buildChatModel();
@@ -128,69 +136,155 @@ public class MemoryEmergenceService {
llmResponse = response.getResult().getOutput().getText();
} catch (Exception e) {
log.warn("[Memory] Emergence LLM call failed for agent={}: {}", agentId, e.getMessage());
- return;
+ return buildFailedReport(agentId, mode, topic, triggerSource, startedAt,
+ scoredCandidates.size(), e.getMessage());
}
- // 5. 解析并应用
+ // 7. Parse and apply
try {
JsonNode root = parseJsonResponse(llmResponse);
if (root == null || !root.path("should_update").asBoolean(false)) {
String reason = root != null ? root.path("reason").asText("") : "parse failed";
log.info("[Memory] No emergence update needed for agent={}: {}", agentId, reason);
- return;
+ return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, reason);
}
JsonNode memoryNode = root.path("memory_content");
- if (!memoryNode.isNull() && memoryNode.isTextual()) {
- String newContent = memoryNode.asText().trim();
- if (!newContent.isEmpty()) {
- workspaceFileService.saveFile(agentId, "MEMORY.md", newContent);
- String reason = root.path("reason").asText("");
- log.info("[Memory] Emergence completed for agent={}: {}", agentId, reason);
+ if (memoryNode.isNull() || !memoryNode.isTextual()) {
+ return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "no memory_content in response");
+ }
- // 逐候选检查:只有内容被 LLM 实际采纳(出现在新 MEMORY.md 中)的才标记为已提升
- if (hasScoredCandidates) {
- List promotedIds = scoredCandidates.stream()
- .filter(c -> candidateAdoptedInMemory(c, newContent))
- .map(MemoryRecallEntity::getId)
- .collect(Collectors.toList());
- if (!promotedIds.isEmpty()) {
- recallService.markPromoted(promotedIds);
- }
- log.info("[Memory] Promoted {}/{} recall candidates for agent={}",
- promotedIds.size(), scoredCandidates.size(), agentId);
+ String newContent = memoryNode.asText().trim();
+ if (newContent.isEmpty()) {
+ return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "empty memory_content");
+ }
- // 写入 DREAMS.md 整合日记
- appendDreamDiary(agentId, scoredCandidates, promotedIds);
+ workspaceFileService.saveFile(agentId, "MEMORY.md", newContent);
+ eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "consolidate", newContent));
+ String llmReason = root.path("reason").asText("");
+ log.info("[Memory] Emergence completed for agent={}: {}", agentId, llmReason);
+
+ // Determine promoted vs rejected candidates
+ List promotedEntries = new ArrayList<>();
+ List rejectedEntries = new ArrayList<>();
+
+ if (hasScoredCandidates) {
+ Set promotedIds = new HashSet<>();
+ for (MemoryRecallEntity c : scoredCandidates) {
+ if (candidateAdoptedInMemory(c, newContent)) {
+ promotedIds.add(c.getId());
+ promotedEntries.add(new PromotedEntry(
+ c.getId(), c.getFilename(), c.getSnippetPreview(), c.getScore()));
+ } else {
+ // Increment review_count for rejected candidates
+ int newReviewCount = (c.getReviewCount() != null ? c.getReviewCount() : 0) + 1;
+ rejectedEntries.add(new RejectedEntry(
+ c.getId(), c.getFilename(), c.getSnippetPreview(),
+ c.getScore(), newReviewCount));
}
}
+
+ if (!promotedIds.isEmpty()) {
+ recallService.markPromoted(new ArrayList<>(promotedIds));
+ }
+ // Update review_count / last_reviewed_at for rejected candidates
+ recallService.incrementReviewCounts(
+ rejectedEntries.stream().map(RejectedEntry::recallId).toList());
+
+ log.info("[Memory] Promoted {}/{} recall candidates for agent={}",
+ promotedIds.size(), scoredCandidates.size(), agentId);
+
+ // Append dream diary
+ appendDreamDiary(agentId, scoredCandidates, new ArrayList<>(promotedIds), mode, topic);
}
+
+ // Compute diff
+ String memoryDiff = computeDiff(oldMemoryContent, newContent);
+
+ // Build and persist report
+ DreamReport report = buildSuccessReport(agentId, mode, topic, triggerSource, startedAt,
+ scoredCandidates.size(), promotedEntries, rejectedEntries, memoryDiff,
+ truncate(llmReason, 500));
+ persistReport(report);
+
+ // Contradiction detection — synchronous step after persist (D11)
+ try {
+ contradictionDetector.detect(agentId, promotedEntries);
+ } catch (Exception ce) {
+ log.debug("[Memory] Contradiction detection failed (non-fatal): {}", ce.getMessage());
+ }
+
+ return report;
+
} catch (Exception e) {
log.warn("[Memory] Failed to parse/apply emergence result for agent={}: {}", agentId, e.getMessage());
+ return buildFailedReport(agentId, mode, topic, triggerSource, startedAt,
+ scoredCandidates.size(), e.getMessage());
}
}
/**
- * 将本轮 dreaming 结果追加到 DREAMS.md 整合日记
+ * Build user prompt based on dream mode.
*/
- private void appendDreamDiary(Long agentId, List allCandidates, List promotedIds) {
+ private String buildUserPrompt(DreamMode mode, String topic, String memoryContent,
+ List scoredCandidates,
+ boolean hasScoredCandidates, String dailyNotes) {
+ if (mode == DreamMode.FOCUSED && topic != null && !topic.isBlank()) {
+ // FOCUSED mode: use topic-biased prompt
+ String candidatesText = hasScoredCandidates ? formatScoredCandidates(scoredCandidates) : "(no scored candidates)";
+ String userTemplate = PromptLoader.loadPrompt("memory/emergence-focused-user");
+ return userTemplate
+ .replace("{memory}", memoryContent)
+ .replace("{topic}", topic)
+ .replace("{scored_candidates}", candidatesText)
+ .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange()))
+ .replace("{daily_notes}", dailyNotes);
+ }
+
+ // NIGHTLY mode: existing scored or plain prompt
+ if (hasScoredCandidates) {
+ String candidatesText = formatScoredCandidates(scoredCandidates);
+ String userTemplate = PromptLoader.loadPrompt("memory/emergence-scored-user");
+ return userTemplate
+ .replace("{memory}", memoryContent)
+ .replace("{scored_candidates}", candidatesText)
+ .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange()))
+ .replace("{daily_notes}", dailyNotes);
+ } else {
+ String userTemplate = PromptLoader.loadPrompt("memory/emergence-user");
+ return userTemplate
+ .replace("{memory}", memoryContent)
+ .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange()))
+ .replace("{daily_notes}", dailyNotes);
+ }
+ }
+
+ /**
+ * Append dream diary to DREAMS.md.
+ */
+ void appendDreamDiary(Long agentId, List allCandidates,
+ List promotedIds, DreamMode mode, String topic) {
try {
- java.util.Set promotedSet = new java.util.HashSet<>(promotedIds);
+ Set promotedSet = new HashSet<>(promotedIds);
List promoted = allCandidates.stream()
.filter(c -> promotedSet.contains(c.getId()))
- .sorted(java.util.Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed())
+ .sorted(Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed())
.toList();
List kept = allCandidates.stream()
.filter(c -> !promotedSet.contains(c.getId()))
- .sorted(java.util.Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed())
+ .sorted(Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed())
.toList();
- String timestamp = java.time.LocalDateTime.now()
+ String timestamp = LocalDateTime.now()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
StringBuilder diary = new StringBuilder();
- diary.append("## ").append(timestamp).append(" Dreaming\n\n");
+ diary.append("## ").append(timestamp).append(" Dreaming");
+ if (mode == DreamMode.FOCUSED && topic != null) {
+ diary.append(" [FOCUSED: ").append(topic).append("]");
+ }
+ diary.append("\n\n");
diary.append(String.format("**评分候选**: %d 条(阈值 %.1f)\n",
allCandidates.size(), properties.getEmergenceScoreThreshold()));
diary.append(String.format("**实际整合**: %d 条\n\n", promoted.size()));
@@ -213,13 +307,13 @@ public class MemoryEmergenceService {
diary.append("\n");
}
- // 读取现有 DREAMS.md,追加新日记
+ // Read existing DREAMS.md, append new diary
String existing = readFileContentSafe(agentId, "DREAMS.md");
String newContent = existing.isBlank()
? "# Dreaming 整合日记\n\n" + diary
: existing + "\n" + diary;
- // 防止无限膨胀:超过 20KB 时截断,只保留最近的内容
+ // Fallback hard truncation at 20KB (Phase 0 behavior preserved when archive flag is off)
if (newContent.length() > 20_000) {
int cutPoint = newContent.length() - 16_000;
int safePoint = newContent.indexOf("\n## ", cutPoint);
@@ -227,7 +321,6 @@ public class MemoryEmergenceService {
newContent = "# Dreaming 整合日记\n\n> 早期记录已归档\n\n"
+ newContent.substring(safePoint + 1);
} else {
- // 没找到 ## 标记,硬截断保留最后 16KB
newContent = "# Dreaming 整合日记\n\n> 早期记录已归档\n\n"
+ newContent.substring(cutPoint);
}
@@ -235,11 +328,86 @@ public class MemoryEmergenceService {
workspaceFileService.saveFile(agentId, "DREAMS.md", newContent);
log.info("[Memory] Dream diary appended for agent={}", agentId);
+
+ // Archive old entries or fall back to 20KB truncation
+ if (properties.getDream().isArchiveEnabled()) {
+ archiveService.archiveOldDreams(agentId);
+ }
} catch (Exception e) {
log.warn("[Memory] Failed to write dream diary for agent={}: {}", agentId, e.getMessage());
}
}
+ // ==================== Report builders ====================
+
+ private DreamReport buildSuccessReport(Long agentId, DreamMode mode, String topic,
+ String triggerSource, LocalDateTime startedAt,
+ int candidateCount,
+ List promoted,
+ List rejected,
+ String memoryDiff, String llmReason) {
+ return new DreamReport(null, agentId, mode, topic, triggerSource, "system",
+ startedAt, LocalDateTime.now(), candidateCount,
+ promoted.size(), rejected.size(), memoryDiff, llmReason,
+ DreamStatus.SUCCESS, null, promoted, rejected);
+ }
+
+ private DreamReport buildSkippedReport(Long agentId, DreamMode mode, String topic,
+ String triggerSource, LocalDateTime startedAt,
+ String reason) {
+ DreamReport report = new DreamReport(null, agentId, mode, topic, triggerSource, "system",
+ startedAt, LocalDateTime.now(), 0, 0, 0, null, reason,
+ DreamStatus.SKIPPED, null, List.of(), List.of());
+ persistReport(report);
+ return report;
+ }
+
+ private DreamReport buildFailedReport(Long agentId, DreamMode mode, String topic,
+ String triggerSource, LocalDateTime startedAt,
+ int candidateCount, String errorMessage) {
+ DreamReport report = new DreamReport(null, agentId, mode, topic, triggerSource, "system",
+ startedAt, LocalDateTime.now(), candidateCount, 0, 0, null, null,
+ DreamStatus.FAILED, errorMessage, List.of(), List.of());
+ persistReport(report);
+ return report;
+ }
+
+ private void persistReport(DreamReport report) {
+ try {
+ DreamReportEntity entity = new DreamReportEntity();
+ entity.setAgentId(report.agentId());
+ entity.setMode(report.mode().name());
+ entity.setTopic(report.topic());
+ entity.setTriggerSource(report.triggerSource());
+ entity.setTriggeredBy(report.triggeredBy());
+ entity.setStartedAt(report.startedAt());
+ entity.setFinishedAt(report.finishedAt());
+ entity.setCandidateCount(report.candidateCount());
+ entity.setPromotedCount(report.promotedCount());
+ entity.setRejectedCount(report.rejectedCount());
+ entity.setMemoryDiff(report.memoryDiff());
+ entity.setLlmReason(report.llmReason());
+ entity.setStatus(report.status().name());
+ entity.setErrorMessage(report.errorMessage());
+ entity.setCreateTime(LocalDateTime.now());
+ entity.setUpdateTime(LocalDateTime.now());
+ entity.setDeleted(0);
+ dreamReportMapper.insert(entity);
+ log.debug("[Memory] DreamReport persisted: agent={}, mode={}, status={}",
+ report.agentId(), report.mode(), report.status());
+ // Publish event for SSE broadcast
+ if (report.status() == DreamStatus.SUCCESS) {
+ eventPublisher.publishEvent(new DreamCompletedEvent(report));
+ } else if (report.status() == DreamStatus.FAILED) {
+ eventPublisher.publishEvent(new DreamFailedEvent(report));
+ }
+ } catch (Exception e) {
+ log.warn("[Memory] Failed to persist DreamReport for agent={}: {}", report.agentId(), e.getMessage());
+ }
+ }
+
+ // ==================== Helpers ====================
+
private String formatScoredCandidates(List candidates) {
StringBuilder sb = new StringBuilder();
for (MemoryRecallEntity entry : candidates) {
@@ -253,16 +421,11 @@ public class MemoryEmergenceService {
return sb.toString().trim();
}
- /**
- * 判断候选片段是否被 LLM 实际采纳到新 MEMORY.md 中。
- * 通过检查片段预览中的关键短语(取前 3 个非空行的前 20 字符)是否出现在新内容中。
- */
private boolean candidateAdoptedInMemory(MemoryRecallEntity candidate, String newMemoryContent) {
String preview = candidate.getSnippetPreview();
if (preview == null || preview.isBlank() || newMemoryContent == null) {
return false;
}
- // 从 snippet 提取关键短语进行匹配
String[] lines = preview.split("\n");
int matched = 0;
int checked = 0;
@@ -271,17 +434,29 @@ public class MemoryEmergenceService {
if (trimmed.isEmpty() || trimmed.startsWith("#")) continue;
if (checked >= 3) break;
checked++;
- // 取行的核心内容(去掉 markdown 标记),检查是否出现在新 MEMORY.md 中
String key = trimmed.replaceAll("^[-*>]+\\s*", "");
if (key.length() > 20) key = key.substring(0, 20);
if (key.length() >= 5 && newMemoryContent.contains(key)) {
matched++;
}
}
- // 至少有 1 个关键短语命中才算采纳
return matched > 0;
}
+ private String computeDiff(String oldContent, String newContent) {
+ if (oldContent == null || oldContent.isBlank()) return "(new file)";
+ if (oldContent.equals(newContent)) return "(no change)";
+ // Simple line-count diff for Phase 1
+ int oldLines = oldContent.split("\n").length;
+ int newLines = newContent.split("\n").length;
+ return String.format("-%d/+%d lines", oldLines, newLines);
+ }
+
+ private String truncate(String s, int maxLen) {
+ if (s == null) return null;
+ return s.length() <= maxLen ? s : s.substring(0, maxLen) + "...";
+ }
+
private ChatModel buildChatModel() {
ModelConfigEntity defaultModel = modelConfigService.getDefaultModel();
return agentGraphBuilder.buildRuntimeChatModel(defaultModel);
@@ -309,7 +484,7 @@ public class MemoryEmergenceService {
}
}
- private String readFileContentSafe(Long agentId, String filename) {
+ String readFileContentSafe(Long agentId, String filename) {
try {
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
return file != null && file.getContent() != null ? file.getContent() : "";
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryHilService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryHilService.java
new file mode 100644
index 00000000..1d38f130
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryHilService.java
@@ -0,0 +1,77 @@
+package vip.mate.memory.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import vip.mate.memory.event.MemoryWriteEvent;
+import vip.mate.workspace.document.WorkspaceFileService;
+import vip.mate.workspace.document.model.WorkspaceFileEntity;
+
+import java.time.LocalDate;
+
+/**
+ * Human-in-the-Loop service for memory editing.
+ *
+ * When a user edits a memory entry, this service writes it back to MEMORY.md
+ * with a hidden metadata marker () so that
+ * future Dream runs do not overwrite user modifications.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MemoryHilService {
+
+ private final WorkspaceFileService workspaceFileService;
+ private final ApplicationEventPublisher eventPublisher;
+
+ /**
+ * Edit a section in MEMORY.md identified by key (section heading).
+ * Appends user-edited metadata so Dream prompts respect user changes.
+ */
+ public void editMemoryEntry(Long agentId, String key, String newContent) {
+ WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "MEMORY.md");
+ if (file == null || file.getContent() == null) {
+ log.warn("[HiL] MEMORY.md not found for agent={}", agentId);
+ return;
+ }
+
+ String memoryContent = file.getContent();
+ String sectionHeader = "## " + key;
+ int headerIdx = memoryContent.indexOf(sectionHeader);
+
+ if (headerIdx < 0) {
+ // Section not found — append as new section
+ String metadata = "";
+ String newSection = "\n\n" + sectionHeader + "\n" + newContent.trim() + "\n" + metadata;
+ memoryContent = memoryContent.trim() + newSection;
+ } else {
+ // Find section boundaries
+ int contentStart = memoryContent.indexOf('\n', headerIdx) + 1;
+ int nextSection = memoryContent.indexOf("\n## ", contentStart);
+ int sectionEnd = nextSection > 0 ? nextSection : memoryContent.length();
+
+ // Replace section content
+ String metadata = "";
+ String replacement = newContent.trim() + "\n" + metadata + "\n";
+ memoryContent = memoryContent.substring(0, contentStart) + replacement
+ + memoryContent.substring(sectionEnd);
+ }
+
+ workspaceFileService.saveFile(agentId, "MEMORY.md", memoryContent);
+ eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "user-edit", newContent));
+ log.info("[HiL] User edited MEMORY.md section '{}' for agent={}", key, agentId);
+ }
+
+ /**
+ * Check if a section heading exists in MEMORY.md.
+ * Used by DreamController to validate edit key before allowing write.
+ */
+ public boolean sectionExists(Long agentId, String key) {
+ WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "MEMORY.md");
+ if (file == null || file.getContent() == null) return false;
+ return file.getContent().contains("## " + key);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java
index 39e4ea70..6cf287f7 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java
@@ -227,7 +227,7 @@ public class MemoryRecallService {
}
/**
- * 标记候选为已提升
+ * Mark candidates as promoted to MEMORY.md.
*/
public void markPromoted(List ids) {
if (ids == null || ids.isEmpty()) return;
@@ -237,6 +237,21 @@ public class MemoryRecallService {
.set(MemoryRecallEntity::getPromoted, true));
}
+ /**
+ * Increment review_count and set last_reviewed_at for rejected candidates.
+ * Phase 1: write-only; filtering by review_count is deferred to Phase 2.
+ */
+ public void incrementReviewCounts(List ids) {
+ if (ids == null || ids.isEmpty()) return;
+ for (Long id : ids) {
+ recallMapper.update(null,
+ new LambdaUpdateWrapper()
+ .eq(MemoryRecallEntity::getId, id)
+ .setSql("review_count = COALESCE(review_count, 0) + 1")
+ .set(MemoryRecallEntity::getLastReviewedAt, java.time.LocalDateTime.now()));
+ }
+ }
+
// ==================== 查询方法(供 API 使用) ====================
/**
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MorningCardService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MorningCardService.java
new file mode 100644
index 00000000..1037d4ae
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MorningCardService.java
@@ -0,0 +1,97 @@
+package vip.mate.memory.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import vip.mate.memory.model.DreamReportEntity;
+import vip.mate.memory.model.MorningCardSeenEntity;
+import vip.mate.memory.repository.DreamReportMapper;
+import vip.mate.memory.repository.MorningCardSeenMapper;
+
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Morning card service — determines whether to show a dream summary card
+ * when a user enters an agent view. Scope is per (userId, agentId).
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MorningCardService {
+
+ private final MorningCardSeenMapper seenMapper;
+ private final DreamReportMapper dreamReportMapper;
+
+ /**
+ * Get the morning card for a user+agent. Returns null if no unseen dream exists.
+ */
+ public Map getCardFor(Long userId, Long agentId) {
+ if (userId == null || agentId == null) return null;
+ // Find the latest successful dream report for this agent
+ DreamReportEntity latestReport = dreamReportMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(DreamReportEntity::getAgentId, agentId)
+ .eq(DreamReportEntity::getStatus, "SUCCESS")
+ .eq(DreamReportEntity::getDeleted, 0)
+ .orderByDesc(DreamReportEntity::getStartedAt)
+ .last("LIMIT 1"));
+
+ if (latestReport == null) {
+ return null; // No dream yet
+ }
+
+ // Check if user has already seen this report
+ MorningCardSeenEntity seen = seenMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(MorningCardSeenEntity::getUserId, userId)
+ .eq(MorningCardSeenEntity::getAgentId, agentId));
+
+ if (seen != null && seen.getLastReportId() != null
+ && seen.getLastReportId().equals(latestReport.getId())) {
+ return null; // Already seen
+ }
+
+ // Build card data
+ Map card = new LinkedHashMap<>();
+ card.put("reportId", latestReport.getId());
+ card.put("mode", latestReport.getMode());
+ card.put("topic", latestReport.getTopic());
+ card.put("startedAt", latestReport.getStartedAt());
+ card.put("promotedCount", latestReport.getPromotedCount());
+ card.put("rejectedCount", latestReport.getRejectedCount());
+ card.put("llmReason", latestReport.getLlmReason());
+ card.put("memoryDiff", latestReport.getMemoryDiff());
+ return card;
+ }
+
+ /**
+ * Mark the morning card as seen for a user+agent.
+ */
+ public void markSeen(Long userId, Long agentId, Long reportId) {
+ MorningCardSeenEntity existing = seenMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(MorningCardSeenEntity::getUserId, userId)
+ .eq(MorningCardSeenEntity::getAgentId, agentId));
+
+ if (existing != null) {
+ existing.setLastSeenAt(LocalDateTime.now());
+ existing.setLastReportId(reportId);
+ existing.setUpdateTime(LocalDateTime.now());
+ seenMapper.updateById(existing);
+ } else {
+ MorningCardSeenEntity entity = new MorningCardSeenEntity();
+ entity.setUserId(userId);
+ entity.setAgentId(agentId);
+ entity.setLastSeenAt(LocalDateTime.now());
+ entity.setLastReportId(reportId);
+ entity.setCreateTime(LocalDateTime.now());
+ entity.setUpdateTime(LocalDateTime.now());
+ seenMapper.insert(entity);
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/PromotedEntry.java b/mateclaw-server/src/main/java/vip/mate/memory/service/PromotedEntry.java
new file mode 100644
index 00000000..a40c1b54
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/PromotedEntry.java
@@ -0,0 +1,13 @@
+package vip.mate.memory.service;
+
+/**
+ * A candidate that was adopted into MEMORY.md during a dream.
+ *
+ * @author MateClaw Team
+ */
+public record PromotedEntry(
+ Long recallId,
+ String filename,
+ String snippetPreview,
+ double score
+) {}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/RejectedEntry.java b/mateclaw-server/src/main/java/vip/mate/memory/service/RejectedEntry.java
new file mode 100644
index 00000000..ac7b1bb4
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/RejectedEntry.java
@@ -0,0 +1,14 @@
+package vip.mate.memory.service;
+
+/**
+ * A candidate that was scored but not adopted into MEMORY.md during a dream.
+ *
+ * @author MateClaw Team
+ */
+public record RejectedEntry(
+ Long recallId,
+ String filename,
+ String snippetPreview,
+ double score,
+ int reviewCount
+) {}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java
new file mode 100644
index 00000000..c012ac92
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java
@@ -0,0 +1,136 @@
+package vip.mate.memory.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.messages.SystemMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import vip.mate.agent.AgentGraphBuilder;
+import vip.mate.agent.prompt.PromptLoader;
+import vip.mate.llm.model.ModelConfigEntity;
+import vip.mate.llm.service.ModelConfigService;
+import vip.mate.memory.MemoryProperties;
+import vip.mate.memory.event.MemoryWriteEvent;
+import vip.mate.workspace.document.WorkspaceFileService;
+import vip.mate.workspace.document.model.WorkspaceFileEntity;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * SOUL.md auto-evolution service.
+ *
+ * Subscribes to {@link MemoryWriteEvent}; after every K writes (configured by
+ * soulUpdateInterval), triggers an LLM call to regenerate SOUL.md from the
+ * agent's current memory state.
+ *
+ *
When soulUpdateInterval=0, this service is a no-op.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SoulSummarizerService {
+
+ private final WorkspaceFileService workspaceFileService;
+ private final ModelConfigService modelConfigService;
+ private final AgentGraphBuilder agentGraphBuilder;
+ private final MemoryProperties properties;
+
+ /** Per-agent write counter since last SOUL update */
+ private final Map writeCounters = new ConcurrentHashMap<>();
+
+ @Async
+ @EventListener
+ public void onMemoryWrite(MemoryWriteEvent event) {
+ int interval = properties.getSoulUpdateInterval();
+ if (interval <= 0) return;
+
+ Long agentId = event.agentId();
+ AtomicInteger counter = writeCounters.computeIfAbsent(agentId, k -> new AtomicInteger(0));
+ int count = counter.incrementAndGet();
+
+ if (count < interval) {
+ log.debug("[SOUL] Write {}/{} for agent={}, waiting...", count, interval, agentId);
+ return;
+ }
+
+ // Reset counter and trigger SOUL update
+ counter.set(0);
+ log.info("[SOUL] Triggering SOUL.md update for agent={} (after {} writes)", agentId, interval);
+
+ try {
+ updateSoul(agentId);
+ } catch (Exception e) {
+ log.warn("[SOUL] Failed to update SOUL.md for agent={}: {}", agentId, e.getMessage());
+ }
+ }
+
+ /**
+ * Regenerate SOUL.md from current agent memory state.
+ */
+ void updateSoul(Long agentId) {
+ // Read current files
+ String memoryContent = readSafe(agentId, "MEMORY.md");
+ String profileContent = readSafe(agentId, "PROFILE.md");
+ String currentSoul = readSafe(agentId, "SOUL.md");
+
+ String systemPrompt = PromptLoader.loadPrompt("memory/soul-summarize");
+ String userPrompt = String.format("""
+ ## Current SOUL.md
+ ```
+ %s
+ ```
+
+ ## PROFILE.md
+ ```
+ %s
+ ```
+
+ ## MEMORY.md
+ ```
+ %s
+ ```
+
+ Based on the above, regenerate SOUL.md. Keep it concise and personal.
+ Output ONLY the new SOUL.md content (no fences, no explanation).
+ """, currentSoul, profileContent, memoryContent);
+
+ ChatModel chatModel = buildChatModel();
+ Prompt prompt = new Prompt(List.of(
+ new SystemMessage(systemPrompt),
+ new UserMessage(userPrompt)
+ ));
+ ChatResponse response = chatModel.call(prompt);
+ String newSoul = response.getResult().getOutput().getText();
+
+ if (newSoul != null && !newSoul.isBlank() && newSoul.length() > 50) {
+ workspaceFileService.saveFile(agentId, "SOUL.md", newSoul.trim());
+ log.info("[SOUL] Updated SOUL.md for agent={} ({} chars)", agentId, newSoul.length());
+ } else {
+ log.debug("[SOUL] LLM returned empty/short response, skipping SOUL update");
+ }
+ }
+
+ private ChatModel buildChatModel() {
+ ModelConfigEntity defaultModel = modelConfigService.getDefaultModel();
+ return agentGraphBuilder.buildRuntimeChatModel(defaultModel);
+ }
+
+ private String readSafe(Long agentId, String filename) {
+ try {
+ WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
+ return file != null && file.getContent() != null ? file.getContent() : "";
+ } catch (Exception e) {
+ return "";
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java
index 73b54911..c5295fd8 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java
@@ -2,7 +2,9 @@ package vip.mate.memory.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
+import vip.mate.memory.event.MemoryWriteEvent;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
@@ -35,6 +37,7 @@ public class StructuredMemoryService {
private static final Pattern SECTION_PATTERN = Pattern.compile("^## (.+)$", Pattern.MULTILINE);
private final WorkspaceFileService workspaceFileService;
+ private final ApplicationEventPublisher eventPublisher;
/** Per-file lock to prevent concurrent read-modify-write on the same file */
private final ConcurrentHashMap fileLocks = new ConcurrentHashMap<>();
@@ -69,6 +72,8 @@ public class StructuredMemoryService {
workspaceFileService.saveFile(agentId, filename, updated);
log.info("[StructuredMemory] {} entry '{}' for agent={} (source={})",
existingSection != null ? "Updated" : "Added", key, agentId, source);
+ // Publish event for SOUL auto-evolution (Phase 2)
+ eventPublisher.publishEvent(new MemoryWriteEvent(agentId, filename, "remember", content));
} finally {
lock.unlock();
}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
index bd5b5423..b5dd387f 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
@@ -1,8 +1,11 @@
package vip.mate.memory.spi;
+import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.memory.MemoryProperties;
+import vip.mate.memory.spi.decorator.MetricsMemoryProvider;
+import vip.mate.memory.spi.decorator.RetryableMemoryProvider;
import java.util.ArrayList;
import java.util.Comparator;
@@ -32,20 +35,43 @@ public class MemoryManager {
/** External plugin memory provider (single-select constraint) */
private volatile MemoryProvider externalPluginProvider = null;
- public MemoryManager(List allProviders, MemoryProperties properties) {
+ public MemoryManager(List allProviders, MemoryProperties properties,
+ org.springframework.beans.factory.ObjectProvider meterRegistryProvider) {
+ MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
Set disabled = properties.getDisabledProviders();
- this.providers = allProviders.stream()
+ List filtered = allProviders.stream()
.filter(MemoryProvider::isAvailable)
.filter(p -> !disabled.contains(p.id()))
.sorted(Comparator.comparingInt(MemoryProvider::order))
.collect(Collectors.toList());
+ // Assemble decorator chain based on flags
+ this.providers = filtered.stream()
+ .map(p -> wrapWithDecorators(p, properties, meterRegistry))
+ .collect(Collectors.toList());
+
if (!disabled.isEmpty()) {
log.info("[MemoryManager] Disabled providers: {}", disabled);
}
- log.info("[MemoryManager] Active providers ({}): {}",
+ String decorators = "";
+ if (properties.getProviderRetryAttempts() > 1) decorators += "+retry(" + properties.getProviderRetryAttempts() + ")";
+ if (properties.isProviderMetricsEnabled()) decorators += "+metrics";
+ log.info("[MemoryManager] Active providers ({}): {} {}",
this.providers.size(),
- this.providers.stream().map(MemoryProvider::id).collect(Collectors.joining(", ")));
+ filtered.stream().map(MemoryProvider::id).collect(Collectors.joining(", ")),
+ decorators);
+ }
+
+ private MemoryProvider wrapWithDecorators(MemoryProvider provider, MemoryProperties properties,
+ MeterRegistry meterRegistry) {
+ MemoryProvider result = provider;
+ if (properties.getProviderRetryAttempts() > 1) {
+ result = new RetryableMemoryProvider(result, properties.getProviderRetryAttempts());
+ }
+ if (properties.isProviderMetricsEnabled() && meterRegistry != null) {
+ result = new MetricsMemoryProvider(result, meterRegistry);
+ }
+ return result;
}
// ==================== System Prompt ====================
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
index 5784e079..af8b3328 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
@@ -95,4 +95,38 @@ public interface MemoryProvider {
default String onPreCompress(Long agentId, List> messages) {
return "";
}
+
+ /**
+ * Notification that a memory write occurred. Called after canonical memory
+ * files (structured/*.md, MEMORY.md) are updated.
+ *
+ * Phase 2: SOUL auto-evolution subscribes to this.
+ *
+ * @param agentId the agent ID
+ * @param target which file was written (e.g. "MEMORY.md", "structured/user_pref.md")
+ * @param action what happened ("append", "update", "consolidate")
+ * @param content the written content
+ */
+ default void onMemoryWrite(Long agentId, String target, String action, String content) {
+ }
+
+ /**
+ * Warm up provider internal state (embeddings, index handles, connection pools).
+ * Called when an agent session is likely to start. Providers decide what to cache.
+ *
+ *
Phase 2: provider internal-state cache (not recall text cache — F2).
+ *
+ * @param agentId the agent ID
+ */
+ default void warmup(Long agentId) {
+ }
+
+ /**
+ * Evict cached internal state for an agent. Called on agent deactivation or
+ * memory pressure.
+ *
+ * @param agentId the agent ID
+ */
+ default void evict(Long agentId) {
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java
new file mode 100644
index 00000000..850a7370
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java
@@ -0,0 +1,38 @@
+package vip.mate.memory.spi.decorator;
+
+import vip.mate.memory.spi.MemoryProvider;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Base decorator for MemoryProvider. All methods delegate to the wrapped provider.
+ * Subclass and override specific methods to add behavior (retry, metrics, etc.).
+ *
+ * @author MateClaw Team
+ */
+public abstract class MemoryProviderDecorator implements MemoryProvider {
+
+ protected final MemoryProvider delegate;
+
+ protected MemoryProviderDecorator(MemoryProvider delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override public String id() { return delegate.id(); }
+ @Override public int order() { return delegate.order(); }
+ @Override public boolean isAvailable() { return delegate.isAvailable(); }
+ @Override public String systemPromptBlock(Long agentId) { return delegate.systemPromptBlock(agentId); }
+ @Override public String prefetch(Long agentId, String userQuery) { return delegate.prefetch(agentId, userQuery); }
+ @Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) {
+ delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
+ }
+ @Override public List getToolBeans() { return delegate.getToolBeans(); }
+ @Override public void onSessionEnd(Long agentId, String conversationId) { delegate.onSessionEnd(agentId, conversationId); }
+ @Override public String onPreCompress(Long agentId, List> messages) { return delegate.onPreCompress(agentId, messages); }
+ @Override public void onMemoryWrite(Long agentId, String target, String action, String content) {
+ delegate.onMemoryWrite(agentId, target, action, content);
+ }
+ @Override public void warmup(Long agentId) { delegate.warmup(agentId); }
+ @Override public void evict(Long agentId) { delegate.evict(agentId); }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java
new file mode 100644
index 00000000..90b1f792
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java
@@ -0,0 +1,71 @@
+package vip.mate.memory.spi.decorator;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import vip.mate.memory.spi.MemoryProvider;
+
+/**
+ * Decorator that records Micrometer metrics for prefetch/sync/session-end operations.
+ *
+ * Metrics emitted (all with tag provider=...):
+ * - memory.prefetch.latency (Timer)
+ * - memory.prefetch.failures (Counter)
+ * - memory.sync.duration (Timer)
+ * - memory.sync.failures (Counter)
+ * - memory.session_end.duration (Timer)
+ *
+ * @author MateClaw Team
+ */
+public class MetricsMemoryProvider extends MemoryProviderDecorator {
+
+ private final MeterRegistry meterRegistry;
+ private final Timer prefetchTimer;
+ private final Timer syncTimer;
+ private final Timer sessionEndTimer;
+
+ public MetricsMemoryProvider(MemoryProvider delegate, MeterRegistry meterRegistry) {
+ super(delegate);
+ this.meterRegistry = meterRegistry;
+ String providerId = delegate.id();
+ this.prefetchTimer = Timer.builder("memory.prefetch.latency")
+ .tag("provider", providerId)
+ .register(meterRegistry);
+ this.syncTimer = Timer.builder("memory.sync.duration")
+ .tag("provider", providerId)
+ .register(meterRegistry);
+ this.sessionEndTimer = Timer.builder("memory.session_end.duration")
+ .tag("provider", providerId)
+ .register(meterRegistry);
+ }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery) {
+ return prefetchTimer.record(() -> {
+ try {
+ return delegate.prefetch(agentId, userQuery);
+ } catch (Exception e) {
+ meterRegistry.counter("memory.prefetch.failures",
+ "provider", delegate.id()).increment();
+ throw e;
+ }
+ });
+ }
+
+ @Override
+ public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) {
+ syncTimer.record(() -> {
+ try {
+ delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
+ } catch (Exception e) {
+ meterRegistry.counter("memory.sync.failures",
+ "provider", delegate.id()).increment();
+ throw e;
+ }
+ });
+ }
+
+ @Override
+ public void onSessionEnd(Long agentId, String conversationId) {
+ sessionEndTimer.record(() -> delegate.onSessionEnd(agentId, conversationId));
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java
new file mode 100644
index 00000000..6d88583c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java
@@ -0,0 +1,68 @@
+package vip.mate.memory.spi.decorator;
+
+import lombok.extern.slf4j.Slf4j;
+import vip.mate.memory.spi.MemoryProvider;
+
+/**
+ * Decorator that retries failed prefetch/syncTurn calls with exponential backoff.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+public class RetryableMemoryProvider extends MemoryProviderDecorator {
+
+ private final int maxAttempts;
+
+ public RetryableMemoryProvider(MemoryProvider delegate, int maxAttempts) {
+ super(delegate);
+ this.maxAttempts = maxAttempts;
+ }
+
+ @Override
+ public String prefetch(Long agentId, String userQuery) {
+ Exception lastException = null;
+ for (int attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ return delegate.prefetch(agentId, userQuery);
+ } catch (Exception e) {
+ lastException = e;
+ if (attempt < maxAttempts) {
+ log.debug("[Retry] prefetch attempt {}/{} failed for provider={}: {}",
+ attempt, maxAttempts, delegate.id(), e.getMessage());
+ sleep(attempt);
+ }
+ }
+ }
+ log.warn("[Retry] prefetch exhausted {} attempts for provider={}: {}",
+ maxAttempts, delegate.id(), lastException != null ? lastException.getMessage() : "");
+ return "";
+ }
+
+ @Override
+ public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) {
+ Exception lastException = null;
+ for (int attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
+ return;
+ } catch (Exception e) {
+ lastException = e;
+ if (attempt < maxAttempts) {
+ log.debug("[Retry] syncTurn attempt {}/{} failed for provider={}: {}",
+ attempt, maxAttempts, delegate.id(), e.getMessage());
+ sleep(attempt);
+ }
+ }
+ }
+ log.warn("[Retry] syncTurn exhausted {} attempts for provider={}: {}",
+ maxAttempts, delegate.id(), lastException != null ? lastException.getMessage() : "");
+ }
+
+ private void sleep(int attempt) {
+ try {
+ Thread.sleep((long) Math.pow(2, attempt - 1) * 100);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java b/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java
index e3398632..f9f56ef0 100644
--- a/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java
@@ -49,7 +49,6 @@ public class PlanEntity {
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
- @TableLogic
private Integer deleted;
/** 子计划列表(非数据库字段,查询时填充) */
diff --git a/mateclaw-server/src/main/java/vip/mate/planning/model/SubPlanEntity.java b/mateclaw-server/src/main/java/vip/mate/planning/model/SubPlanEntity.java
index 63513500..3b0ab79c 100644
--- a/mateclaw-server/src/main/java/vip/mate/planning/model/SubPlanEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/planning/model/SubPlanEntity.java
@@ -45,6 +45,5 @@ public class SubPlanEntity {
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
- @TableLogic
private Integer deleted;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java
index cf2c77fd..11e443af 100644
--- a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java
@@ -61,6 +61,5 @@ public class PluginEntity {
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
- @TableLogic
private Integer deleted;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java
index 9cb24b4f..b9d7b157 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java
@@ -1,5 +1,6 @@
package vip.mate.skill.controller;
+import com.baomidou.mybatisplus.core.metadata.IPage;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@@ -34,10 +35,28 @@ public class SkillController {
private final SkillWorkspaceManager workspaceManager;
private final SkillSynthesisService synthesisService;
- @Operation(summary = "获取技能列表")
+ @Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
@GetMapping
- public R> list() {
- return R.ok(skillService.listSkills());
+ public R> list(
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int size,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false) String skillType,
+ @RequestParam(required = false) Boolean enabled,
+ @RequestParam(required = false) String scanStatus) {
+ return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled, scanStatus));
+ }
+
+ @Operation(summary = "获取各类型技能计数(tab 徽章用)")
+ @GetMapping("/counts")
+ public R> counts() {
+ return R.ok(skillService.countByType());
+ }
+
+ @Operation(summary = "重新扫描单个技能(RFC-042 §2.3.4)")
+ @PostMapping("/{id}/rescan")
+ public R rescan(@PathVariable Long id) {
+ return R.ok(skillService.rescanSecurity(id));
}
@Operation(summary = "获取已启用技能列表")
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java
new file mode 100644
index 00000000..f951f64d
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java
@@ -0,0 +1,305 @@
+package vip.mate.skill.installer;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.core.annotation.Order;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import org.springframework.stereotype.Service;
+import vip.mate.skill.model.SkillEntity;
+import vip.mate.skill.repository.SkillMapper;
+import vip.mate.skill.runtime.SkillFrontmatterParser;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Builtin skill seed service — RFC-044 §4.2.
+ *
+ * Scans {@code classpath:skills/*\/SKILL.md} on startup, parses each
+ * frontmatter, and upserts the row into {@code mate_skill} so the bundled
+ * SKILL.md becomes the single source of truth.
+ *
+ *
Replaces (and obsoletes) the per-skill {@code INSERT INTO mate_skill}
+ * blocks in {@code data-{locale}.sql}. Those are kept for one release as a
+ * compatibility shim — see RFC-044 §4.2 step 3.
+ *
+ *
Upsert key: {@code name}. The mate_skill primary key {@code id}
+ * is preserved on update, so nothing referencing a skill by id breaks.
+ *
+ *
Field merge policy: frontmatter wins where present; if the
+ * frontmatter omits a field (e.g. {@code icon}, {@code tags}, {@code author}),
+ * the existing DB value is preserved rather than blanked out. New skills
+ * (with no DB row yet) get sensible defaults.
+ *
+ *
Order: 110 — runs after Flyway and {@link
+ * vip.mate.config.DatabaseBootstrapRunner} (Order 1), so SQL seeds load first
+ * and this service then overlays the authoritative classpath SKILL.md.
+ */
+@Slf4j
+@Service
+@Order(110)
+@RequiredArgsConstructor
+public class BuiltinSkillSeedService implements ApplicationRunner {
+
+ private static final String SKILL_GLOB = "classpath*:skills/*/SKILL.md";
+ private static final String DEFAULT_AUTHOR = "MateClaw";
+ private static final String DEFAULT_ICON = "🛠️";
+ private static final String DEFAULT_VERSION = "1.0.0";
+ private static final String SKILL_TYPE_BUILTIN = "builtin";
+
+ private final SkillMapper skillMapper;
+ private final SkillFrontmatterParser frontmatterParser;
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public void run(ApplicationArguments args) {
+ try {
+ syncBuiltinSkills();
+ } catch (Exception e) {
+ log.warn("[SkillSeed] Sync failed (table may not exist yet): {}", e.getMessage());
+ }
+ }
+
+ /** Public so tests and admin endpoints can re-trigger sync. */
+ public SyncStats syncBuiltinSkills() {
+ ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
+ Resource[] resources;
+ try {
+ resources = resolver.getResources(SKILL_GLOB);
+ } catch (Exception e) {
+ log.warn("[SkillSeed] Failed to scan {}: {}", SKILL_GLOB, e.getMessage());
+ return new SyncStats(0, 0, 0, 0);
+ }
+
+ int inserted = 0, updated = 0, unchanged = 0, skipped = 0;
+ for (Resource resource : resources) {
+ try {
+ String content = readContent(resource);
+ SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
+ String name = parsed.getName();
+ if (name == null || name.isBlank()) {
+ log.warn("[SkillSeed] {}: SKILL.md has no `name` in frontmatter — skipped",
+ resource.getDescription());
+ skipped++;
+ continue;
+ }
+
+ SkillEntity existing = skillMapper.selectOne(
+ new LambdaQueryWrapper().eq(SkillEntity::getName, name));
+
+ if (existing == null) {
+ SkillEntity row = buildNew(parsed, content);
+ skillMapper.insert(row);
+ inserted++;
+ log.info("[SkillSeed] inserted '{}' (version={})", name, row.getVersion());
+ } else if (mergeIntoExisting(existing, parsed, content)) {
+ skillMapper.updateById(existing);
+ updated++;
+ log.info("[SkillSeed] updated '{}' (version={})", name, existing.getVersion());
+ } else {
+ unchanged++;
+ }
+ } catch (Exception e) {
+ log.warn("[SkillSeed] Failed to process {}: {}", resource.getDescription(), e.getMessage());
+ skipped++;
+ }
+ }
+ log.info("[SkillSeed] Builtin skills: {} inserted, {} updated, {} unchanged, {} skipped",
+ inserted, updated, unchanged, skipped);
+ return new SyncStats(inserted, updated, unchanged, skipped);
+ }
+
+ private String readContent(Resource resource) throws Exception {
+ try (InputStream is = resource.getInputStream()) {
+ return new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ /** Build a brand-new entity for a skill that has no row in mate_skill yet. */
+ private SkillEntity buildNew(SkillFrontmatterParser.ParsedSkillMd parsed, String content) {
+ SkillEntity row = new SkillEntity();
+ row.setName(parsed.getName());
+ row.setDescription(nullIfBlank(parsed.getDescription()));
+ row.setSkillType(SKILL_TYPE_BUILTIN);
+ row.setBuiltin(true);
+ row.setEnabled(true);
+ row.setSkillContent(content);
+ row.setVersion(stringFromFrontmatter(parsed, "version", DEFAULT_VERSION));
+ row.setIcon(stringFromFrontmatter(parsed, "icon", DEFAULT_ICON));
+ row.setAuthor(stringFromFrontmatter(parsed, "author", DEFAULT_AUTHOR));
+ row.setTags(tagsFromFrontmatter(parsed, parsed.getName()));
+ // RFC-042 §2.2 — optional bilingual display names from frontmatter.
+ row.setNameZh(stringFromFrontmatter(parsed, "nameZh", null));
+ row.setNameEn(stringFromFrontmatter(parsed, "nameEn", null));
+ row.setConfigJson(buildConfigJson(parsed));
+ LocalDateTime now = LocalDateTime.now();
+ row.setCreateTime(now);
+ row.setUpdateTime(now);
+ row.setDeleted(0);
+ return row;
+ }
+
+ /**
+ * Apply frontmatter onto an existing row. Returns {@code true} if any
+ * tracked field changed and the row needs an UPDATE.
+ *
+ * Frontmatter wins where present. Fields the frontmatter omits are
+ * left as-is so we don't blank out values populated elsewhere (UI,
+ * legacy SQL seed, manual admin tweaks).
+ */
+ private boolean mergeIntoExisting(SkillEntity existing,
+ SkillFrontmatterParser.ParsedSkillMd parsed,
+ String content) {
+ boolean dirty = false;
+
+ String desc = nullIfBlank(parsed.getDescription());
+ if (desc != null && !Objects.equals(existing.getDescription(), desc)) {
+ existing.setDescription(desc);
+ dirty = true;
+ }
+
+ String version = stringFromFrontmatter(parsed, "version", null);
+ if (version != null && !Objects.equals(existing.getVersion(), version)) {
+ existing.setVersion(version);
+ dirty = true;
+ }
+
+ String icon = stringFromFrontmatter(parsed, "icon", null);
+ if (icon != null && !Objects.equals(existing.getIcon(), icon)) {
+ existing.setIcon(icon);
+ dirty = true;
+ }
+
+ String author = stringFromFrontmatter(parsed, "author", null);
+ if (author != null && !Objects.equals(existing.getAuthor(), author)) {
+ existing.setAuthor(author);
+ dirty = true;
+ }
+
+ String tags = tagsFromFrontmatter(parsed, null);
+ if (tags != null && !Objects.equals(existing.getTags(), tags)) {
+ existing.setTags(tags);
+ dirty = true;
+ }
+
+ // RFC-042 §2.2 — bilingual display names. Frontmatter wins; if silent,
+ // preserve whatever the SQL seed or a manual UI edit populated.
+ String nameZh = stringFromFrontmatter(parsed, "nameZh", null);
+ if (nameZh != null && !Objects.equals(existing.getNameZh(), nameZh)) {
+ existing.setNameZh(nameZh);
+ dirty = true;
+ }
+ String nameEn = stringFromFrontmatter(parsed, "nameEn", null);
+ if (nameEn != null && !Objects.equals(existing.getNameEn(), nameEn)) {
+ existing.setNameEn(nameEn);
+ dirty = true;
+ }
+
+ String configJson = buildConfigJson(parsed);
+ if (!Objects.equals(existing.getConfigJson(), configJson)) {
+ existing.setConfigJson(configJson);
+ dirty = true;
+ }
+
+ if (!Objects.equals(existing.getSkillContent(), content)) {
+ existing.setSkillContent(content);
+ dirty = true;
+ }
+
+ // Re-affirm builtin classification — historic rows occasionally drifted.
+ if (!SKILL_TYPE_BUILTIN.equals(existing.getSkillType())) {
+ existing.setSkillType(SKILL_TYPE_BUILTIN);
+ dirty = true;
+ }
+ if (!Boolean.TRUE.equals(existing.getBuiltin())) {
+ existing.setBuiltin(true);
+ dirty = true;
+ }
+
+ return dirty;
+ }
+
+ @SuppressWarnings("unchecked")
+ private String stringFromFrontmatter(SkillFrontmatterParser.ParsedSkillMd parsed,
+ String key, String fallback) {
+ Map fm = parsed.getFrontmatter();
+ if (fm == null) return fallback;
+ Object value = fm.get(key);
+ if (value == null) return fallback;
+ String s = value.toString().trim();
+ return s.isEmpty() ? fallback : s;
+ }
+
+ /**
+ * Build the canonical {@code tags} string. Accepts either a CSV string,
+ * a YAML list, or — when the frontmatter is silent — derives a single
+ * tag from the supplied default (typically the skill name).
+ *
+ * Returns {@code null} when nothing usable was supplied; callers use
+ * that as "do not touch".
+ */
+ @SuppressWarnings("unchecked")
+ private String tagsFromFrontmatter(SkillFrontmatterParser.ParsedSkillMd parsed, String defaultTag) {
+ Map fm = parsed.getFrontmatter();
+ if (fm != null) {
+ Object raw = fm.get("tags");
+ if (raw instanceof List> list) {
+ StringBuilder sb = new StringBuilder();
+ for (Object item : list) {
+ if (item == null) continue;
+ String s = item.toString().trim();
+ if (s.isEmpty()) continue;
+ if (sb.length() > 0) sb.append(',');
+ sb.append(s);
+ }
+ if (sb.length() > 0) return sb.toString();
+ } else if (raw instanceof String s && !s.isBlank()) {
+ return s.trim();
+ }
+ }
+ return defaultTag != null ? defaultTag : null;
+ }
+
+ /**
+ * Stable {@code config_json} payload. Preserves the historical shape
+ * ({@code upstream}, {@code entryFile}) and adds {@code requiredTools}
+ * derived from {@code dependencies.tools}.
+ */
+ private String buildConfigJson(SkillFrontmatterParser.ParsedSkillMd parsed) {
+ // LinkedHashMap → stable key ordering → stable diff against existing.
+ Map config = new LinkedHashMap<>();
+ config.put("upstream", "mateclaw");
+ config.put("entryFile", "SKILL.md");
+
+ SkillFrontmatterParser.SkillDependencies deps = parsed.getDependencies();
+ if (deps != null && deps.getTools() != null && !deps.getTools().isEmpty()) {
+ config.put("requiredTools", deps.getTools());
+ }
+ if (parsed.getPlatforms() != null && !parsed.getPlatforms().isEmpty()) {
+ config.put("platforms", parsed.getPlatforms());
+ }
+ try {
+ return objectMapper.writeValueAsString(config);
+ } catch (Exception e) {
+ // Fall back to legacy shape — never break startup over JSON encoding.
+ return "{\"upstream\":\"mateclaw\",\"entryFile\":\"SKILL.md\"}";
+ }
+ }
+
+ private String nullIfBlank(String s) {
+ return s == null || s.isBlank() ? null : s;
+ }
+
+ public record SyncStats(int inserted, int updated, int unchanged, int skipped) {}
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java
index 0528cb4a..5fcc5fe8 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java
@@ -18,9 +18,23 @@ public class SkillEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
- /** 技能名称 */
+ /** 技能名称 — immutable slug, used as primary identifier */
private String name;
+ /**
+ * RFC-042 §2.2 — locale-specific display name for zh-CN.
+ * {@code null} → UI falls back to {@code name}.
+ */
+ @TableField(value = "name_zh", updateStrategy = FieldStrategy.ALWAYS)
+ private String nameZh;
+
+ /**
+ * RFC-042 §2.2 — locale-specific display name for en-US.
+ * {@code null} → UI falls back to {@code name}.
+ */
+ @TableField(value = "name_en", updateStrategy = FieldStrategy.ALWAYS)
+ private String nameEn;
+
/** 技能描述 */
private String description;
@@ -84,12 +98,24 @@ public class SkillEntity {
*/
private String securityScanStatus;
+ /**
+ * RFC-042 §2.3 — persisted JSON array of the last scan's findings
+ * ({@code [{ruleId,severity,category,title,description,filePath,
+ * lineNumber,snippet,remediation}]}). Populated by
+ * {@code SkillPackageResolver} after every scan so the admin UI can
+ * render "why blocked" without re-resolving.
+ */
+ @TableField(value = "security_scan_result", updateStrategy = FieldStrategy.ALWAYS)
+ private String securityScanResult;
+
+ /** RFC-042 §2.3 — wall-clock time of the last scan write-back. */
+ private LocalDateTime securityScanTime;
+
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
- @TableLogic
private Integer deleted;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java
index ae0cff6c..f6102be1 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java
@@ -5,8 +5,10 @@ import lombok.Builder;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import vip.mate.skill.runtime.SkillFrontmatterParser.SkillDependencies;
+import vip.mate.tool.ToolRegistry;
import vip.mate.tool.model.ToolEntity;
import vip.mate.tool.repository.ToolMapper;
@@ -15,6 +17,7 @@ import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
+import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
@@ -23,10 +26,20 @@ import java.util.concurrent.TimeUnit;
*/
@Slf4j
@Service
-@RequiredArgsConstructor
public class SkillDependencyChecker {
private final ToolMapper toolMapper;
+ private final ToolRegistry toolRegistry;
+
+ /**
+ * {@code @Lazy} on {@link ToolRegistry}: this bean is constructed during startup,
+ * and ToolRegistry transitively depends on MCP / plugin infrastructure that also
+ * runs early — the lazy proxy breaks that cycle.
+ */
+ public SkillDependencyChecker(ToolMapper toolMapper, @Lazy ToolRegistry toolRegistry) {
+ this.toolMapper = toolMapper;
+ this.toolRegistry = toolRegistry;
+ }
private static final String CURRENT_OS = detectOS();
@@ -75,9 +88,13 @@ public class SkillDependencyChecker {
}
}
- // 4. 内部工具检查
+ // 4. 内部工具检查 — fetch the runtime function-name set once per skill
+ // so we don't hit reflection N times when a skill lists many tools.
+ Set runtimeFunctionNames = dependencies.getTools().isEmpty()
+ ? Set.of()
+ : fetchRuntimeFunctionNames();
for (String toolName : dependencies.getTools()) {
- if (!isToolAvailable(toolName)) {
+ if (!isToolAvailable(toolName, runtimeFunctionNames)) {
missing.add("tool:" + toolName);
allSatisfied = false;
}
@@ -118,18 +135,25 @@ public class SkillDependencyChecker {
}
}
- private boolean isToolAvailable(String toolName) {
+ /**
+ * The runtime registry (ToolRegistry) is authoritative: it knows the exact
+ * function names LLMs and skills call by ({@code @Tool} method name / MCP
+ * tool id / plugin tool name). The {@code mate_tool} DB overlay stores
+ * class names + bean names and does NOT match that vocabulary, so checking
+ * the DB alone mis-reports every real skill dependency as "missing".
+ *
+ * We keep the DB lookup as a secondary fallback for the edge case where
+ * someone has inserted a custom row whose {@code name} happens to equal the
+ * function name.
+ */
+ private boolean isToolAvailable(String toolName, Set runtimeFunctionNames) {
+ if (runtimeFunctionNames.contains(toolName)) {
+ return true;
+ }
try {
- // 先按 name 精确匹配
Long count = toolMapper.selectCount(new LambdaQueryWrapper()
.eq(ToolEntity::getName, toolName)
.eq(ToolEntity::getEnabled, true));
- if (count > 0) return true;
-
- // 再按 beanName 匹配(兼容 Spring Bean 名称)
- count = toolMapper.selectCount(new LambdaQueryWrapper()
- .eq(ToolEntity::getBeanName, toolName)
- .eq(ToolEntity::getEnabled, true));
return count > 0;
} catch (Exception e) {
log.debug("Tool check failed for '{}': {}", toolName, e.getMessage());
@@ -137,6 +161,16 @@ public class SkillDependencyChecker {
}
}
+ private Set fetchRuntimeFunctionNames() {
+ try {
+ return toolRegistry.availableFunctionNames();
+ } catch (Exception e) {
+ log.warn("Failed to fetch runtime tool function names, falling back to DB check only: {}",
+ e.getMessage());
+ return Set.of();
+ }
+ }
+
private static boolean isWindows() {
return CURRENT_OS.equals("windows");
}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java
index aee8b9c2..32859256 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java
@@ -5,14 +5,17 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.skill.model.SkillEntity;
+import vip.mate.skill.repository.SkillMapper;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.stream.Collectors;
/**
@@ -38,6 +41,7 @@ public class SkillPackageResolver {
private final SkillDependencyChecker dependencyChecker;
private final ObjectMapper objectMapper;
private final SkillWorkspaceManager workspaceManager;
+ private final SkillMapper skillMapper;
/**
* 解析技能实体为运行时技能包(完整流程)
@@ -72,9 +76,75 @@ public class SkillPackageResolver {
// 4. 综合判定 runtimeAvailable
resolveRuntimeAvailability(resolved);
+ // 5. RFC-042 §2.3 — persist the scan outcome so the admin UI can show
+ // findings after a restart (previously they lived only in memory).
+ persistScanOutcome(entity, resolved);
+
return resolved;
}
+ /**
+ * Write back the latest scan status / findings JSON / timestamp when
+ * they differ from what's already on the row. Keeps the DB in sync
+ * without re-writing on every idempotent refresh.
+ *
+ * Diff-based so a fresh resolve loop across N enabled skills is
+ * effectively free when nothing has changed on disk. Errors here are
+ * non-fatal — the scan result is already attached to {@code resolved},
+ * so the UI will still see it for this request.
+ */
+ private void persistScanOutcome(SkillEntity entity, ResolvedSkill resolved) {
+ if (entity == null || entity.getId() == null) return;
+
+ String newStatus = deriveScanStatus(resolved);
+ String newJson = serializeFindings(resolved.getSecurityFindings());
+ boolean statusChanged = !Objects.equals(entity.getSecurityScanStatus(), newStatus);
+ boolean findingsChanged = !Objects.equals(entity.getSecurityScanResult(), newJson);
+
+ if (!statusChanged && !findingsChanged) {
+ return;
+ }
+
+ try {
+ SkillEntity update = new SkillEntity();
+ update.setId(entity.getId());
+ update.setSecurityScanStatus(newStatus);
+ update.setSecurityScanResult(newJson);
+ update.setSecurityScanTime(LocalDateTime.now());
+ skillMapper.updateById(update);
+ // Keep the in-memory entity coherent with the DB so the next
+ // resolve in the same tick doesn't redundantly write again.
+ entity.setSecurityScanStatus(newStatus);
+ entity.setSecurityScanResult(newJson);
+ entity.setSecurityScanTime(update.getSecurityScanTime());
+ } catch (Exception e) {
+ log.warn("Failed to persist scan outcome for skill '{}': {}", entity.getName(), e.getMessage());
+ }
+ }
+
+ /**
+ * Collapse the resolver's rich security state back into the {@code
+ * PASSED / FAILED / null} tri-state used on the row.
+ */
+ private String deriveScanStatus(ResolvedSkill resolved) {
+ if (resolved.isSecurityBlocked()) return "FAILED";
+ List findings = resolved.getSecurityFindings();
+ if (findings != null && !findings.isEmpty()) return "PASSED"; // scanned and found non-blocking issues
+ // No block, no findings — treat as scanned-clean (still PASSED so
+ // listEnabledSkills() doesn't treat it as never-scanned).
+ return "PASSED";
+ }
+
+ private String serializeFindings(List findings) {
+ if (findings == null || findings.isEmpty()) return null;
+ try {
+ return objectMapper.writeValueAsString(findings);
+ } catch (Exception e) {
+ log.debug("Failed to serialize findings: {}", e.getMessage());
+ return null;
+ }
+ }
+
// ==================== 阶段 1:内容解析 ====================
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
index 761ba0c4..47cfe4c2 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
@@ -104,6 +104,21 @@ public class SkillRuntimeService {
.collect(Collectors.toList());
}
+ /**
+ * Rescan one skill on demand (RFC-042 §2.3.4) — runs the full resolver
+ * pipeline (content + security + dependency), which writes the updated
+ * scan result to DB as a side-effect, and then invalidates the active
+ * skills cache so subsequent reads reflect the new status.
+ */
+ public ResolvedSkill rescanSingle(SkillEntity skill) {
+ ResolvedSkill resolved = packageResolver.resolve(skill);
+ activeSkillsCache.invalidateAll();
+ log.info("Rescanned skill '{}' (id={}): status={}, blocked={}",
+ skill.getName(), skill.getId(),
+ skill.getSecurityScanStatus(), resolved.isSecurityBlocked());
+ return resolved;
+ }
+
/**
* 根据名称查找 active skill
*/
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java
index a903155c..51eafe5c 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java
@@ -1,6 +1,8 @@
package vip.mate.skill.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -12,6 +14,7 @@ import vip.mate.skill.workspace.SkillWorkspaceProperties;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -58,6 +61,81 @@ public class SkillService {
.orderByDesc(SkillEntity::getCreateTime));
}
+ /**
+ * Paginated skill listing for the SkillMarket admin UI.
+ *
+ * RFC-042 §2.1 — replaces the unbounded {@code /skills} list. Filters
+ * are all optional; empty or {@code null} means "no filter". Keyword
+ * searches name / description / tags with LIKE.
+ *
+ *
{@code scanStatus} (RFC-042 §2.3.5) filters on {@code
+ * security_scan_status}: {@code "FAILED"} surfaces blocked skills so the
+ * admin can inspect findings and rescan, {@code "PASSED"} shows scanned
+ * clean rows, {@code null} / empty means no scan filter.
+ */
+ public IPage pageSkills(int page, int size, String keyword,
+ String skillType, Boolean enabled,
+ String scanStatus) {
+ Page pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+
+ if (keyword != null && !keyword.isBlank()) {
+ String kw = keyword.trim();
+ wrapper.and(w -> w
+ .like(SkillEntity::getName, kw)
+ .or().like(SkillEntity::getDescription, kw)
+ .or().like(SkillEntity::getTags, kw));
+ }
+ if (skillType != null && !skillType.isBlank()) {
+ wrapper.eq(SkillEntity::getSkillType, skillType);
+ }
+ if (enabled != null) {
+ wrapper.eq(SkillEntity::getEnabled, enabled);
+ }
+ if (scanStatus != null && !scanStatus.isBlank()) {
+ wrapper.eq(SkillEntity::getSecurityScanStatus, scanStatus.trim().toUpperCase());
+ }
+
+ wrapper.orderByDesc(SkillEntity::getBuiltin)
+ .orderByDesc(SkillEntity::getCreateTime);
+
+ return skillMapper.selectPage(pageParam, wrapper);
+ }
+
+ /**
+ * Manually re-run security + dependency resolution for a single skill
+ * (RFC-042 §2.3.4). Triggered from the admin UI after the user fixes
+ * flagged code and wants an immediate verdict instead of waiting for
+ * the next refresh event.
+ *
+ * The resolver itself persists the outcome — this method just kicks
+ * it and returns the reloaded row.
+ */
+ public SkillEntity rescanSecurity(Long id) {
+ SkillEntity skill = getSkill(id); // throws MateClawException if missing
+ if (runtimeService == null) {
+ throw new MateClawException("err.skill.runtime_unavailable",
+ "Skill runtime not initialized yet; retry in a moment");
+ }
+ runtimeService.rescanSingle(skill);
+ return skillMapper.selectById(id);
+ }
+
+ /**
+ * Aggregate skill counts per {@code skill_type}, plus an {@code all}
+ * rollup. Feeds the SkillMarket tab badges without pulling every row.
+ */
+ public Map countByType() {
+ Map result = new LinkedHashMap<>();
+ result.put("all", skillMapper.selectCount(null));
+ for (String type : List.of("builtin", "mcp", "dynamic")) {
+ result.put(type, skillMapper.selectCount(
+ new LambdaQueryWrapper()
+ .eq(SkillEntity::getSkillType, type)));
+ }
+ return result;
+ }
+
/**
* 获取已启用的技能列表(Agent 运行时使用)
*
diff --git a/mateclaw-server/src/main/java/vip/mate/stt/AudioMimeTypes.java b/mateclaw-server/src/main/java/vip/mate/stt/AudioMimeTypes.java
new file mode 100644
index 00000000..98ecadfc
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/stt/AudioMimeTypes.java
@@ -0,0 +1,90 @@
+package vip.mate.stt;
+
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Filename inference helper for the OpenAI Whisper STT path.
+ *
+ *
Whisper's {@code /v1/audio/transcriptions} endpoint reads the
+ * filename extension on the multipart {@code file} part to infer
+ * audio format; the {@code Content-Type} alone isn't enough because
+ * Hutool's 3-arg {@code form(name, bytes, fileName)} overload deduces
+ * the multipart Content-Type from the extension we pass. Hence this
+ * helper picks an extension that matches the actual bytes.
+ *
+ *
Previous bug (pre-fix): both providers hardcoded {@code "audio.ogg"}
+ * as the default filename even when the upstream content was WebM/Opus,
+ * which DashScope's HTTP path then tried to decode as Ogg and 400'd.
+ * That bug + DashScope's HTTP STT are both gone now (DashScope went to
+ * WebSocket); this class survives because Whisper still cares.
+ */
+public final class AudioMimeTypes {
+
+ /** Fallback when neither contentType nor filename gives us a hint. */
+ private static final String DEFAULT_EXTENSION = "wav";
+
+ /** content-type → conventional file extension. Ordered for documentation only. */
+ private static final Map CONTENT_TYPE_TO_EXTENSION = Map.ofEntries(
+ Map.entry("audio/wav", "wav"),
+ Map.entry("audio/wave", "wav"),
+ Map.entry("audio/x-wav", "wav"),
+ Map.entry("audio/mpeg", "mp3"),
+ Map.entry("audio/mp3", "mp3"),
+ Map.entry("audio/mp4", "m4a"),
+ Map.entry("audio/m4a", "m4a"),
+ Map.entry("audio/x-m4a", "m4a"),
+ Map.entry("audio/aac", "aac"),
+ Map.entry("audio/flac", "flac"),
+ Map.entry("audio/ogg", "ogg"),
+ Map.entry("audio/webm", "webm"),
+ Map.entry("audio/amr", "amr"));
+
+ /** file extension → conventional content-type (the inverse, for filename-first cases). */
+ private static final Map EXTENSION_TO_CONTENT_TYPE = Map.ofEntries(
+ Map.entry("wav", "audio/wav"),
+ Map.entry("mp3", "audio/mpeg"),
+ Map.entry("m4a", "audio/mp4"),
+ Map.entry("mp4", "audio/mp4"),
+ Map.entry("aac", "audio/aac"),
+ Map.entry("flac", "audio/flac"),
+ Map.entry("ogg", "audio/ogg"),
+ Map.entry("webm", "audio/webm"),
+ Map.entry("amr", "audio/amr"));
+
+ private AudioMimeTypes() {}
+
+ /**
+ * Choose a filename for the upload. Order of precedence:
+ *
+ * The caller-supplied filename, when it has a known audio extension.
+ * A name synthesised from the content-type, e.g. {@code audio/mpeg → audio.mp3}.
+ * {@code audio.wav} as a final fallback (WAV is universally accepted).
+ *
+ */
+ public static String resolveFileName(String fileName, String contentType) {
+ if (fileName != null && !fileName.isBlank() && extensionOf(fileName) != null) {
+ return fileName;
+ }
+ String extension = extensionForContentType(contentType);
+ return "audio." + (extension != null ? extension : DEFAULT_EXTENSION);
+ }
+
+ /** Extract the lower-cased extension (without the dot), or null. Package-private for tests. */
+ static String extensionOf(String fileName) {
+ if (fileName == null) return null;
+ int dot = fileName.lastIndexOf('.');
+ if (dot < 0 || dot == fileName.length() - 1) return null;
+ String ext = fileName.substring(dot + 1).toLowerCase(Locale.ROOT);
+ return EXTENSION_TO_CONTENT_TYPE.containsKey(ext) ? ext : null;
+ }
+
+ /** Map a content-type to its conventional extension, or null when unknown. */
+ static String extensionForContentType(String contentType) {
+ if (contentType == null) return null;
+ // Strip parameters: "audio/webm; codecs=opus" → "audio/webm"
+ int semi = contentType.indexOf(';');
+ String base = (semi >= 0 ? contentType.substring(0, semi) : contentType).trim().toLowerCase(Locale.ROOT);
+ return CONTENT_TYPE_TO_EXTENSION.get(base);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/stt/SttProvider.java b/mateclaw-server/src/main/java/vip/mate/stt/SttProvider.java
index 63c2e2a4..92c78714 100644
--- a/mateclaw-server/src/main/java/vip/mate/stt/SttProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/stt/SttProvider.java
@@ -3,7 +3,16 @@ package vip.mate.stt;
import vip.mate.system.model.SystemSettingsDTO;
/**
- * STT 语音识别提供商接口
+ * STT 语音识别提供商接口.
+ *
+ * Auto-detect ordering uses ascending priority (low number = preferred).
+ * Most providers can use the default {@link #autoDetectOrder()} value, but
+ * providers with strong language bias should override
+ * {@link #autoDetectOrder(String)} so the registry picks the right primary
+ * for the user's locale: OpenAI Whisper is the canonical English path,
+ * DashScope Paraformer is the canonical Chinese path. Mixing them up at
+ * dispatch time costs accuracy AND latency (the wrong primary tends to
+ * produce garbage that the fallback can't easily compensate for).
*/
public interface SttProvider {
String id();
@@ -12,6 +21,23 @@ public interface SttProvider {
int autoDetectOrder();
boolean isAvailable(SystemSettingsDTO config);
+ /**
+ * Per-language priority hook. Default implementation returns the
+ * language-agnostic {@link #autoDetectOrder()} value, so existing
+ * providers stay backwards-compatible. Override when the provider has
+ * a known language strength — e.g. OpenAI Whisper returns a smaller
+ * number for {@code "en"} than for {@code "zh"} to win the auto-pick
+ * for English users.
+ *
+ * @param language IETF / ISO-639 language hint, possibly {@code null}.
+ * Implementations should match conservatively (prefix
+ * match on {@code zh}, {@code en}, etc.) and gracefully
+ * fall back to {@link #autoDetectOrder()} on unknown.
+ */
+ default int autoDetectOrder(String language) {
+ return autoDetectOrder();
+ }
+
/**
* 转写音频
*/
diff --git a/mateclaw-server/src/main/java/vip/mate/stt/SttProviderRegistry.java b/mateclaw-server/src/main/java/vip/mate/stt/SttProviderRegistry.java
index 63468dca..7846e7f9 100644
--- a/mateclaw-server/src/main/java/vip/mate/stt/SttProviderRegistry.java
+++ b/mateclaw-server/src/main/java/vip/mate/stt/SttProviderRegistry.java
@@ -11,41 +11,85 @@ import java.util.function.Function;
import java.util.stream.Collectors;
/**
- * STT 提供商注册表
+ * STT 提供商注册表.
+ *
+ *
Provider selection is two-stage:
+ *
+ * If the user pinned a specific provider in settings, that wins.
+ * Otherwise sort all available providers by
+ * {@link SttProvider#autoDetectOrder(String)} — the per-language
+ * hook lets Whisper win on English while Paraformer wins on Chinese.
+ * Falls back to the language-agnostic order when no language hint
+ * is supplied.
+ *
+ *
+ * The registry is constructed once at startup and the unsorted provider
+ * list is held — sorting happens on demand because the order depends on the
+ * incoming language hint.
*/
@Slf4j
@Component
public class SttProviderRegistry {
- private final List sortedProviders;
+ private final List providers;
private final Map providerMap;
public SttProviderRegistry(List providers) {
- this.sortedProviders = providers.stream()
- .sorted(Comparator.comparingInt(SttProvider::autoDetectOrder))
- .toList();
+ this.providers = List.copyOf(providers);
this.providerMap = providers.stream()
.collect(Collectors.toMap(SttProvider::id, Function.identity()));
- log.info("注册 STT 提供商 {} 个: {}", sortedProviders.size(),
- sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList());
+ log.info("注册 STT 提供商 {} 个: {}", providers.size(),
+ providers.stream()
+ .map(p -> p.id() + "(default-order=" + p.autoDetectOrder() + ")")
+ .toList());
}
+ /** Backwards-compatible no-language overload. Prefer the (config, language) form. */
public SttProvider resolve(SystemSettingsDTO config) {
+ return resolve(config, null);
+ }
+
+ /**
+ * Pick the primary provider given the user's settings + a language hint.
+ * Returns null when nothing is available — callers should treat that as
+ * "no API key configured anywhere" and surface the actionable error.
+ */
+ public SttProvider resolve(SystemSettingsDTO config, String language) {
String configuredId = config.getSttProvider();
if (configuredId != null && !configuredId.isBlank() && !"auto".equals(configuredId)) {
SttProvider p = providerMap.get(configuredId);
if (p != null && p.isAvailable(config)) return p;
+ // Configured-but-unavailable falls through to auto so the user
+ // still gets a result if any other provider has its key set.
}
- for (SttProvider p : sortedProviders) {
+ for (SttProvider p : sortedByLanguage(language)) {
if (p.isAvailable(config)) return p;
}
return null;
}
+ /** Backwards-compatible no-language overload. */
public List fallbackCandidates(SystemSettingsDTO config, String excludeId) {
- return sortedProviders.stream()
+ return fallbackCandidates(config, excludeId, null);
+ }
+
+ /**
+ * Available providers other than {@code excludeId}, ordered by their
+ * priority for the given language. The fallback list always uses the
+ * language-aware order — if Whisper failed on Chinese, Paraformer is
+ * the right next-best, not whatever happened to come next in the
+ * default order.
+ */
+ public List fallbackCandidates(SystemSettingsDTO config, String excludeId, String language) {
+ return sortedByLanguage(language).stream()
.filter(p -> !p.id().equals(excludeId))
.filter(p -> p.isAvailable(config))
.toList();
}
+
+ private List sortedByLanguage(String language) {
+ return providers.stream()
+ .sorted(Comparator.comparingInt(p -> p.autoDetectOrder(language)))
+ .toList();
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/stt/SttService.java b/mateclaw-server/src/main/java/vip/mate/stt/SttService.java
index 44432bac..c7da7d8d 100644
--- a/mateclaw-server/src/main/java/vip/mate/stt/SttService.java
+++ b/mateclaw-server/src/main/java/vip/mate/stt/SttService.java
@@ -26,6 +26,15 @@ public class SttService {
return Map.of("success", false, "error", "STT 功能未启用,请在系统设置中开启");
}
+ // Per-call dispatch trace — without this the only signal that STT
+ // is even being attempted is the eventual provider success/failure
+ // log, which makes "no audio reached us" indistinguishable from
+ // "audio reached us but provider rejected it".
+ int bytes = audioData != null ? audioData.length : 0;
+ log.info("[STT] dispatch bytes={} fileName={} contentType={} language={} provider={}",
+ bytes, fileName, contentType, language,
+ config.getSttProvider() != null ? config.getSttProvider() : "auto");
+
SttRequest request = SttRequest.builder()
.audioData(audioData)
.fileName(fileName)
@@ -45,26 +54,61 @@ public class SttService {
}
private SttResult transcribeWithFallback(SttRequest request, SystemSettingsDTO config) {
- SttProvider primary = providerRegistry.resolve(config);
- if (primary == null) {
- return SttResult.failure("没有可用的 STT Provider,请检查配置");
+ // Language hint resolution order:
+ // 1. Caller-supplied request.language (explicit per-call override)
+ // 2. UI language from system settings (zh-CN / en-US)
+ // 3. null — registry falls back to the language-agnostic order
+ // Both registry primary-pick and fallback-candidate ordering use the
+ // same hint; without it Paraformer/Whisper would frequently swap
+ // priorities mid-fallback for the same conversation.
+ //
+ // Critical: write the resolved hint BACK into the request, so
+ // providers (especially DashScope's run-task language_hints field)
+ // see it. Pre-fix, providers got null even though we routed by
+ // zh-CN — DashScope's auto-detect would then sit through 2-3s of
+ // Chinese audio and emit zero result-generated events.
+ String languageHint = request.getLanguage();
+ if (languageHint == null || languageHint.isBlank()) {
+ languageHint = config.getLanguage();
+ if (languageHint != null && !languageHint.isBlank()) {
+ request.setLanguage(languageHint);
+ }
}
+ SttProvider primary = providerRegistry.resolve(config, languageHint);
+ if (primary == null) {
+ // Most common cause: no provider has its API key configured. Tell
+ // the operator that explicitly so they don't dig through provider
+ // logs looking for the real reason.
+ log.warn("[STT] no provider available — check DashScope / OpenAI API keys in 模型管理");
+ return SttResult.failure("没有可用的 STT Provider,请在模型管理中配置 DashScope 或 OpenAI API Key");
+ }
+ log.info("[STT] primary provider resolved: {} (language={})", primary.id(), languageHint);
SttResult result = primary.transcribe(request, config);
- if (result.isSuccess()) return result;
+ if (result.isSuccess()) {
+ log.info("[STT] success via {} ({} chars)", primary.id(), result.getText() != null ? result.getText().length() : 0);
+ return result;
+ }
+ log.warn("[STT] primary {} failed: {}", primary.id(), result.getErrorMessage());
List errors = new ArrayList<>();
errors.add(primary.id() + ": " + result.getErrorMessage());
if (Boolean.TRUE.equals(config.getSttFallbackEnabled())) {
- for (SttProvider fb : providerRegistry.fallbackCandidates(config, primary.id())) {
- log.info("[STT] Trying fallback provider: {}", fb.id());
+ for (SttProvider fb : providerRegistry.fallbackCandidates(config, primary.id(), languageHint)) {
+ log.info("[STT] trying fallback provider: {}", fb.id());
result = fb.transcribe(request, config);
- if (result.isSuccess()) return result;
+ if (result.isSuccess()) {
+ log.info("[STT] fallback success via {} ({} chars)", fb.id(),
+ result.getText() != null ? result.getText().length() : 0);
+ return result;
+ }
+ log.warn("[STT] fallback {} failed: {}", fb.id(), result.getErrorMessage());
errors.add(fb.id() + ": " + result.getErrorMessage());
}
}
+ log.error("[STT] all providers failed — errors: {}", errors);
return SttResult.failure("所有 STT Provider 均失败\n" + String.join("\n", errors));
}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/stt/WavPcmExtractor.java b/mateclaw-server/src/main/java/vip/mate/stt/WavPcmExtractor.java
new file mode 100644
index 00000000..1f634385
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/stt/WavPcmExtractor.java
@@ -0,0 +1,69 @@
+package vip.mate.stt;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+/**
+ * Strip the RIFF/WAVE header off a WAV blob to expose raw PCM samples.
+ *
+ * DashScope's realtime ASR expects the {@code parameters.format = "pcm"}
+ * input as **bare 16-bit signed little-endian PCM**, not WAV. The frontend
+ * (see {@code mateclaw-ui/src/utils/wavEncoder.ts}) emits a 16 kHz mono
+ * 16-bit WAV with the canonical 44-byte header — this helper unwraps it.
+ *
+ *
Why not just send the WAV: DashScope rejects with "format mismatch"
+ * because the first 44 bytes look like garbage when interpreted as PCM
+ * samples — they're the RIFF magic + format chunk metadata.
+ *
+ *
Limitations: handles only the canonical 44-byte WAV layout produced by
+ * MateClaw's WavRecorder. WAVs with extra chunks (LIST, JUNK, …) before the
+ * data chunk would need a chunk-walking parser. We don't currently accept
+ * arbitrary uploads, so the tighter scope is fine; if this changes,
+ * extend {@link #extract} to scan for the {@code "data"} chunk header
+ * instead of assuming offset 36.
+ */
+public final class WavPcmExtractor {
+
+ /** Bytes before the "data" chunk in a canonical mono 16-bit PCM WAV. */
+ public static final int CANONICAL_HEADER_BYTES = 44;
+
+ /** Sample rate field offset in the canonical WAV header. */
+ private static final int OFFSET_SAMPLE_RATE = 24;
+
+ private WavPcmExtractor() {}
+
+ /**
+ * Extract raw PCM bytes from a WAV blob. Throws when the input is too short
+ * or the magic header bytes don't look like RIFF/WAVE — better to fail loud
+ * here than ship garbage to DashScope and chase a confusing error code.
+ */
+ public static byte[] extract(byte[] wavBytes) {
+ if (wavBytes == null || wavBytes.length < CANONICAL_HEADER_BYTES) {
+ throw new IllegalArgumentException(
+ "WAV input too short: " + (wavBytes == null ? 0 : wavBytes.length) + " bytes");
+ }
+ if (wavBytes[0] != 'R' || wavBytes[1] != 'I' || wavBytes[2] != 'F' || wavBytes[3] != 'F'
+ || wavBytes[8] != 'W' || wavBytes[9] != 'A' || wavBytes[10] != 'V' || wavBytes[11] != 'E') {
+ throw new IllegalArgumentException("Not a WAV (missing RIFF/WAVE magic)");
+ }
+ byte[] pcm = new byte[wavBytes.length - CANONICAL_HEADER_BYTES];
+ System.arraycopy(wavBytes, CANONICAL_HEADER_BYTES, pcm, 0, pcm.length);
+ return pcm;
+ }
+
+ /**
+ * Read the sample rate from a WAV header. Used by callers that need to
+ * tell DashScope the actual rate of the audio (the API requires the rate
+ * up front in the {@code run-task} message — getting it wrong produces
+ * recognisable but distorted transcripts).
+ */
+ public static int sampleRate(byte[] wavBytes) {
+ if (wavBytes == null || wavBytes.length < CANONICAL_HEADER_BYTES) {
+ throw new IllegalArgumentException("WAV input too short for sample-rate read");
+ }
+ return ByteBuffer.wrap(wavBytes, OFFSET_SAMPLE_RATE, 4)
+ .order(ByteOrder.LITTLE_ENDIAN)
+ .getInt();
+ }
+
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java b/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java
index 18e9c5d3..8c4a8415 100644
--- a/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java
@@ -1,7 +1,5 @@
package vip.mate.stt.provider;
-import cn.hutool.http.HttpRequest;
-import cn.hutool.http.HttpResponse;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
@@ -11,63 +9,486 @@ import vip.mate.llm.service.ModelProviderService;
import vip.mate.stt.SttProvider;
import vip.mate.stt.SttRequest;
import vip.mate.stt.SttResult;
+import vip.mate.stt.WavPcmExtractor;
import vip.mate.system.model.SystemSettingsDTO;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
+
/**
- * DashScope STT Provider — Paraformer(OpenAI 兼容接口)
- *
- * 复用模型管理中的 DashScope API Key。中文识别效果优秀。
+ * DashScope STT Provider — Paraformer Realtime via WebSocket.
+ *
+ *
DashScope's only sync-callable STT path is the realtime WebSocket API
+ * — there is no /audio/transcriptions endpoint on either the
+ * native or OpenAI-compatible HTTP surface (verified empirically, returns
+ * 404). The earlier sync-HTTP version of this provider was speculative and
+ * has been replaced by this one.
+ *
+ *
Wire protocol
+ * Documented at Aliyun DashScope Realtime ASR . Message exchange:
+ *
+ * Open WS to {@value #WS_URL} with {@code Authorization: bearer
+ * } header.
+ * Client sends a {@code run-task} text frame with task_id +
+ * paraformer-realtime-v2 model + format/sample-rate parameters.
+ * Server replies with {@code task-started} text frame.
+ * Client streams raw 16-bit PCM bytes as binary frames (chunked at
+ * ~100ms each = {@value #CHUNK_BYTES} bytes for 16 kHz mono).
+ * Server emits {@code result-generated} events as transcripts come
+ * in. Each event carries a sentence keyed by {@code begin_time};
+ * later events with the same {@code begin_time} update the same
+ * sentence (interim → final).
+ * Client sends {@code finish-task} text frame; server replies with
+ * {@code task-finished}; both sides close.
+ *
+ *
+ * The {@link SttProvider} interface is sync — we bridge the async WS
+ * conversation to a blocking call via {@link CountDownLatch} (run-task ack
+ * + task-finished ack) plus an overall hard timeout. The whole transcribe
+ * call returns either a full transcript or a domain-typed
+ * {@link SttResult#failure} after at most {@value #OVERALL_TIMEOUT_MS}ms.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DashScopeSttProvider implements SttProvider {
+ /** DashScope WS endpoint for realtime inference (audio/text/multimodal). */
+ static final URI WS_URL = URI.create("wss://dashscope.aliyuncs.com/api-ws/v1/inference/");
+
+ /** Default model — paraformer-realtime-v2 is the canonical 2024+ realtime ASR. */
+ static final String DEFAULT_MODEL = "paraformer-realtime-v2";
+
+ /** Default sample rate in Hz. Must match the actual WAV — the helper reads it. */
+ static final int DEFAULT_SAMPLE_RATE_HZ = 16_000;
+
+ /** ~100ms of 16 kHz / 16-bit / mono PCM. DashScope recommends 100-300ms chunks. */
+ static final int CHUNK_BYTES = 3200;
+
+ /**
+ * How long to sleep between chunks. Paraformer-Realtime expects audio to
+ * arrive at roughly the natural recording rate; if we dump the whole clip
+ * in tens of milliseconds the server discards the stream and replies with
+ * task-finished + zero result-generated events. The official Python SDK
+ * does the same with {@code time.sleep(0.1)} between chunks. Matches
+ * {@link #CHUNK_BYTES} (100ms of audio → 100ms wall sleep).
+ */
+ static final long CHUNK_PACING_MS = 100L;
+
+ /** How long to wait for the WS handshake + task-started ack before giving up. */
+ static final long TASK_STARTED_TIMEOUT_MS = 10_000L;
+
+ /** Overall budget for a single transcribe — beyond this we abort the WS. */
+ static final long OVERALL_TIMEOUT_MS = 60_000L;
+
private final ModelProviderService modelProviderService;
private final ObjectMapper objectMapper;
-
- private static final String BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
- private static final String DEFAULT_MODEL = "paraformer-v2";
+ /** Shared HttpClient — JDK's WebSocket builder doesn't reuse the underlying
+ * connection pool when you allocate a fresh client per call, so making
+ * this a field saves a connection-pool spin-up on every transcribe. */
+ private final HttpClient httpClient = HttpClient.newHttpClient();
@Override public String id() { return "dashscope"; }
- @Override public String label() { return "DashScope (Paraformer)"; }
+ @Override public String label() { return "DashScope (Paraformer Realtime)"; }
@Override public boolean requiresCredential() { return true; }
@Override public int autoDetectOrder() { return 150; }
+ /**
+ * Per-language priority. Paraformer is the strongest mainstream Chinese
+ * STT, so push it ahead of Whisper on zh — see {@link SttProvider} javadoc
+ * for the routing rationale.
+ */
+ @Override
+ public int autoDetectOrder(String language) {
+ if (language == null) return autoDetectOrder();
+ String lang = language.toLowerCase();
+ if (lang.startsWith("zh")) return 60;
+ return autoDetectOrder();
+ }
+
@Override
public boolean isAvailable(SystemSettingsDTO config) {
- try { return modelProviderService.isProviderConfigured("dashscope"); }
- catch (Exception e) { return false; }
+ try {
+ return modelProviderService.isProviderConfigured("dashscope");
+ } catch (Exception e) {
+ log.warn("[DashScope STT] availability check failed: {}", e.getMessage());
+ return false;
+ }
}
@Override
public SttResult transcribe(SttRequest request, SystemSettingsDTO config) {
try {
String apiKey = modelProviderService.getProviderConfig("dashscope").getApiKey();
- if (apiKey == null) return SttResult.failure("DashScope API Key 未配置");
-
- String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL;
- String fileName = request.getFileName() != null ? request.getFileName() : "audio.ogg";
-
- HttpResponse response = HttpRequest.post(BASE_URL + "/audio/transcriptions")
- .header("Authorization", "Bearer " + apiKey)
- .form("model", model)
- .form("file", request.getAudioData(), request.getContentType(), fileName)
- .timeout(60_000)
- .execute();
-
- if (response.getStatus() == 200) {
- JsonNode result = objectMapper.readTree(response.body());
- String text = result.path("text").asText("");
- log.info("[DashScope STT] Transcribed {} chars (model={})", text.length(), model);
- return SttResult.success(text);
- } else {
- log.warn("[DashScope STT] Failed: HTTP {} - {}", response.getStatus(), response.body());
- return SttResult.failure("DashScope STT 失败: HTTP " + response.getStatus());
+ if (apiKey == null || apiKey.isBlank()) {
+ return SttResult.failure("DashScope API Key 未配置");
}
+ byte[] audio = request.getAudioData();
+ if (audio == null || audio.length < WavPcmExtractor.CANONICAL_HEADER_BYTES) {
+ return SttResult.failure("音频为空或过短");
+ }
+ byte[] pcm = WavPcmExtractor.extract(audio);
+ int sampleRate = WavPcmExtractor.sampleRate(audio);
+ String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL;
+ String taskId = UUID.randomUUID().toString().replace("-", "");
+
+ // Peak/RMS check — the silence path is a failure mode worth its
+ // own log line so users can tell "mic captured nothing" from
+ // "DashScope rejected real audio". Successful calls log peak/rms
+ // at DEBUG only; a healthy call shouldn't produce a per-request
+ // INFO log every time the user holds the talk button.
+ int[] peakRms = computePcmPeakRms(pcm);
+ if (peakRms[0] == 0) {
+ log.warn("[DashScope STT] PCM is silent (peak=0, bytes={}) — check mic permission / frontend recording",
+ pcm.length);
+ return SttResult.failure(
+ "音频为静音(PCM peak=0)— 检查麦克风权限或前端录制实现");
+ }
+ log.debug("[DashScope STT] PCM stats — bytes={} samples={} peak={} rms={} sampleRate={}",
+ pcm.length, pcm.length / 2, peakRms[0], peakRms[1], sampleRate);
+
+ DashScopeSession session = new DashScopeSession(taskId, objectMapper);
+ WebSocket ws;
+ try {
+ ws = httpClient.newWebSocketBuilder()
+ .header("Authorization", "bearer " + apiKey)
+ .connectTimeout(Duration.ofMillis(TASK_STARTED_TIMEOUT_MS))
+ .buildAsync(WS_URL, session)
+ .get(TASK_STARTED_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException e) {
+ return SttResult.failure("DashScope WS 握手超时");
+ }
+
+ try {
+ // 1. run-task. Envelope dumped at DEBUG only — the JSON is
+ // identical across calls modulo task_id + language hint, so
+ // logging it on every transcribe just clutters logs.
+ String runTask = buildRunTask(taskId, model, sampleRate, request.getLanguage());
+ log.debug("[DashScope STT] run-task envelope: {}", runTask);
+ ws.sendText(runTask, true).get(TASK_STARTED_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+
+ // 2. wait for task-started ack
+ if (!session.awaitTaskStarted(TASK_STARTED_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+ return SttResult.failure("DashScope task-started 超时");
+ }
+ if (session.failed()) {
+ return SttResult.failure("DashScope: " + session.errorMessage());
+ }
+
+ // 3. stream PCM chunks at real-time pace. Paraformer-Realtime
+ // is built for live mic input and silently drops audio when it
+ // arrives faster than wall-clock — symptom is 0 chars
+ // transcribed even though the protocol completes successfully
+ // (no task-failed). Sleep 100ms between 100ms chunks so total
+ // send time ≈ audio duration, matching what DashScope's own
+ // SDK examples do (time.sleep(0.1) per chunk).
+ int chunksSent = 0;
+ long sendStart = System.currentTimeMillis();
+ for (int offset = 0; offset < pcm.length; offset += CHUNK_BYTES) {
+ int len = Math.min(CHUNK_BYTES, pcm.length - offset);
+ ByteBuffer chunk = ByteBuffer.wrap(pcm, offset, len);
+ ws.sendBinary(chunk, true).get(TASK_STARTED_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ chunksSent++;
+ Thread.sleep(CHUNK_PACING_MS);
+ // Cheap fail-fast: if the server already said we're done /
+ // failed mid-stream, stop sending so we don't waste seconds
+ // sleeping on a dead connection.
+ if (session.failed() || session.taskFinishedRaised()) break;
+ }
+ long sendDuration = System.currentTimeMillis() - sendStart;
+ log.debug("[DashScope STT] streamed {} chunks ({} bytes) in {} ms",
+ chunksSent, pcm.length, sendDuration);
+
+ // 4. finish-task
+ ws.sendText(buildFinishTask(taskId), true).get(TASK_STARTED_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+
+ // 5. wait for task-finished
+ if (!session.awaitTaskFinished(OVERALL_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+ return SttResult.failure("DashScope task-finished 超时");
+ }
+ if (session.failed()) {
+ return SttResult.failure("DashScope: " + session.errorMessage());
+ }
+
+ String text = session.aggregatedText();
+ log.info("[DashScope STT] Transcribed {} chars from {} result-events "
+ + "(model={}, sampleRate={}, pcmBytes={})",
+ text.length(), session.resultEventCount(), model, sampleRate, pcm.length);
+ if (text.isEmpty() && session.resultEventCount() == 0) {
+ // Distinct failure mode: protocol completed cleanly but
+ // server never sent a single result-generated event.
+ // Almost always means the audio was discarded for
+ // pacing/format reasons. Surface as a typed failure so
+ // the fallback chain (Whisper) can still try.
+ return SttResult.failure(
+ "DashScope 收到 0 个识别事件——可能是音频格式或节奏问题");
+ }
+ return SttResult.success(text);
+ } finally {
+ // Best-effort close. abort() is fire-and-forget; we don't need to wait.
+ try {
+ ws.sendClose(WebSocket.NORMAL_CLOSURE, "done");
+ } catch (Exception ignored) {
+ ws.abort();
+ }
+ }
+ } catch (TimeoutException e) {
+ log.warn("[DashScope STT] timeout: {}", e.getMessage());
+ return SttResult.failure("DashScope STT 超时: " + e.getMessage());
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ log.error("[DashScope STT] WS error: {}", cause.getMessage(), cause);
+ return SttResult.failure("DashScope STT WS 错误: " + cause.getMessage());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return SttResult.failure("DashScope STT 被中断");
} catch (Exception e) {
log.error("[DashScope STT] Error: {}", e.getMessage(), e);
return SttResult.failure("DashScope STT 异常: " + e.getMessage());
}
}
+
+ /* ====================================================================== */
+ /* Wire-format helpers (package-private for unit testing). */
+ /* ====================================================================== */
+
+ String buildRunTask(String taskId, String model, int sampleRate, String language) throws Exception {
+ Map parameters = new LinkedHashMap<>();
+ parameters.put("format", "pcm");
+ parameters.put("sample_rate", sampleRate);
+ // Language hint when supplied — paraformer-realtime-v2 supports
+ // "zh", "en", "ja", "ko" via language_hints. Skip when null/blank
+ // to let the model auto-detect.
+ if (language != null && !language.isBlank()) {
+ // Strip locale suffix (zh-CN → zh).
+ String hint = language.toLowerCase();
+ int dash = hint.indexOf('-');
+ if (dash > 0) hint = hint.substring(0, dash);
+ parameters.put("language_hints", new String[]{hint});
+ }
+
+ Map payload = Map.of(
+ "task_group", "audio",
+ "task", "asr",
+ "function", "recognition",
+ "model", model,
+ "parameters", parameters,
+ "input", Map.of());
+ Map message = Map.of(
+ "header", Map.of(
+ "action", "run-task",
+ "task_id", taskId,
+ "streaming", "duplex"),
+ "payload", payload);
+ return objectMapper.writeValueAsString(message);
+ }
+
+ /**
+ * Compute peak (max absolute value) and RMS for 16-bit signed
+ * little-endian PCM bytes. Returns {peak, rms} as ints for log-friendly
+ * formatting. Both metrics are in raw int16 units (-32768..32767).
+ *
+ * Reference values for 16-bit PCM at typical recording levels:
+ *
+ * Silence / muted mic: peak ≤ 5, rms ≤ 2
+ * Quiet speech: peak ≈ 1000-5000, rms ≈ 200-1000
+ * Normal speech: peak ≈ 5000-20000, rms ≈ 1000-5000
+ * Loud / close-mic: peak ≈ 20000-32000, rms ≈ 5000-15000
+ *
+ */
+ static int[] computePcmPeakRms(byte[] pcm) {
+ if (pcm == null || pcm.length < 2) {
+ return new int[]{0, 0};
+ }
+ int peak = 0;
+ long sumSq = 0;
+ int sampleCount = pcm.length / 2;
+ for (int i = 0; i < sampleCount; i++) {
+ // Little-endian 16-bit signed: low byte first.
+ int lo = pcm[i * 2] & 0xFF;
+ int hi = pcm[i * 2 + 1]; // signed
+ int sample = (hi << 8) | lo;
+ int abs = Math.abs(sample);
+ if (abs > peak) peak = abs;
+ sumSq += (long) sample * sample;
+ }
+ int rms = (int) Math.sqrt((double) sumSq / sampleCount);
+ return new int[]{peak, rms};
+ }
+
+ String buildFinishTask(String taskId) throws Exception {
+ Map message = Map.of(
+ "header", Map.of(
+ "action", "finish-task",
+ "task_id", taskId,
+ "streaming", "duplex"),
+ "payload", Map.of("input", Map.of()));
+ return objectMapper.writeValueAsString(message);
+ }
+
+ /* ====================================================================== */
+ /* WebSocket.Listener: collects events and signals task-started/finished. */
+ /* ====================================================================== */
+
+ /**
+ * State machine for one DashScope ASR conversation. Package-private so
+ * unit tests can drive it with synthetic JSON without hitting the network.
+ */
+ static class DashScopeSession implements WebSocket.Listener {
+ private final String taskId;
+ private final ObjectMapper mapper;
+ private final CountDownLatch taskStarted = new CountDownLatch(1);
+ private final CountDownLatch taskFinished = new CountDownLatch(1);
+
+ /**
+ * Sentence buffer keyed by begin_time. DashScope emits multiple
+ * {@code result-generated} events for the same sentence as it gets
+ * refined (interim → final); each new event for a given begin_time
+ * supersedes the previous text. LinkedHashMap preserves arrival
+ * order, which roughly matches speech order, for the final concat.
+ */
+ private final Map sentencesByBeginTime = new LinkedHashMap<>();
+
+ /** Buffer for fragmented text frames (WS allows partial messages). */
+ private final StringBuilder textFrameBuf = new StringBuilder();
+
+ private final AtomicReference errorMessage = new AtomicReference<>();
+
+ /** Counts result-generated events — distinguishes "server got our audio
+ * but recognised nothing" (>0 events, all empty text) from "server
+ * saw zero audio frames" (0 events). Helps diagnose pacing /
+ * format issues. */
+ private int resultEventCount;
+
+ DashScopeSession(String taskId, ObjectMapper mapper) {
+ this.taskId = taskId;
+ this.mapper = mapper;
+ }
+
+ @Override
+ public CompletionStage> onText(WebSocket webSocket, CharSequence data, boolean last) {
+ textFrameBuf.append(data);
+ if (last) {
+ handleMessage(textFrameBuf.toString());
+ textFrameBuf.setLength(0);
+ }
+ webSocket.request(1);
+ return null;
+ }
+
+ @Override
+ public void onError(WebSocket webSocket, Throwable error) {
+ errorMessage.compareAndSet(null, "WS error: " + error.getMessage());
+ taskStarted.countDown();
+ taskFinished.countDown();
+ }
+
+ @Override
+ public CompletionStage> onClose(WebSocket webSocket, int statusCode, String reason) {
+ // If the server closes before task-finished, unblock waiters.
+ if (taskFinished.getCount() > 0) {
+ errorMessage.compareAndSet(null,
+ "WS closed before task-finished (status=" + statusCode + ", reason=" + reason + ")");
+ }
+ taskStarted.countDown();
+ taskFinished.countDown();
+ return null;
+ }
+
+ /** Package-private hook so unit tests can drive {@link DashScopeSession} without a real WebSocket. */
+ void handleMessage(String json) {
+ // Always trace the raw frame at DEBUG — this is invaluable when
+ // the protocol completes "successfully" but produces no
+ // transcripts. Without seeing every frame it's impossible to
+ // tell whether DashScope sent us a status-update / warning we
+ // ignored, or just stayed silent between task-started and
+ // task-finished.
+ log.debug("[DashScope STT] frame: {}", json);
+ try {
+ JsonNode node = mapper.readTree(json);
+ String event = node.path("header").path("event").asText();
+ switch (event) {
+ case "task-started" -> taskStarted.countDown();
+ case "result-generated" -> {
+ resultEventCount++;
+ JsonNode sentence = node.path("payload").path("output").path("sentence");
+ if (sentence.isObject()) {
+ long beginTime = sentence.path("begin_time").asLong(0L);
+ String text = sentence.path("text").asText("");
+ // Always overwrite — later events for the same begin_time
+ // carry the more-final transcript.
+ sentencesByBeginTime.put(beginTime, text);
+ }
+ }
+ case "task-finished" -> taskFinished.countDown();
+ case "task-failed" -> {
+ String msg = node.path("header").path("error_message").asText("unknown");
+ String code = node.path("header").path("error_code").asText("");
+ errorMessage.compareAndSet(null,
+ code.isEmpty() ? msg : (code + " — " + msg));
+ // Wake both latches so the caller can return the typed
+ // failure instead of timing out for the full budget.
+ taskStarted.countDown();
+ taskFinished.countDown();
+ }
+ // Anything else (status updates, model warnings, beta
+ // events) gets surfaced at INFO so it shows up without
+ // turning DEBUG on. If DashScope rolls out a new event
+ // type we should know about, this catches it.
+ default -> log.info("[DashScope STT] unhandled event '{}' frame={}", event, json);
+ }
+ } catch (Exception e) {
+ log.warn("[DashScope STT] failed to parse WS message: {}", e.getMessage());
+ }
+ }
+
+ boolean awaitTaskStarted(long timeout, TimeUnit unit) throws InterruptedException {
+ return taskStarted.await(timeout, unit);
+ }
+
+ boolean awaitTaskFinished(long timeout, TimeUnit unit) throws InterruptedException {
+ return taskFinished.await(timeout, unit);
+ }
+
+ boolean failed() {
+ return errorMessage.get() != null;
+ }
+
+ String errorMessage() {
+ return errorMessage.get();
+ }
+
+ /** True once task-finished has been observed — used by the sender
+ * loop to bail out early instead of pacing through dead-WS sleeps. */
+ boolean taskFinishedRaised() {
+ return taskFinished.getCount() == 0;
+ }
+
+ int resultEventCount() {
+ return resultEventCount;
+ }
+
+ String aggregatedText() {
+ // Concat in begin_time order. Different sentences typically don't
+ // need separator characters because Chinese text streams are
+ // already glued; for safety against missed punctuation we leave
+ // a soft join ("") rather than space — Whisper-style space
+ // joining produces odd-looking Chinese transcripts.
+ StringBuilder sb = new StringBuilder();
+ sentencesByBeginTime.values().forEach(sb::append);
+ return sb.toString();
+ }
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java b/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java
index 923dd261..1ac7a75e 100644
--- a/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java
@@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.llm.service.ModelProviderService;
+import vip.mate.stt.AudioMimeTypes;
import vip.mate.stt.SttProvider;
import vip.mate.stt.SttRequest;
import vip.mate.stt.SttResult;
@@ -33,10 +34,33 @@ public class OpenAiSttProvider implements SttProvider {
@Override public boolean requiresCredential() { return true; }
@Override public int autoDetectOrder() { return 100; }
+ /**
+ * Whisper is the canonical English STT and noticeably weaker on Chinese
+ * (it tends to produce simplified-character output even for traditional
+ * input, and short Chinese clips frequently transcribe to gibberish).
+ * Boost Whisper's priority for English/Japanese/Korean (where it leads),
+ * and de-prioritise it for Chinese so DashScope (Paraformer) wins the
+ * auto-pick.
+ */
+ @Override
+ public int autoDetectOrder(String language) {
+ if (language == null) return autoDetectOrder();
+ String lang = language.toLowerCase();
+ if (lang.startsWith("zh")) return 250; // pushed below DashScope Paraformer
+ if (lang.startsWith("en")
+ || lang.startsWith("ja")
+ || lang.startsWith("ko")) return 80; // pulled above DashScope
+ return autoDetectOrder();
+ }
+
@Override
public boolean isAvailable(SystemSettingsDTO config) {
- try { return modelProviderService.isProviderConfigured("openai"); }
- catch (Exception e) { return false; }
+ try {
+ return modelProviderService.isProviderConfigured("openai");
+ } catch (Exception e) {
+ log.warn("[OpenAI STT] availability check failed: {}", e.getMessage());
+ return false;
+ }
}
@Override
@@ -48,12 +72,18 @@ public class OpenAiSttProvider implements SttProvider {
String url = (baseUrl != null ? baseUrl : "https://api.openai.com") + "/v1/audio/transcriptions";
String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL;
- String fileName = request.getFileName() != null ? request.getFileName() : "audio.ogg";
+ // AudioMimeTypes ensures the filename extension matches the
+ // actual bytes (audio.wav, audio.mp3, etc.), which Hutool then
+ // uses to infer the multipart Content-Type. Don't pass
+ // contentType to .form() explicitly — Hutool has no
+ // form(String,byte[],String,String) overload, and the wrong
+ // dispatch crashes with ClassCastException on byte[] → Object[].
+ String fileName = AudioMimeTypes.resolveFileName(request.getFileName(), request.getContentType());
HttpResponse response = HttpRequest.post(url)
.header("Authorization", "Bearer " + apiKey)
.form("model", model)
- .form("file", request.getAudioData(), request.getContentType(), fileName)
+ .form("file", request.getAudioData(), fileName)
.timeout(60_000)
.execute();
diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java
index 9d0471a8..75852890 100644
--- a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java
+++ b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java
@@ -70,6 +70,16 @@ public class SystemSettingsDTO {
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String minimaxApiKey;
private String minimaxApiKeyMasked;
+ /**
+ * MiniMax API region — selects which host to call. Shared by image + video
+ * providers because the API key is the same across both:
+ *
+ * {@code "global"} (default) → {@code https://api.minimax.io}
+ * {@code "cn"} → {@code https://api.minimaxi.com} (lower latency from
+ * mainland China; required for accounts registered there).
+ *
+ */
+ private String minimaxRegion;
// ===== 图片生成配置 =====
/** 是否启用图片生成能力 */
diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java
index ef4e1a7a..1128ffb4 100644
--- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java
+++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java
@@ -8,6 +8,7 @@ import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ProviderInfoDTO;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.llm.service.ModelProviderService;
+import vip.mate.tool.browser.BrowserDiagnosticsService;
import vip.mate.tool.mcp.model.McpServerEntity;
import vip.mate.tool.mcp.runtime.McpClientManager;
import vip.mate.tool.mcp.runtime.McpClientManager.ConnectionResult;
@@ -34,6 +35,7 @@ public class SystemHealthService {
private final McpClientManager mcpClientManager;
private final McpServerService mcpServerService;
private final DatabaseBootstrapRunner bootstrapRunner;
+ private final BrowserDiagnosticsService browserDiagnostics;
public HealthResponse check() {
List checks = new ArrayList<>();
@@ -50,6 +52,9 @@ public class SystemHealthService {
// 4. Database initialization check
checks.add(checkDatabase());
+ // 5. Browser launch pre-flight (common failure source on fresh win/linux hosts)
+ checks.add(checkBrowser());
+
// Determine overall status
String overall = "healthy";
for (HealthCheck c : checks) {
@@ -161,6 +166,28 @@ public class SystemHealthService {
);
}
+ private HealthCheck checkBrowser() {
+ try {
+ BrowserDiagnosticsService.Report report = browserDiagnostics.run();
+ String status = switch (report.overall()) {
+ case "healthy" -> "healthy";
+ case "warning" -> "warning";
+ default -> "error";
+ };
+ String message = "healthy".equals(report.overall())
+ ? "Browser launch ready"
+ : String.join(" | ", report.advice());
+ HealthAction action = "healthy".equals(report.overall())
+ ? null
+ : new HealthAction("Diagnose", "/api/v1/system/browser-health");
+ return new HealthCheck("browser", status, message, action);
+ } catch (Exception e) {
+ log.warn("Browser diagnostics failed: {}", e.getMessage());
+ return new HealthCheck("browser", "warning",
+ "Browser diagnostics failed: " + e.getMessage(), null);
+ }
+ }
+
// ==================== Response Records ====================
public record HealthResponse(String overall, List checks) {}
diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java
index 8471102c..6251c0f4 100644
--- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java
@@ -64,6 +64,18 @@ public class SystemSettingService {
private final SystemSettingMapper systemSettingMapper;
+ /**
+ * Resolve the SearXNG base URL: DB value takes priority; fall back to the
+ * {@code SEARXNG_BASE_URL} environment variable so Docker deployments work
+ * out-of-the-box without manual configuration in the UI.
+ */
+ private String resolveSearxngBaseUrl() {
+ String dbValue = getValue(SEARXNG_BASE_URL_KEY, "");
+ if (dbValue != null && !dbValue.isBlank()) return dbValue;
+ String envValue = System.getenv("SEARXNG_BASE_URL");
+ return (envValue != null && !envValue.isBlank()) ? envValue : "";
+ }
+
public SystemSettingsDTO getSettings() {
SystemSettingsDTO dto = new SystemSettingsDTO();
dto.setLanguage(getValue(LANGUAGE_KEY, "zh-CN"));
@@ -79,7 +91,7 @@ public class SystemSettingService {
dto.setTavilyBaseUrl(getValue(TAVILY_BASE_URL_KEY, "https://api.tavily.com/search"));
// Keyless provider 配置
dto.setDuckduckgoEnabled(Boolean.parseBoolean(getValue(DUCKDUCKGO_ENABLED_KEY, "true")));
- dto.setSearxngBaseUrl(getValue(SEARXNG_BASE_URL_KEY, ""));
+ dto.setSearxngBaseUrl(resolveSearxngBaseUrl());
// API Key 脱敏回显
dto.setSerperApiKeyMasked(maskApiKey(getValue(SERPER_API_KEY_KEY, "")));
dto.setTavilyApiKeyMasked(maskApiKey(getValue(TAVILY_API_KEY_KEY, "")));
@@ -153,7 +165,7 @@ public class SystemSettingService {
dto.setTavilyApiKey(getValue(TAVILY_API_KEY_KEY, ""));
dto.setTavilyBaseUrl(getValue(TAVILY_BASE_URL_KEY, "https://api.tavily.com/search"));
dto.setDuckduckgoEnabled(Boolean.parseBoolean(getValue(DUCKDUCKGO_ENABLED_KEY, "true")));
- dto.setSearxngBaseUrl(getValue(SEARXNG_BASE_URL_KEY, ""));
+ dto.setSearxngBaseUrl(resolveSearxngBaseUrl());
return dto;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ConcurrencyUnsafe.java b/mateclaw-server/src/main/java/vip/mate/tool/ConcurrencyUnsafe.java
new file mode 100644
index 00000000..22bdb97c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/ConcurrencyUnsafe.java
@@ -0,0 +1,46 @@
+package vip.mate.tool;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a {@link org.springframework.ai.tool.annotation.Tool}-annotated method
+ * as not safe to run concurrently with itself or with other tools that
+ * touch the same state .
+ *
+ * Read by {@link vip.mate.tool.ToolConcurrencyRegistry} at startup. The
+ * registry returns {@code true} from
+ * {@link ToolConcurrencyRegistry#isUnsafe(String)} for marked tools, which
+ * causes {@code ToolExecutionExecutor} to execute them in their own batch
+ * (no parallelism, no overlap with the surrounding safe batch).
+ *
+ * Use cases:
+ *
+ * File writes / edits ({@code WriteFileTool}, {@code EditFileTool})
+ * Shell command execution ({@code ShellExecuteTool})
+ * Stateful workspace mutations ({@code WorkspaceMemoryTool}, {@code SkillManageTool})
+ * Persistent operations on shared resources ({@code CronJobTool}, {@code DatasourceTool})
+ * Long-running generative tools where API rate limits forbid parallel calls
+ * ({@code ImageGenerateTool}, {@code VideoGenerateTool})
+ *
+ *
+ * Read-only and idempotent tools should remain unannotated; they will be
+ * batched together for parallel execution by the executor.
+ *
+ * For MCP-provided tools the executor will eventually consult the
+ * {@code annotations.readOnlyHint} field from the MCP {@code Tool} schema;
+ * that integration is tracked as a Phase 4 follow-up. Until then MCP tools
+ * default to safe (their pre-existing behavior).
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface ConcurrencyUnsafe {
+
+ /**
+ * Optional human-readable reason. Surfaced in startup logs to help
+ * operators audit which tools have been marked unsafe.
+ */
+ String value() default "";
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolConcurrencyRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolConcurrencyRegistry.java
new file mode 100644
index 00000000..c6624c69
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolConcurrencyRegistry.java
@@ -0,0 +1,129 @@
+package vip.mate.tool;
+
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.stereotype.Component;
+import org.springframework.util.ClassUtils;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Startup-scanned registry of tools that must run sequentially
+ * ({@link ConcurrencyUnsafe}-annotated). Replaces the previous hardcoded
+ * {@code DEFAULT_UNSAFE_TOOLS} set in {@code ToolExecutionExecutor}.
+ *
+ * Discovery walks every bean definition and inspects the declared
+ * class's methods for the {@link Tool} + {@link ConcurrencyUnsafe} pair.
+ * Beans are not instantiated by this scan — we only resolve the bean
+ * class name and load it via the class loader, which preserves {@code @Lazy}
+ * semantics and avoids triggering ChatModel / DataSource / MCP-client
+ * construction at registry init.
+ *
+ * Tool name resolution mirrors Spring AI's logic: {@code @Tool#name()}
+ * when set, otherwise the method's simple name.
+ *
+ * The registry is immutable after {@link #scan()}; the unsafe set is
+ * populated once and consulted on every tool execution. MCP tools are not
+ * scanned (their {@link Tool} annotations live inside the MCP framework, not
+ * on user-visible methods); MCP support is tracked as a follow-up.
+ */
+@Slf4j
+@Component
+public class ToolConcurrencyRegistry {
+
+ private final ConfigurableApplicationContext applicationContext;
+
+ /** Populated once at startup; never mutated thereafter. */
+ private volatile Set unsafeNames = Collections.emptySet();
+
+ public ToolConcurrencyRegistry(ConfigurableApplicationContext applicationContext) {
+ this.applicationContext = applicationContext;
+ }
+
+ @PostConstruct
+ void scan() {
+ Set discovered = new HashSet<>();
+ ConfigurableListableBeanFactory factory = applicationContext.getBeanFactory();
+ ClassLoader classLoader = applicationContext.getClassLoader();
+
+ for (String beanName : factory.getBeanDefinitionNames()) {
+ Class> beanClass = resolveBeanClassWithoutInstantiating(factory, beanName, classLoader);
+ if (beanClass == null) continue;
+
+ // Unwrap CGLIB subclasses (proxies) so we see user-declared methods.
+ Class> userClass = ClassUtils.getUserClass(beanClass);
+ for (Method method : userClass.getDeclaredMethods()) {
+ Tool tool = method.getAnnotation(Tool.class);
+ if (tool == null) continue;
+ ConcurrencyUnsafe unsafe = method.getAnnotation(ConcurrencyUnsafe.class);
+ if (unsafe == null) continue;
+ String toolName = tool.name() != null && !tool.name().isEmpty() ? tool.name() : method.getName();
+ discovered.add(toolName);
+ log.info("[ToolConcurrencyRegistry] Marked tool '{}' as unsafe ({}#{}): {}",
+ toolName, userClass.getSimpleName(), method.getName(),
+ unsafe.value().isEmpty() ? "no reason given" : unsafe.value());
+ }
+ }
+ // Keep the legacy hardcoded names so existing deployments without
+ // annotations still see the same behavior. New code should rely on
+ // the @ConcurrencyUnsafe annotation rather than this list.
+ discovered.addAll(Arrays.asList("browser_use", "BrowserUseTool", "write_file", "edit_file"));
+ this.unsafeNames = Collections.unmodifiableSet(discovered);
+ log.info("[ToolConcurrencyRegistry] Concurrency-unsafe tools ({}): {}",
+ unsafeNames.size(), unsafeNames);
+ }
+
+ /**
+ * Resolve a bean's class without instantiating it.
+ * Preference order:
+ *
+ * {@link BeanDefinition#getBeanClassName()} → {@link Class#forName} via the context class loader
+ * (works for stereotype-scanned components).
+ * {@code factory.getType(beanName, false)} as a fallback for
+ * {@code @Bean}-defined or programmatically registered beans.
+ * The {@code false} flag forbids FactoryBean initialization.
+ *
+ * Returns {@code null} when neither path yields a class — for example,
+ * lambda-defined beans without a resolvable class name.
+ */
+ private static Class> resolveBeanClassWithoutInstantiating(ConfigurableListableBeanFactory factory,
+ String beanName,
+ ClassLoader classLoader) {
+ try {
+ BeanDefinition bd = factory.getBeanDefinition(beanName);
+ String className = bd.getBeanClassName();
+ if (className != null && !className.isEmpty()) {
+ try {
+ return Class.forName(className, false, classLoader);
+ } catch (ClassNotFoundException | LinkageError ignored) {
+ // Fall through to factory.getType fallback.
+ }
+ }
+ } catch (Exception ignored) {
+ // No bean definition (singleton registered programmatically); fall through.
+ }
+ try {
+ return factory.getType(beanName, false);
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ /** {@code true} when the named tool must execute alone (no parallelism). */
+ public boolean isUnsafe(String toolName) {
+ return toolName != null && unsafeNames.contains(toolName);
+ }
+
+ /** Defensive copy for diagnostics / admin endpoints. */
+ public Set snapshot() {
+ return unsafeNames;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java
index 24cdadd2..8a736fe1 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java
@@ -18,6 +18,8 @@ import vip.mate.i18n.LocaleAwareToolCallback;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -71,6 +73,19 @@ public class ToolRegistry {
* 通过数据库 enabled 标志过滤,确保 UI 开关真正生效
*/
public List getEnabledTools() {
+ return List.copyOf(getEnabledToolBeansByName().values());
+ }
+
+ /**
+ * Iterate Spring beans once, returning a {@code beanName → bean} map of every
+ * currently-enabled @Tool bean.
+ *
+ * This is the single source of truth for "which @Tool beans should the agent see"; both
+ * {@link #getEnabledTools()} and {@link #getEnabledToolSet()} build on it. Returning
+ * {@link LinkedHashMap} preserves the discovery order from {@code getBeansWithAnnotation},
+ * which {@link AgentToolSet} relies on (built-in tools first, MCP tools second).
+ */
+ private LinkedHashMap getEnabledToolBeansByName() {
// 1. 从数据库获取明确禁用的 beanName 黑名单
// 逻辑:只有 DB 中存在记录且 enabled=false 的才跳过
// DB 中没有记录的 bean 默认启用(向后兼容 + 新工具自动可用)
@@ -82,7 +97,7 @@ public class ToolRegistry {
.map(ToolEntity::getBeanName)
.collect(Collectors.toSet());
- List tools = new ArrayList<>();
+ LinkedHashMap enabled = new LinkedHashMap<>();
// 2. 扫描 Spring 容器中所有带 @Tool 方法的 Bean
Map beans = applicationContext.getBeansWithAnnotation(Component.class);
@@ -93,19 +108,20 @@ public class ToolRegistry {
boolean hasToolMethod = java.util.Arrays.stream(bean.getClass().getMethods())
.anyMatch(m -> m.isAnnotationPresent(Tool.class));
- if (hasToolMethod) {
- // 3. 只有 DB 中明确 enabled=false 的才跳过,其余全部启用
- if (disabledBeanNames.contains(beanName)) {
- log.debug("Skipped disabled tool bean: {} (beanName={})", bean.getClass().getSimpleName(), beanName);
- } else {
- tools.add(bean);
- log.debug("Registered tool bean: {} (beanName={})", bean.getClass().getSimpleName(), beanName);
- }
+ if (!hasToolMethod) {
+ continue;
+ }
+ // 3. 只有 DB 中明确 enabled=false 的才跳过,其余全部启用
+ if (disabledBeanNames.contains(beanName)) {
+ log.debug("Skipped disabled tool bean: {} (beanName={})", bean.getClass().getSimpleName(), beanName);
+ } else {
+ enabled.put(beanName, bean);
+ log.debug("Registered tool bean: {} (beanName={})", bean.getClass().getSimpleName(), beanName);
}
}
- log.info("Total enabled tools: {}", tools.size());
- return tools;
+ log.info("Total enabled tools: {}", enabled.size());
+ return enabled;
}
/**
@@ -116,7 +132,16 @@ public class ToolRegistry {
* 2. 当前容器中所有 ToolCallbackProvider(MCP server 等)
*/
public AgentToolSet getEnabledToolSet() {
- List toolBeans = getEnabledTools();
+ // Build both the bean list and the identity-based name lookup in one pass — the
+ // latter lets AgentToolSet's alias index resolve a saved binding like
+ // "BrowserUseTool" or "browserUseTool" back to the same callback as "browser_use".
+ LinkedHashMap beansByName = getEnabledToolBeansByName();
+ List toolBeans = new ArrayList<>(beansByName.values());
+ IdentityHashMap nameByBean = new IdentityHashMap<>();
+ for (Map.Entry e : beansByName.entrySet()) {
+ nameByBean.put(e.getValue(), e.getKey());
+ }
+
Map providerBeans = applicationContext.getBeansOfType(ToolCallbackProvider.class);
List providers = new ArrayList<>(providerBeans.values());
@@ -164,7 +189,66 @@ public class ToolRegistry {
log.info("Building AgentToolSet: toolBeans={}, providers={}, pluginTools={}, totalCallbacks={}",
toolBeans.size(), providers.size(), pluginToolCount, localizedCallbacks.size());
- return AgentToolSet.fromCallbacks(toolBeans, localizedCallbacks);
+ return AgentToolSet.fromCallbacks(toolBeans, localizedCallbacks, nameByBean::get);
+ }
+
+ /**
+ * Returns every runtime identifier by which a currently-enabled tool can be
+ * referenced — SKILL.md authors use all three conventions interchangeably:
+ *
+ * {@code @Tool} function name (e.g. {@code browser_use}, {@code runSkillScript})
+ * Spring bean name (e.g. {@code browserUseTool}, {@code skillScriptTool})
+ * MCP tool id / plugin tool name (routed via {@code ToolCallbackProvider})
+ *
+ * Returning the union lets {@link vip.mate.skill.runtime.SkillDependencyChecker}
+ * accept whichever convention a skill happens to declare.
+ */
+ public Set availableFunctionNames() {
+ Set names = new java.util.HashSet<>();
+
+ Set disabledBeanNames = toolMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ToolEntity::getEnabled, false)
+ .isNotNull(ToolEntity::getBeanName)
+ ).stream().map(ToolEntity::getBeanName).collect(Collectors.toSet());
+
+ // 1. @Tool beans — register both the bean name and every function name exposed.
+ Map beans = applicationContext.getBeansWithAnnotation(Component.class);
+ for (Map.Entry entry : beans.entrySet()) {
+ String beanName = entry.getKey();
+ Object bean = entry.getValue();
+ if (disabledBeanNames.contains(beanName)) continue;
+ boolean hasToolMethod = java.util.Arrays.stream(bean.getClass().getMethods())
+ .anyMatch(m -> m.isAnnotationPresent(Tool.class));
+ if (!hasToolMethod) continue;
+ names.add(beanName);
+ for (ToolCallback cb : ToolCallbacks.from(bean)) {
+ names.add(cb.getToolDefinition().name());
+ }
+ }
+
+ // 2. MCP providers — only function names exist here.
+ Map providers = applicationContext.getBeansOfType(ToolCallbackProvider.class);
+ for (ToolCallbackProvider provider : providers.values()) {
+ ToolCallback[] cbs = provider.getToolCallbacks();
+ if (cbs == null) continue;
+ for (ToolCallback cb : cbs) {
+ names.add(cb.getToolDefinition().name());
+ }
+ }
+
+ // 3. Plugin-registered tools — evaluate availability lazily so disabled plugins drop out.
+ for (PluginToolEntry entry : pluginTools) {
+ try {
+ if (Boolean.TRUE.equals(entry.availabilityCheck().get())) {
+ names.add(entry.callback().getToolDefinition().name());
+ }
+ } catch (Exception ignored) {
+ // Unreachable plugin tools don't contribute to the set.
+ }
+ }
+
+ return names;
}
/**
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserDiagnosticsService.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserDiagnosticsService.java
new file mode 100644
index 00000000..4d216bda
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserDiagnosticsService.java
@@ -0,0 +1,320 @@
+package vip.mate.tool.browser;
+
+import cn.hutool.http.HttpUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Diagnoses why the browser tool might fail to launch on this host.
+ *
+ * Runs a dry inventory — detecting system browsers, Playwright cache, Node runtime,
+ * required shared libraries on Linux, container / root context — without actually
+ * launching a session. Produces a structured report with actionable next steps.
+ */
+@Slf4j
+@Service
+public class BrowserDiagnosticsService {
+
+ private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
+ .toLowerCase(Locale.ROOT).contains("win");
+ private static final boolean IS_LINUX = System.getProperty("os.name", "")
+ .toLowerCase(Locale.ROOT).contains("linux");
+
+ /** Shared libraries Chromium needs on Linux. Missing any is a hard block. */
+ private static final List REQUIRED_LINUX_LIBS = List.of(
+ "libnss3", "libgbm", "libasound", "libxkbcommon", "libx11", "libxcomposite",
+ "libxdamage", "libxrandr", "libxfixes", "libatk", "libcups", "libpango"
+ );
+
+ private final BrowserProperties props;
+
+ public BrowserDiagnosticsService(BrowserProperties props) {
+ this.props = props;
+ }
+
+ public Report run() {
+ List findings = new ArrayList<>();
+ findings.add(inspectEnvironment());
+ findings.add(inspectConfiguredCdp());
+ findings.add(inspectConfiguredPath());
+ findings.add(inspectEnvPath());
+ findings.add(inspectSystemBrowsers());
+ findings.add(inspectPlaywrightCache());
+ if (IS_LINUX) {
+ findings.add(inspectLinuxLibs());
+ }
+
+ String overall = deriveOverall(findings);
+ List advice = deriveAdvice(findings);
+ return new Report(overall, findings, advice);
+ }
+
+ // ==================== Individual probes ====================
+
+ private Finding inspectEnvironment() {
+ Map data = new LinkedHashMap<>();
+ data.put("os", System.getProperty("os.name"));
+ data.put("arch", System.getProperty("os.arch"));
+ data.put("user", System.getProperty("user.name"));
+ data.put("container", BrowserLauncher.isRunningInContainer());
+ data.put("root", BrowserLauncher.isRunningAsRoot());
+ return new Finding("environment", Status.INFO, "Runtime environment", data, null);
+ }
+
+ private Finding inspectConfiguredCdp() {
+ String url = props.getCdpUrl();
+ if (url == null || url.isBlank()) {
+ return new Finding("config.cdp-url", Status.INFO, "mateclaw.browser.cdp-url not set", Map.of(), null);
+ }
+ Map data = new LinkedHashMap<>();
+ data.put("url", url);
+ try {
+ String resp = HttpUtil.get(stripTrailing(url) + "/json/version", 2000);
+ if (resp != null && resp.contains("webSocketDebuggerUrl")) {
+ data.put("reachable", true);
+ return new Finding("config.cdp-url", Status.OK,
+ "CDP endpoint reachable", data, null);
+ }
+ data.put("reachable", false);
+ data.put("response", resp);
+ return new Finding("config.cdp-url", Status.ERROR,
+ "CDP endpoint did not return a valid /json/version payload", data,
+ "Ensure Chrome was started with --remote-debugging-port=" + port(url) + " and /json/version is reachable.");
+ } catch (Exception e) {
+ data.put("error", e.getMessage());
+ return new Finding("config.cdp-url", Status.ERROR,
+ "CDP endpoint unreachable: " + e.getMessage(), data,
+ "Start Chrome with --remote-debugging-port or clear mateclaw.browser.cdp-url.");
+ }
+ }
+
+ private Finding inspectConfiguredPath() {
+ String path = props.getChromePath();
+ if (path == null || path.isBlank()) {
+ return new Finding("config.chrome-path", Status.INFO, "mateclaw.browser.chrome-path not set", Map.of(), null);
+ }
+ Path p = Path.of(path);
+ if (!Files.exists(p)) {
+ return new Finding("config.chrome-path", Status.ERROR,
+ "Configured chrome-path does not exist: " + path, Map.of("path", path),
+ "Install Chrome at that path, or clear mateclaw.browser.chrome-path.");
+ }
+ if (!Files.isExecutable(p)) {
+ return new Finding("config.chrome-path", Status.ERROR,
+ "Configured chrome-path is not executable: " + path, Map.of("path", path),
+ "chmod +x the binary, or point to the real chrome executable.");
+ }
+ return new Finding("config.chrome-path", Status.OK, "Configured chrome-path is valid",
+ Map.of("path", path), null);
+ }
+
+ private Finding inspectEnvPath() {
+ String env = System.getenv("CHROME_PATH");
+ if (env == null || env.isBlank()) {
+ return new Finding("env.CHROME_PATH", Status.INFO, "CHROME_PATH not set", Map.of(), null);
+ }
+ Path p = Path.of(env);
+ if (!Files.exists(p)) {
+ return new Finding("env.CHROME_PATH", Status.WARN,
+ "CHROME_PATH points to a missing file: " + env, Map.of("path", env),
+ "Fix CHROME_PATH or unset it to let auto-detection run.");
+ }
+ return new Finding("env.CHROME_PATH", Status.OK, "CHROME_PATH resolves to a real file",
+ Map.of("path", env), null);
+ }
+
+ private Finding inspectSystemBrowsers() {
+ List> found = new ArrayList<>();
+ for (Path candidate : BrowserLauncher.systemBrowserCandidates()) {
+ if (Files.exists(candidate)) {
+ Map entry = new LinkedHashMap<>();
+ entry.put("path", candidate.toString());
+ entry.put("executable", Files.isExecutable(candidate));
+ found.add(entry);
+ }
+ }
+ if (found.isEmpty()) {
+ return new Finding("system.browsers", Status.WARN,
+ "No system Chrome / Edge / Brave found on well-known paths",
+ Map.of("scanned", BrowserLauncher.systemBrowserCandidates().stream().map(Path::toString).toList()),
+ installBrowserAdvice());
+ }
+ return new Finding("system.browsers", Status.OK,
+ "Found " + found.size() + " system browser(s)", Map.of("found", found), null);
+ }
+
+ private Finding inspectPlaywrightCache() {
+ Path cacheDir = playwrightCacheDir();
+ Map data = new LinkedHashMap<>();
+ data.put("cacheDir", cacheDir.toString());
+ if (!Files.isDirectory(cacheDir)) {
+ return new Finding("playwright.cache", Status.WARN,
+ "Playwright browser cache not found (bundled chromium unavailable)", data,
+ "Run `mvn exec:java -e -Dexec.mainClass=\"com.microsoft.playwright.CLI\" -Dexec.args=\"install chromium\"` " +
+ "or rely on system Chrome (recommended).");
+ }
+ try (var stream = Files.list(cacheDir)) {
+ List entries = stream.map(p -> p.getFileName().toString()).filter(n -> n.contains("chromium")).toList();
+ data.put("chromiumBuilds", entries);
+ if (entries.isEmpty()) {
+ return new Finding("playwright.cache", Status.WARN,
+ "Playwright cache has no chromium build", data,
+ "Run playwright install chromium or use system Chrome.");
+ }
+ return new Finding("playwright.cache", Status.OK,
+ "Playwright bundled chromium available (" + entries.size() + " build(s))", data, null);
+ } catch (IOException e) {
+ data.put("error", e.getMessage());
+ return new Finding("playwright.cache", Status.WARN,
+ "Failed to read Playwright cache: " + e.getMessage(), data, null);
+ }
+ }
+
+ private Finding inspectLinuxLibs() {
+ // Pick the first available system browser to ldd-check.
+ Path binary = BrowserLauncher.systemBrowserCandidates().stream()
+ .filter(Files::exists).findFirst().orElse(null);
+ if (binary == null) {
+ return new Finding("linux.libs", Status.INFO, "No system browser to ldd-check", Map.of(), null);
+ }
+ try {
+ Process p = new ProcessBuilder("ldd", binary.toString())
+ .redirectErrorStream(true).start();
+ StringBuilder out = new StringBuilder();
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ out.append(line).append('\n');
+ }
+ }
+ p.waitFor(5, TimeUnit.SECONDS);
+ String dump = out.toString();
+ List missing = new ArrayList<>();
+ for (String line : dump.split("\n")) {
+ if (line.contains("not found")) {
+ missing.add(line.trim());
+ }
+ }
+ if (!missing.isEmpty()) {
+ Map data = new LinkedHashMap<>();
+ data.put("binary", binary.toString());
+ data.put("missing", missing);
+ return new Finding("linux.libs", Status.ERROR,
+ "Chromium shared libraries missing — browser will fail to start", data,
+ "apt-get install -y " + String.join(" ", REQUIRED_LINUX_LIBS.stream().map(l -> l + "-dev").toList())
+ + " (or your distro's equivalent)");
+ }
+ return new Finding("linux.libs", Status.OK, "All required shared libraries resolved",
+ Map.of("binary", binary.toString()), null);
+ } catch (Exception e) {
+ return new Finding("linux.libs", Status.INFO,
+ "ldd probe failed: " + e.getMessage(), Map.of(), null);
+ }
+ }
+
+ // ==================== Helpers ====================
+
+ private static Path playwrightCacheDir() {
+ String override = System.getenv("PLAYWRIGHT_BROWSERS_PATH");
+ if (override != null && !override.isBlank() && !"0".equals(override)) {
+ return Path.of(override);
+ }
+ String home = System.getProperty("user.home");
+ if (IS_WINDOWS) {
+ String local = System.getenv("LOCALAPPDATA");
+ if (local != null && !local.isBlank()) {
+ return Path.of(local, "ms-playwright");
+ }
+ return Path.of(home, "AppData", "Local", "ms-playwright");
+ }
+ if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac")) {
+ return Path.of(home, "Library", "Caches", "ms-playwright");
+ }
+ return Path.of(home, ".cache", "ms-playwright");
+ }
+
+ private static String stripTrailing(String url) {
+ String s = url.trim();
+ while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
+ return s;
+ }
+
+ private static String port(String url) {
+ int colon = url.lastIndexOf(':');
+ if (colon < 0) return "?";
+ String tail = url.substring(colon + 1);
+ int slash = tail.indexOf('/');
+ return slash > 0 ? tail.substring(0, slash) : tail;
+ }
+
+ private static String installBrowserAdvice() {
+ if (IS_WINDOWS) {
+ return "Install Chrome (https://www.google.com/chrome/) or Edge, or set mateclaw.browser.chrome-path.";
+ }
+ if (IS_LINUX) {
+ return "Install Chrome: `wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - && apt install google-chrome-stable` or `apt install chromium`.";
+ }
+ return "Install Chrome or Edge, or set mateclaw.browser.chrome-path to point at a browser binary.";
+ }
+
+ private static String deriveOverall(List findings) {
+ boolean hasError = findings.stream().anyMatch(f -> f.status == Status.ERROR);
+ boolean hasWarn = findings.stream().anyMatch(f -> f.status == Status.WARN);
+ boolean canLaunch = findings.stream().anyMatch(
+ f -> f.status == Status.OK && (f.id.equals("system.browsers")
+ || f.id.equals("config.cdp-url") || f.id.equals("config.chrome-path")
+ || f.id.equals("playwright.cache")));
+ if (canLaunch && !hasError) return "healthy";
+ if (canLaunch) return "warning";
+ if (hasError || !canLaunch) return "error";
+ return hasWarn ? "warning" : "healthy";
+ }
+
+ private static List deriveAdvice(List findings) {
+ List out = new ArrayList<>();
+ for (Finding f : findings) {
+ if (f.advice != null && (f.status == Status.ERROR || f.status == Status.WARN)) {
+ out.add("[" + f.id + "] " + f.advice);
+ }
+ }
+ if (out.isEmpty()) {
+ out.add("Browser stack looks healthy.");
+ }
+ return out;
+ }
+
+ // ==================== Records ====================
+
+ public enum Status { OK, WARN, ERROR, INFO }
+
+ public record Finding(String id, Status status, String message, Map data, String advice) {}
+
+ public record Report(String overall, List findings, List advice) {}
+
+ /** Summarise the report as a short string suitable for logs / tool responses. */
+ public static String summarise(Report r) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Browser diagnostics: ").append(r.overall).append('\n');
+ for (Finding f : r.findings) {
+ sb.append(" [").append(f.status).append("] ").append(f.id).append(" — ").append(f.message).append('\n');
+ }
+ if (!r.advice.isEmpty()) {
+ sb.append("Advice:\n");
+ for (String a : r.advice) sb.append(" - ").append(a).append('\n');
+ }
+ return sb.toString();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserHealthController.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserHealthController.java
new file mode 100644
index 00000000..a1c37837
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserHealthController.java
@@ -0,0 +1,29 @@
+package vip.mate.tool.browser;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import vip.mate.common.result.R;
+
+/**
+ * Browser self-check endpoint. Call this when the browser tool fails — the response
+ * tells you exactly what's broken (missing binary, missing libs, broken CDP, etc.)
+ * and how to fix it, without needing to inspect server logs.
+ */
+@Tag(name = "System Health")
+@RestController
+@RequestMapping("/api/v1/system")
+@RequiredArgsConstructor
+public class BrowserHealthController {
+
+ private final BrowserDiagnosticsService diagnostics;
+
+ @Operation(summary = "Browser launch diagnostics")
+ @GetMapping("/browser-health")
+ public R getBrowserHealth() {
+ return R.ok(diagnostics.run());
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java
new file mode 100644
index 00000000..de14bb4f
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java
@@ -0,0 +1,516 @@
+package vip.mate.tool.browser;
+
+import com.microsoft.playwright.Browser;
+import com.microsoft.playwright.BrowserContext;
+import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.Playwright;
+import com.microsoft.playwright.PlaywrightException;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Multi-strategy browser launcher. Tries, in order: an existing CDP endpoint, a
+ * user-configured executable, a Playwright channel, auto-detected system Chrome /
+ * Edge / Brave, Playwright's bundled Chromium, and finally self-launching a system
+ * browser with {@code --remote-debugging-port=0} and attaching over CDP (the same
+ * pattern openfang uses).
+ *
+ * Each attempt is recorded with its outcome so diagnostics can surface exactly
+ * what failed and how the user can fix it.
+ */
+@Slf4j
+@Component
+public class BrowserLauncher {
+
+ private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
+ .toLowerCase(Locale.ROOT).contains("win");
+ private static final boolean IS_MAC = System.getProperty("os.name", "")
+ .toLowerCase(Locale.ROOT).contains("mac");
+
+ private final BrowserProperties props;
+
+ public BrowserLauncher(BrowserProperties props) {
+ this.props = props;
+ }
+
+ public BrowserProperties properties() {
+ return props;
+ }
+
+ /**
+ * Launch a browser session. Tries every available strategy until one succeeds.
+ * The returned result always contains an {@code attempts} trace, even on success,
+ * so callers can surface "what we ended up using".
+ */
+ public Result launch(Playwright pw, boolean headed) {
+ List trace = new ArrayList<>();
+
+ // 1. Explicit CDP endpoint — user manages the Chrome process
+ String cdpUrl = props.getCdpUrl();
+ if (cdpUrl != null && !cdpUrl.isBlank()) {
+ Result r = tryCdp(pw, cdpUrl, trace, Strategy.CONFIG_CDP);
+ if (r != null) return r;
+ }
+
+ // 2. Explicit executable path (property or env var)
+ String explicitPath = firstNonBlank(props.getChromePath(), System.getenv("CHROME_PATH"));
+ if (explicitPath != null) {
+ Result r = tryExecutablePath(pw, explicitPath, headed, trace, Strategy.CONFIG_PATH);
+ if (r != null) return r;
+ }
+
+ // 3. Explicit channel (chrome / msedge / etc.)
+ String channel = props.getChannel();
+ if (channel != null && !channel.isBlank()) {
+ Result r = tryChannel(pw, channel, headed, trace, Strategy.CONFIG_CHANNEL);
+ if (r != null) return r;
+ }
+
+ // 4. Prefer system browser via channel auto-detection (chrome, then msedge)
+ if (props.isPreferSystem()) {
+ for (String autoChannel : new String[]{"chrome", "msedge"}) {
+ Result r = tryChannel(pw, autoChannel, headed, trace, Strategy.AUTO_CHANNEL);
+ if (r != null) return r;
+ }
+
+ // 5. Scan well-known install paths and launch via executablePath
+ for (Path candidate : systemBrowserCandidates()) {
+ Result r = tryExecutablePath(pw, candidate.toString(), headed, trace, Strategy.AUTO_PATH);
+ if (r != null) return r;
+ }
+ }
+
+ // 6. Playwright's bundled Chromium (requires `playwright install`)
+ Result bundled = tryBundled(pw, headed, trace);
+ if (bundled != null) return bundled;
+
+ // 7. Last resort: spawn system chrome with --remote-debugging-port=0 and attach via CDP.
+ // This bypasses Playwright's Node launcher entirely — useful when Playwright install is broken.
+ if (props.isAllowExternalCdpFallback()) {
+ Result external = tryExternalCdpLaunch(pw, headed, trace);
+ if (external != null) return external;
+ }
+
+ // All strategies failed
+ log.warn("[BrowserLauncher] All launch strategies failed. Trace:\n{}", formatTrace(trace));
+ return Result.failure(trace, summariseFailure(trace));
+ }
+
+ // ==================== Strategy implementations ====================
+
+ private Result tryCdp(Playwright pw, String url, List trace, Strategy strategy) {
+ String normalized = normalizeCdpUrl(url);
+ long t0 = System.currentTimeMillis();
+ try {
+ Browser browser = pw.chromium().connectOverCDP(normalized);
+ BrowserContext context;
+ Page page;
+ List contexts = browser.contexts();
+ if (!contexts.isEmpty()) {
+ context = contexts.get(0);
+ List pages = context.pages();
+ page = pages.isEmpty() ? context.newPage() : pages.get(0);
+ } else {
+ context = browser.newContext();
+ page = context.newPage();
+ }
+ long elapsed = System.currentTimeMillis() - t0;
+ trace.add(Attempt.ok(strategy, "connectOverCDP(" + normalized + ")", elapsed));
+ return Result.success(browser, context, page, true, normalized, strategy, trace);
+ } catch (Exception e) {
+ trace.add(Attempt.fail(strategy, "connectOverCDP(" + normalized + ")",
+ System.currentTimeMillis() - t0, e.getMessage()));
+ return null;
+ }
+ }
+
+ private Result tryExecutablePath(Playwright pw, String path, boolean headed,
+ List trace, Strategy strategy) {
+ if (!Files.exists(Path.of(path))) {
+ trace.add(Attempt.fail(strategy, "executablePath=" + path, 0, "file not found"));
+ return null;
+ }
+ long t0 = System.currentTimeMillis();
+ try {
+ BrowserType.LaunchOptions opts = baseLaunchOptions(headed)
+ .setExecutablePath(Path.of(path));
+ Browser browser = pw.chromium().launch(opts);
+ Result r = wrapLocalBrowser(browser, strategy, "executablePath=" + path,
+ System.currentTimeMillis() - t0, trace);
+ return r;
+ } catch (PlaywrightException e) {
+ trace.add(Attempt.fail(strategy, "executablePath=" + path,
+ System.currentTimeMillis() - t0, e.getMessage()));
+ return null;
+ }
+ }
+
+ private Result tryChannel(Playwright pw, String channel, boolean headed,
+ List trace, Strategy strategy) {
+ long t0 = System.currentTimeMillis();
+ try {
+ BrowserType.LaunchOptions opts = baseLaunchOptions(headed).setChannel(channel);
+ Browser browser = pw.chromium().launch(opts);
+ return wrapLocalBrowser(browser, strategy, "channel=" + channel,
+ System.currentTimeMillis() - t0, trace);
+ } catch (PlaywrightException e) {
+ trace.add(Attempt.fail(strategy, "channel=" + channel,
+ System.currentTimeMillis() - t0, e.getMessage()));
+ return null;
+ }
+ }
+
+ private Result tryBundled(Playwright pw, boolean headed, List trace) {
+ long t0 = System.currentTimeMillis();
+ try {
+ Browser browser = pw.chromium().launch(baseLaunchOptions(headed));
+ return wrapLocalBrowser(browser, Strategy.BUNDLED, "playwright-bundled-chromium",
+ System.currentTimeMillis() - t0, trace);
+ } catch (PlaywrightException e) {
+ trace.add(Attempt.fail(Strategy.BUNDLED, "playwright-bundled-chromium",
+ System.currentTimeMillis() - t0, e.getMessage()));
+ return null;
+ }
+ }
+
+ /**
+ * Spawn a system browser ourselves with {@code --remote-debugging-port=0}, parse stderr
+ * to recover the actual DevTools WebSocket URL, then attach via Playwright's CDP client.
+ * This is the openfang pattern — it sidesteps Playwright's Node-based launcher entirely,
+ * so it still works when `playwright install` has not been run or Node is flaky.
+ */
+ private Result tryExternalCdpLaunch(Playwright pw, boolean headed, List trace) {
+ long t0 = System.currentTimeMillis();
+ Path browserBin = null;
+ for (Path candidate : systemBrowserCandidates()) {
+ if (Files.exists(candidate)) {
+ browserBin = candidate;
+ break;
+ }
+ }
+ if (browserBin == null) {
+ trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, "external-chrome-spawn",
+ System.currentTimeMillis() - t0, "no system browser executable found"));
+ return null;
+ }
+
+ List command = new ArrayList<>();
+ command.add(browserBin.toString());
+ command.add("--remote-debugging-port=0");
+ command.add("--no-first-run");
+ command.add("--no-default-browser-check");
+ command.add("--disable-extensions");
+ command.add("--disable-background-networking");
+ if (props.isHeadless() && !headed) {
+ command.add("--headless=new");
+ }
+ if (isRunningAsRoot() || IS_WINDOWS) {
+ command.add("--no-sandbox");
+ }
+ if (isRunningInContainer()) {
+ command.add("--disable-dev-shm-usage");
+ }
+ command.add("about:blank");
+
+ ProcessBuilder pb = new ProcessBuilder(command).redirectErrorStream(false);
+ // SECURITY: don't leak the parent process's secrets (API keys, etc.) into chrome.
+ // Keep only the vars Chrome actually needs to run. openfang does the same via env_clear.
+ java.util.Map env = pb.environment();
+ java.util.Map keep = new java.util.LinkedHashMap<>();
+ for (String key : new String[]{"PATH", "HOME", "USERPROFILE", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR",
+ "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "DISPLAY", "WAYLAND_DISPLAY"}) {
+ String v = env.get(key);
+ if (v != null) keep.put(key, v);
+ }
+ env.clear();
+ env.putAll(keep);
+
+ Process proc;
+ try {
+ proc = pb.start();
+ } catch (Exception e) {
+ trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, browserBin + " --remote-debugging-port",
+ System.currentTimeMillis() - t0, "spawn failed: " + e.getMessage()));
+ return null;
+ }
+
+ String wsUrl;
+ try {
+ wsUrl = readDevToolsUrl(proc, props.getCdpTimeoutSeconds());
+ } catch (Exception e) {
+ proc.destroyForcibly();
+ trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, browserBin.toString(),
+ System.currentTimeMillis() - t0, e.getMessage()));
+ return null;
+ }
+
+ // Derive http base — Playwright's connectOverCDP accepts ws:// directly, but http:// is safer.
+ String cdpBase = wsUrl.replaceFirst("^ws://", "http://").replaceFirst("/devtools/.*", "");
+ try {
+ Browser browser = pw.chromium().connectOverCDP(cdpBase);
+ BrowserContext context = browser.contexts().isEmpty()
+ ? browser.newContext()
+ : browser.contexts().get(0);
+ Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0);
+ long elapsed = System.currentTimeMillis() - t0;
+ trace.add(Attempt.ok(Strategy.EXTERNAL_CDP, browserBin + " + connectOverCDP(" + cdpBase + ")", elapsed));
+ return Result.success(browser, context, page, true, cdpBase, Strategy.EXTERNAL_CDP, trace);
+ } catch (Exception e) {
+ proc.destroyForcibly();
+ trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, "connectOverCDP(" + cdpBase + ")",
+ System.currentTimeMillis() - t0, e.getMessage()));
+ return null;
+ }
+ }
+
+ // ==================== Helpers ====================
+
+ private BrowserType.LaunchOptions baseLaunchOptions(boolean headed) {
+ BrowserType.LaunchOptions opts = new BrowserType.LaunchOptions().setHeadless(!headed);
+ List args = chromiumLaunchArgs();
+ if (!args.isEmpty()) {
+ opts.setArgs(args);
+ }
+ return opts;
+ }
+
+ private Result wrapLocalBrowser(Browser browser, Strategy strategy, String desc,
+ long elapsedMs, List trace) {
+ BrowserContext context = browser.newContext(new Browser.NewContextOptions()
+ .setViewportSize(props.getViewportWidth(), props.getViewportHeight())
+ .setLocale("zh-CN"));
+ Page page = context.newPage();
+ trace.add(Attempt.ok(strategy, desc, elapsedMs));
+ return Result.success(browser, context, page, false, null, strategy, trace);
+ }
+
+ public static List chromiumLaunchArgs() {
+ List args = new ArrayList<>();
+ boolean inContainer = isRunningInContainer();
+ boolean asRoot = isRunningAsRoot();
+ if (IS_WINDOWS || inContainer || asRoot) {
+ args.add("--no-sandbox");
+ }
+ if (inContainer) {
+ args.add("--disable-dev-shm-usage");
+ }
+ if (IS_WINDOWS) {
+ args.add("--disable-gpu");
+ }
+ return args;
+ }
+
+ /** Platform-specific candidate paths — same list openfang uses. */
+ public static List systemBrowserCandidates() {
+ List paths = new ArrayList<>();
+ if (IS_WINDOWS) {
+ String pf = System.getenv("ProgramFiles");
+ String pf86 = System.getenv("ProgramFiles(x86)");
+ String local = System.getenv("LOCALAPPDATA");
+ for (String root : new String[]{pf, pf86}) {
+ if (root == null || root.isBlank()) continue;
+ paths.add(Path.of(root, "Google", "Chrome", "Application", "chrome.exe"));
+ paths.add(Path.of(root, "Microsoft", "Edge", "Application", "msedge.exe"));
+ paths.add(Path.of(root, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"));
+ }
+ if (local != null && !local.isBlank()) {
+ paths.add(Path.of(local, "Google", "Chrome", "Application", "chrome.exe"));
+ paths.add(Path.of(local, "Microsoft", "Edge", "Application", "msedge.exe"));
+ }
+ } else if (IS_MAC) {
+ paths.add(Path.of("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"));
+ paths.add(Path.of("/Applications/Chromium.app/Contents/MacOS/Chromium"));
+ paths.add(Path.of("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"));
+ paths.add(Path.of("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"));
+ } else {
+ // Linux
+ paths.add(Path.of("/usr/bin/google-chrome"));
+ paths.add(Path.of("/usr/bin/google-chrome-stable"));
+ paths.add(Path.of("/usr/bin/chromium"));
+ paths.add(Path.of("/usr/bin/chromium-browser"));
+ paths.add(Path.of("/snap/bin/chromium"));
+ paths.add(Path.of("/usr/bin/microsoft-edge"));
+ paths.add(Path.of("/usr/bin/microsoft-edge-stable"));
+ paths.add(Path.of("/usr/bin/brave-browser"));
+ }
+ return paths;
+ }
+
+ public static boolean isRunningInContainer() {
+ try {
+ if (Files.exists(Path.of("/.dockerenv"))) return true;
+ Path cgroup = Path.of("/proc/1/cgroup");
+ if (Files.exists(cgroup)) {
+ String content = Files.readString(cgroup);
+ return content.contains("docker") || content.contains("kubepods") || content.contains("containerd");
+ }
+ } catch (Exception ignored) {}
+ return false;
+ }
+
+ public static boolean isRunningAsRoot() {
+ if (IS_WINDOWS) return false;
+ try {
+ Path self = Path.of("/proc/self/status");
+ if (Files.exists(self)) {
+ for (String line : Files.readAllLines(self)) {
+ if (line.startsWith("Uid:")) {
+ String[] parts = line.split("\\s+");
+ return parts.length > 1 && "0".equals(parts[1]);
+ }
+ }
+ }
+ String userName = System.getProperty("user.name", "");
+ return "root".equals(userName);
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ private static String normalizeCdpUrl(String url) {
+ String s = url.trim();
+ if (!s.startsWith("http")) {
+ s = "http://" + s;
+ }
+ s = s.replace("://localhost:", "://127.0.0.1:");
+ s = s.replace("://localhost/", "://127.0.0.1/");
+ if (s.endsWith("://localhost")) {
+ s = s.replace("://localhost", "://127.0.0.1");
+ }
+ return s;
+ }
+
+ private static String firstNonBlank(String... values) {
+ if (values == null) return null;
+ for (String v : values) {
+ if (v != null && !v.isBlank()) return v;
+ }
+ return null;
+ }
+
+ private static String readDevToolsUrl(Process proc, int timeoutSeconds) throws Exception {
+ long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(timeoutSeconds);
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(proc.getErrorStream(), StandardCharsets.UTF_8))) {
+ StringBuilder accumulated = new StringBuilder();
+ String line;
+ while (System.currentTimeMillis() < deadline) {
+ if (!reader.ready()) {
+ if (!proc.isAlive()) {
+ throw new IllegalStateException(
+ "Chromium exited before printing DevTools URL. stderr=" + accumulated);
+ }
+ Thread.sleep(50);
+ continue;
+ }
+ line = reader.readLine();
+ if (line == null) break;
+ accumulated.append(line).append('\n');
+ int idx = line.indexOf("DevTools listening on ");
+ if (idx >= 0) {
+ return line.substring(idx + "DevTools listening on ".length()).trim();
+ }
+ }
+ }
+ throw new IllegalStateException(
+ "Timed out (" + timeoutSeconds + "s) waiting for 'DevTools listening on' from chromium stderr");
+ }
+
+ public static String formatTrace(List trace) {
+ StringBuilder sb = new StringBuilder();
+ for (Attempt a : trace) {
+ sb.append(String.format(" [%s] %s %-7s %dms %s%n",
+ a.strategy(), a.ok() ? "\u2713" : "\u2717", a.strategy().name(),
+ a.elapsedMs(), a.ok() ? a.detail() : (a.detail() + " \u2014 " + a.error())));
+ }
+ return sb.toString();
+ }
+
+ private static String summariseFailure(List trace) {
+ StringBuilder sb = new StringBuilder("Browser launch failed. Tried: ");
+ for (int i = 0; i < trace.size(); i++) {
+ if (i > 0) sb.append("; ");
+ Attempt a = trace.get(i);
+ sb.append(a.strategy().name()).append(" ").append(a.ok() ? "ok" : "(" + brief(a.error()) + ")");
+ }
+ return sb.toString();
+ }
+
+ private static String brief(String err) {
+ if (err == null) return "unknown";
+ String first = err.lines().findFirst().orElse(err);
+ return first.length() > 120 ? first.substring(0, 120) + "..." : first;
+ }
+
+ // ==================== Types ====================
+
+ public enum Strategy {
+ /** User-configured CDP endpoint (mateclaw.browser.cdp-url). */
+ CONFIG_CDP,
+ /** User-configured executable path (mateclaw.browser.chrome-path or CHROME_PATH env). */
+ CONFIG_PATH,
+ /** User-configured channel (mateclaw.browser.channel). */
+ CONFIG_CHANNEL,
+ /** Auto-detected Playwright channel (chrome, msedge). */
+ AUTO_CHANNEL,
+ /** Auto-detected system browser on well-known install paths. */
+ AUTO_PATH,
+ /** Playwright's bundled Chromium (requires `playwright install`). */
+ BUNDLED,
+ /** Spawn system chrome with --remote-debugging-port=0 and attach via CDP. */
+ EXTERNAL_CDP
+ }
+
+ public record Attempt(Strategy strategy, String detail, long elapsedMs, boolean ok, String error) {
+ static Attempt ok(Strategy s, String d, long ms) { return new Attempt(s, d, ms, true, null); }
+ static Attempt fail(Strategy s, String d, long ms, String e) { return new Attempt(s, d, ms, false, e); }
+ }
+
+ @Getter
+ public static final class Result {
+ private final Browser browser;
+ private final BrowserContext context;
+ private final Page page;
+ private final boolean connectedViaCdp;
+ private final String cdpUrl;
+ private final Strategy strategy;
+ private final List attempts;
+ private final boolean success;
+ private final String failureSummary;
+
+ private Result(Browser browser, BrowserContext context, Page page,
+ boolean connectedViaCdp, String cdpUrl, Strategy strategy,
+ List attempts, boolean success, String failureSummary) {
+ this.browser = browser;
+ this.context = context;
+ this.page = page;
+ this.connectedViaCdp = connectedViaCdp;
+ this.cdpUrl = cdpUrl;
+ this.strategy = strategy;
+ this.attempts = attempts;
+ this.success = success;
+ this.failureSummary = failureSummary;
+ }
+
+ static Result success(Browser browser, BrowserContext context, Page page,
+ boolean cdp, String cdpUrl, Strategy strategy, List attempts) {
+ return new Result(browser, context, page, cdp, cdpUrl, strategy, attempts, true, null);
+ }
+
+ static Result failure(List attempts, String summary) {
+ return new Result(null, null, null, false, null, null, attempts, false, summary);
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java
new file mode 100644
index 00000000..c51c16bf
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java
@@ -0,0 +1,60 @@
+package vip.mate.tool.browser;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Browser launch configuration. Supports multiple fallback strategies so we can
+ * launch a browser on machines where Playwright's bundled Chromium download is
+ * unavailable (offline CI, corporate firewalls, minimal containers).
+ *
+ * Precedence when launching (highest first):
+ *
+ * {@link #cdpUrl} — connect to an already-running Chrome via DevTools Protocol
+ * {@link #chromePath} or {@code CHROME_PATH} env — explicit executable
+ * {@link #channel} — Playwright channel ("chrome", "msedge", ...)
+ * Auto-detect system Chrome/Edge/Brave on well-known paths
+ * Playwright's bundled Chromium (requires {@code playwright install})
+ * External-process CDP launch (run system chrome with --remote-debugging-port and attach)
+ *
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "mateclaw.browser")
+public class BrowserProperties {
+
+ /** Pre-started Chrome CDP endpoint (e.g. http://127.0.0.1:9222). Highest priority when set. */
+ private String cdpUrl = "";
+
+ /** Absolute path to chrome.exe / google-chrome / msedge. Overrides channel/auto-detect. */
+ private String chromePath = "";
+
+ /** Playwright channel: chrome | msedge | chrome-beta | chrome-dev | msedge-beta | msedge-dev. */
+ private String channel = "";
+
+ /** Try system-installed browsers (channel + path scan) before Playwright's bundled Chromium. */
+ private boolean preferSystem = true;
+
+ /** Default headless for auto-started sessions. {@code action=start headed=true} overrides. */
+ private boolean headless = true;
+
+ /** Enable the last-resort strategy: spawn chrome --remote-debugging-port=0 and connect via CDP. */
+ private boolean allowExternalCdpFallback = true;
+
+ /** Connect timeout (seconds) for CDP / external-CDP attach. */
+ private int cdpTimeoutSeconds = 20;
+
+ /** Maximum concurrent browser sessions across all agents. Prevents runaway memory usage. */
+ private int maxSessions = 5;
+
+ /** Block navigations to loopback, private, link-local and cloud-metadata hosts. */
+ private boolean ssrfCheckEnabled = true;
+
+ /** Viewport width (px) for launched browsers. */
+ private int viewportWidth = 1280;
+
+ /** Viewport height (px) for launched browsers. */
+ private int viewportHeight = 800;
+}
+
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java
new file mode 100644
index 00000000..bed67935
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java
@@ -0,0 +1,82 @@
+package vip.mate.tool.browser;
+
+import java.net.InetAddress;
+import java.net.URI;
+import java.util.Set;
+
+/**
+ * SSRF guard — rejects URLs that resolve to loopback, link-local, private, or
+ * known cloud-metadata endpoints. Mirrors openfang's {@code check_ssrf} behaviour.
+ *
+ * Call this before passing any user-controlled URL to the browser or to an
+ * outbound HTTP client.
+ */
+public final class UrlSafetyChecker {
+
+ /** Hostnames that must never be reachable via user-supplied URLs. */
+ private static final Set BLOCKED_HOSTNAMES = Set.of(
+ "localhost",
+ "ip6-localhost",
+ "metadata.google.internal",
+ "metadata.aws.internal",
+ "instance-data",
+ "169.254.169.254", // AWS / Azure / GCP IMDS
+ "100.100.100.200", // Alibaba Cloud IMDS
+ "192.0.0.192", // Azure IMDS alternative
+ "0.0.0.0",
+ "::1"
+ );
+
+ private UrlSafetyChecker() {}
+
+ /**
+ * Throw {@link SecurityException} if the URL is unsafe. Accepts http:// and https:// only.
+ */
+ public static void check(String url) {
+ if (url == null || url.isBlank()) {
+ throw new SecurityException("URL is required");
+ }
+ URI uri;
+ try {
+ uri = URI.create(url.trim());
+ } catch (IllegalArgumentException e) {
+ throw new SecurityException("Malformed URL: " + url);
+ }
+ String scheme = uri.getScheme();
+ if (scheme == null || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) {
+ throw new SecurityException("Only http:// and https:// URLs are allowed (got: " + scheme + ")");
+ }
+ String host = uri.getHost();
+ if (host == null || host.isBlank()) {
+ throw new SecurityException("URL must have a host");
+ }
+ String hostname = host.startsWith("[") && host.endsWith("]")
+ ? host.substring(1, host.length() - 1)
+ : host;
+ if (BLOCKED_HOSTNAMES.contains(hostname.toLowerCase())) {
+ throw new SecurityException("SSRF blocked: " + hostname + " is a restricted hostname");
+ }
+ try {
+ for (InetAddress addr : InetAddress.getAllByName(hostname)) {
+ if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()
+ || addr.isLinkLocalAddress() || addr.isSiteLocalAddress()
+ || addr.isMulticastAddress() || isMetadataIp(addr)) {
+ throw new SecurityException("SSRF blocked: " + hostname
+ + " resolves to restricted address " + addr.getHostAddress());
+ }
+ }
+ } catch (SecurityException e) {
+ throw e;
+ } catch (Exception e) {
+ // DNS resolution failure — let the caller deal with it (browser will show its own error).
+ }
+ }
+
+ private static boolean isMetadataIp(InetAddress addr) {
+ String ip = addr.getHostAddress();
+ return "169.254.169.254".equals(ip)
+ || "100.100.100.200".equals(ip)
+ || "192.0.0.192".equals(ip)
+ || "fd00:ec2::254".equalsIgnoreCase(ip);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java
index d2880408..87c27d78 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java
@@ -4,20 +4,27 @@ import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
-import com.microsoft.playwright.*;
+import com.microsoft.playwright.Browser;
+import com.microsoft.playwright.BrowserContext;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.Playwright;
+import com.microsoft.playwright.PlaywrightException;
import com.microsoft.playwright.options.LoadState;
import jakarta.annotation.PreDestroy;
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.tool.browser.BrowserDiagnosticsService;
+import vip.mate.tool.browser.BrowserLauncher;
+import vip.mate.tool.browser.UrlSafetyChecker;
import java.net.Socket;
import java.nio.file.Paths;
-import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
-import java.util.Locale;
import java.util.concurrent.*;
/**
@@ -34,14 +41,17 @@ public class BrowserUseTool {
private static final int CDP_SCAN_PORT_MIN = 9000;
private static final int CDP_SCAN_PORT_MAX = 10000;
- private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
- .toLowerCase(Locale.ROOT).contains("win");
-
- /** SSE 推送器(用于将浏览器操作实时推送到前端) */
+ /** SSE broadcaster for pushing browser actions to the frontend in real time. */
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
+ private final BrowserLauncher launcher;
+ private final BrowserDiagnosticsService diagnostics;
- public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker) {
+ public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker,
+ BrowserLauncher launcher,
+ BrowserDiagnosticsService diagnostics) {
this.streamTracker = streamTracker;
+ this.launcher = launcher;
+ this.diagnostics = diagnostics;
}
/**
@@ -53,6 +63,15 @@ public class BrowserUseTool {
private final Object playwrightLock = new Object();
private final ConcurrentHashMap sessions = new ConcurrentHashMap<>();
+
+ /**
+ * RFC-063r §2.5 transition: ToolContext for the current invocation, set
+ * at the @Tool entry point and read by {@link #broadcastBrowserEvent}.
+ * Tool calls are serialized per ToolExecutionExecutor instance so this
+ * volatile field is safe; the field is read-only inside the action
+ * handlers.
+ */
+ private volatile ToolContext currentToolContext;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "browser-idle-watchdog");
t.setDaemon(true);
@@ -60,12 +79,15 @@ public class BrowserUseTool {
});
@Tool(description = """
- Control a browser (Playwright). Default is headless. Use headed=true with action=start for a visible window.
+ Control a browser (Playwright with multi-strategy launch: system Chrome/Edge channel, explicit path, bundled, or external CDP).
+ Default is headless. Use headed=true with action=start for a visible window.
Typical flow: start → open(url) → snapshot → click/type → stop.
- For CDP: connect_cdp(url="http://localhost:9222") to attach to an existing Chrome, or list_cdp_targets to scan.
+ If start fails, run action=diagnose for a full report of what's missing and how to fix it.
+ When web_search is unavailable (no Serper/Tavily API key), use this tool to fetch content directly:
+ e.g. action=open url=https://news.google.com/search?q=... then action=snapshot to read the page.
Supported actions:
- - start: Launch a new browser. Optional headed=true for visible window.
+ - start: Launch a new browser (tries system Chrome, system Edge, then Playwright bundled). Optional headed=true.
- stop: Close browser. If connected via CDP, only disconnects (Chrome keeps running).
- open: Navigate to a URL. Requires url parameter. Auto-starts browser if not running.
- snapshot: Get page text content, interactive elements, and title.
@@ -76,17 +98,25 @@ public class BrowserUseTool {
- connect_cdp: Connect to an existing Chrome via CDP. Requires url (e.g. "http://localhost:9222").
- list_cdp_targets: Scan local ports (9000-10000) for CDP endpoints. Optional cdpPort for single port.
- navigate_back: Go back in browser history.
+ - diagnose: Run a self-check — reports which launch strategies are available and what to install if none are.
""")
public String browser_use(
- @ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|eval|connect_cdp|list_cdp_targets|navigate_back") String action,
+ @ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|eval|connect_cdp|list_cdp_targets|navigate_back|diagnose") String action,
@ToolParam(description = "URL to navigate to (for open), or CDP base URL (for connect_cdp, e.g. http://localhost:9222)", required = false) String url,
@ToolParam(description = "CSS selector for target element (for click/type)", required = false) String selector,
@ToolParam(description = "Text to type (for action=type)", required = false) String text,
@ToolParam(description = "JavaScript code to execute (for action=eval)", required = false) String code,
@ToolParam(description = "File path to save screenshot (for action=screenshot)", required = false) String path,
@ToolParam(description = "Launch visible browser window (for action=start, default false)", required = false) Boolean headed,
- @ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort
+ @ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort,
+ // RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator.
+ @Nullable ToolContext ctx
) {
+ // The conversationId resolution lives in broadcastBrowserEvent below;
+ // capture the ctx into a field so the helper can read it without
+ // passing it down every action handler. Race-free because tool calls
+ // are serialized per executor.
+ this.currentToolContext = ctx;
if (action == null || action.isBlank()) {
return error("action is required");
}
@@ -107,7 +137,8 @@ public class BrowserUseTool {
case "connect_cdp" -> doConnectCdp(sessionKey, url);
case "list_cdp_targets" -> doListCdpTargets(cdpPort);
case "navigate_back" -> doNavigateBack(sessionKey);
- default -> error("Unknown action: " + action + ". Supported: start, stop, open, snapshot, screenshot, click, type, eval, connect_cdp, list_cdp_targets, navigate_back");
+ case "diagnose" -> doDiagnose();
+ default -> error("Unknown action: " + action + ". Supported: start, stop, open, snapshot, screenshot, click, type, eval, connect_cdp, list_cdp_targets, navigate_back, diagnose");
};
} catch (PlaywrightException e) {
log.error("[BrowserUse] Playwright error: {}", e.getMessage());
@@ -150,7 +181,7 @@ public class BrowserUseTool {
*/
private void broadcastBrowserEvent(String action, boolean success, String url, String title,
String screenshot, long durationMs) {
- String conversationId = ToolExecutionContext.conversationId();
+ String conversationId = ToolExecutionContext.conversationId(currentToolContext);
if (conversationId == null || streamTracker == null) {
return;
}
@@ -181,34 +212,40 @@ public class BrowserUseTool {
doStop(sessionKey);
}
- log.info("[BrowserUse] Starting browser (headed={})", headed);
+ int max = launcher.properties().getMaxSessions();
+ if (max > 0 && sessions.size() >= max) {
+ return error("Maximum browser sessions reached (" + max
+ + "). Stop an existing session first or raise mateclaw.browser.max-sessions.");
+ }
+
+ log.info("[BrowserUse] Starting browser via launcher (headed={})", headed);
long startTime = System.currentTimeMillis();
Playwright pw = getOrCreatePlaywright();
- BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
- .setHeadless(!headed);
+ BrowserLauncher.Result r = launcher.launch(pw, headed);
- // 平台特定启动参数
- List extraArgs = chromiumLaunchArgs();
- if (!extraArgs.isEmpty()) {
- launchOptions.setArgs(extraArgs);
- log.debug("[BrowserUse] Chromium extra args: {}", extraArgs);
+ if (!r.isSuccess()) {
+ log.warn("[BrowserUse] All launch strategies failed:\n{}",
+ BrowserLauncher.formatTrace(r.getAttempts()));
+ broadcastBrowserEvent("start", false, null, null, null,
+ System.currentTimeMillis() - startTime);
+ JSONObject result = new JSONObject();
+ result.set("ok", false);
+ result.set("error", r.getFailureSummary());
+ result.set("hint", "Run action=diagnose for a detailed report and fix suggestions.");
+ return JSONUtil.toJsonPrettyStr(result);
}
- Browser browser = pw.chromium().launch(launchOptions);
- BrowserContext context = browser.newContext(new Browser.NewContextOptions()
- .setViewportSize(1280, 800)
- .setLocale("zh-CN"));
- Page page = context.newPage();
-
- BrowserSession session = new BrowserSession(browser, context, page, headed, false, null);
+ BrowserSession session = new BrowserSession(r.getBrowser(), r.getContext(), r.getPage(),
+ headed, r.isConnectedViaCdp(), r.getCdpUrl());
sessions.put(sessionKey, session);
scheduleIdleCheck(sessionKey);
long elapsed = System.currentTimeMillis() - startTime;
- log.info("[BrowserUse] Browser started successfully (headed={}) in {}ms", headed, elapsed);
+ log.info("[BrowserUse] Browser started via {} in {}ms", r.getStrategy(), elapsed);
broadcastBrowserEvent("start", true, null, null, null, elapsed);
- return ok("Browser started (headed=" + headed + ") in " + elapsed + "ms. Use action=open with url to navigate.");
+ return ok("Browser started via " + r.getStrategy() + " (headed=" + headed + ") in "
+ + elapsed + "ms. Use action=open with url to navigate.");
}
private String doConnectCdp(String sessionKey, String cdpUrl) {
@@ -216,60 +253,78 @@ public class BrowserUseTool {
return error("url is required for action=connect_cdp (e.g. http://127.0.0.1:9222)");
}
- // Stop existing session if any
BrowserSession existing = sessions.get(sessionKey);
if (existing != null) {
doStop(sessionKey);
}
- // Normalize CDP URL and force IPv4 to avoid ECONNREFUSED ::1 on macOS
- String normalizedCdpUrl = cdpUrl.trim();
- if (!normalizedCdpUrl.startsWith("http")) {
- normalizedCdpUrl = "http://" + normalizedCdpUrl;
- }
- normalizedCdpUrl = normalizedCdpUrl.replace("://localhost:", "://127.0.0.1:");
- normalizedCdpUrl = normalizedCdpUrl.replace("://localhost/", "://127.0.0.1/");
- if (normalizedCdpUrl.endsWith("://localhost")) {
- normalizedCdpUrl = normalizedCdpUrl.replace("://localhost", "://127.0.0.1");
- }
-
- log.info("[BrowserUse] Connecting to CDP at: {}", normalizedCdpUrl);
+ // Delegate to the launcher with the user-provided URL injected as a one-shot override.
+ // The launcher handles URL normalisation (localhost → 127.0.0.1, protocol prefix).
long startTime = System.currentTimeMillis();
-
Playwright pw = getOrCreatePlaywright();
- Browser browser = pw.chromium().connectOverCDP(normalizedCdpUrl);
-
- // Get existing contexts and pages
- List contexts = browser.contexts();
- BrowserContext context;
- Page page;
-
- if (!contexts.isEmpty()) {
- context = contexts.get(0);
- List pages = context.pages();
- page = pages.isEmpty() ? context.newPage() : pages.get(0);
- } else {
- context = browser.newContext();
- page = context.newPage();
+ String priorCdp = launcher.properties().getCdpUrl();
+ launcher.properties().setCdpUrl(cdpUrl);
+ BrowserLauncher.Result r;
+ try {
+ r = launcher.launch(pw, true);
+ } finally {
+ launcher.properties().setCdpUrl(priorCdp);
}
- BrowserSession session = new BrowserSession(browser, context, page, true, true, normalizedCdpUrl);
+ if (!r.isSuccess() || !r.isConnectedViaCdp()) {
+ log.warn("[BrowserUse] CDP connect failed. Trace:\n{}",
+ BrowserLauncher.formatTrace(r.getAttempts()));
+ return error("Failed to connect to CDP at " + cdpUrl + ": " + r.getFailureSummary());
+ }
+
+ BrowserSession session = new BrowserSession(r.getBrowser(), r.getContext(), r.getPage(),
+ true, true, r.getCdpUrl());
sessions.put(sessionKey, session);
scheduleIdleCheck(sessionKey);
- String title = page.title();
- String currentUrl = page.url();
long elapsed = System.currentTimeMillis() - startTime;
-
- log.info("[BrowserUse] Connected to CDP at {} in {}ms (page: {} - {})", normalizedCdpUrl, elapsed, currentUrl, title);
+ String title = r.getPage().title();
+ String currentUrl = r.getPage().url();
+ log.info("[BrowserUse] Connected to CDP at {} in {}ms (page: {} - {})",
+ r.getCdpUrl(), elapsed, currentUrl, title);
JSONObject result = new JSONObject();
result.set("ok", true);
- result.set("cdpUrl", normalizedCdpUrl);
+ result.set("cdpUrl", r.getCdpUrl());
result.set("currentUrl", currentUrl);
result.set("currentTitle", title);
- result.set("pagesCount", context.pages().size());
- result.set("message", "Connected to Chrome via CDP at " + normalizedCdpUrl + ". Current page: " + title);
+ result.set("pagesCount", r.getContext().pages().size());
+ result.set("message", "Connected to Chrome via CDP at " + r.getCdpUrl() + ". Current page: " + title);
+ return JSONUtil.toJsonPrettyStr(result);
+ }
+
+ private String doDiagnose() {
+ BrowserDiagnosticsService.Report report = diagnostics.run();
+ JSONObject result = new JSONObject();
+ result.set("ok", "healthy".equals(report.overall()) || "warning".equals(report.overall()));
+ result.set("overall", report.overall());
+
+ // Hutool's JSONUtil reflects on JavaBean-style getters and does not recognise
+ // Java record accessors (r.id() vs r.getId()), so toJsonStr(record) yields {}.
+ // Build the array by hand to keep the payload useful to the LLM.
+ JSONArray findingsArr = new JSONArray();
+ for (BrowserDiagnosticsService.Finding f : report.findings()) {
+ JSONObject fo = new JSONObject();
+ fo.set("id", f.id());
+ fo.set("status", f.status() != null ? f.status().name() : null);
+ fo.set("message", f.message());
+ if (f.data() != null && !f.data().isEmpty()) {
+ fo.set("data", f.data());
+ }
+ if (f.advice() != null) {
+ fo.set("advice", f.advice());
+ }
+ findingsArr.add(fo);
+ }
+ result.set("findings", findingsArr);
+
+ result.set("advice", report.advice());
+ result.set("summary", BrowserDiagnosticsService.summarise(report));
return JSONUtil.toJsonPrettyStr(result);
}
@@ -342,20 +397,32 @@ public class BrowserUseTool {
return error("url is required for action=open");
}
- BrowserSession session = getSession(sessionKey);
- if (session == null) {
- doStart(sessionKey, false);
- session = getSession(sessionKey);
- }
-
- session.touch();
- Page page = session.page;
-
String normalizedUrl = url.trim();
if (!normalizedUrl.matches("^https?://.*")) {
normalizedUrl = "https://" + normalizedUrl;
}
+ if (launcher.properties().isSsrfCheckEnabled()) {
+ try {
+ UrlSafetyChecker.check(normalizedUrl);
+ } catch (SecurityException se) {
+ log.warn("[BrowserUse] SSRF check rejected url={}: {}", normalizedUrl, se.getMessage());
+ return error(se.getMessage());
+ }
+ }
+
+ BrowserSession session = getSession(sessionKey);
+ if (session == null) {
+ String startResp = doStart(sessionKey, false);
+ session = getSession(sessionKey);
+ if (session == null) {
+ return startResp;
+ }
+ }
+
+ session.touch();
+ Page page = session.page;
+
page.navigate(normalizedUrl);
page.waitForLoadState(LoadState.DOMCONTENTLOADED);
@@ -580,49 +647,6 @@ public class BrowserUseTool {
return JSONUtil.toJsonPrettyStr(result);
}
- // ==================== Platform Helpers ====================
-
- /**
- * 返回 Chromium 在当前平台下需要的额外启动参数。
- *
- * Windows: --no-sandbox(沙箱兼容性)+ --disable-gpu(GPU 硬件加速问题)
- * 容器环境: --no-sandbox + --disable-dev-shm-usage(共享内存不足)
- */
- private static List chromiumLaunchArgs() {
- List args = new ArrayList<>();
- boolean inContainer = isRunningInContainer();
-
- if (IS_WINDOWS || inContainer) {
- args.add("--no-sandbox");
- }
- if (inContainer) {
- args.add("--disable-dev-shm-usage");
- }
- if (IS_WINDOWS) {
- args.add("--disable-gpu");
- }
- return args;
- }
-
- /**
- * 检测是否运行在 Docker/容器环境中。
- */
- private static boolean isRunningInContainer() {
- try {
- // Docker 容器中通常存在 /.dockerenv 文件
- if (java.nio.file.Files.exists(java.nio.file.Path.of("/.dockerenv"))) {
- return true;
- }
- // 或者 /proc/1/cgroup 包含 docker/kubepods
- java.nio.file.Path cgroup = java.nio.file.Path.of("/proc/1/cgroup");
- if (java.nio.file.Files.exists(cgroup)) {
- String content = java.nio.file.Files.readString(cgroup);
- return content.contains("docker") || content.contains("kubepods") || content.contains("containerd");
- }
- } catch (Exception ignored) {}
- return false;
- }
-
// ==================== CDP Helpers ====================
private boolean isPortOpen(int port) {
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java
new file mode 100644
index 00000000..96a2abf2
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java
@@ -0,0 +1,78 @@
+package vip.mate.tool.builtin;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * Resolves a user-supplied file path against the current conversation's chat-upload
+ * directory ({@code data/chat-uploads/{conversationId}/}).
+ *
+ * Chat attachments are stored as {@code {timestamp}_{safeFilename}} where
+ * {@code safeFilename} replaces every non-{@code [a-zA-Z0-9._-]} character with
+ * {@code _}. This means a file uploaded as {@code 人人有虾.docx} is stored on disk
+ * as e.g. {@code 1777391026594_____.docx}. The LLM only ever sees the original
+ * filename in the rendered "[附件] foo.docx" prefix, so when a tool gets called
+ * with the original name it won't match anything on disk via direct lookup.
+ *
+ * This helper rescues such calls by matching basenames inside the conversation's
+ * upload directory. Used by both {@link ReadFileTool} and {@link DocumentExtractTool}.
+ */
+@Slf4j
+final class ChatUploadResolver {
+
+ static final Path CHAT_UPLOAD_ROOT = Paths.get("data", "chat-uploads");
+
+ private ChatUploadResolver() {}
+
+ /**
+ * @return absolute path of the matched attachment, or {@code null} if no match
+ */
+ static Path resolve(String rawPath) {
+ if (rawPath == null || rawPath.isBlank()) {
+ return null;
+ }
+ String conversationId = ToolExecutionContext.conversationId();
+ if (conversationId == null || conversationId.isBlank()) {
+ return null;
+ }
+ Path uploadDir = CHAT_UPLOAD_ROOT.resolve(conversationId).toAbsolutePath().normalize();
+ if (!Files.isDirectory(uploadDir)) {
+ return null;
+ }
+
+ String basename;
+ try {
+ Path requested = Paths.get(rawPath).getFileName();
+ basename = requested != null ? requested.toString() : null;
+ } catch (Exception e) {
+ return null;
+ }
+ if (basename == null || basename.isBlank()) {
+ return null;
+ }
+
+ Path direct = uploadDir.resolve(basename);
+ if (Files.isRegularFile(direct)) {
+ return direct;
+ }
+
+ // Stored as "{millis}_{safeFilename}" where safeFilename replaces non-ASCII
+ // characters with underscores; match by sanitized basename suffix.
+ String safeBasename = basename.replaceAll("[^a-zA-Z0-9._-]", "_");
+ String suffix = "_" + safeBasename;
+ try (var stream = Files.list(uploadDir)) {
+ return stream
+ .filter(Files::isRegularFile)
+ .filter(p -> p.getFileName().toString().endsWith(suffix))
+ .findFirst()
+ .orElse(null);
+ } catch (IOException e) {
+ log.warn("[ChatUploadResolver] Failed to scan chat-upload dir {}: {}", uploadDir, e.getMessage());
+ return null;
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java
index ca6c1b7c..0837f59b 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java
@@ -5,9 +5,12 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
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.agent.context.ChatOrigin;
import vip.mate.cron.model.CronJobDTO;
import vip.mate.cron.service.CronJobService;
@@ -30,6 +33,7 @@ public class CronJobTool {
private final CronJobService cronJobService;
+ @vip.mate.tool.ConcurrencyUnsafe("cron job creation persists to mate_cron_job; concurrent creates can race on name")
@Tool(description = "Create a scheduled task (cron job). The task will run automatically at the specified time "
+ "and send the trigger message to the current agent. Use 5-field cron expressions: minute hour day month weekday. "
+ "Examples: '0 9 * * *' = daily at 9am, '0 9 * * 1-5' = weekdays at 9am, '*/30 * * * *' = every 30 minutes.")
@@ -37,12 +41,30 @@ public class CronJobTool {
@ToolParam(description = "Task name, e.g. 'Daily AI News Summary'") String name,
@ToolParam(description = "5-field cron expression: minute hour day month weekday") String cronExpression,
@ToolParam(description = "Message to send when the task triggers, e.g. 'Search for the latest AI news and summarize'") String triggerMessage,
- @ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone) {
+ @ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone,
+ // RFC-063r §2.4: ToolContext is *not* exposed to the LLM —
+ // JsonSchemaGenerator skips it (Spring AI 1.1 framework convention)
+ @Nullable ToolContext ctx) {
try {
- // Resolve current agent ID from conversation context
- String conversationId = ToolExecutionContext.conversationId();
- Long agentId = resolveAgentId(conversationId);
+ // RFC-063r §2.5: the ChatOrigin must carry agentId — buildInitialState
+ // injects it from the agent that owns the StateGraph. If it's missing
+ // here, something upstream broke (no holder set, KeyStrategyFactory
+ // dropped CHAT_ORIGIN, etc.) — fail loudly rather than silently
+ // binding to agent #1, which could be disabled / non-existent /
+ // user-renamed and would surface as "scheduled but never runs".
+ ChatOrigin origin = ChatOrigin.from(ctx);
+ String conversationId = origin.conversationId() != null && !origin.conversationId().isEmpty()
+ ? origin.conversationId()
+ : ToolExecutionContext.conversationId();
+ Long agentId = origin.agentId();
+ if (agentId == null) {
+ log.warn("[CronJobTool] create_cron_job invoked without an agentId in ChatOrigin " +
+ "(conv={}); refusing to silently bind to a default agent.", conversationId);
+ return errorResult("Cannot create cron job: agent context unavailable. " +
+ "This is an internal wiring bug — the originating agent id was not threaded " +
+ "through ToolContext. Re-issue the request; if it persists, see RFC-063r §2.5.");
+ }
CronJobDTO dto = new CronJobDTO();
dto.setName(name);
@@ -53,7 +75,17 @@ public class CronJobTool {
dto.setTaskType("text");
dto.setEnabled(true);
- CronJobDTO created = cronJobService.create(dto);
+ // RFC-063r §2.4 / PR-2: when the originating context carries a
+ // channelId, the cron job inherits the binding so its results can
+ // be delivered back to the same channel. Fields are wired via
+ // reflection until PR-2 adds them to CronJobDTO + CronJobEntity.
+ propagateChannelBinding(dto, origin);
+
+ // RFC-083: stamp workspace from the originating ChatOrigin so the
+ // cron job is created in the agent's current workspace; fall back
+ // to the default workspace when origin is unscoped (legacy paths).
+ Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L;
+ CronJobDTO created = cronJobService.create(dto, workspaceId);
JSONObject result = new JSONObject();
result.set("success", true);
@@ -73,9 +105,12 @@ public class CronJobTool {
@Tool(description = "List all scheduled tasks (cron jobs) for the current agent. "
+ "Returns task name, cron expression, next run time, enabled status, and last run time.")
- public String list_cron_jobs() {
+ public String list_cron_jobs(@Nullable ToolContext ctx) {
try {
- List jobs = cronJobService.list();
+ // RFC-083: scope to the originating workspace so an agent only
+ // sees the cron jobs of the workspace it's running in.
+ Long workspaceId = workspaceFromContext(ctx);
+ List jobs = cronJobService.list(workspaceId);
JSONArray arr = new JSONArray();
for (CronJobDTO job : jobs) {
JSONObject obj = new JSONObject();
@@ -99,14 +134,18 @@ public class CronJobTool {
}
}
+ @vip.mate.tool.ConcurrencyUnsafe("toggles row state in mate_cron_job; serialize to keep enabled/disabled deterministic")
@Tool(description = "Enable or disable a scheduled task by its job ID. "
+ "Use list_cron_jobs first to find the job ID.")
public String toggle_cron_job(
@ToolParam(description = "Job ID (number)") Long jobId,
- @ToolParam(description = "true to enable, false to disable") Boolean enabled) {
+ @ToolParam(description = "true to enable, false to disable") Boolean enabled,
+ @Nullable ToolContext ctx) {
try {
- cronJobService.toggle(jobId, enabled);
- CronJobDTO updated = cronJobService.getById(jobId);
+ // RFC-083: scope toggle to the originating workspace.
+ Long workspaceId = workspaceFromContext(ctx);
+ cronJobService.toggle(jobId, enabled, workspaceId);
+ CronJobDTO updated = cronJobService.getById(jobId, workspaceId);
JSONObject result = new JSONObject();
result.set("success", true);
result.set("jobId", jobId);
@@ -120,14 +159,18 @@ public class CronJobTool {
}
}
+ @vip.mate.tool.ConcurrencyUnsafe("destructive — removes row from mate_cron_job")
@Tool(description = "Delete a scheduled task by its job ID. This action requires user approval. "
+ "Use list_cron_jobs first to find the job ID.")
public String delete_cron_job(
- @ToolParam(description = "Job ID (number) to delete") Long jobId) {
+ @ToolParam(description = "Job ID (number) to delete") Long jobId,
+ @Nullable ToolContext ctx) {
try {
- CronJobDTO job = cronJobService.getById(jobId);
+ // RFC-083: scope delete to the originating workspace.
+ Long workspaceId = workspaceFromContext(ctx);
+ CronJobDTO job = cronJobService.getById(jobId, workspaceId);
String jobName = job.getName();
- cronJobService.delete(jobId);
+ cronJobService.delete(jobId, workspaceId);
JSONObject result = new JSONObject();
result.set("success", true);
result.set("deleted", jobName);
@@ -138,29 +181,34 @@ public class CronJobTool {
}
}
- /**
- * Resolve agent ID from conversation ID.
- * Convention: cron conversations use "cron:{jobId}", normal chats use "{agentId}:{uuid}".
- */
- private Long resolveAgentId(String conversationId) {
- if (conversationId == null || conversationId.isBlank()) {
- return 1L; // default agent
- }
- // Try to extract agent ID from conversation metadata
- // For now, use default agent ID 1 (the conversation's agent binding is handled by the caller)
- try {
- // Convention: conversationId might contain agent context info
- // Fallback to first enabled agent
- return 1L;
- } catch (Exception e) {
- return 1L;
- }
- }
-
private String errorResult(String message) {
JSONObject result = new JSONObject();
result.set("success", false);
result.set("error", message);
return JSONUtil.toJsonPrettyStr(result);
}
+
+ /**
+ * RFC-083: resolve the workspace ID from the originating ChatOrigin so
+ * cron-tool reads/writes are scoped to the agent's current workspace.
+ * Falls back to the default workspace (1) when origin is unscoped — same
+ * behaviour as the controller-layer {@code resolve()} helper.
+ */
+ private Long workspaceFromContext(@Nullable ToolContext ctx) {
+ ChatOrigin origin = ChatOrigin.from(ctx);
+ return origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L;
+ }
+
+ /**
+ * RFC-063r §2.4: propagate the originating channel binding into the cron
+ * job DTO so PR-3's delivery dispatcher can route results back to the
+ * originating channel.
+ */
+ private void propagateChannelBinding(CronJobDTO dto, ChatOrigin origin) {
+ if (origin == null || origin.channelId() == null) return;
+ dto.setChannelId(origin.channelId());
+ if (origin.channelTarget() != null) {
+ dto.setDeliveryConfig(vip.mate.cron.model.DeliveryConfig.from(origin.channelTarget()));
+ }
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java
index c2e12e30..e3793a80 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java
@@ -5,10 +5,13 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
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.agent.AgentService;
+import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.channel.web.ChatStreamTracker;
@@ -19,16 +22,17 @@ import java.util.concurrent.*;
import java.util.stream.Collectors;
/**
- * 内置工具:Agent 委派(多 Agent 协作)
+ * Built-in tool: Agent delegation (multi-agent collaboration).
*
- * 支持两种模式:
+ * Two modes:
*
- * {@link #delegateToAgent} — 单任务委派(串行)
- * {@link #delegateParallel} — 多任务并行委派(最多 3 个子 Agent 同时执行)
+ * {@link #delegateToAgent} — single-task serial delegation
+ * {@link #delegateParallel} — parallel delegation to up to 3 child agents simultaneously
*
- * 被委派的 Agent 在独立子会话中运行(记录父子关系),
- * 执行期间通过 SSE 事件 relay 向父会话实时推送进度。
- * 子 Agent 的工具集自动收窄——禁止递归委派和 Agent 发现工具。
+ * Each delegated agent runs in an isolated child conversation (parent-child relationship is
+ * persisted). Progress is relayed to the parent session via SSE events in real time.
+ * Child agents have a narrowed tool set — recursive delegation and agent-discovery tools are
+ * blocked.
*
* @author MateClaw Team
*/
@@ -40,16 +44,22 @@ public class DelegateAgentTool {
private static final int MAX_DELEGATION_DEPTH = 3;
private static final int MAX_RESULT_LENGTH = 4000;
private static final int MAX_PARALLEL_CHILDREN = 3;
- private static final int PARALLEL_TIMEOUT_SECONDS = 300; // 5 分钟
+ /**
+ * Per-child timeout — raised from 60 s to 120 s so that slow LLM models
+ * (kimi-code observed p99 ≈ 91 s) can complete before the parent gives up.
+ * The previous 60 s limit was structurally impossible to satisfy once any
+ * child called an LLM-backed tool.
+ */
+ private static final int PARALLEL_TIMEOUT_SECONDS = 120;
- /** 子 Agent 禁用的工具:防递归 + 防副作用 */
+ /** Tools blocked for child agents — prevents recursion and side effects. */
private static final Set CHILD_DENIED_TOOLS = Set.of(
- "delegateToAgent", // 禁止递归委派
- "delegateParallel", // 禁止并行递归
- "listAvailableAgents" // 子 Agent 不需要发现其他 Agent
+ "delegateToAgent", // no recursive serial delegation
+ "delegateParallel", // no recursive parallel delegation
+ "listAvailableAgents" // child agents do not need to discover other agents
);
- /** 并行委派执行器:JDK 21 虚拟线程,每个子 Agent 一个轻量级虚拟线程 */
+ /** Executor for parallel delegation — one JDK 21 virtual thread per child agent. */
private static final ExecutorService DELEGATION_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor();
@@ -59,8 +69,9 @@ public class DelegateAgentTool {
private final ConversationService conversationService;
private final ObjectMapper objectMapper;
- // ==================== 单任务委派 ====================
+ // ==================== Single-task delegation ====================
+ @vip.mate.tool.ConcurrencyUnsafe("spawns a child agent session and writes to mate_conversation; serialize to keep session graph deterministic")
@Tool(description = """
Delegate a task to another Agent for multi-agent collaboration. \
Target Agent executes in an independent session and returns its final reply. \
@@ -68,7 +79,12 @@ public class DelegateAgentTool {
For multiple parallel tasks, use delegateParallel instead.""")
public String delegateToAgent(
@ToolParam(description = "Target Agent name (exact match)") String agentName,
- @ToolParam(description = "Task description with complete context information") String task) {
+ @ToolParam(description = "Task description with complete context information") String task,
+ // RFC-063r §2.5 改动点 5: parent ChatOrigin (channel binding /
+ // workspace) propagates into the delegated child so a sub-agent
+ // creating a cron job still binds back to the originating channel.
+ // Hidden from the LLM by JsonSchemaGenerator.
+ @Nullable ToolContext ctx) {
if (agentName == null || agentName.isBlank()) {
return "[错误] 请指定目标 Agent 名称。" + availableAgentsHint();
@@ -90,10 +106,10 @@ public class DelegateAgentTool {
String parentConversationId = resolveParentConversationId();
String childConversationId = createChildConv(target, parentConversationId);
- log.info("Agent 委派: depth={}, target={}({}), childConv={}, parentConv={}",
+ log.info("Agent delegation: depth={}, target={}({}), childConv={}, parentConv={}",
depth + 1, target.getName(), target.getId(), childConversationId, parentConversationId);
- // SSE 广播 + relay
+ // Broadcast delegation_start + register event relay to parent session
boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId);
if (hasParent) {
streamTracker.broadcastObject(parentConversationId, "delegation_start", Map.of(
@@ -103,10 +119,13 @@ public class DelegateAgentTool {
}
Runnable stopRelay = hasParent ? registerRelay(childConversationId, parentConversationId, target.getName()) : null;
- // 执行
- ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId);
+ // Execute child agent — RFC-063r §2.5 改动点 5: inherit the parent
+ // ChatOrigin and only swap the agentId, so channel binding /
+ // workspace / requester all flow into the child.
+ ChatOrigin parentOrigin = ChatOrigin.from(ctx);
+ ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId, parentOrigin);
- // 清理 + 广播结果
+ // Cleanup relay, then broadcast final result
if (stopRelay != null) stopRelay.run();
if (hasParent) {
broadcastEnd(parentConversationId, childConversationId, target.getName(), result);
@@ -115,8 +134,9 @@ public class DelegateAgentTool {
return result.toToolResponse(target.getName());
}
- // ==================== 并行委派 ====================
+ // ==================== Parallel delegation ====================
+ @vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize")
@Tool(description = """
Delegate multiple tasks to different Agents in parallel (max 3). \
Each task runs concurrently in an independent child session. \
@@ -124,9 +144,11 @@ public class DelegateAgentTool {
Input is a JSON array: [{"agentName":"Agent名称","task":"任务描述"}, ...]""")
public String delegateParallel(
@ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\"}, ...]")
- String tasksJson) {
+ String tasksJson,
+ // RFC-063r §2.5 改动点 5: hidden from LLM, used to inherit ChatOrigin into children.
+ @Nullable ToolContext ctx) {
- // 1. 解析任务列表
+ // 1. Parse task list
List> tasks;
try {
tasks = objectMapper.readValue(tasksJson, new TypeReference<>() {});
@@ -149,7 +171,7 @@ public class DelegateAgentTool {
String parentConversationId = resolveParentConversationId();
boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId);
- // 2. 主线程:校验所有 Agent + 创建子会话 + 注册 relay
+ // 2. Main thread: validate agents, create child conversations, register relays
record PreparedChild(int index, AgentEntity agent, String task, String childConvId, Runnable stopRelay) {}
List prepared = new ArrayList<>();
List errors = new ArrayList<>();
@@ -179,9 +201,9 @@ public class DelegateAgentTool {
return "[错误] 所有任务校验失败:\n" + String.join("\n", errors);
}
- log.info("并行委派: {} 个任务, parentConv={}", prepared.size(), parentConversationId);
+ log.info("Parallel delegation: {} tasks, parentConv={}", prepared.size(), parentConversationId);
- // 3. 广播 delegation_start(并行模式)
+ // 3. Broadcast delegation_start (parallel mode)
if (hasParent) {
List> childrenInfo = prepared.stream().map(p -> Map.of(
"childConversationId", p.childConvId,
@@ -193,29 +215,66 @@ public class DelegateAgentTool {
"children", childrenInfo));
}
- // 4. 并行执行
+ // 4. Fan out — execute children in parallel
long startTime = System.currentTimeMillis();
Map> futures = new LinkedHashMap<>();
+ // RFC-063r §2.5 改动点 5: capture parent origin once on this thread,
+ // then hand it to each child future — the worker virtual threads
+ // can't re-read the ToolContext (no parameter scope), so we close
+ // over the captured origin.
+ ChatOrigin parentOriginParallel = ChatOrigin.from(ctx);
for (PreparedChild p : prepared) {
CompletableFuture future = CompletableFuture.supplyAsync(
- () -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId),
+ () -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, parentOriginParallel),
DELEGATION_EXECUTOR);
+
+ // Broadcast per-child completion as soon as each child finishes
+ // — frontend can update that child's status without waiting for all children.
+ // Guard: skip CancellationException (fired when the timeout loop calls cancel(true))
+ // because the timeout result is already handled in the collection loop below and
+ // emitting here first would race-replace the correct "timeout" error before delegation_end
+ // has a chance to patch remaining running segments.
+ if (hasParent) {
+ final String parentConvIdFinal = parentConversationId;
+ future.whenComplete((result, ex) -> {
+ if (ex instanceof java.util.concurrent.CancellationException) return;
+ if (!streamTracker.isRunning(parentConvIdFinal)) return;
+ ChildResult r = (result != null) ? result
+ : ChildResult.ofError(p.index, p.agent.getName(),
+ ex != null ? ex.getMessage() : "Unknown error");
+ Map payload = new java.util.LinkedHashMap<>();
+ payload.put("taskIndex", r.taskIndex);
+ payload.put("childConversationId", p.childConvId);
+ payload.put("childAgentName", r.agentName);
+ payload.put("success", r.success);
+ payload.put("outcome", r.outcome);
+ payload.put("rawLength", r.rawLength);
+ payload.put("trimmedLength", r.trimmedLength);
+ payload.put("blank", r.isBlank());
+ payload.put("durationMs", r.durationMs);
+ payload.put("resultPreview", r.success
+ ? truncate(r.result, 400)
+ : (r.error != null ? r.error : "error"));
+ streamTracker.broadcastObject(parentConvIdFinal, "delegation_child_complete", payload);
+ });
+ }
+
futures.put(p.index, future);
}
- // 5. 等待全部完成(带超时)
+ // 5. Wait for all children (with timeout)
List results = new ArrayList<>();
try {
CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0]))
.get(PARALLEL_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException e) {
- log.warn("并行委派超时 ({}s),收集已完成的结果", PARALLEL_TIMEOUT_SECONDS);
+ log.warn("Parallel delegation timed out ({}s), collecting completed results", PARALLEL_TIMEOUT_SECONDS);
} catch (Exception e) {
- log.error("并行委派异常: {}", e.getMessage());
+ log.error("Parallel delegation error: {}", e.getMessage());
}
- // 收集结果(已完成的 + 超时的)
+ // Collect results — completed futures get their value; unfinished ones are cancelled and recorded as timeout
for (var entry : futures.entrySet()) {
int idx = entry.getKey();
CompletableFuture f = entry.getValue();
@@ -226,87 +285,233 @@ public class DelegateAgentTool {
try {
results.add(f.get());
} catch (Exception ex) {
- results.add(ChildResult.error(idx, agentName, ex.getMessage()));
+ results.add(ChildResult.ofError(idx, agentName, ex.getMessage()));
}
} else {
f.cancel(true);
- results.add(ChildResult.error(idx, agentName, "超时 (" + PARALLEL_TIMEOUT_SECONDS + "s)"));
+ // Use ofTimeout so outcome="timeout" is explicit and distinct from "error".
+ results.add(ChildResult.ofTimeout(idx, agentName, PARALLEL_TIMEOUT_SECONDS));
}
}
long totalDurationMs = System.currentTimeMillis() - startTime;
- // 6. 清理 relay
+ // 6. Stop all relays
for (PreparedChild p : prepared) {
if (p.stopRelay != null) p.stopRelay.run();
}
- // 7. 广播 delegation_end
+ // 7. Broadcast delegation_end with per-child structured summary
if (hasParent) {
+ List> childResults = results.stream().map(r -> {
+ Map m = new java.util.LinkedHashMap<>();
+ m.put("taskIndex", r.taskIndex);
+ m.put("agentName", r.agentName);
+ m.put("success", r.success);
+ m.put("outcome", r.outcome); // "success"|"blank_success"|"timeout"|"error"
+ m.put("rawLength", r.rawLength); // chars before truncation
+ m.put("trimmedLength", r.trimmedLength);
+ m.put("blank", r.isBlank());
+ m.put("durationMs", r.durationMs);
+ // childConversationId for stable frontend segment lookup
+ prepared.stream()
+ .filter(p -> p.index == r.taskIndex)
+ .findFirst()
+ .ifPresent(p -> m.put("childConversationId", p.childConvId));
+ if (!r.success && r.error != null) m.put("error", r.error);
+ return m;
+ }).toList();
streamTracker.broadcastObject(parentConversationId, "delegation_end", Map.of(
"parallel", true,
"totalDurationMs", totalDurationMs,
"success", results.stream().allMatch(r -> r.success),
"completedCount", results.stream().filter(r -> r.success).count(),
- "totalCount", results.size()));
+ "blankCount", results.stream().filter(ChildResult::isBlank).count(),
+ "totalCount", results.size(),
+ "childResults", childResults));
}
- // 8. 构建返回结果
+ // 8. Build return text — structured so the parent LLM cannot misread current results
+ // using memory of past timeouts. The machine-readable header line is the source of truth.
results.sort(Comparator.comparingInt(r -> r.taskIndex));
+ long successCount = results.stream().filter(r -> r.success && !r.isBlank()).count();
+ long blankCount = results.stream().filter(ChildResult::isBlank).count();
+ long timeoutCount = results.stream().filter(r -> "timeout".equals(r.outcome)).count();
+ long errorCount = results.stream().filter(r -> "error".equals(r.outcome)).count();
+
StringBuilder sb = new StringBuilder();
+
+ // Machine-readable summary line (highest priority, appears first).
+ // Explicit blank/timeout/error counts prevent the parent agent from misreading a
+ // successful run as a timeout even when historical memory says "this agent often times out".
+ sb.append("[PARALLEL_DELEGATION_RESULT]")
+ .append(" total=").append(results.size())
+ .append(" success=").append(successCount)
+ .append(" blank_success=").append(blankCount)
+ .append(" timeout=").append(timeoutCount)
+ .append(" error=").append(errorCount)
+ .append(" durationMs=").append(totalDurationMs)
+ .append("\n\n");
+
+ // Important: this result is from the current execution. Any timeout entries in the
+ // conversation history were from previous runs and must not be applied to this result.
+ sb.append("⚠ 注意:本次结果基于当前执行,与历史对话中出现的超时记录无关。\n\n");
+
if (!errors.isEmpty()) {
- sb.append("⚠️ 部分任务未执行:\n");
+ sb.append("⚠️ 部分任务未执行(Agent 未找到或参数错误):\n");
errors.forEach(e -> sb.append(" ").append(e).append("\n"));
sb.append("\n");
}
- sb.append("并行执行 ").append(results.size()).append(" 个任务(总耗时 ")
- .append(totalDurationMs / 1000).append("s):\n\n");
+
+ sb.append("## 各子任务执行结果\n\n");
for (ChildResult r : results) {
- sb.append("---\n### [任务 ").append(r.taskIndex + 1).append("] Agent「").append(r.agentName).append("」");
- sb.append(r.success ? " ✓" : " ✗").append(" (").append(r.durationMs / 1000).append("s)\n\n");
- sb.append(r.success ? r.result : "[错误] " + r.error).append("\n\n");
+ sb.append("### [任务 ").append(r.taskIndex + 1).append("] ").append(r.agentName).append("\n");
+ // Per-row machine-readable status — impossible to confuse with a different outcome
+ sb.append("outcome=").append(r.outcome)
+ .append(" | contentLength=").append(r.trimmedLength).append("chars")
+ .append(" | rawLength=").append(r.rawLength).append("chars")
+ .append(" | duration=").append(r.durationMs / 1000).append("s")
+ .append("\n\n");
+
+ switch (r.outcome) {
+ case "success" -> {
+ sb.append("✅ 执行成功,有实质内容(").append(r.trimmedLength).append(" 字符)\n\n");
+ sb.append(r.result);
+ }
+ case "blank_success" -> {
+ sb.append("⚠ 执行成功,但返回内容为空(rawLength=").append(r.rawLength)
+ .append(",trim 后 0 字符)。请勿将此误报为超时或失败——子 Agent 已正常完成,只是本次无输出。\n");
+ }
+ case "timeout" ->
+ sb.append("❌ 超时(").append(PARALLEL_TIMEOUT_SECONDS).append("s 内未返回)\n");
+ default ->
+ sb.append("❌ 失败:").append(r.error).append("\n");
+ }
+ sb.append("\n");
}
return truncate(sb.toString(), MAX_RESULT_LENGTH * 2); // 并行结果允许更长
}
- // ==================== 子 Agent 执行(单/并行共用) ====================
+ // ==================== Child agent execution (shared by single and parallel paths) ====================
/**
- * 执行单个子 Agent。在子线程内独立设置 DelegationContext,解决 ThreadLocal 并行问题。
+ * Runs a single child agent. Sets up {@link DelegationContext} independently per virtual thread
+ * so that parallel children do not share ThreadLocal state.
+ *
+ * Raw result length must be measured before calling {@code truncate()}, otherwise
+ * {@link ChildResult#rawLength} and {@link ChildResult#trimmedLength} would always reflect the
+ * truncated length, making "blank_success" detection unreliable.
*/
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
- String parentConversationId, String childConversationId) {
+ String parentConversationId, String childConversationId,
+ ChatOrigin parentOrigin) {
DelegationContext.enter(parentConversationId, CHILD_DENIED_TOOLS);
try {
long startTime = System.currentTimeMillis();
- String result = agentService.chat(target.getId(), task, childConversationId);
+ // RFC-063r §2.5 改动点 5: inherit parent origin, swap agentId
+ // so child reads correct identity from ToolContext while keeping
+ // channelId / channelTarget / workspace context intact.
+ ChatOrigin childOrigin = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY)
+ .withAgent(target.getId())
+ .withConversationId(childConversationId);
+ String rawResult = agentService.chat(target.getId(), task, childConversationId, childOrigin);
long durationMs = System.currentTimeMillis() - startTime;
- return ChildResult.success(taskIndex, target.getName(), truncate(result, MAX_RESULT_LENGTH), durationMs);
+ // Measure lengths before truncation so ChildResult carries accurate metadata.
+ return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs,
+ MAX_RESULT_LENGTH);
} catch (Exception e) {
- log.error("子 Agent 执行失败: taskIndex={}, agent={}, error={}",
+ log.error("Child agent failed: taskIndex={}, agent={}, error={}",
taskIndex, target.getName(), e.getMessage());
- return ChildResult.error(taskIndex, target.getName(), e.getMessage());
+ return ChildResult.ofError(taskIndex, target.getName(), e.getMessage());
} finally {
DelegationContext.exit();
}
}
- /** 子 Agent 执行结果 */
- private record ChildResult(int taskIndex, String agentName, boolean success,
- String result, String error, long durationMs) {
+ /**
+ * Result carrier for a single child agent execution.
+ *
+ *
{@code outcome} values:
+ *
+ * {@code "success"} — completed successfully with non-empty content (trimmedLength > 0)
+ * {@code "blank_success"} — completed successfully but returned empty content (trimmedLength == 0)
+ * {@code "timeout"} — did not complete within the parallel wait window
+ * {@code "error"} — threw an exception during execution
+ *
+ *
+ * {@code rawLength} and {@code trimmedLength} are measured before truncation and reflect the
+ * true content length.
+ */
+ private record ChildResult(
+ int taskIndex, String agentName, boolean success,
+ String result, String error, long durationMs,
+ /** "success" | "blank_success" | "timeout" | "error" */
+ String outcome,
+ int rawLength, int trimmedLength) {
+
+ /** Whether the child returned no usable content (blank_success). */
+ boolean isBlank() { return "blank_success".equals(outcome); }
+
+ /**
+ * Factory for a successful child execution.
+ * Measures lengths from the raw result before applying the truncation limit.
+ */
+ static ChildResult ofSuccess(int idx, String name, String rawResult, long ms, int maxLen) {
+ String safe = rawResult != null ? rawResult : "";
+ String trimmed = safe.trim();
+ boolean blank = trimmed.isEmpty();
+ return new ChildResult(
+ idx, name, true,
+ truncate(safe, maxLen),
+ null, ms,
+ blank ? "blank_success" : "success",
+ safe.length(), trimmed.length());
+ }
+
+ /**
+ * Factory for a child that failed (exception or timeout).
+ * Detects timeout by inspecting the error message so callers don't need to branch.
+ */
+ static ChildResult ofError(int idx, String name, String err) {
+ String msg = err != null ? err : "Unknown error";
+ boolean isTimeout = msg.contains("超时") || msg.toLowerCase().contains("timeout");
+ return new ChildResult(idx, name, false, null, msg, 0,
+ isTimeout ? "timeout" : "error", 0, 0);
+ }
+
+ /** Factory for an explicit timeout (parallel window exceeded). */
+ static ChildResult ofTimeout(int idx, String name, int timeoutSec) {
+ String msg = "超时 (" + timeoutSec + "s)";
+ return new ChildResult(idx, name, false, null, msg, (long) timeoutSec * 1000L,
+ "timeout", 0, 0);
+ }
+
+ // Legacy shims — kept for callers that pre-date the factory methods
static ChildResult success(int idx, String name, String result, long ms) {
- return new ChildResult(idx, name, true, result, null, ms);
+ // result may already be truncated at call site — lengths will be approximate
+ String safe = result != null ? result : "";
+ String trimmed = safe.trim();
+ boolean blank = trimmed.isEmpty();
+ return new ChildResult(idx, name, true, safe, null, ms,
+ blank ? "blank_success" : "success", safe.length(), trimmed.length());
}
static ChildResult error(int idx, String name, String err) {
- return new ChildResult(idx, name, false, null, err != null ? err : "Unknown error", 0);
+ return ofError(idx, name, err);
}
+
String toToolResponse(String agentName) {
- if (success) return "[Agent「" + agentName + "」的回复]\n\n" + result;
+ if (success) return "[Agent「" + agentName + "」的回复]\n\n" + (result != null ? result : "");
return "[错误] Agent「" + agentName + "」执行失败: " + error;
}
+
+ private static String truncate(String text, int maxLength) {
+ if (text == null) return "";
+ if (text.length() <= maxLength) return text;
+ return text.substring(0, maxLength) + "\n... [截断,原文 " + text.length() + " 字符]";
+ }
}
- // ==================== 辅助方法 ====================
+ // ==================== Helper methods ====================
@Tool(description = "List all available Agents (enabled), including name, type, and description.")
public String listAvailableAgents() {
@@ -348,11 +553,20 @@ public class DelegateAgentTool {
return streamTracker.addEventRelay(childConvId, (eventName, jsonData) -> {
if ("tool_call_started".equals(eventName) || "tool_call_completed".equals(eventName) || "phase".equals(eventName)) {
try {
+ // Parse jsonData into a plain Object so the frontend receives a proper
+ // JSON object under "data", not a string containing serialized JSON.
+ // If parsing fails (e.g. plain text payload), fall back to the raw string.
+ Object parsedData;
+ try {
+ parsedData = objectMapper.readValue(jsonData, Object.class);
+ } catch (Exception ignored) {
+ parsedData = jsonData;
+ }
streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of(
"childConversationId", childConvId,
"childAgentName", childAgentName,
"originalEvent", eventName,
- "data", jsonData));
+ "data", parsedData));
} catch (Exception e) {
log.debug("Relay error: {}", e.getMessage());
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java
index 840b2fa6..c0b81b10 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java
@@ -1,62 +1,65 @@
package vip.mate.tool.builtin;
+import java.util.ArrayDeque;
+import java.util.Deque;
import java.util.Set;
/**
- * 跟踪 Agent 委派调用的上下文信息,防止无限递归并传递父会话信息。
+ * Tracks Agent delegation call context to prevent infinite recursion and carry parent session info.
*
- * 使用 ThreadLocal 存储当前线程的委派层级、父会话 ID 和子 Agent 禁用工具集。
- * 每次 {@link DelegateAgentTool} 发起委派时调用 enter(),返回后调用 exit()。
+ * Uses a ThreadLocal stack so that nested delegations correctly restore the previous layer's
+ * parentConversationId and childDeniedTools on exit.
+ * Each {@link DelegateAgentTool} delegation calls enter() before and exit() after execution.
*
* @author MateClaw Team
*/
public final class DelegationContext {
- private static final ThreadLocal DEPTH = ThreadLocal.withInitial(() -> 0);
- private static final ThreadLocal PARENT_CONVERSATION_ID = new ThreadLocal<>();
- private static final ThreadLocal> CHILD_DENIED_TOOLS = new ThreadLocal<>();
+ /**
+ * Snapshot of one delegation layer's state.
+ */
+ private record Frame(String parentConversationId, Set childDeniedTools) {}
+
+ private static final ThreadLocal> STACK = ThreadLocal.withInitial(ArrayDeque::new);
private DelegationContext() {}
- /** 获取当前委派深度(0 = 顶层调用) */
+ /** Current delegation depth (0 = top-level call, not inside any delegation) */
public static int currentDepth() {
- return DEPTH.get();
+ return STACK.get().size();
}
- /** 获取父会话 ID(用于事件 relay) */
+ /** Parent conversation ID for event relay (from the current frame) */
public static String parentConversationId() {
- return PARENT_CONVERSATION_ID.get();
+ Frame top = STACK.get().peek();
+ return top != null ? top.parentConversationId : null;
}
- /** 获取子 Agent 禁用的工具集 */
+ /** Denied tools set for the child Agent (from the current frame) */
public static Set childDeniedTools() {
- Set denied = CHILD_DENIED_TOOLS.get();
- return denied != null ? denied : Set.of();
+ Frame top = STACK.get().peek();
+ return top != null && top.childDeniedTools != null ? top.childDeniedTools : Set.of();
}
- /** 进入下一层委派(带父会话 ID 和子 Agent 工具限制) */
+ /** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */
public static void enter(String parentConversationId, Set deniedTools) {
- DEPTH.set(DEPTH.get() + 1);
- PARENT_CONVERSATION_ID.set(parentConversationId);
- if (deniedTools != null) {
- CHILD_DENIED_TOOLS.set(deniedTools);
- }
+ STACK.get().push(new Frame(parentConversationId, deniedTools));
}
- /** 进入下一层委派(兼容旧调用) */
+ /** Enter the next delegation layer (backward-compatible overload) */
public static void enter() {
enter(null, null);
}
- /** 退出当前委派层 */
+ /** Exit the current delegation layer, restoring the previous layer's context */
public static void exit() {
- int current = DEPTH.get();
- if (current <= 1) {
- DEPTH.remove();
- PARENT_CONVERSATION_ID.remove();
- CHILD_DENIED_TOOLS.remove();
- } else {
- DEPTH.set(current - 1);
+ Deque stack = STACK.get();
+ if (!stack.isEmpty()) {
+ stack.pop();
+ }
+ // Clean up ThreadLocal entirely when the stack is empty to prevent memory leaks
+ if (stack.isEmpty()) {
+ STACK.remove();
}
}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java
index 5c4cd5bc..8587b89b 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java
@@ -32,7 +32,7 @@ import java.util.zip.ZipInputStream;
public class DocumentExtractTool {
private static final int COMMAND_TIMEOUT_SECONDS = 30;
- private static final int MAX_OUTPUT_LENGTH = 100000; // 100KB 限制
+ private static final int MAX_OUTPUT_LENGTH = 500000; // 500KB — CLOB column has no size limit
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
.toLowerCase(Locale.ROOT).contains("win");
@@ -45,20 +45,26 @@ public class DocumentExtractTool {
- Excel (.xlsx, .xls) - 提取为文本表格
- PowerPoint (.pptx, .ppt)
- 提取策略(自动选择最优方式):
+ 提取策略(默认自动选择最优方式):
1. 优先使用系统命令(pdftotext, textutil, pandoc 等)
2. 系统命令不可用时使用纯 Java 实现
- 3. 返回详细的提取过程和元数据
+ 3. PDF 扫描版进入 OCR
+ 4. 全部失败前用 Apache Tika 兜底(覆盖 SmartArt、共享字符串表等盲区)
+ 5. 返回详细的提取过程和元数据
参数 options 可包含:
- pages: 指定页码范围(如 "1-5" 或 "1,3,5")
- preserveLayout: 是否保留布局(默认 true)
+ - method: 强制指定提取器,跳过自动 fallback 链。当前支持:
+ * "auto"(默认)—— 走完整 fallback 链
+ * "tika" —— 直接用 Apache Tika 抽取,适合 Windows 上没装
+ Poppler/Python 的环境,或验证 Tika 单独是否能解开
如果提取失败,会返回详细的尝试过程和错误信息
""")
public String extract_document_text(
@ToolParam(description = "文件的绝对路径或相对路径") String filePath,
- @ToolParam(description = "可选参数 JSON,如 {\"pages\": \"1-5\", \"preserveLayout\": true}", required = false) String options) {
+ @ToolParam(description = "可选参数 JSON,如 {\"pages\": \"1-5\", \"method\": \"tika\"}", required = false) String options) {
JSONObject result = new JSONObject();
result.set("filePath", filePath);
@@ -68,13 +74,55 @@ public class DocumentExtractTool {
Path path = Paths.get(filePath).toAbsolutePath().normalize();
if (!Files.exists(path)) {
- return errorResult(filePath, "文件不存在: " + path, attempts);
+ // The user-uploaded chat attachment is rendered to the LLM as
+ // "[附件] foo.docx" without its stored path, and Chinese / non-ASCII
+ // filenames are sanitized at upload time (see ChatController#upload),
+ // so the LLM-supplied path won't match anything on disk. Fall back to
+ // basename matching inside the conversation's chat-upload directory.
+ Path attachment = ChatUploadResolver.resolve(filePath);
+ if (attachment == null) {
+ return errorResult(filePath, "文件不存在: " + path, attempts);
+ }
+ log.info("[DocumentExtract] Resolved chat-upload attachment fallback: {} -> {}", filePath, attachment);
+ path = attachment;
}
// 解析文件类型
String mimeType = detectMimeType(path);
result.set("mimeType", mimeType);
+ // RFC-051: method=tika 短路 —— 跳过整条 fallback 链,直接调 Tika。
+ // 用于:1) 测试 Tika 集成是否健康;2) 用户明知系统命令不可用、想免去
+ // 那一长串失败日志的场景。结果里仍然带 attempts 数组,告知"应用户要求跳过自动链"。
+ String forcedMethod = extractOption(options, "method");
+ if ("tika".equalsIgnoreCase(forcedMethod)) {
+ long t = System.currentTimeMillis();
+ String text = TikaExtractor.extract(path);
+ attempts.add("user-forced method=tika: skipped automatic fallback chain");
+ if (text == null || text.isBlank()) {
+ attempts.add("tika: 失败或不可用 (" + (System.currentTimeMillis() - t) + "ms)");
+ return errorResult(filePath, "Tika 抽取无文本(可能格式不支持或文件损坏)", attempts);
+ }
+ attempts.add("tika: 成功 (" + (System.currentTimeMillis() - t) + "ms)");
+
+ String capped = text;
+ boolean trunc = false;
+ if (capped.length() > MAX_OUTPUT_LENGTH) {
+ capped = capped.substring(0, MAX_OUTPUT_LENGTH)
+ + "\n\n... [内容已截断,总长度: " + text.length() + " 字符]";
+ trunc = true;
+ }
+ result.set("text", capped);
+ result.set("method", "tika");
+ result.set("pages", estimatePages(text));
+ result.set("attempts", attempts);
+ result.set("truncated", trunc);
+ result.set("success", true);
+ log.info("[DocumentExtract] {} 使用 method=tika 强制提取成功,{} 字符",
+ filePath, text.length());
+ return JSONUtil.toJsonPrettyStr(result);
+ }
+
// 根据类型选择提取器
ExtractedContent content;
if (mimeType.contains("pdf")) {
@@ -222,15 +270,26 @@ public class DocumentExtractTool {
}
// attempts 已由 tryOcrExtract 内部记录失败原因
+ // 5. Tika 兜底(RFC-051 §5.2):所有命令行 / Python / PDFBox / OCR 都失败时
+ // 用 Java 内置的 Tika 再试一次。主要服务于 Windows 没装 Poppler / Python 的桌面用户。
+ long t4 = System.currentTimeMillis();
+ content = TikaExtractor.extract(path);
+ if (content != null && !content.isBlank()) {
+ attempts.add("tika: 成功 (" + (System.currentTimeMillis() - t4) + "ms)");
+ int pages = realPageCount > 0 ? realPageCount : estimatePages(content);
+ return new ExtractedContent(content, "tika", pages);
+ }
+ attempts.add("tika: 失败或不可用");
+
// 返回之前级别的部分结果(如果有)
if (bestContent != null) {
- log.warn("[DocumentExtract] OCR 不可用,返回部分文本结果: method={}, length={}",
+ log.warn("[DocumentExtract] OCR/Tika 不可用,返回部分文本结果: method={}, length={}",
bestMethod, bestContent.strip().length());
int pages = realPageCount > 0 ? realPageCount : estimatePages(bestContent);
return new ExtractedContent(bestContent, bestMethod + "_partial", pages);
}
- throw new Exception("所有 PDF 提取方法都失败(包括 OCR)");
+ throw new Exception("所有 PDF 提取方法都失败(包括 OCR 与 Tika)");
}
/**
@@ -556,7 +615,17 @@ public class DocumentExtractTool {
}
attempts.add("java_zip_xml: 失败");
- throw new Exception("所有 DOCX 提取方法都失败");
+ // 5. Tika 兜底(RFC-051 §5.2)—— 当 textutil/pandoc/libreoffice/ZIP-XML 全失败时。
+ // Tika 的 Microsoft 模块覆盖到 .docx 内嵌 SmartArt、批注、复杂表格等场景,正好填补
+ // 我们手写的 ZIP XML 解析器的盲区。
+ content = TikaExtractor.extract(path);
+ if (content != null && !content.isBlank()) {
+ attempts.add("tika: 成功");
+ return new ExtractedContent(content, "tika", 0);
+ }
+ attempts.add("tika: 失败或不可用");
+
+ throw new Exception("所有 DOCX 提取方法都失败(包括 Tika)");
}
private String tryTextutil(Path path) {
@@ -687,6 +756,17 @@ public class DocumentExtractTool {
}
}
+ // Our ZIP-XML extractor only reads tags and skips the shared-strings table,
+ // so cells full of text labels look "empty". When that happens, fall through to
+ // Tika which knows how to resolve the shared-strings indirection.
+ if (text.toString().replaceAll("---.*?---", "").strip().isEmpty()) {
+ String fallback = TikaExtractor.extract(path);
+ if (fallback != null && !fallback.isBlank()) {
+ attempts.add("tika: 成功(ZIP-XML 仅有数字 / 共享字符串未解析)");
+ return new ExtractedContent(fallback, "tika", 0);
+ }
+ }
+
attempts.add("java_zip_xml: 成功");
return new ExtractedContent(text.toString(), "java_zip_xml", 0);
}
@@ -721,6 +801,17 @@ public class DocumentExtractTool {
}
}
+ // Slide layouts with text inside SmartArt / charts / grouped shapes don't surface
+ // through the simple grep — Tika walks the full DrawingML graph and pulls
+ // them out. Only invoke when our walker produced nothing useful.
+ if (text.toString().replaceAll("---.*?---", "").strip().isEmpty()) {
+ String fallback = TikaExtractor.extract(path);
+ if (fallback != null && !fallback.isBlank()) {
+ attempts.add("tika: 成功(ZIP-XML 未抓到正文,可能是 SmartArt / 图表)");
+ return new ExtractedContent(fallback, "tika", Math.max(0, slideNum - 1));
+ }
+ }
+
attempts.add("java_zip_xml: 成功");
return new ExtractedContent(text.toString(), "java_zip_xml", Math.max(0, slideNum - 1));
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java
new file mode 100644
index 00000000..63975cee
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java
@@ -0,0 +1,312 @@
+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.tool.document.GeneratedFileCache;
+import vip.mate.tool.document.MarkdownDocxRenderer;
+import vip.mate.tool.guard.WorkspacePathGuard;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Render a brand-new .docx from Markdown without ever forking a process.
+ *
+ * The previous path forwarded these requests to {@code skills/docx} which
+ * runs {@code npm install docx} on first use (3-5 minutes). For "create new
+ * document" intents that subprocess is wholly unnecessary; this tool produces
+ * the bytes in the JVM, stashes them in {@link GeneratedFileCache}, and
+ * returns a Markdown link the user can click to download.
+ *
+ *
The skill workflow is still authoritative for editing existing .docx,
+ * tracked changes, and other XML-level operations.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DocxRenderTool {
+
+ private static final String DOCX_MIME =
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+
+ private final MarkdownDocxRenderer renderer;
+ private final GeneratedFileCache cache;
+
+ @Tool(description = """
+ Render a new .docx file from Markdown text and return a one-time download URL.
+ Use for creating NEW documents: reports, memos, contracts, letters, resumes.
+ Supports: headings (# ## ###), bold (**text**), bullet lists (- item),
+ numbered lists (1. item), tables (| col | col |), plain paragraphs,
+ images () — SVG is rasterized
+ to PNG; image lines must contain only the image syntax.
+
+ For markdown bodies larger than ~5 KB, prefer renderDocxFromFile (read from
+ disk) — passing huge markdown as a tool argument burns LLM tokens needlessly.
+
+ Do NOT use for:
+ - Editing an existing .docx file (use run_skill_script with unpack/edit/pack)
+ - Adding tracked changes or comments (use run_skill_script)
+ - GB/T 9704 official documents (use writeGongwen tool, BmacClaw only)
+
+ Returns a markdown link the user can click to download the file.
+ The link is valid for 10 minutes.
+ """)
+ public String renderDocx(
+ @ToolParam(description = "Document content in Markdown format")
+ String markdown,
+ @ToolParam(description = "Output filename without extension, e.g. 'monthly-report'")
+ String filename,
+ @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false)
+ String pageSize) {
+
+ if (markdown == null || markdown.isBlank()) {
+ return "错误:markdown 参数为空,无法生成文档。";
+ }
+
+ String safeName = sanitizeFilename(filename);
+ String displayName = safeName + ".docx";
+ String size = (pageSize == null || pageSize.isBlank()) ? "A4" : pageSize.trim();
+
+ try {
+ long t0 = System.currentTimeMillis();
+ byte[] bytes = renderer.render(markdown, size);
+ String id = cache.put(bytes, displayName, DOCX_MIME);
+ long elapsed = System.currentTimeMillis() - t0;
+ log.info("[DocxRender] generated {} ({} bytes, {}ms, id={})",
+ displayName, bytes.length, elapsed, id);
+
+ String url = "/api/v1/files/generated/" + id;
+ // Explicit instruction to suppress LLM hallucinating an absolute host.
+ // DeepSeek/Claude have been observed prepending placeholder domains
+ // (e.g. https://ai-tools-system.com) when echoing the URL back to the user,
+ // breaking the download link. Repeat the path verbatim with no host.
+ return "文档已生成:[" + displayName + "](" + url + ")(链接 10 分钟内有效)。\n"
+ + "重要:回答用户时**必须**使用上述相对路径 `" + url + "`,"
+ + "**不要**添加任何 https://、http:// 域名前缀,前端会自动拼接当前主机。";
+ } catch (Exception e) {
+ log.error("[DocxRender] render failed for {}: {}", displayName, e.getMessage(), e);
+ return "渲染失败:" + e.getMessage();
+ }
+ }
+
+ /**
+ * File-based renderer — reads markdown from disk instead of taking it as a
+ * tool argument. Bypasses the LLM token-cost cliff: a 80 KB markdown body
+ * would otherwise be streamed through the chat completion as part of
+ * {@code renderDocx.markdown} args (≈ 20 K tokens, several minutes of
+ * generation just to repeat back content the LLM already wrote to disk).
+ *
+ * Workflow: agent uses {@code write_file} / {@code edit_file} to assemble
+ * the markdown locally → calls this tool with the file path → docx is
+ * rendered from disk in one IO call. Token cost ≈ 50 (just the path).
+ */
+ @Tool(description = """
+ Render a .docx file from a markdown FILE on disk and return a one-time download URL.
+ Use this instead of `renderDocx` when the markdown body is large (>5 KB) — the
+ LLM does not need to repeat its own previous output as a tool argument.
+
+ Typical workflow:
+ 1. write_file(path="report.md", content="# Report\\n...") // assemble markdown
+ 2. renderDocxFromFile(filePath="report.md", filename="monthly-report")
+ 3. return the download link to the user
+
+ The markdown file is read with UTF-8. Path resolution honors the workspace
+ boundary (same rules as read_file / write_file).
+
+ Same supported markdown subset as renderDocx (headings, bold, lists, tables,
+ images). Image references  are rendered when path resolves to a
+ readable file in the workspace. SVG sources are rasterized to PNG via Batik;
+ PNG/JPG/GIF/BMP are embedded directly.
+ """)
+ public String renderDocxFromFile(
+ @ToolParam(description = "Absolute or workspace-relative path to a markdown file")
+ String filePath,
+ @ToolParam(description = "Output filename without extension, e.g. 'monthly-report'")
+ String filename,
+ @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false)
+ String pageSize) {
+
+ if (filePath == null || filePath.isBlank()) {
+ return "Error: filePath parameter is empty.";
+ }
+
+ Path resolved;
+ try {
+ resolved = WorkspacePathGuard.validatePath(filePath);
+ } catch (Exception e) {
+ return "Error: path validation failed — " + e.getMessage();
+ }
+ if (!Files.exists(resolved)) {
+ return "Error: file not found at " + resolved;
+ }
+ if (!Files.isRegularFile(resolved) || !Files.isReadable(resolved)) {
+ return "Error: path is not a readable regular file " + resolved;
+ }
+
+ String markdown;
+ long mdBytes;
+ try {
+ mdBytes = Files.size(resolved);
+ markdown = Files.readString(resolved, StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ log.error("[DocxRender] read markdown failed for {}: {}", resolved, e.getMessage(), e);
+ return "Error: failed to read markdown — " + e.getMessage();
+ }
+ if (markdown.isBlank()) {
+ return "Error: markdown file is empty " + resolved;
+ }
+
+ String safeName = sanitizeFilename(filename);
+ String displayName = safeName + ".docx";
+ String size = (pageSize == null || pageSize.isBlank()) ? "A4" : pageSize.trim();
+
+ try {
+ long t0 = System.currentTimeMillis();
+ byte[] bytes = renderer.render(markdown, size);
+ String id = cache.put(bytes, displayName, DOCX_MIME);
+ long elapsed = System.currentTimeMillis() - t0;
+ log.info("[DocxRender] generated {} ({} bytes from {} bytes md, {}ms, id={})",
+ displayName, bytes.length, mdBytes, elapsed, id);
+
+ String url = "/api/v1/files/generated/" + id;
+ return "Document generated: [" + displayName + "](" + url + ") (link valid for 10 minutes).\n"
+ + "IMPORTANT: when replying to the user you **must** use the relative path `"
+ + url + "` verbatim. Do **not** prepend any https://, http:// or domain — "
+ + "the frontend will resolve the current host automatically.";
+ } catch (Exception e) {
+ log.error("[DocxRender] render failed for {} (source: {}): {}",
+ displayName, resolved, e.getMessage(), e);
+ return "Render failed: " + e.getMessage();
+ }
+ }
+
+ /**
+ * Multi-file renderer — read several markdown files in order and concatenate
+ * them into one docx. Lets the agent split a long report into chapters
+ * (cover.md, intro.md, ch1.md, ...) and render the whole thing in one call,
+ * so a 30-page deliverable does not need to live in a single source file.
+ *
+ * Files are joined with a blank line so heading hierarchy and paragraph
+ * structure carry over cleanly; no extra separator markup is injected.
+ * Empty / missing files abort the render with a clear error so the agent
+ * can fix its file list before retrying.
+ */
+ @Tool(description = """
+ Render a .docx by concatenating MULTIPLE markdown files in order and return a
+ download URL. Use when a report is split into chapters / sections, or when the
+ agent assembled the document piece by piece (cover, table of contents, body,
+ appendix) across several files.
+
+ Typical workflow:
+ 1. write_file(path="cover.md", content="# Title\\n...")
+ 2. write_file(path="ch1.md", content="## Chapter 1\\n...")
+ 3. write_file(path="ch2.md", content="## Chapter 2\\n...")
+ 4. renderDocxFromFiles(filePaths=["cover.md","ch1.md","ch2.md"],
+ filename="quarterly-report")
+
+ Files are read with UTF-8, joined with one blank line between them, and
+ rendered with the same markdown subset as renderDocx (headings, bold,
+ lists, tables). All paths must pass the workspace boundary check.
+ """)
+ public String renderDocxFromFiles(
+ @ToolParam(description = "List of markdown file paths in render order")
+ List filePaths,
+ @ToolParam(description = "Output filename without extension, e.g. 'quarterly-report'")
+ String filename,
+ @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false)
+ String pageSize) {
+
+ if (filePaths == null || filePaths.isEmpty()) {
+ return "Error: filePaths is empty.";
+ }
+
+ StringBuilder combined = new StringBuilder();
+ long totalBytes = 0;
+ List resolvedPaths = new ArrayList<>();
+ for (int idx = 0; idx < filePaths.size(); idx++) {
+ String raw = filePaths.get(idx);
+ if (raw == null || raw.isBlank()) {
+ return "Error: filePaths[" + idx + "] is empty.";
+ }
+ Path resolved;
+ try {
+ resolved = WorkspacePathGuard.validatePath(raw);
+ } catch (Exception e) {
+ return "Error: filePaths[" + idx + "] validation failed — " + e.getMessage();
+ }
+ if (!Files.exists(resolved)) {
+ return "Error: filePaths[" + idx + "] not found at " + resolved;
+ }
+ if (!Files.isRegularFile(resolved) || !Files.isReadable(resolved)) {
+ return "Error: filePaths[" + idx + "] is not a readable regular file " + resolved;
+ }
+ String content;
+ try {
+ totalBytes += Files.size(resolved);
+ content = Files.readString(resolved, StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ log.error("[DocxRender] read failed for {}: {}", resolved, e.getMessage(), e);
+ return "Error: read failed for " + resolved + " — " + e.getMessage();
+ }
+ if (content.isBlank()) {
+ return "Error: filePaths[" + idx + "] is blank " + resolved;
+ }
+ if (combined.length() > 0) combined.append("\n\n");
+ combined.append(content);
+ resolvedPaths.add(resolved.toString());
+ }
+
+ String safeName = sanitizeFilename(filename);
+ String displayName = safeName + ".docx";
+ String size = (pageSize == null || pageSize.isBlank()) ? "A4" : pageSize.trim();
+
+ try {
+ long t0 = System.currentTimeMillis();
+ byte[] bytes = renderer.render(combined.toString(), size);
+ String id = cache.put(bytes, displayName, DOCX_MIME);
+ long elapsed = System.currentTimeMillis() - t0;
+ log.info("[DocxRender] generated {} ({} bytes from {} files / {} bytes md, {}ms, id={})",
+ displayName, bytes.length, resolvedPaths.size(), totalBytes, elapsed, id);
+
+ String url = "/api/v1/files/generated/" + id;
+ return "Document generated from " + resolvedPaths.size() + " files: ["
+ + displayName + "](" + url + ") (link valid for 10 minutes).\n"
+ + "IMPORTANT: when replying to the user you **must** use the relative path `"
+ + url + "` verbatim. Do **not** prepend any https://, http:// or domain — "
+ + "the frontend will resolve the current host automatically.";
+ } catch (Exception e) {
+ log.error("[DocxRender] render failed for {} (sources: {}): {}",
+ displayName, resolvedPaths, e.getMessage(), e);
+ return "Render failed: " + e.getMessage();
+ }
+ }
+
+ /**
+ * Strip path separators and other unsafe characters from a user-supplied
+ * filename. Falls back to a generic name when nothing usable remains.
+ */
+ private String sanitizeFilename(String name) {
+ if (name == null) return "document";
+ String trimmed = name.trim();
+ if (trimmed.toLowerCase().endsWith(".docx")) {
+ trimmed = trimmed.substring(0, trimmed.length() - 5);
+ }
+ StringBuilder sb = new StringBuilder(trimmed.length());
+ for (char c : trimmed.toCharArray()) {
+ if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?'
+ || c == '"' || c == '<' || c == '>' || c == '|' || c < 0x20) {
+ sb.append('_');
+ } else {
+ sb.append(c);
+ }
+ }
+ String cleaned = sb.toString().strip();
+ return cleaned.isEmpty() ? "document" : cleaned;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java
index d57d5621..ca1fbe45 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java
@@ -33,6 +33,7 @@ public class EditFileTool {
private final vip.mate.i18n.I18nService i18n;
+ @vip.mate.tool.ConcurrencyUnsafe("in-place file edit — must not race with reads/writes on the same path")
@Tool(description = "Edit file content via find-and-replace. Finds exact match of old_text and replaces with new_text. "
+ "Returns structured JSON with filePath, replacements count. "
+ "Requires user approval. Replaces first occurrence by default; set replaceAll=true for all.")
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java
index 938c0fae..b423e6be 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java
@@ -50,7 +50,14 @@ public class FileTypeDetectorTool {
Path path = Paths.get(filePath).toAbsolutePath().normalize();
if (!Files.exists(path)) {
- return errorResult(filePath, "文件不存在: " + path);
+ // Fall back to chat-upload basename matching for filenames that were
+ // sanitized at upload time (e.g. Chinese characters → underscores).
+ Path attachment = ChatUploadResolver.resolve(filePath);
+ if (attachment == null) {
+ return errorResult(filePath, "文件不存在: " + path);
+ }
+ log.info("[FileTypeDetector] Resolved chat-upload attachment fallback: {} -> {}", filePath, attachment);
+ path = attachment;
}
if (Files.isDirectory(path)) {
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ImageGenerateTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ImageGenerateTool.java
index 0a8d31b8..a15c4538 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ImageGenerateTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ImageGenerateTool.java
@@ -2,8 +2,10 @@ 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;
@@ -29,6 +31,7 @@ public class ImageGenerateTool {
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 = "Image generation tool. Supports actions: generate (default), list (show available providers), "
+ "status (check task status). Some providers are async (30s-2min), results auto-displayed in conversation.")
public String image_generate(
@@ -38,14 +41,16 @@ public class ImageGenerateTool {
@ToolParam(description = "Aspect ratio: 1:1 / 16:9 / 9:16, default 1:1", required = false) String aspectRatio,
@ToolParam(description = "Generation count (1-4), default 1", required = false) Integer count,
@ToolParam(description = "Model name (optional)", required = false) String model,
- @ToolParam(description = "Task ID to check status (for status action)", required = false) String taskId
+ @ToolParam(description = "Task ID to check status (for status action)", required = false) String taskId,
+ // RFC-063r §2.5: ToolContext is hidden from the LLM by JsonSchemaGenerator.
+ @Nullable ToolContext ctx
) {
String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase();
return switch (normalizedAction) {
case "list" -> handleListAction();
- case "status" -> handleStatusAction(taskId);
- default -> handleGenerateAction(prompt, size, aspectRatio, count, model);
+ case "status" -> handleStatusAction(taskId, ctx);
+ default -> handleGenerateAction(prompt, size, aspectRatio, count, model, ctx);
};
}
@@ -83,8 +88,8 @@ public class ImageGenerateTool {
// ==================== action=status ====================
- private String handleStatusAction(String taskId) {
- String conversationId = ToolExecutionContext.conversationId();
+ private String handleStatusAction(String taskId, @Nullable ToolContext ctx) {
+ String conversationId = ToolExecutionContext.conversationId(ctx);
if (taskId != null && !taskId.isBlank()) {
AsyncTaskInfo info = imageGenerationService.checkTaskStatus(taskId);
@@ -116,9 +121,9 @@ public class ImageGenerateTool {
// ==================== action=generate ====================
private String handleGenerateAction(String prompt, String size, String aspectRatio,
- Integer count, String model) {
- String conversationId = ToolExecutionContext.conversationId();
- String username = ToolExecutionContext.username();
+ Integer count, String model, @Nullable ToolContext ctx) {
+ String conversationId = ToolExecutionContext.conversationId(ctx);
+ String username = ToolExecutionContext.username(ctx);
if (conversationId == null || conversationId.isBlank()) {
return "错误:无法获取当前会话信息,请重试";
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java
index e3bf30f9..6c582826 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java
@@ -3,16 +3,16 @@ package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
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 java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@@ -54,7 +54,9 @@ public class ReadFileTool {
public String read_file(
@ToolParam(description = "Absolute or relative file path") String filePath,
@ToolParam(description = "Start line number (1-based, inclusive). Omit to start from line 1", required = false) Integer startLine,
- @ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine) {
+ @ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine,
+ // RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator.
+ @Nullable ToolContext ctx) {
JSONObject result = new JSONObject();
result.set("filePath", filePath);
@@ -62,14 +64,34 @@ public class ReadFileTool {
try {
Path path;
try {
- path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath);
+ // RFC-063r §2.5: forward ToolContext so workspace boundary
+ // honors ChatOrigin.workspaceBasePath when available.
+ path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath, ctx);
} catch (IllegalArgumentException e) {
- return errorResult(filePath, e.getMessage());
+ // Sandbox rejected the literal path. The LLM may have hallucinated
+ // a Linux-style path (e.g. /app/Dockerfile) for a chat-upload that
+ // actually lives under data/chat-uploads/{conversationId}/. Retry
+ // by basename before surfacing the boundary error.
+ Path attachment = ChatUploadResolver.resolve(filePath);
+ if (attachment == null) {
+ return errorResult(filePath, e.getMessage());
+ }
+ path = attachment;
}
// 文件存在性和类型校验
if (!Files.exists(path)) {
- return errorResult(filePath, i18n.msg("tool.read_file.error.not_found", path));
+ // The user-uploaded chat attachment is rendered to the LLM as
+ // "[附件] foo.txt" without its stored path, so LLMs often pass
+ // just the basename or a guessed absolute path. Fall back to
+ // looking up the basename inside the current conversation's
+ // chat-upload directory before reporting not-found.
+ Path attachment = ChatUploadResolver.resolve(filePath);
+ if (attachment == null) {
+ return errorResult(filePath, i18n.msg("tool.read_file.error.not_found", path));
+ }
+ log.info("[ReadFile] Resolved chat-upload attachment fallback: {} -> {}", filePath, attachment);
+ path = attachment;
}
if (Files.isDirectory(path)) {
return errorResult(filePath, i18n.msg("tool.read_file.error.is_directory", path));
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java
index 9b0e85b0..2f24aed5 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java
@@ -43,6 +43,7 @@ public class ShellExecuteTool {
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
.toLowerCase(Locale.ROOT).contains("win");
+ @vip.mate.tool.ConcurrencyUnsafe("shell command execution can mutate global state in ways the executor can't reason about")
@Tool(description = "Execute a shell command on the local server. For running system commands, viewing files, running scripts. "
+ "Uses cmd.exe on Windows, /bin/sh on Linux/macOS. "
+ "Dangerous operations trigger security approval. Returns structured result with exitCode, stdout, stderr, timedOut.")
@@ -151,14 +152,32 @@ public class ShellExecuteTool {
}
/**
- * 将命令中的嵌入换行符替换为空格。
- * LLM 在 JSON tool_call 中产生的 \n 解码后变成真实换行,
- * 在 Windows cmd.exe 中会导致命令被截断,在 Unix sh 中可能被误解为命令分隔符。
+ * Collapse embedded newlines for Windows cmd.exe (where they break parsing),
+ * but **leave them alone on Unix**.
+ *
+ * The original implementation collapsed on every platform under the worry
+ * that a stray newline could be misread as a command separator on POSIX
+ * shells. In practice that worry is wrong for two common idioms the LLM
+ * actually uses to write files: heredocs (`cat <<EOF\nbody\nEOF`) and
+ * `python <<EOF` invocations. Both depend on real line breaks to
+ * delimit the body from the closing tag — collapsing newlines turns
+ * `cat <<EOF\nbody\nEOF` into `cat <<EOF body EOF`, which the
+ * shell reads as "open heredoc, immediately close, write 0 bytes." The
+ * symptom: every chapter file produced by the agent ends up 0-byte.
+ *
+ * Unix shell already separates commands with `;` or `&&`, not
+ * unquoted newlines, so leaving newlines in is actually safer — and
+ * heredocs / multi-line commands now behave as the LLM expects. Windows
+ * cmd.exe still gets the collapse because there it really does break.
*/
private static String collapseEmbeddedNewlines(String command) {
if (command == null || !command.contains("\n")) {
return command;
}
+ if (!IS_WINDOWS) {
+ // POSIX shell handles newlines correctly within heredocs / scripts
+ return command;
+ }
return command.replace("\r\n", " ").replace("\n", " ");
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java
index 261c76f6..7340272c 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java
@@ -43,6 +43,7 @@ public class SkillManageTool {
/** Skill 内容最大长度(~25K tokens) */
private static final int MAX_CONTENT_CHARS = 100_000;
+ @vip.mate.tool.ConcurrencyUnsafe("create/edit/patch/delete on the shared skill registry; concurrent ops on the same skill name race")
@Tool(description = """
Manage reusable skills: create, edit, patch, or delete skill procedures (SKILL.md format).
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java
index 603853cd..ab7d1d67 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java
@@ -28,6 +28,7 @@ public class SkillScriptTool {
private final SkillFileAccessPolicy accessPolicy;
private final SkillScriptExecutionService executionService;
+ @vip.mate.tool.ConcurrencyUnsafe("script execution can have arbitrary side effects on the host process and filesystem")
@Tool(description = """
Execute a script from a skill's scripts/ directory.
Use this when you need to run skill-provided automation or utilities.
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/TikaExtractor.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/TikaExtractor.java
new file mode 100644
index 00000000..1965adc0
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/TikaExtractor.java
@@ -0,0 +1,91 @@
+package vip.mate.tool.builtin;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.tika.exception.WriteLimitReachedException;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * RFC-051 §5.2: Apache Tika as the last-resort document extractor.
+ *
+ * Used by {@link DocumentExtractTool} only after every other path
+ * (pdftotext / pdfplumber / pdfbox / OCR for PDFs, and the system-command
+ * + ZIP-XML chain for Office formats) has failed. Tika ships its own
+ * PDFBox + POI internals, so it works on Windows installs without Python
+ * or Poppler — which is the actual scenario the RFC §13.1 pointed to.
+ *
+ *
Safety
+ *
+ * {@link BodyContentHandler} caps output at {@code maxChars}; when the
+ * cap is hit Tika throws {@link WriteLimitReachedException}, which we
+ * treat as a successful (truncated) extract rather than a failure.
+ * Tika 3.x has built-in zip-bomb defenses on its zip readers (POI's
+ * {@code ZipSecureFile}); we don't disable them.
+ * Any other parse failure returns {@code null} so the caller can fall
+ * through to its existing structured-error path.
+ *
+ *
+ * The extractor is deliberately stateless and synchronous: callers drive
+ * concurrency externally.
+ */
+@Slf4j
+public final class TikaExtractor {
+
+ /**
+ * Reasonable default for a single-document parse. 5MB of text is
+ * well above any source we'd actually feed into the wiki pipeline,
+ * and well below what would OOM a typical desktop install.
+ */
+ public static final int DEFAULT_MAX_CHARS = 5_000_000;
+
+ private TikaExtractor() {}
+
+ /** Extract with the default cap. */
+ public static String extract(Path path) {
+ return extract(path, DEFAULT_MAX_CHARS);
+ }
+
+ /**
+ * Extract text from {@code path} using Tika's {@link AutoDetectParser},
+ * capping output at {@code maxChars}. Returns the extracted text on
+ * success (possibly truncated), or {@code null} on any failure.
+ */
+ public static String extract(Path path, int maxChars) {
+ if (path == null) return null;
+ if (!Files.isRegularFile(path)) {
+ log.debug("[Tika] Path is not a regular file: {}", path);
+ return null;
+ }
+ int cap = maxChars <= 0 ? DEFAULT_MAX_CHARS : maxChars;
+
+ BodyContentHandler handler = new BodyContentHandler(cap);
+ AutoDetectParser parser = new AutoDetectParser();
+ Metadata metadata = new Metadata();
+ ParseContext context = new ParseContext();
+
+ try (InputStream is = Files.newInputStream(path)) {
+ parser.parse(is, handler, metadata, context);
+ return handler.toString();
+ } catch (WriteLimitReachedException truncated) {
+ // Cap hit — Tika filled the handler before parsing finished. The
+ // partial text is still useful, especially since callers will chunk
+ // anyway and only want the leading prose for routing/embedding.
+ String partial = handler.toString();
+ log.info("[Tika] Output cap reached at {} chars for {}; returning partial",
+ partial.length(), path.getFileName());
+ return partial.isBlank() ? null : partial;
+ } catch (Throwable t) {
+ // Catching Throwable on purpose: Tika can throw NoClassDefFoundError /
+ // LinkageError when an obscure transitive parser is missing on a
+ // minimal classpath, and that should not crash the extract chain.
+ log.warn("[Tika] Parse failed for {}: {}", path.getFileName(), t.getMessage());
+ return null;
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java
index 6e64553c..1f199b09 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java
@@ -1,11 +1,21 @@
package vip.mate.tool.builtin;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.lang.Nullable;
+import vip.mate.agent.context.ChatOrigin;
+
/**
* 工具执行上下文 — 通过 ThreadLocal 向 @Tool 方法传递执行环境信息
*
* 在 ToolExecutionExecutor.executeSingleTool() 中 set,在 finally 中 clear。
* 视频生成等需要知道 conversationId 的工具从此处获取。
*
+ *
RFC-063r §2.5 兼容期:执行器会同时填充本 ThreadLocal 和 Spring AI 的
+ * {@link ToolContext}(携带 {@link ChatOrigin})。优先读 ToolContext 的工具
+ * 调用 {@link #conversationId(ToolContext)} / {@link #username(ToolContext)}
+ * / {@link #workspaceBasePath(ToolContext)} 等三参重载即可——传入 ctx 不为
+ * null 时优先返回 origin 的字段,否则回退到 ThreadLocal。
+ *
* @author MateClaw Team
*/
public final class ToolExecutionContext {
@@ -47,4 +57,34 @@ public final class ToolExecutionContext {
USERNAME.remove();
WORKSPACE_BASE_PATH.remove();
}
+
+ // ===== RFC-063r §2.5: ToolContext-aware accessors =====
+ //
+ // Preferred over the parameter-less variants: read from the explicit
+ // Spring AI ToolContext (carries ChatOrigin) when available, otherwise
+ // fall back to the legacy ThreadLocal so legacy paths keep working.
+
+ public static String conversationId(@Nullable ToolContext ctx) {
+ if (ctx != null) {
+ String v = ChatOrigin.from(ctx).conversationId();
+ if (v != null && !v.isEmpty()) return v;
+ }
+ return CONVERSATION_ID.get();
+ }
+
+ public static String username(@Nullable ToolContext ctx) {
+ if (ctx != null) {
+ String v = ChatOrigin.from(ctx).requesterId();
+ if (v != null && !v.isEmpty()) return v;
+ }
+ return USERNAME.get();
+ }
+
+ public static String workspaceBasePath(@Nullable ToolContext ctx) {
+ if (ctx != null) {
+ String v = ChatOrigin.from(ctx).workspaceBasePath();
+ if (v != null && !v.isBlank()) return v;
+ }
+ return WORKSPACE_BASE_PATH.get();
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/VideoGenerateTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/VideoGenerateTool.java
index e26e9381..7363f682 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/VideoGenerateTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/VideoGenerateTool.java
@@ -2,8 +2,10 @@ 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;
@@ -32,6 +34,7 @@ public class VideoGenerateTool {
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"
@@ -44,15 +47,18 @@ public class VideoGenerateTool {
@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
+ @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);
- default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model);
+ case "status" -> handleStatusAction(taskId, ctx);
+ default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model, ctx);
};
}
@@ -91,8 +97,8 @@ public class VideoGenerateTool {
// ==================== action=status ====================
- private String handleStatusAction(String taskId) {
- String conversationId = ToolExecutionContext.conversationId();
+ private String handleStatusAction(String taskId, @Nullable ToolContext ctx) {
+ String conversationId = ToolExecutionContext.conversationId(ctx);
// 指定 taskId 查询
if (taskId != null && !taskId.isBlank()) {
@@ -123,9 +129,9 @@ public class VideoGenerateTool {
// ==================== action=generate ====================
private String handleGenerateAction(String prompt, String aspectRatio, Integer duration,
- String imageUrl, String model) {
- String conversationId = ToolExecutionContext.conversationId();
- String username = ToolExecutionContext.username();
+ String imageUrl, String model, @Nullable ToolContext ctx) {
+ String conversationId = ToolExecutionContext.conversationId(ctx);
+ String username = ToolExecutionContext.username(ctx);
if (conversationId == null || conversationId.isBlank()) {
return "错误:无法获取当前会话信息,请重试";
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java
index 34b18559..4083b2f4 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java
@@ -100,6 +100,7 @@ public class WorkspaceMemoryTool {
return JSONUtil.toJsonPrettyStr(result);
}
+ @vip.mate.tool.ConcurrencyUnsafe("workspace memory write — concurrent writes to the same file would clobber each other")
@Tool(description = """
创建或覆写指定 Agent 的数据库工作区记忆文件。
适用于把提炼后的长期记忆写入 MEMORY.md,或把原始事件写入 memory/YYYY-MM-DD.md。
@@ -133,6 +134,7 @@ public class WorkspaceMemoryTool {
return JSONUtil.toJsonPrettyStr(result);
}
+ @vip.mate.tool.ConcurrencyUnsafe("workspace memory edit — find/replace must serialize per file")
@Tool(description = """
通过精确查找替换编辑指定 Agent 的数据库工作区记忆文件。
适用于在 MEMORY.md 的某个 section 中做增量更新,避免整篇重写。
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java
index 6c3de390..352cda98 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java
@@ -33,6 +33,7 @@ public class WriteFileTool {
private final vip.mate.i18n.I18nService i18n;
+ @vip.mate.tool.ConcurrencyUnsafe("file write — must serialize with reads/writes on overlapping paths")
@Tool(description = "Write content to a file. Overwrites if exists, creates if not (auto-creates parent directories). "
+ "Returns structured JSON with filePath, bytesWritten. "
+ "Requires user approval.")
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java
new file mode 100644
index 00000000..e7bd8547
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java
@@ -0,0 +1,69 @@
+package vip.mate.tool.document;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * In-memory cache of bytes produced by tools (e.g. {@code DocxRenderTool}) and
+ * served by {@link GeneratedFileController}. Entries expire after {@link #TTL}
+ * and are evicted lazily on every {@link #put} call.
+ *
+ *
The cache is process-local and intentionally not persisted: a JVM restart
+ * invalidates all outstanding download links. The download URL embeds a random
+ * {@link UUID}, which acts as the only access credential.
+ */
+@Slf4j
+@Component
+public class GeneratedFileCache {
+
+ public static final Duration TTL = Duration.ofMinutes(10);
+
+ private final ConcurrentHashMap entries = new ConcurrentHashMap<>();
+
+ public record Entry(byte[] bytes, String filename, String mimeType, long expireAt) {
+
+ public boolean expired() {
+ return System.currentTimeMillis() > expireAt;
+ }
+ }
+
+ /**
+ * Store the given bytes and return a fresh, unguessable identifier.
+ * Callers should embed the id in a URL of the form
+ * {@code /api/v1/files/generated/{id}}.
+ */
+ public String put(byte[] bytes, String filename, String mimeType) {
+ evictExpired();
+ String id = UUID.randomUUID().toString();
+ long expireAt = System.currentTimeMillis() + TTL.toMillis();
+ entries.put(id, new Entry(bytes, filename, mimeType, expireAt));
+ log.debug("Cached generated file id={} filename={} bytes={}", id, filename, bytes.length);
+ return id;
+ }
+
+ /**
+ * Look up an entry. Returns {@link Optional#empty()} if missing or expired
+ * (expired entries are removed as a side-effect).
+ */
+ public Optional get(String id) {
+ Entry entry = entries.get(id);
+ if (entry == null) {
+ return Optional.empty();
+ }
+ if (entry.expired()) {
+ entries.remove(id, entry);
+ return Optional.empty();
+ }
+ return Optional.of(entry);
+ }
+
+ private void evictExpired() {
+ long now = System.currentTimeMillis();
+ entries.entrySet().removeIf(e -> e.getValue().expireAt() <= now);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java
new file mode 100644
index 00000000..acd1e3d0
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java
@@ -0,0 +1,59 @@
+package vip.mate.tool.document;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+/**
+ * Serves bytes produced by tools and stashed in {@link GeneratedFileCache}.
+ *
+ * Endpoint is intentionally unauthenticated; the UUID in the URL is the only
+ * access credential. Entries expire after {@link GeneratedFileCache#TTL}.
+ */
+@Tag(name = "Generated Files")
+@RestController
+@RequestMapping("/api/v1/files/generated")
+@RequiredArgsConstructor
+public class GeneratedFileController {
+
+ private final GeneratedFileCache cache;
+
+ @Operation(summary = "Download a tool-generated file by its one-time id")
+ @GetMapping("/{id}")
+ public ResponseEntity> download(@PathVariable String id) {
+ return cache.get(id)
+ .>map(entry -> {
+ String encodedName = URLEncoder.encode(entry.filename(), StandardCharsets.UTF_8)
+ .replace("+", "%20");
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.parseMediaType(entry.mimeType()));
+ // RFC 5987 filename* lets non-ASCII names round-trip in browsers.
+ headers.add(HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"" + sanitizeAscii(entry.filename())
+ + "\"; filename*=UTF-8''" + encodedName);
+ headers.setContentLength(entry.bytes().length);
+ return ResponseEntity.ok().headers(headers).body(entry.bytes());
+ })
+ .orElseGet(() -> ResponseEntity.status(404)
+ .body(Map.of("error", "File not found or expired")));
+ }
+
+ private String sanitizeAscii(String name) {
+ StringBuilder sb = new StringBuilder(name.length());
+ for (char c : name.toCharArray()) {
+ sb.append(c < 0x20 || c >= 0x7F || c == '"' || c == '\\' ? '_' : c);
+ }
+ return sb.toString();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownDocxRenderer.java b/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownDocxRenderer.java
new file mode 100644
index 00000000..3d957f68
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownDocxRenderer.java
@@ -0,0 +1,487 @@
+package vip.mate.tool.document;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.xwpf.usermodel.ParagraphAlignment;
+import org.apache.poi.xwpf.usermodel.UnderlinePatterns;
+import org.apache.poi.xwpf.usermodel.XWPFAbstractNum;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFNumbering;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.apache.poi.xwpf.usermodel.XWPFTableRow;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTAbstractNum;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFonts;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTLvl;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPrGeneral;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblBorders;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STNumberFormat;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STShd;
+import org.apache.batik.transcoder.TranscoderInput;
+import org.apache.batik.transcoder.TranscoderOutput;
+import org.apache.batik.transcoder.image.PNGTranscoder;
+import org.apache.poi.util.Units;
+import org.apache.poi.xwpf.usermodel.Document;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Render a Markdown string into a Word .docx byte array using Apache POI,
+ * entirely in-process. Replaces the docx-js Node.js subprocess used by
+ * {@code skills/docx} for the "create new document" code path.
+ *
+ * Supported elements: ATX headings (# / ## / ###), bold (**...**),
+ * bullet lists (- / *), numbered lists (1. / 2. ...), pipe-style tables, and
+ * plain paragraphs. Empty Markdown lines are preserved as empty paragraphs.
+ *
+ *
Inline parsing is intentionally minimal: only **bold** is recognized.
+ * For more advanced layouts (images, headers/footers, exact OOXML edits), the
+ * {@code skills/docx} unpack/edit/pack workflow remains the right choice.
+ */
+@Slf4j
+@Component
+public class MarkdownDocxRenderer {
+
+ /** Matches **bold** spans (non-greedy, refuses empty content). */
+ private static final Pattern BOLD = Pattern.compile("\\*\\*(.+?)\\*\\*");
+
+ private static final Pattern UNORDERED_ITEM = Pattern.compile("^\\s*[-*]\\s+(.*)$");
+ private static final Pattern ORDERED_ITEM = Pattern.compile("^\\s*\\d+\\.\\s+(.*)$");
+ private static final Pattern TABLE_SEPARATOR = Pattern.compile("^\\s*\\|?\\s*:?-{3,}:?\\s*(\\|\\s*:?-{3,}:?\\s*)+\\|?\\s*$");
+
+ /**
+ * Image-only line, e.g. {@code }. Whitespace around
+ * the syntax is allowed but inline images mixed with other text in the same
+ * paragraph are intentionally NOT recognized — they would require splitting
+ * a single paragraph into multiple POI runs with image positioning that the
+ * markdown subset doesn't otherwise support.
+ */
+ private static final Pattern IMAGE_LINE = Pattern.compile(
+ "^\\s*!\\[([^\\]]*)\\]\\(([^)\\s]+)\\)\\s*$");
+
+ /**
+ * Page width in EMU after default A4 margins (page width 11906 twips - left
+ * 1800 - right 1800 = 8306 twips ≈ 5.77 inches). POI's image API works in EMU
+ * (1 inch = 914400 EMU); precomputing the maximum width keeps oversized images
+ * from spilling outside the printable area while still allowing small images
+ * to render at native size.
+ */
+ private static final int MAX_IMAGE_WIDTH_EMU = Units.toEMU(5.77 * 72);
+
+ private static final String LATIN_FONT = "Arial";
+ private static final String CJK_BODY_FONT = "FangSong"; // 仿宋
+ private static final String CJK_HEADING_FONT = "SimHei"; // 黑体
+
+ public byte[] render(String markdown, String pageSize) throws IOException {
+ try (XWPFDocument doc = new XWPFDocument()) {
+ configurePageSize(doc, pageSize);
+ BigInteger bulletNumId = configureNumbering(doc, true);
+ BigInteger decimalNumId = configureNumbering(doc, false);
+
+ List lines = splitLines(markdown == null ? "" : markdown);
+ int i = 0;
+ while (i < lines.size()) {
+ String line = lines.get(i);
+ String stripped = line.strip();
+
+ // Table block: header line + separator + body rows
+ if (stripped.startsWith("|") && i + 1 < lines.size()
+ && TABLE_SEPARATOR.matcher(lines.get(i + 1)).matches()) {
+ int end = i + 2;
+ while (end < lines.size() && lines.get(end).strip().startsWith("|")) {
+ end++;
+ }
+ renderTable(doc, lines.subList(i, end));
+ i = end;
+ continue;
+ }
+
+ Matcher imageMatch = IMAGE_LINE.matcher(line);
+ if (imageMatch.matches()) {
+ renderImage(doc, imageMatch.group(1), imageMatch.group(2));
+ } else if (stripped.startsWith("### ")) {
+ renderHeading(doc, stripped.substring(4), 3);
+ } else if (stripped.startsWith("## ")) {
+ renderHeading(doc, stripped.substring(3), 2);
+ } else if (stripped.startsWith("# ")) {
+ renderHeading(doc, stripped.substring(2), 1);
+ } else {
+ Matcher ul = UNORDERED_ITEM.matcher(line);
+ Matcher ol = ORDERED_ITEM.matcher(line);
+ if (ul.matches()) {
+ renderListItem(doc, ul.group(1), bulletNumId);
+ } else if (ol.matches()) {
+ renderListItem(doc, ol.group(1), decimalNumId);
+ } else if (stripped.isEmpty()) {
+ doc.createParagraph();
+ } else {
+ renderParagraph(doc, line);
+ }
+ }
+ i++;
+ }
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ doc.write(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ // ==================== page setup ====================
+
+ private void configurePageSize(XWPFDocument doc, String pageSize) {
+ CTSectPr sectPr = doc.getDocument().getBody().isSetSectPr()
+ ? doc.getDocument().getBody().getSectPr()
+ : doc.getDocument().getBody().addNewSectPr();
+
+ CTPageSz pgSz = sectPr.isSetPgSz() ? sectPr.getPgSz() : sectPr.addNewPgSz();
+ boolean letter = pageSize != null && pageSize.equalsIgnoreCase("LETTER");
+ if (letter) {
+ pgSz.setW(BigInteger.valueOf(12240));
+ pgSz.setH(BigInteger.valueOf(15840));
+ } else {
+ // A4 default
+ pgSz.setW(BigInteger.valueOf(11906));
+ pgSz.setH(BigInteger.valueOf(16838));
+ }
+
+ CTPageMar pgMar = sectPr.isSetPgMar() ? sectPr.getPgMar() : sectPr.addNewPgMar();
+ pgMar.setTop(BigInteger.valueOf(1440));
+ pgMar.setBottom(BigInteger.valueOf(1440));
+ pgMar.setLeft(BigInteger.valueOf(1800));
+ pgMar.setRight(BigInteger.valueOf(1800));
+ pgMar.setHeader(BigInteger.valueOf(720));
+ pgMar.setFooter(BigInteger.valueOf(720));
+ pgMar.setGutter(BigInteger.ZERO);
+ }
+
+ // ==================== numbering ====================
+
+ private BigInteger configureNumbering(XWPFDocument doc, boolean bullet) {
+ XWPFNumbering numbering = doc.createNumbering();
+ CTAbstractNum abstractNum = CTAbstractNum.Factory.newInstance();
+ // Temporary id; XWPFAbstractNum will assign the real one when added.
+ abstractNum.setAbstractNumId(BigInteger.ZERO);
+
+ CTLvl lvl = abstractNum.addNewLvl();
+ lvl.setIlvl(BigInteger.ZERO);
+ lvl.addNewStart().setVal(BigInteger.ONE);
+ if (bullet) {
+ lvl.addNewNumFmt().setVal(STNumberFormat.BULLET);
+ lvl.addNewLvlText().setVal("•");
+ } else {
+ lvl.addNewNumFmt().setVal(STNumberFormat.DECIMAL);
+ lvl.addNewLvlText().setVal("%1.");
+ }
+ CTPPrGeneral ppr = lvl.addNewPPr();
+ CTInd ind = ppr.addNewInd();
+ ind.setLeft(BigInteger.valueOf(720));
+ ind.setHanging(BigInteger.valueOf(360));
+
+ XWPFAbstractNum xwpfAbstractNum = new XWPFAbstractNum(abstractNum);
+ BigInteger absNumId = numbering.addAbstractNum(xwpfAbstractNum);
+ return numbering.addNum(absNumId);
+ }
+
+ // ==================== headings & paragraphs ====================
+
+ private void renderHeading(XWPFDocument doc, String text, int level) {
+ XWPFParagraph p = doc.createParagraph();
+ p.setStyle("Heading" + level);
+ // Spacing before/after, in twentieths of a point.
+ switch (level) {
+ case 1 -> { p.setSpacingBefore(240); p.setSpacingAfter(120); }
+ case 2 -> { p.setSpacingBefore(160); p.setSpacingAfter(80); }
+ default -> { p.setSpacingBefore(120); p.setSpacingAfter(60); }
+ }
+ renderInline(p, text, true, level);
+ }
+
+ private void renderParagraph(XWPFDocument doc, String text) {
+ XWPFParagraph p = doc.createParagraph();
+ p.setAlignment(ParagraphAlignment.LEFT);
+ renderInline(p, text, false, 0);
+ }
+
+ private void renderListItem(XWPFDocument doc, String text, BigInteger numId) {
+ XWPFParagraph p = doc.createParagraph();
+ p.setNumID(numId);
+ renderInline(p, text, false, 0);
+ }
+
+ // ==================== images ====================
+
+ /**
+ * Render an {@code } line as an embedded image. Falls back to
+ * showing the alt text in italics on any failure (file missing, unsupported
+ * format, SVG conversion error) so the rest of the document still renders.
+ *
+ * Path resolution: the markdown is treated as living in the workspace root,
+ * so a path like {@code assets/x.png} resolves relative to the JVM working
+ * directory. Absolute paths are accepted as-is. {@code .svg} files are
+ * rasterized to PNG via Apache Batik before embedding because OOXML images
+ * must be a raster format.
+ */
+ private void renderImage(XWPFDocument doc, String alt, String rawPath) {
+ XWPFParagraph p = doc.createParagraph();
+ p.setAlignment(ParagraphAlignment.CENTER);
+ XWPFRun run = p.createRun();
+
+ Path path;
+ try {
+ path = Paths.get(rawPath);
+ if (!path.isAbsolute()) {
+ path = Paths.get(".").resolve(rawPath).normalize();
+ }
+ } catch (Exception e) {
+ renderImageFallback(run, alt, "invalid path: " + e.getMessage());
+ return;
+ }
+
+ if (!Files.exists(path) || !Files.isReadable(path)) {
+ renderImageFallback(run, alt, "file not found: " + path);
+ return;
+ }
+
+ String lower = path.getFileName().toString().toLowerCase(Locale.ROOT);
+ int format;
+ byte[] imageBytes;
+ try {
+ if (lower.endsWith(".svg")) {
+ imageBytes = svgToPng(Files.readAllBytes(path));
+ format = Document.PICTURE_TYPE_PNG;
+ } else if (lower.endsWith(".png")) {
+ imageBytes = Files.readAllBytes(path);
+ format = Document.PICTURE_TYPE_PNG;
+ } else if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
+ imageBytes = Files.readAllBytes(path);
+ format = Document.PICTURE_TYPE_JPEG;
+ } else if (lower.endsWith(".gif")) {
+ imageBytes = Files.readAllBytes(path);
+ format = Document.PICTURE_TYPE_GIF;
+ } else if (lower.endsWith(".bmp")) {
+ imageBytes = Files.readAllBytes(path);
+ format = Document.PICTURE_TYPE_BMP;
+ } else {
+ renderImageFallback(run, alt, "unsupported image format: " + lower);
+ return;
+ }
+ } catch (Exception e) {
+ log.warn("[MarkdownDocxRenderer] failed to read image {}: {}", path, e.getMessage());
+ renderImageFallback(run, alt, "read failed: " + e.getMessage());
+ return;
+ }
+
+ // Choose width: scale to MAX_IMAGE_WIDTH_EMU. POI's addPicture expects
+ // EMU; we don't know the source image's intrinsic size cheaply, so
+ // pin width and let height scale proportionally via height=0 → POI
+ // does not infer height for us, so use a reasonable height ratio
+ // (4:3 default) to avoid stretching extremely wide diagrams.
+ int width = MAX_IMAGE_WIDTH_EMU;
+ int height = (int) (MAX_IMAGE_WIDTH_EMU * 0.6);
+ try (ByteArrayInputStream in = new ByteArrayInputStream(imageBytes)) {
+ run.addPicture(in, format, path.getFileName().toString(), width, height);
+ } catch (Exception e) {
+ log.warn("[MarkdownDocxRenderer] addPicture failed for {}: {}",
+ path, e.getMessage());
+ renderImageFallback(run, alt, "embed failed: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Convert an SVG byte array to PNG using Batik's PNGTranscoder. Width is
+ * pinned so the rasterized output matches the docx page-width target;
+ * height scales proportionally per the SVG's own viewBox.
+ */
+ private byte[] svgToPng(byte[] svgBytes) throws IOException {
+ PNGTranscoder t = new PNGTranscoder();
+ // Roughly 1400px wide → renders crisply at our docx target width.
+ t.addTranscodingHint(PNGTranscoder.KEY_WIDTH, 1400f);
+ TranscoderInput input = new TranscoderInput(new ByteArrayInputStream(svgBytes));
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ TranscoderOutput output = new TranscoderOutput(out);
+ try {
+ t.transcode(input, output);
+ } catch (Exception e) {
+ throw new IOException("SVG transcode failed: " + e.getMessage(), e);
+ }
+ return out.toByteArray();
+ }
+
+ private void renderImageFallback(XWPFRun run, String alt, String reason) {
+ run.setItalic(true);
+ run.setText("[image: " + (alt == null || alt.isBlank() ? "(no alt)" : alt)
+ + " — " + reason + "]");
+ }
+
+ // ==================== inline (bold) ====================
+
+ private void renderInline(XWPFParagraph p, String text, boolean heading, int headingLevel) {
+ if (text == null || text.isEmpty()) {
+ // Make sure even empty headings still produce a run so style applies.
+ createRun(p, "", heading, headingLevel, false);
+ return;
+ }
+ Matcher m = BOLD.matcher(text);
+ int last = 0;
+ while (m.find()) {
+ if (m.start() > last) {
+ createRun(p, text.substring(last, m.start()), heading, headingLevel, false);
+ }
+ createRun(p, m.group(1), heading, headingLevel, true);
+ last = m.end();
+ }
+ if (last < text.length()) {
+ createRun(p, text.substring(last), heading, headingLevel, false);
+ }
+ }
+
+ private void createRun(XWPFParagraph p, String text, boolean heading, int headingLevel, boolean bold) {
+ XWPFRun run = p.createRun();
+ run.setText(text);
+ run.setUnderline(UnderlinePatterns.NONE);
+
+ // Font sizes per RFC §4.2.
+ int halfPoints;
+ if (heading) {
+ halfPoints = switch (headingLevel) {
+ case 1 -> 40; // 20pt
+ case 2 -> 32; // 16pt
+ default -> 28; // 14pt
+ };
+ run.setBold(true);
+ } else {
+ halfPoints = 24; // 12pt
+ run.setBold(bold);
+ }
+ run.setFontSize(halfPoints / 2);
+
+ // Latin + East-Asian fonts. Each run is freshly created, so we always
+ // append a brand new child rather than try to reuse one.
+ CTRPr rPr = run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr();
+ CTFonts fonts = rPr.sizeOfRFontsArray() > 0 ? rPr.getRFontsArray(0) : rPr.addNewRFonts();
+ fonts.setAscii(LATIN_FONT);
+ fonts.setHAnsi(LATIN_FONT);
+ fonts.setCs(LATIN_FONT);
+ fonts.setEastAsia(heading ? CJK_HEADING_FONT : CJK_BODY_FONT);
+ }
+
+ // ==================== tables ====================
+
+ private void renderTable(XWPFDocument doc, List tableLines) {
+ List rows = new ArrayList<>(tableLines.size());
+ for (int idx = 0; idx < tableLines.size(); idx++) {
+ if (idx == 1) continue; // skip the |---|---| separator
+ rows.add(parseTableRow(tableLines.get(idx)));
+ }
+ if (rows.isEmpty()) return;
+
+ int cols = 0;
+ for (String[] row : rows) cols = Math.max(cols, row.length);
+
+ XWPFTable table = doc.createTable(rows.size(), cols);
+ styleTableBorders(table);
+
+ for (int r = 0; r < rows.size(); r++) {
+ String[] cells = rows.get(r);
+ XWPFTableRow row = table.getRow(r);
+ for (int c = 0; c < cols; c++) {
+ XWPFTableCell cell = row.getCell(c);
+ String value = c < cells.length ? cells[c] : "";
+
+ // POI auto-creates an empty paragraph in each new cell — reuse it.
+ cell.removeParagraph(0);
+ XWPFParagraph p = cell.addParagraph();
+ renderInline(p, value, false, 0);
+
+ if (r == 0) {
+ shadeHeaderCell(cell);
+ for (XWPFRun run : p.getRuns()) {
+ run.setBold(true);
+ }
+ }
+ }
+ }
+ }
+
+ private String[] parseTableRow(String line) {
+ String trimmed = line.strip();
+ if (trimmed.startsWith("|")) trimmed = trimmed.substring(1);
+ if (trimmed.endsWith("|")) trimmed = trimmed.substring(0, trimmed.length() - 1);
+ String[] parts = trimmed.split("\\|", -1);
+ for (int i = 0; i < parts.length; i++) {
+ parts[i] = parts[i].strip();
+ }
+ return parts;
+ }
+
+ private void styleTableBorders(XWPFTable table) {
+ CTTblPr tblPr = table.getCTTbl().getTblPr() != null
+ ? table.getCTTbl().getTblPr()
+ : table.getCTTbl().addNewTblPr();
+ CTTblBorders borders = tblPr.isSetTblBorders() ? tblPr.getTblBorders() : tblPr.addNewTblBorders();
+ applyBorder(borders.isSetTop() ? borders.getTop() : borders.addNewTop());
+ applyBorder(borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom());
+ applyBorder(borders.isSetLeft() ? borders.getLeft() : borders.addNewLeft());
+ applyBorder(borders.isSetRight() ? borders.getRight() : borders.addNewRight());
+ applyBorder(borders.isSetInsideH() ? borders.getInsideH() : borders.addNewInsideH());
+ applyBorder(borders.isSetInsideV() ? borders.getInsideV() : borders.addNewInsideV());
+ }
+
+ private void applyBorder(CTBorder border) {
+ border.setVal(STBorder.SINGLE);
+ border.setSz(BigInteger.valueOf(4));
+ border.setColor("999999");
+ }
+
+ private void shadeHeaderCell(XWPFTableCell cell) {
+ CTTcPr tcPr = cell.getCTTc().getTcPr() != null ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr();
+ CTShd shd = tcPr.isSetShd() ? tcPr.getShd() : tcPr.addNewShd();
+ shd.setVal(STShd.CLEAR);
+ shd.setColor("auto");
+ shd.setFill("E0E0E0");
+ }
+
+ // ==================== utils ====================
+
+ private List splitLines(String text) {
+ List out = new ArrayList<>();
+ int start = 0;
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (c == '\n') {
+ int end = i;
+ if (end > start && text.charAt(end - 1) == '\r') end--;
+ out.add(text.substring(start, end));
+ start = i + 1;
+ }
+ }
+ if (start <= text.length()) {
+ out.add(text.substring(start));
+ }
+ return out;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java
index 0b2d8b51..d6112d7d 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java
@@ -1,6 +1,9 @@
package vip.mate.tool.guard;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.lang.Nullable;
+import vip.mate.agent.context.ChatOrigin;
import vip.mate.tool.builtin.ToolExecutionContext;
import java.io.IOException;
@@ -33,9 +36,19 @@ public final class WorkspacePathGuard {
* @throws IllegalArgumentException 路径不在允许范围内
*/
public static Path validatePath(String rawPath) {
+ return validatePath(rawPath, null);
+ }
+
+ /**
+ * RFC-063r §2.5: ToolContext-aware overload. Reads the workspace base path
+ * from the explicit {@link ChatOrigin} when present; falls back to the
+ * legacy {@link ToolExecutionContext} ThreadLocal during the PR-1
+ * transition window.
+ */
+ public static Path validatePath(String rawPath, @Nullable ToolContext ctx) {
Path normalized = Paths.get(rawPath).toAbsolutePath().normalize();
- String basePath = ToolExecutionContext.workspaceBasePath();
+ String basePath = resolveBasePath(ctx);
if (basePath == null || basePath.isBlank()) {
return normalized; // 未配置活动目录,不限制
}
@@ -72,10 +85,35 @@ public final class WorkspacePathGuard {
* @return 活动目录 Path,未配置时返回 null
*/
public static Path getWorkingDirectory() {
- String basePath = ToolExecutionContext.workspaceBasePath();
+ return getWorkingDirectory(null);
+ }
+
+ /**
+ * RFC-063r §2.5: ToolContext-aware variant — prefer the explicit
+ * {@link ChatOrigin} workspaceBasePath when available.
+ */
+ public static Path getWorkingDirectory(@Nullable ToolContext ctx) {
+ String basePath = resolveBasePath(ctx);
if (basePath == null || basePath.isBlank()) {
return null;
}
return Paths.get(basePath).toAbsolutePath().normalize();
}
+
+ /**
+ * Resolve the active workspace base path. Order of preference:
+ *
+ * ChatOrigin from ToolContext (RFC-063r §2.5)
+ * Legacy {@link ToolExecutionContext} ThreadLocal (PR-1 transition)
+ *
+ */
+ private static String resolveBasePath(@Nullable ToolContext ctx) {
+ if (ctx != null) {
+ ChatOrigin origin = ChatOrigin.from(ctx);
+ if (origin.workspaceBasePath() != null && !origin.workspaceBasePath().isBlank()) {
+ return origin.workspaceBasePath();
+ }
+ }
+ return ToolExecutionContext.workspaceBasePath();
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java
index 68f3226b..38b5290d 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java
@@ -1,20 +1,25 @@
package vip.mate.tool.guard.guardian;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Component;
import vip.mate.tool.guard.model.*;
import java.util.List;
import java.util.Set;
/**
- * 文件写入守卫
- *
- * 标记写文件/编辑文件操作为 MEDIUM 风险。
- * 最终是否需要审批由 ToolPolicyResolver 决定。
+ * File-write guardian — historically marked every write_file / edit_file call
+ * as MEDIUM risk and forced an approval popup. Disabled (no @Component)
+ * because in-workspace writes are already path-bounded by
+ * {@code FilePathGuardian} + {@code WorkspacePathGuard.validatePath()}, and
+ * the per-call approval prompt drove operators to give up on multi-file
+ * workflows (a 22-chapter docx generation = 22 popups). The class is kept on
+ * disk for two reasons: (1) re-enabling guardian-level write approval is a
+ * one-line `@Component` change if a deployment really wants it, (2) it
+ * documents the historical behavior for anyone diffing why approval suddenly
+ * stopped firing on write_file. The mate_tool_guard_config row's
+ * guarded_tools_json was narrowed to {@code execute_shell_command} in V51.
*/
@Slf4j
-@Component
public class FileWriteGuardian implements ToolGuardGuardian {
private static final Set FILE_WRITE_TOOL_NAMES = Set.of(
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java
index 972fa661..debb6bf0 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java
@@ -33,6 +33,5 @@ public class ToolGuardAuditLogEntity {
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
- @TableLogic
private Integer deleted;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardRuleEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardRuleEntity.java
index 7dd2cefb..7678bbe2 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardRuleEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardRuleEntity.java
@@ -36,6 +36,5 @@ public class ToolGuardRuleEntity {
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
- @TableLogic
private Integer deleted;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/MiniMaxImageProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/MiniMaxImageProvider.java
index e3f61c96..371dc195 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/MiniMaxImageProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/MiniMaxImageProvider.java
@@ -20,9 +20,9 @@ import java.util.Set;
* MiniMax 图片生成 Provider — image-01 模型
*
* 同步模式:返回 Base64 图片。
- * 复用视频生成中的 MiniMax API Key。
+ * 复用视频生成中的 MiniMax API Key + region 设置({@code minimaxRegion})。
*
- * API: POST https://api.minimax.io/v1/image_generation
+ * API: POST {@code /v1/image_generation} —— host 由 region 决定。
*
* @author MateClaw Team
*/
@@ -33,9 +33,30 @@ public class MiniMaxImageProvider implements ImageGenerationProvider {
private final ObjectMapper objectMapper;
- private static final String BASE_URL = "https://api.minimax.io";
+ /** Global endpoint. */
+ static final String BASE_URL_GLOBAL = "https://api.minimax.io";
+
+ /** China endpoint (mainland-CN low-latency host; same JSON shape). */
+ static final String BASE_URL_CN = "https://api.minimaxi.com";
+
+ /** Region value selecting the CN endpoint. Anything else → global. */
+ static final String REGION_CN = "cn";
+
private static final String DEFAULT_MODEL = "image-01";
+ /**
+ * Resolve MiniMax base URL from system settings region. Shared semantics
+ * with {@code MiniMaxVideoProvider.resolveBaseUrl} (single field controls
+ * both image + video routing because the API key is the same).
+ * Package-private for unit tests.
+ */
+ static String resolveBaseUrl(SystemSettingsDTO config) {
+ if (config != null && REGION_CN.equalsIgnoreCase(config.getMinimaxRegion())) {
+ return BASE_URL_CN;
+ }
+ return BASE_URL_GLOBAL;
+ }
+
@Override
public String id() {
return "minimax";
@@ -96,7 +117,8 @@ public class MiniMaxImageProvider implements ImageGenerationProvider {
body.put("aspect_ratio", request.getAspectRatio());
}
- HttpResponse response = HttpRequest.post(BASE_URL + "/v1/image_generation")
+ String baseUrl = resolveBaseUrl(config);
+ HttpResponse response = HttpRequest.post(baseUrl + "/v1/image_generation")
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(body.toString())
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java
index 21e46bc9..151f53b1 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java
@@ -17,11 +17,17 @@ import java.util.List;
import java.util.Set;
/**
- * OpenAI 图片生成 Provider — 支持 DALL-E 3 / DALL-E 2 / gpt-image-1
+ * OpenAI 图片生成 Provider
+ * — 支持 DALL-E 3 / DALL-E 2 / gpt-image-1 / gpt-image-2 (low/medium/high)
*
- * 同步模式:直接返回图片 URL。
+ * 同步模式:返回图片 URL(DALL-E 系列)或 base64 data URL(gpt-image-2 系列)。
* 复用已有的 OpenAI LLM provider 的 API Key。
*
+ *
gpt-image-2 三档质量做成 3 个虚拟 model ID(参考 hermes-agent
+ * plugins/image_gen/openai/__init__.py 的 model catalog 设计),让 picker
+ * 能直接选 fast/balanced/high。三档底层都打到 API model {@code "gpt-image-2"},
+ * 区别仅在 {@code quality} 参数。
+ *
* @author MateClaw Team
*/
@Slf4j
@@ -34,6 +40,20 @@ public class OpenAiImageProvider implements ImageGenerationProvider {
private static final String DEFAULT_MODEL = "dall-e-3";
+ /**
+ * gpt-image-2 真实 API model 名。三档虚拟 ID(gpt-image-2-low/medium/high)
+ * 在 submit 时全部打到这个 API model + 不同 quality 参数。
+ */
+ private static final String GPT_IMAGE_2_API_MODEL = "gpt-image-2";
+
+ /** gpt-image-2 系列虚拟 ID 列表 — 用于 capabilities 与分支判定。 */
+ private static final List GPT_IMAGE_2_TIERS =
+ List.of("gpt-image-2-low", "gpt-image-2-medium", "gpt-image-2-high");
+
+ /** gpt-image-2 支持的尺寸(与 DALL-E 不同!1536x1024 / 1024x1024 / 1024x1536)。 */
+ private static final List GPT_IMAGE_2_SIZES =
+ List.of("1024x1024", "1536x1024", "1024x1536");
+
@Override
public String id() {
return "openai";
@@ -61,13 +81,28 @@ public class OpenAiImageProvider implements ImageGenerationProvider {
@Override
public ImageProviderCapabilities detailedCapabilities() {
+ // 合并 DALL-E 与 gpt-image-2 两套尺寸(去重)。运行时按选定 model
+ // 做尺寸校验,picker 只展示并集即可。
+ List allSizes = new ArrayList<>();
+ allSizes.add("1024x1024");
+ allSizes.add("1024x1792"); // dall-e
+ allSizes.add("1792x1024"); // dall-e
+ allSizes.add("1024x1536"); // gpt-image-2
+ allSizes.add("1536x1024"); // gpt-image-2
+
+ List models = new ArrayList<>();
+ models.add("dall-e-3");
+ models.add("dall-e-2");
+ models.add("gpt-image-1");
+ models.addAll(GPT_IMAGE_2_TIERS); // gpt-image-2-low/medium/high
+
return ImageProviderCapabilities.builder()
.modes(capabilities())
- .supportedSizes(List.of("1024x1024", "1024x1792", "1792x1024"))
+ .supportedSizes(allSizes)
.aspectRatios(List.of("1:1", "9:16", "16:9"))
- .maxCount(1) // DALL-E 3 只支持 n=1
+ .maxCount(1) // DALL-E 3 / gpt-image-2 都只支持 n=1
.defaultModel(DEFAULT_MODEL)
- .models(List.of("dall-e-3", "dall-e-2", "gpt-image-1"))
+ .models(models)
.build();
}
@@ -89,15 +124,26 @@ public class OpenAiImageProvider implements ImageGenerationProvider {
}
try {
- String model = request.getModel() != null && !request.getModel().isBlank()
+ String requestedModel = request.getModel() != null && !request.getModel().isBlank()
? request.getModel() : DEFAULT_MODEL;
+ // gpt-image-2 系列:三档虚拟 ID 全部打到 API model "gpt-image-2"
+ // + 对应 quality 参数;DALL-E 系列保留原行为。
+ boolean isGptImage2 = GPT_IMAGE_2_TIERS.contains(requestedModel);
ObjectNode body = objectMapper.createObjectNode();
- body.put("model", model);
+ body.put("model", isGptImage2 ? GPT_IMAGE_2_API_MODEL : requestedModel);
body.put("prompt", request.getPrompt());
- body.put("size", normalizeSize(request.getSize(), request.getAspectRatio()));
+ body.put("size", normalizeSize(request.getSize(), request.getAspectRatio(), isGptImage2));
body.put("n", 1);
- body.put("response_format", "url");
+
+ if (isGptImage2) {
+ // gpt-image-2 强制 b64_json,且 REJECT 任何 response_format 字段
+ // (API 会以 unknown parameter 报错)。仅传 quality。
+ body.put("quality", qualityForTier(requestedModel));
+ } else {
+ // DALL-E 系列保留 URL 模式。
+ body.put("response_format", "url");
+ }
String url = (baseUrl != null ? baseUrl : "https://api.openai.com") + "/v1/images/generations";
@@ -105,7 +151,8 @@ public class OpenAiImageProvider implements ImageGenerationProvider {
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(body.toString())
- .timeout(60_000)
+ // gpt-image-2 high 档官方文档约 ~2min;这里给到 180s 留余地
+ .timeout(isGptImage2 ? 180_000 : 60_000)
.execute();
JsonNode result = objectMapper.readTree(response.body());
@@ -113,15 +160,28 @@ public class OpenAiImageProvider implements ImageGenerationProvider {
if (response.getStatus() == 200 && result.has("data")) {
List imageUrls = new ArrayList<>();
for (JsonNode item : result.get("data")) {
- String imageUrl = item.has("url") ? item.get("url").asText() : null;
- if (imageUrl != null) {
- imageUrls.add(imageUrl);
+ if (isGptImage2) {
+ // gpt-image-2 永远返回 b64_json。包成 data URL,交给前端
+ // 直接 渲染,沿用
+ // GoogleImagenProvider / MiniMaxImageProvider 的现成模式。
+ String b64 = item.has("b64_json") ? item.get("b64_json").asText() : null;
+ if (b64 != null && !b64.isBlank()) {
+ imageUrls.add("data:image/png;base64," + b64);
+ }
+ } else {
+ String imageUrl = item.has("url") ? item.get("url").asText() : null;
+ if (imageUrl != null) {
+ imageUrls.add(imageUrl);
+ }
}
}
if (imageUrls.isEmpty()) {
- return ImageSubmitResult.failure(id(), "API 返回成功但未包含图片 URL");
+ return ImageSubmitResult.failure(id(),
+ isGptImage2 ? "API 返回成功但未包含 b64_json 图片数据"
+ : "API 返回成功但未包含图片 URL");
}
- log.info("[OpenAI Image] Generated {} image(s) (model={})", imageUrls.size(), model);
+ log.info("[OpenAI Image] Generated {} image(s) (model={})",
+ imageUrls.size(), requestedModel);
return ImageSubmitResult.syncSuccess(id(), imageUrls);
} else {
String errMsg = result.has("error")
@@ -136,6 +196,24 @@ public class OpenAiImageProvider implements ImageGenerationProvider {
}
}
+ /** Map gpt-image-2-{low|medium|high} → quality string sent to API.
+ * Package-private + static for unit testability. */
+ static String qualityForTier(String tierModelId) {
+ return switch (tierModelId) {
+ case "gpt-image-2-low" -> "low";
+ case "gpt-image-2-high" -> "high";
+ default -> "medium"; // gpt-image-2-medium + 任何未来 tier 都默认 medium
+ };
+ }
+
+ /** Returns true if the given model id is a gpt-image-2 virtual tier.
+ * Package-private + static for unit testability.
+ * Null-safe: {@code List.of(...).contains(null)} throws NPE, which we
+ * pre-empt with an explicit null check. */
+ static boolean isGptImage2Tier(String modelId) {
+ return modelId != null && GPT_IMAGE_2_TIERS.contains(modelId);
+ }
+
private String getOpenAiApiKey() {
try {
var providerEntity = modelProviderService.getProviderConfig("openai");
@@ -154,14 +232,34 @@ public class OpenAiImageProvider implements ImageGenerationProvider {
}
}
- private String normalizeSize(String size, String aspectRatio) {
- // 优先使用 size
- if (size != null && !size.isBlank()) {
- List supported = List.of("1024x1024", "1024x1792", "1792x1024");
- if (supported.contains(size)) return size;
+ /**
+ * 按 model 选合适的尺寸集合:
+ *
+ * DALL-E:1024x1024 / 1024x1792 / 1792x1024
+ * gpt-image-2:1024x1024 / 1024x1536 / 1536x1024(不一样!)
+ *
+ */
+ /** Package-private + static-ish for unit testability. Kept instance-method to
+ * stay close to the call site — no instance state is touched. */
+ String normalizeSize(String size, String aspectRatio, boolean isGptImage2) {
+ List supported = isGptImage2
+ ? GPT_IMAGE_2_SIZES
+ : List.of("1024x1024", "1024x1792", "1792x1024");
+
+ // 优先使用 size(如果在该 model 支持范围内)
+ if (size != null && !size.isBlank() && supported.contains(size)) {
+ return size;
}
- // 根据 aspectRatio 推断
+
+ // 根据 aspectRatio 推断(gpt-image-2 与 DALL-E 的竖图/横图尺寸不一样)
if (aspectRatio != null) {
+ if (isGptImage2) {
+ return switch (aspectRatio) {
+ case "9:16", "2:3", "3:4" -> "1024x1536";
+ case "16:9", "3:2", "4:3" -> "1536x1024";
+ default -> "1024x1024";
+ };
+ }
return switch (aspectRatio) {
case "9:16" -> "1024x1792";
case "16:9" -> "1792x1024";
@@ -170,4 +268,10 @@ public class OpenAiImageProvider implements ImageGenerationProvider {
}
return "1024x1024";
}
+
+ // 保留旧签名给可能存在的其它 caller(向后兼容)。新增 boolean 默认 false (DALL-E)。
+ @SuppressWarnings("unused")
+ private String normalizeSize(String size, String aspectRatio) {
+ return normalizeSize(size, aspectRatio, false);
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java
index 9edce83d..1e0bd228 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java
@@ -77,6 +77,5 @@ public class McpServerEntity {
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
- @TableLogic
private Integer deleted;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpReturnDirectProperties.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpReturnDirectProperties.java
new file mode 100644
index 00000000..6f83f4b9
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpReturnDirectProperties.java
@@ -0,0 +1,52 @@
+package vip.mate.tool.mcp.runtime;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * RFC-052 §3.4 / PR-4: MCP tool return-direct opt-in list.
+ *
+ * Tools listed here are wrapped in {@link ReturnDirectMcpToolCallback} so
+ * their results bypass the LLM context (see {@code ToolExecutionExecutor} and
+ * {@code ObservationDispatcher} for the routing).
+ *
+ *
Configuration ({@code application.yml}):
+ *
+ * mateclaw:
+ * mcp:
+ * return-direct:
+ * tools:
+ * - query_employee_salary
+ * - read_medical_record
+ *
+ *
+ * Match is by tool name only (matching the upstream {@code ToolDefinition.name()}).
+ * Per-server scoping is intentionally out of scope for the first iteration; if
+ * the same tool name comes from two servers and only one should be direct, give
+ * one of them a name prefix at the MCP server config layer.
+ *
+ * @author MateClaw Team
+ */
+@Component
+@ConfigurationProperties(prefix = "mateclaw.mcp.return-direct")
+public class McpReturnDirectProperties {
+
+ /** Tool names that should be treated as returnDirect. */
+ private Set tools = Collections.emptySet();
+
+ public Set getTools() {
+ return tools;
+ }
+
+ public void setTools(Set tools) {
+ this.tools = tools != null ? new LinkedHashSet<>(tools) : Collections.emptySet();
+ }
+
+ public boolean isReturnDirect(String toolName) {
+ return toolName != null && tools.contains(toolName);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java
index 3a00897b..4a8496ea 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java
@@ -6,6 +6,9 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.stereotype.Component;
+import java.util.ArrayList;
+import java.util.List;
+
/**
* MCP 工具回调提供者
*
@@ -15,6 +18,9 @@ import org.springframework.stereotype.Component;
* 每次调用 getToolCallbacks() 都会从 McpClientManager 获取最新的 active tools,
* 因此新增/删除 MCP server 后无需重启即可生效。
*
+ *
RFC-052: tools listed in {@link McpReturnDirectProperties} are wrapped in
+ * {@link ReturnDirectMcpToolCallback} so their results bypass the LLM context.
+ *
* @author MateClaw Team
*/
@Slf4j
@@ -23,6 +29,7 @@ import org.springframework.stereotype.Component;
public class McpToolCallbackProvider implements ToolCallbackProvider {
private final McpClientManager mcpClientManager;
+ private final McpReturnDirectProperties returnDirectProperties;
@Override
public ToolCallback[] getToolCallbacks() {
@@ -32,7 +39,21 @@ public class McpToolCallbackProvider implements ToolCallbackProvider {
log.debug("McpToolCallbackProvider providing {} tools from {} active MCP servers",
callbacks.size(), mcpClientManager.getActiveCount());
}
- return callbacks.toArray(new ToolCallback[0]);
+
+ // RFC-052: opt-in returnDirect wrapping. The decorator only changes
+ // ToolMetadata.returnDirect(); guard/approval/observability still
+ // see the original callback through the wrapper.
+ List wrapped = new ArrayList<>(callbacks.size());
+ for (ToolCallback cb : callbacks) {
+ String name = cb.getToolDefinition() != null ? cb.getToolDefinition().name() : null;
+ if (returnDirectProperties.isReturnDirect(name)) {
+ log.info("[McpToolCallbackProvider] wrapping MCP tool '{}' as returnDirect (RFC-052)", name);
+ wrapped.add(new ReturnDirectMcpToolCallback(cb));
+ } else {
+ wrapped.add(cb);
+ }
+ }
+ return wrapped.toArray(new ToolCallback[0]);
} catch (Exception e) {
log.warn("Failed to collect MCP tool callbacks: {}", e.getMessage());
return new ToolCallback[0];
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallback.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallback.java
new file mode 100644
index 00000000..e321aa3f
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallback.java
@@ -0,0 +1,62 @@
+package vip.mate.tool.mcp.runtime;
+
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.ai.tool.metadata.ToolMetadata;
+
+/**
+ * RFC-052 §3.4: thin decorator that overrides {@link ToolCallback#getToolMetadata()}
+ * to report {@code returnDirect=true} for MCP tools.
+ *
+ * Spring AI 1.1.4's {@code SyncMcpToolCallback} / {@code AsyncMcpToolCallback}
+ * never override {@code getToolMetadata()} (they inherit the framework default
+ * which yields {@code returnDirect=false}), and the upstream MCP protocol layer
+ * has no equivalent field. So MateClaw must wrap MCP callbacks at registration
+ * time when their server+tool config opts in via
+ * {@code mateclaw.mcp.return-direct.tools}.
+ *
+ *
Everything else (definition, invocation, exceptions) is delegated verbatim
+ * — guard, approval, observability, audit all see the original callback.
+ *
+ * @author MateClaw Team
+ */
+public final class ReturnDirectMcpToolCallback implements ToolCallback {
+
+ private static final ToolMetadata RETURN_DIRECT_METADATA =
+ ToolMetadata.builder().returnDirect(true).build();
+
+ private final ToolCallback delegate;
+
+ public ReturnDirectMcpToolCallback(ToolCallback delegate) {
+ if (delegate == null) {
+ throw new IllegalArgumentException("delegate must not be null");
+ }
+ this.delegate = delegate;
+ }
+
+ @Override
+ public ToolDefinition getToolDefinition() {
+ return delegate.getToolDefinition();
+ }
+
+ @Override
+ public ToolMetadata getToolMetadata() {
+ return RETURN_DIRECT_METADATA;
+ }
+
+ @Override
+ public String call(String arguments) {
+ return delegate.call(arguments);
+ }
+
+ @Override
+ public String call(String arguments, ToolContext toolContext) {
+ return delegate.call(arguments, toolContext);
+ }
+
+ /** Test/diagnostic accessor — not part of the framework contract. */
+ public ToolCallback getDelegate() {
+ return delegate;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java
index f2705723..27f08e5c 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java
@@ -55,6 +55,5 @@ public class ToolEntity {
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
- @TableLogic
private Integer deleted;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerateTool.java b/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerateTool.java
index a02e48ef..e0e38498 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerateTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerateTool.java
@@ -2,8 +2,10 @@ package vip.mate.tool.music;
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.tool.builtin.ToolExecutionContext;
@@ -23,9 +25,11 @@ public class MusicGenerateTool {
public String music_generate(
@ToolParam(description = "音乐风格/场景描述,如:'轻快的钢琴爵士乐'、'史诗电影配乐'、'欢快的流行歌曲'") String prompt,
@ToolParam(description = "歌词文本(可选,不填则由 AI 生成或生成纯音乐)") String lyrics,
- @ToolParam(description = "是否生成纯音乐(无人声),默认 false") Boolean instrumental) {
+ @ToolParam(description = "是否生成纯音乐(无人声),默认 false") Boolean instrumental,
+ // RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator.
+ @Nullable ToolContext ctx) {
- String conversationId = ToolExecutionContext.conversationId();
+ String conversationId = ToolExecutionContext.conversationId(ctx);
if (conversationId == null) {
return "无法获取会话 ID";
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java
index ec3c73c1..aa05211e 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java
@@ -81,24 +81,42 @@ public class SearXNGSearchProvider implements SearchProvider {
urlBuilder.append("&time_range=").append(searchQuery.freshness().toLowerCase());
}
- String response = HttpUtil.createGet(urlBuilder.toString())
+ var resp = HttpUtil.createGet(urlBuilder.toString())
.header("Accept", "application/json")
.timeout(15000)
- .execute()
- .body();
+ .execute();
+ int status = resp.getStatus();
+ String response = resp.body();
+ String contentType = resp.header("Content-Type");
- log.debug("SearXNG result for '{}': length={}", searchQuery.query(), response != null ? response.length() : 0);
- return parseResponse(response, searchQuery.resolvedCount());
+ log.debug("SearXNG response for '{}': status={}, contentType={}, length={}",
+ searchQuery.query(), status, contentType, response != null ? response.length() : 0);
+ return parseResponse(response, status, contentType, searchQuery.resolvedCount(), urlBuilder.toString());
}
- private List parseResponse(String response, int limit) {
+ private List parseResponse(String response, int status, String contentType,
+ int limit, String requestUrl) {
List results = new ArrayList<>();
- if (response == null || response.isBlank()) return results;
+ if (response == null || response.isBlank()) {
+ log.warn("SearXNG returned empty body (status={}, url={})", status, requestUrl);
+ return results;
+ }
+ if (status >= 400) {
+ log.warn("SearXNG returned HTTP {} — preview: {}", status, preview(response));
+ return results;
+ }
+ if (contentType != null && !contentType.contains("json")) {
+ // Most common cause: settings.yml has no `json` under search.formats
+ // or the Limiter plugin rewrote the response to HTML.
+ log.warn("SearXNG did not return JSON (contentType={}). Check settings.yml has search.formats including 'json' and server.limiter: false. Preview: {}",
+ contentType, preview(response));
+ return results;
+ }
try {
JSONObject json = JSONUtil.parseObj(response);
JSONArray items = json.getJSONArray("results");
- if (items == null) return results;
+ if (items == null || items.isEmpty()) return results;
limit = Math.min(items.size(), limit);
for (int i = 0; i < limit; i++) {
@@ -114,11 +132,17 @@ public class SearXNGSearchProvider implements SearchProvider {
.build());
}
} catch (Exception e) {
- log.warn("SearXNG 结果解析失败: {}", e.getMessage());
+ log.warn("SearXNG parse failed: {} — preview: {}", e.getMessage(), preview(response));
}
return results;
}
+ private static String preview(String body) {
+ if (body == null) return "";
+ String flat = body.replaceAll("\\s+", " ").trim();
+ return flat.length() > 200 ? flat.substring(0, 200) + "..." : flat;
+ }
+
private String extractDomain(String url) {
try {
return URI.create(url).getHost();
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/video/provider/MiniMaxVideoProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/video/provider/MiniMaxVideoProvider.java
index e05195f7..2d57e7ff 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/video/provider/MiniMaxVideoProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/video/provider/MiniMaxVideoProvider.java
@@ -21,6 +21,13 @@ import java.util.Set;
*
* API 文档: https://platform.minimaxi.com/document/video-generation
* 鉴权: Bearer Token
+ *
+ * Region 切换:根据 {@link SystemSettingsDTO#getMinimaxRegion()} 选 host:
+ *
+ * {@code "global"} (默认) → {@code https://api.minimax.io}
+ * {@code "cn"} → {@code https://api.minimaxi.com} (mainland-CN
+ * lower-latency endpoint; required for accounts registered in CN).
+ *
*
* @author MateClaw Team
*/
@@ -31,9 +38,44 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider {
private final ObjectMapper objectMapper;
- private static final String BASE_URL = "https://api.minimax.io";
+ /** Global endpoint (used by accounts on api.minimax.io). */
+ static final String BASE_URL_GLOBAL = "https://api.minimax.io";
+
+ /** China endpoint (api.minimaxi.com — same JSON shape, different host). */
+ static final String BASE_URL_CN = "https://api.minimaxi.com";
+
+ /** Region value selecting the CN endpoint. Anything else → global. */
+ static final String REGION_CN = "cn";
+
private static final String DEFAULT_MODEL = "MiniMax-Hailuo-2.3";
+ /**
+ * Full MiniMax video model catalog (matches openclaw
+ * {@code extensions/minimax/provider-models.ts}). Includes both T2V
+ * (Hailuo family) and I2V (I2V-01-* family) entries.
+ */
+ private static final List MODEL_CATALOG = List.of(
+ // T2V (text-to-video)
+ "MiniMax-Hailuo-2.3",
+ "MiniMax-Hailuo-2.3-Fast",
+ "MiniMax-Hailuo-02",
+ // I2V (image-to-video)
+ "I2V-01-Director",
+ "I2V-01-live",
+ "I2V-01"
+ );
+
+ /**
+ * Resolve the MiniMax base URL from the system settings region. Package-private
+ * for unit tests — the only branching point that needs verification.
+ */
+ static String resolveBaseUrl(SystemSettingsDTO config) {
+ if (config != null && REGION_CN.equalsIgnoreCase(config.getMinimaxRegion())) {
+ return BASE_URL_CN;
+ }
+ return BASE_URL_GLOBAL;
+ }
+
@Override
public String id() {
return "minimax";
@@ -67,7 +109,7 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider {
.supportedDurations(List.of(6, 10))
.maxDurationSeconds(10)
.defaultModel(DEFAULT_MODEL)
- .models(List.of("MiniMax-Hailuo-2.3", "MiniMax-Hailuo-2.3-Fast", "I2V-01-live"))
+ .models(MODEL_CATALOG)
.build();
}
@@ -80,6 +122,7 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider {
public VideoSubmitResult submit(VideoGenerationRequest request, SystemSettingsDTO config) {
try {
String apiKey = config.getMinimaxApiKey();
+ String baseUrl = resolveBaseUrl(config);
String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL;
ObjectNode body = objectMapper.createObjectNode();
@@ -93,7 +136,7 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider {
body.put("duration", request.getDurationSeconds());
}
- HttpResponse response = HttpRequest.post(BASE_URL + "/v1/video_generation")
+ HttpResponse response = HttpRequest.post(baseUrl + "/v1/video_generation")
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(body.toString())
@@ -106,11 +149,11 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider {
int statusCode = result.path("base_resp").path("status_code").asInt(-1);
if (statusCode == 0 && result.has("task_id")) {
String taskId = result.get("task_id").asText();
- log.info("[MiniMax] Submitted task: {} (model={})", taskId, model);
+ log.info("[MiniMax] Submitted task: {} (model={}, host={})", taskId, model, baseUrl);
return VideoSubmitResult.success(taskId, id());
} else {
String errMsg = result.path("base_resp").path("status_msg").asText("未知错误");
- log.warn("[MiniMax] Submit failed: {}", errMsg);
+ log.warn("[MiniMax] Submit failed (host={}): {}", baseUrl, errMsg);
return VideoSubmitResult.failure(id(), errMsg);
}
} catch (Exception e) {
@@ -123,9 +166,10 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider {
public TaskPollResult checkStatus(String providerTaskId, SystemSettingsDTO config) {
try {
String apiKey = config.getMinimaxApiKey();
+ String baseUrl = resolveBaseUrl(config);
HttpResponse response = HttpRequest.get(
- BASE_URL + "/v1/query/video_generation?task_id=" + providerTaskId)
+ baseUrl + "/v1/query/video_generation?task_id=" + providerTaskId)
.header("Authorization", "Bearer " + apiKey)
.timeout(15_000)
.execute();
@@ -138,7 +182,7 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider {
// 优先取 video_url,备选 file_id
String videoUrl = result.has("video_url") ? result.get("video_url").asText(null) : null;
if (videoUrl == null && result.has("file_id")) {
- videoUrl = resolveFileUrl(result.get("file_id").asText(), apiKey);
+ videoUrl = resolveFileUrl(result.get("file_id").asText(), apiKey, baseUrl);
}
yield TaskPollResult.succeeded(videoUrl, null, result.toString());
}
@@ -156,12 +200,13 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider {
}
/**
- * 通过 file_id 获取视频下载 URL
+ * 通过 file_id 获取视频下载 URL。Region must match the host that produced
+ * the file_id — otherwise the cross-host lookup 404s.
*/
- private String resolveFileUrl(String fileId, String apiKey) {
+ private String resolveFileUrl(String fileId, String apiKey, String baseUrl) {
try {
HttpResponse response = HttpRequest.get(
- BASE_URL + "/v1/files/retrieve?file_id=" + fileId)
+ baseUrl + "/v1/files/retrieve?file_id=" + fileId)
.header("Authorization", "Bearer " + apiKey)
.timeout(10_000)
.execute();
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiAutoConfiguration.java
index 1954237e..a01510db 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/WikiAutoConfiguration.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiAutoConfiguration.java
@@ -1,14 +1,39 @@
package vip.mate.wiki;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
+import org.springframework.context.event.EventListener;
+import vip.mate.wiki.job.WikiProcessingJobService;
+import vip.mate.wiki.service.WikiRawMaterialService;
/**
- * Wiki 知识库模块自动配置
+ * Wiki module auto-configuration
*
* @author MateClaw Team
*/
+@Slf4j
@Configuration
@EnableConfigurationProperties(WikiProperties.class)
+@RequiredArgsConstructor
public class WikiAutoConfiguration {
+
+ private final WikiProcessingJobService wikiProcessingJobService;
+ private final WikiRawMaterialService wikiRawMaterialService;
+
+ /**
+ * Recover stuck wiki state on startup:
+ * 1. Job table: routing/*_running → queued (RFC-030)
+ * 2. Raw material table: processing → pending (avoids forever-spinning progress bars)
+ */
+ @EventListener(ApplicationReadyEvent.class)
+ public void recoverWikiJobs(ApplicationReadyEvent event) {
+ wikiProcessingJobService.recoverOnStartup();
+ int recovered = wikiRawMaterialService.recoverStuckRawMaterialsOnStartup();
+ if (recovered > 0) {
+ log.info("[Wiki] Recovered {} stuck raw materials on startup", recovered);
+ }
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java
index 47f194ce..8a3df9f1 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java
@@ -33,12 +33,15 @@ public class WikiProperties {
private int maxParallelRawMaterials = 3;
/**
- * 单个材料内 chunk 的并行处理数上限。
+ * Max parallel chunks within a single raw material.
*
- * RFC-012 Change 1:从硬编码 3 提到 5,并暴露为配置项。默认总并发为
- * maxParallelRawMaterials × maxParallelChunks = 15,仍在常见 60 RPM 限额下。
+ * RFC-047 P3: Changed from 5 to 1. Parallel chunks all share the same existingPagesIndex
+ * snapshot, so chunk N cannot see pages created by chunk N-1 — causing duplicate pages and
+ * stale-index collisions. Serializing chunks eliminates this class of bug. Document-level
+ * parallelism (maxParallelRawMaterials) is preserved, so overall throughput is unchanged
+ * for multi-document batches.
*/
- private int maxParallelChunks = 5;
+ private int maxParallelChunks = 1;
/**
* 单个 chunk 内 phase B 阶段的 page 并行处理数上限。
@@ -84,6 +87,25 @@ public class WikiProperties {
*/
private long llmMaxTotalDurationMs = 240_000;
+ /**
+ * RFC-047 P1: Max pages per BatchCreate LLM call.
+ * Pages planned by route are chunked into sub-batches of this size;
+ * a local liveIndex is updated between sub-batches so later pages can
+ * link to earlier ones created in the same chunk.
+ * Default 2: keeps total output tokens well under typical provider caps
+ * (~2k–3k completion tokens) so the FILE-block JSON doesn't get truncated
+ * mid-object. Raising this risks unparseable JSON skips on long content.
+ */
+ private int batchCreatePageSize = 2;
+
+ /**
+ * RFC-047: Minimum chunk length (chars) for the chunk-fallback mechanism.
+ * If route returns 0 create+update entries and the chunk exceeds this threshold,
+ * an overview page is auto-injected so no substantial content is silently dropped.
+ * Chunks shorter than this (e.g. TOC lines, blank pages) are allowed to produce nothing.
+ */
+ private int chunkFallbackMinChars = 200;
+
/**
* 是否启用两阶段消化(路由 → 逐页 merge)。
*
@@ -110,4 +132,95 @@ public class WikiProperties {
/** 混合搜索默认模式:keyword / semantic / hybrid */
private String searchDefaultMode = "hybrid";
+
+ // ==================== RFC-031: Light processing tiers ====================
+
+ /** Whether to auto-dispatch a LIGHT_ENRICH job after heavy ingest completes */
+ private boolean lightEnrichEnabled = true;
+
+ /** Delay before light enrichment starts (ms) */
+ private long lightEnrichDelayMs = 2000;
+
+ /**
+ * Minimum ratio of enriched content length to original content length.
+ * If the LLM returns text shorter than this ratio, the enrichment is rejected.
+ */
+ private double wikilinkMinContentRatio = 0.5;
+
+ /** Maximum characters for local repair single-page regeneration */
+ private int localRepairMaxChars = 8000;
+
+ /**
+ * Whether to run a document-level analysis pass before routing.
+ * When enabled, a single LLM call produces a concept map (topics + key_concepts)
+ * that is injected into every chunk's route prompt, giving the router global
+ * awareness of the document structure and reducing concept omissions.
+ * Adds ~1 LLM call and 10-20s per raw material.
+ */
+ private boolean useDocumentAnalysis = true;
+
+ /**
+ * Max characters of document text fed to the analysis pass.
+ * Larger values improve coverage but increase prompt tokens.
+ * Default 15000 covers most documents while staying well within model limits.
+ */
+ private int documentAnalysisSampleChars = 15000;
+
+ /**
+ * RFC-051 PR-6b: route-phase output binding. When {@code true}, the route
+ * LLM call uses a Spring AI {@code BeanOutputConverter} —
+ * the format hint is injected into the user prompt and the response is
+ * parsed strictly into the DTO. Failures fall back to the legacy lenient
+ * JSON parser so a flaky model never blocks ingest.
+ *
+ * Default {@code false} keeps existing behavior on first upgrade. Flip on
+ * once you've validated the route prompt against the models you actually
+ * run (DashScope / OpenAI / Anthropic / DeepSeek tend to be fine; Ollama
+ * and weaker models may need the fallback).
+ */
+ private boolean useStructuredRoute = false;
+
+ /**
+ * RFC-051 follow-up: how many pages the enrich service packs into a single
+ * LLM call. {@code 1} (default) reproduces the legacy behavior of one
+ * call per page. {@code 5}–{@code 10} is reasonable for most chat models;
+ * weaker locally-served models may need to stay at 1.
+ *
+ * Larger batches reduce LLM cost roughly proportional to the batch size,
+ * but each batch's prompt grows linearly with the included page bodies,
+ * so very long pages still benefit from single-page mode. Pages exceeding
+ * {@link #enrichBatchPerPageMaxChars} are excluded from the batch and
+ * enriched individually.
+ */
+ private int enrichBatchSize = 1;
+
+ /**
+ * RFC-051 follow-up: per-page content cap when packing pages into an
+ * enrich batch. Pages whose body exceeds this size fall through to a
+ * single-page enrich call so the batch prompt stays bounded. The cap
+ * applies only to the prompt; the applier always sees full content.
+ */
+ private int enrichBatchPerPageMaxChars = 3000;
+
+ /**
+ * RFC-051 §9.4: replace the legacy flat 0.15 relation boost with a
+ * normalized score per query, scaled by {@link #relationBoostLambda}.
+ * Default {@code false} keeps the legacy ranking; flip on after
+ * validating against your retrieval test set.
+ *
+ * Why it matters: the flat 0.15 was bigger than typical RRF scores
+ * (~0.02–0.05), so boosted neighbors routinely leapfrogged real RRF
+ * hits. Normalization keeps boost on the same scale as fused scores.
+ */
+ private boolean useNormalizedRelationBoost = false;
+
+ /**
+ * RFC-051 §9.4: maximum boost contribution from the relation pass when
+ * {@link #useNormalizedRelationBoost} is on. Each boosted candidate
+ * gets {@code (rawRelationScore / maxRawRelationScore) * lambda} added
+ * to its fused score. Default {@code 0.05} is roughly the size of a
+ * top-3 RRF score, so a max-relation neighbor competes evenly with a
+ * top-3 RRF hit but doesn't dominate it.
+ */
+ private double relationBoostLambda = 0.05;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java
new file mode 100644
index 00000000..3ff9cde4
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java
@@ -0,0 +1,81 @@
+package vip.mate.wiki.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import vip.mate.wiki.job.WikiChunkTokenBackfillJob;
+import vip.mate.wiki.service.WikiOverviewService;
+import vip.mate.wiki.service.WikiScaffoldService;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * RFC-051 follow-up: small set of operator-facing endpoints for things the
+ * scheduled jobs / event hooks normally handle automatically. Useful when the
+ * cron hasn't fired yet (fresh upgrade), the auto-rebuild was skipped, or you
+ * just want to force-refresh during debugging.
+ *
+ *
All endpoints are idempotent and synchronous.
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/wiki/admin")
+@RequiredArgsConstructor
+@Tag(name = "Wiki Admin", description = "Operator endpoints for system pages and backfill jobs")
+public class WikiAdminController {
+
+ private final WikiScaffoldService scaffoldService;
+
+ /** Optional so the controller can boot in environments where the rebuilder isn't wired (e.g. minimal tests). */
+ @Autowired(required = false)
+ private WikiOverviewService overviewService;
+
+ @Autowired(required = false)
+ private WikiChunkTokenBackfillJob backfillJob;
+
+ @Operation(summary = "Ensure overview/log scaffold + rebuild overview stats now",
+ description = "Idempotent. Use after manual data imports or when stats look stale.")
+ @PostMapping("/kb/{kbId}/rebuild-overview")
+ public ResponseEntity> rebuildOverview(@PathVariable Long kbId) {
+ Map body = new HashMap<>();
+ scaffoldService.ensureScaffold(kbId);
+ if (overviewService != null) {
+ overviewService.rebuild(kbId);
+ body.put("rebuilt", true);
+ } else {
+ body.put("rebuilt", false);
+ body.put("note", "Overview service not wired; only scaffold ensured");
+ }
+ body.put("kbId", kbId);
+ return ResponseEntity.ok(body);
+ }
+
+ @Operation(summary = "Force-run the token-count backfill batch now",
+ description = "Picks up to BATCH_SIZE chunks with token_count IS NULL and fills them. "
+ + "Returns the pending count after the batch so callers can poll.")
+ @PostMapping("/backfill-tokens")
+ public ResponseEntity> backfillTokens() {
+ Map body = new HashMap<>();
+ if (backfillJob == null) {
+ body.put("ok", false);
+ body.put("note", "Backfill job not wired");
+ return ResponseEntity.ok(body);
+ }
+ long beforePending = backfillJob.pendingCount();
+ backfillJob.runOnce();
+ long afterPending = backfillJob.pendingCount();
+ body.put("ok", true);
+ body.put("pendingBefore", beforePending);
+ body.put("pendingAfter", afterPending);
+ body.put("filledThisBatch", Math.max(0, beforePending - afterPending));
+ return ResponseEntity.ok(body);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java
index fff1216c..9b6a6dd3 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java
@@ -9,6 +9,7 @@ import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import vip.mate.channel.web.Utf8SseEmitter;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
@@ -186,12 +187,33 @@ public class WikiController {
// ==================== Raw Materials ====================
@RequireWorkspaceRole("viewer")
- @Operation(summary = "获取原始材料列表")
+ @Operation(summary = "获取原始材料列表(含每条材料生成的页面数)")
@GetMapping("/knowledge-bases/{kbId}/raw")
- public R> listRaw(@PathVariable Long kbId,
- @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ public R>> listRaw(@PathVariable Long kbId,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
- return R.ok(rawService.listByKbId(kbId));
+ List raws = rawService.listByKbId(kbId);
+ List> result = new java.util.ArrayList<>(raws.size());
+ for (WikiRawMaterialEntity raw : raws) {
+ Map item = new LinkedHashMap<>();
+ // Serialize all entity fields via Jackson-friendly approach
+ item.put("id", raw.getId());
+ item.put("kbId", raw.getKbId());
+ item.put("title", raw.getTitle());
+ item.put("sourceType", raw.getSourceType());
+ item.put("processingStatus", raw.getProcessingStatus());
+ item.put("errorMessage", raw.getErrorMessage());
+ item.put("progressPhase", raw.getProgressPhase());
+ item.put("progressDone", raw.getProgressDone());
+ item.put("progressTotal", raw.getProgressTotal());
+ item.put("contentHash", raw.getContentHash());
+ item.put("createTime", raw.getCreateTime());
+ item.put("updateTime", raw.getUpdateTime());
+ // Enriched field: page count derived from this raw material
+ item.put("pageCount", pageService.countBySourceRawId(kbId, raw.getId()));
+ result.add(item);
+ }
+ return R.ok(result);
}
@RequireWorkspaceRole("member")
@@ -274,14 +296,90 @@ public class WikiController {
return R.ok();
}
+ @RequireWorkspaceRole("viewer")
+ @Operation(summary = "下载原始材料")
+ @GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")
+ public org.springframework.http.ResponseEntity downloadRaw(
+ @PathVariable Long kbId,
+ @PathVariable Long rawId,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) throws IOException {
+ verifyKBWorkspace(kbId, workspaceId);
+ WikiRawMaterialEntity raw = rawService.getById(rawId);
+ if (raw == null || !kbId.equals(raw.getKbId())) {
+ return org.springframework.http.ResponseEntity.notFound().build();
+ }
+
+ String rawTitle = raw.getTitle();
+ String filename = (rawTitle != null && !rawTitle.isBlank())
+ ? rawTitle : ("source-" + rawId);
+
+ org.springframework.core.io.Resource resource;
+ long contentLength;
+ org.springframework.http.MediaType mediaType;
+ String sourceType = raw.getSourceType();
+
+ if ("text".equals(sourceType)) {
+ // Text materials live in the DB column — re-encode the stored content as bytes.
+ String content = raw.getOriginalContent();
+ if (content == null) {
+ return org.springframework.http.ResponseEntity.notFound().build();
+ }
+ byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
+ resource = new org.springframework.core.io.ByteArrayResource(bytes);
+ contentLength = bytes.length;
+ mediaType = org.springframework.http.MediaType.parseMediaType("text/plain;charset=UTF-8");
+ // Manually-pasted text rows often have no extension on the title — give the
+ // download a sane suffix so the OS knows what to do with it.
+ if (!filename.contains(".")) filename = filename + ".txt";
+ } else {
+ // Binary materials live on disk — sandbox to the configured upload dir so
+ // a tampered source_path can't escape and serve arbitrary files.
+ String sourcePath = raw.getSourcePath();
+ if (sourcePath == null || sourcePath.isBlank()) {
+ return org.springframework.http.ResponseEntity.notFound().build();
+ }
+ Path path = Paths.get(sourcePath).toAbsolutePath().normalize();
+ Path uploadDir = Paths.get(properties.getUploadDir()).toAbsolutePath().normalize();
+ if (!path.startsWith(uploadDir)) {
+ log.warn("[Wiki] Download rejected: rawId={} path={} outside uploadDir={}",
+ rawId, path, uploadDir);
+ return org.springframework.http.ResponseEntity
+ .status(org.springframework.http.HttpStatus.FORBIDDEN).build();
+ }
+ if (!Files.isRegularFile(path)) {
+ return org.springframework.http.ResponseEntity.notFound().build();
+ }
+ resource = new org.springframework.core.io.FileSystemResource(path);
+ contentLength = Files.size(path);
+ mediaType = org.springframework.http.MediaType.APPLICATION_OCTET_STREAM;
+ }
+
+ // RFC 5987 — provide both ASCII-safe filename= (for old browsers) and
+ // UTF-8 filename*= so non-ASCII titles (e.g. 中医诊断学.docx) survive intact.
+ String asciiFallback = filename.replaceAll("[^\\x20-\\x7E]", "_")
+ .replace("\"", "_").replace("\\", "_");
+ String encoded = java.net.URLEncoder.encode(filename, StandardCharsets.UTF_8)
+ .replace("+", "%20");
+ String contentDisposition = "attachment; filename=\"" + asciiFallback
+ + "\"; filename*=UTF-8''" + encoded;
+
+ return org.springframework.http.ResponseEntity.ok()
+ .contentType(mediaType)
+ .contentLength(contentLength)
+ .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
+ .body(resource);
+ }
+
// ==================== Wiki Pages ====================
@RequireWorkspaceRole("viewer")
- @Operation(summary = "获取 Wiki 页面列表")
+ @Operation(summary = "获取 Wiki 页面列表(可按原始材料过滤)")
@GetMapping("/knowledge-bases/{kbId}/pages")
public R> listPages(@PathVariable Long kbId,
+ @RequestParam(required = false) Long rawId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
+ if (rawId != null) return R.ok(pageService.listBySourceRawId(kbId, rawId));
return R.ok(pageService.listByKbId(kbId));
}
@@ -338,6 +436,40 @@ public class WikiController {
return R.ok(pageService.getBacklinks(kbId, slug));
}
+ // RFC-051 PR-7 follow-up: archive surfaces. Default-list is filtered, so the UI
+ // needs a dedicated endpoint to enumerate archived pages and a way to flip the
+ // flag via REST (the agent tools wiki_archive_page / wiki_unarchive_page already
+ // exist, but the admin UI shouldn't have to go through agent plumbing).
+
+ @RequireWorkspaceRole("viewer")
+ @Operation(summary = "列出知识库中所有 archived=1 的页面(不含 content)")
+ @GetMapping("/knowledge-bases/{kbId}/pages/archived")
+ public R> listArchivedPages(@PathVariable Long kbId,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ verifyKBWorkspace(kbId, workspaceId);
+ return R.ok(pageService.listArchivedByKbId(kbId));
+ }
+
+ @RequireWorkspaceRole("admin")
+ @Operation(summary = "归档单个页面(软归档;可恢复)")
+ @PostMapping("/knowledge-bases/{kbId}/pages/{slug}/archive")
+ public R