release: v1.3.0

This commit is contained in:
matevip 2026-05-13 10:05:53 +08:00
parent d994be3d04
commit f47cf8c6be
672 changed files with 83595 additions and 2383 deletions

View File

@ -50,6 +50,25 @@ MATECLAW_BROWSER_CDP_URL=
MATECLAW_BROWSER_CHROME_PATH=
MATECLAW_BROWSER_CHANNEL=
# ==================== OpenAI OAuthDocker可选 ====================
#
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code
# 不需要自定义 client secret。
#
# 默认留空即可。后端会根据访问 Host 自动选择:
# - localhost / 127.0.0.1 / ::1 → LOCALPKCE 回调)
# - IP / 域名 / 反向代理访问 → DEVICE_CODE无缝远程授权
#
# 本机 Docker 若希望像桌面版一样直接通过宿主机浏览器完成
# http://localhost:1455/auth/callback 回调,可显式开启 LOCAL并让容器内
# 临时回调服务监听 0.0.0.0,以便通过 `1455:1455` 端口映射被宿主机访问到:
# MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=local
# MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=0.0.0.0
#
# 强制模式调试时也可设为local / device_code / manual_paste
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=
# ── Maven 镜像(国内加速)─────────────────────────────────────────
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。

4
.gitignore vendored
View File

@ -101,3 +101,7 @@ CLAUDE.md
# Sync tooling local state (generated each run; report is intentionally tracked)
scripts/.*-sync-state.json
# Sandbox / external client work that lives in this directory
# but should not ship in the repo.
outputs/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 897 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -77,7 +77,10 @@ services:
DB_NAME: ${DB_NAME:-mateclaw}
DB_USERNAME: ${DB_USERNAME:-mateclaw}
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:-}
# LLM provider keys (DashScope / OpenAI / Anthropic / DeepSeek / Kimi / …) are
# NOT configured via env vars. After startup, add providers in the admin UI:
# Settings → Models → Add Provider
# Keys are stored in mate_model_provider and hot-reloaded.
SERPER_API_KEY: ${SERPER_API_KEY:-}
JWT_SECRET: ${JWT_SECRET:-}
MATECLAW_CORS_ALLOWED_ORIGINS: ${MATECLAW_CORS_ALLOWED_ORIGINS:-}
@ -89,12 +92,17 @@ services:
MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-}
MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-}
MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-}
# OAuth 模式默认保持 autolocalhost 访问走 LOCALIP/域名访问走 DEVICE_CODE。
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-}
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST: ${MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST:-0.0.0.0}
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
# recommendation for Playwright / headless chrome.
shm_size: 2gb
ports:
- "18080:18088" # host:container — app listens on 18088 inside the container
- "1455:1455"
volumes:
- server_data:/app/data

View File

@ -5,7 +5,11 @@
# build container. These files are later copied into the JAR's classpath so
# Spring Boot serves the SPA at the root URL.
FROM node:22-alpine AS frontend-builder
RUN npm install -g pnpm --silent
# Pin pnpm to a major version so the Docker build doesn't break when the npm
# `latest` tag jumps majors. pnpm v10+ blocks dependency lifecycle scripts by
# default; the allowed packages live under `pnpm.onlyBuiltDependencies` in
# mateclaw-ui/package.json.
RUN npm install -g pnpm@10 --silent
WORKDIR /frontend
# Install dependencies first (layer cache)
COPY mateclaw-ui/package.json mateclaw-ui/pnpm-lock.yaml ./
@ -96,4 +100,5 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 18088
EXPOSE 1455
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]

View File

@ -6,7 +6,7 @@
<groupId>vip.mate</groupId>
<artifactId>mateclaw-server</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
<packaging>jar</packaging>
<name>MateClaw Server</name>
@ -22,10 +22,10 @@
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Spring AI 1.1.5 正式版patch upgrade from 1.1.4 -->
<spring-ai.version>1.1.5</spring-ai.version>
<!-- Spring AI Alibaba 1.1.2.2(对应 Spring AI 1.1.x -->
<spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
<!-- Spring AI 1.1.6 正式版patch upgrade from 1.1.5 -->
<spring-ai.version>1.1.6</spring-ai.version>
<!-- Spring AI Alibaba 1.1.2.3(对应 Spring AI 1.1.x -->
<spring-ai-alibaba.version>1.1.2.3</spring-ai-alibaba.version>
<mybatis-plus.version>3.5.16</mybatis-plus.version>
<hutool.version>5.8.26</hutool.version>
<springdoc.version>2.8.16</springdoc.version>
@ -351,6 +351,51 @@
<version>3.0.0</version>
</dependency>
<!-- ===== Markdown -> PDF rendering =====
Flying Saucer 9.13 ships a single `flying-saucer-pdf` artifact that
writes PDF via OpenPDF (LGPL fork of iText 5). It does NOT depend on
PDFBox, so it sidesteps a version conflict with the existing
pdfbox:3.0.3 dependency. CSS3 paged-media features (@page,
counter(page), counter(pages), @top-center / @bottom-center) are
supported, which the cover / header / footer rendering relies on.
commonmark-java is the reference CommonMark implementation,
actively maintained on a monthly cadence (vs. flexmark, whose
upstream stalled at 0.64.8 in 2023). It parses markdown into the
XHTML Flying Saucer consumes. The alternative LibreOffice path in
PdfRenderTool reuses MarkdownDocxRenderer + a soffice subprocess
and adds no dependencies of its own. -->
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf</artifactId>
<version>9.13.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.28.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
<version>0.28.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-yaml-front-matter</artifactId>
<version>0.28.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
<version>0.28.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-autolink</artifactId>
<version>0.28.0</version>
</dependency>
<!-- ===== Database Migration (Flyway) ===== -->
<dependency>
<groupId>org.flywaydb</groupId>
@ -413,6 +458,17 @@
<artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency>
<!-- Expression language used by the workflow compiler to evaluate
conditional step expressions and template variable references.
Restricted to a small subset (~20 operators / filters) at the
evaluator wrapper layer; arbitrary template includes / extends
are blocked. -->
<dependency>
<groupId>io.pebbletemplates</groupId>
<artifactId>pebble</artifactId>
<version>3.2.2</version>
</dependency>
</dependencies>
<build>
@ -567,5 +623,27 @@
</pluginRepository>
</pluginRepositories>
</profile>
<!--
Profile: focused test run for image / video generation features.
Activate with `mvn test -P media-gen` (or `mvn verify -P media-gen`).
Limits surefire to JUnit 5 tests carrying @Tag("media-gen") so the
full ~50-min suite is skipped when iterating on this surface.
Add a tag to a new test with @Tag("media-gen") to opt it in.
-->
<profile>
<id>media-gen</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<groups>media-gen</groups>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@ -15,13 +15,19 @@ import org.springframework.scheduling.annotation.EnableScheduling;
* @author MateClaw Team
*/
@SpringBootApplication(exclude = {
// 禁用 Spring AI MCP Client 自动配置 McpClientManager 自行管理生命周期
// Disable Spring AI MCP Client auto-configuration (lifecycle owned by McpClientManager).
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.class,
org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration.class,
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
// DashScopeAgent is the Bailian "Application Agent" (Bailian-hosted prompt+tool app),
// not the chat model. We don't use it model configuration is admin-UI driven and
// built by AgentDashScopeChatModelBuilder. Its auto-config strictly requires
// spring.ai.dashscope.api-key to be non-empty at startup, which makes the whole
// ApplicationContext fail when users deploy via Docker without setting the key.
com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class,
})
@EnableScheduling
@MapperScan("vip.mate.**.repository")

View File

@ -131,8 +131,11 @@ public class AgentGraphBuilder {
private final vip.mate.llm.failover.ProviderHealthTracker providerHealthTracker;
private final vip.mate.llm.chatmodel.ProviderChatModelFactory chatModelFactory;
private final vip.mate.llm.failover.AvailableProviderPool providerPool;
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
/** PR-0b: DashScope-specific construction lives here now; we only call into it for the search-on log. */
private final vip.mate.agent.chatmodel.AgentDashScopeChatModelBuilder dashScopeBuilder;
private final vip.mate.llm.routing.MultimodalRouter multimodalRouter;
private final vip.mate.llm.routing.MediaCaptionService mediaCaptionService;
/**
* Optional audit pipeline. Setter injection (rather than a constructor
@ -293,6 +296,11 @@ public class AgentGraphBuilder {
agent.modelCapabilities = modelCapabilityService.resolve(
runtimeModel.getModelName(), runtimeModel.getModalities());
agent.runtimeProviderId = provider != null ? provider.getProviderId() : "";
agent.runtimeModelConfig = runtimeModel;
agent.toolSet = toolSet;
agent.multimodalRouter = multimodalRouter;
agent.mediaCaptionService = mediaCaptionService;
agent.userLocale = resolveLocale();
agent.temperature = runtimeModel.getTemperature();
agent.maxTokens = runtimeModel.getMaxTokens();
agent.maxInputTokens = runtimeModel.getMaxInputTokens();
@ -450,6 +458,8 @@ public class AgentGraphBuilder {
// 丢这个键evidence_insufficient 检查会"静默地不生效"
// StateKeyRegistrationCoverageTest 专门兜这条
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
// Multimodal sidecar routing decision for the current turn.
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
.build();
// Graph 拓扑
@ -556,7 +566,7 @@ public class AgentGraphBuilder {
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);
SummarizingNode summarizingNode = new SummarizingNode(chatModel, streamingHelper, streamTracker);
LimitExceededNode limitExceededNode = new LimitExceededNode(chatModel, observationProcessor, streamingHelper, i18nService);
FinalAnswerNode finalAnswerNode = new FinalAnswerNode();
FinalAnswerNode finalAnswerNode = new FinalAnswerNode(generatedFileCache);
KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder()
// 输入字段
@ -636,6 +646,8 @@ public class AgentGraphBuilder {
// 丢这个键evidence_insufficient 检查会"静默地不生效"
// StateKeyRegistrationCoverageTest 专门兜这条
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
// Multimodal sidecar routing decision for the current turn.
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
.build();
StateGraph graph = new StateGraph("react-agent-v2", keyStrategyFactory)
@ -701,6 +713,21 @@ public class AgentGraphBuilder {
return buildRuntimeChatModel(runtimeModel, this.retryTemplate);
}
/**
* Resolve the user-facing locale used for sidecar caption prompts.
* Reads {@code language} from system settings; falls back to
* {@code zh-CN} so CN deployments stay consistent with the chat UI.
*/
private java.util.Locale resolveLocale() {
try {
String lang = systemSettingService.getLanguage();
if (lang == null || lang.isBlank()) return java.util.Locale.SIMPLIFIED_CHINESE;
return java.util.Locale.forLanguageTag(lang);
} catch (Exception e) {
return java.util.Locale.SIMPLIFIED_CHINESE;
}
}
/**
* 构建运行时 ChatModel并指定自定义的 Spring AI {@link RetryTemplate}
* <p>
@ -1584,6 +1611,7 @@ public class AgentGraphBuilder {
private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.version(HttpClient.Version.HTTP_1_1)
.build();
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
rf.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
@ -1614,8 +1642,13 @@ public class AgentGraphBuilder {
* {@link #applyHttpTimeouts(RestClient.Builder, Integer)}.
*/
private WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {
// Pin HTTP/1.1: many self-hosted OpenAI-compatible servers (vLLM, lmstudio,
// llama.cpp, ollama all uvicorn/ASGI based) only speak HTTP/1.1 over
// cleartext and slam the socket on the JDK client's default H2C upgrade
// probe, surfacing as "header parser received no bytes" with no body sent.
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.version(HttpClient.Version.HTTP_1_1)
.build();
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);

View File

@ -3,12 +3,15 @@ package vip.mate.agent;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.event.AgentLifecycleEvent;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
@ -43,6 +46,11 @@ public class AgentService {
private final MemoryLifecycleMediator lifecycleMediator;
private final MemoryProperties memoryProperties;
/** Field-injected publisher for agent_lifecycle trigger events; the
* trigger module's bridge listens and forwards into ingest. */
@Autowired(required = false)
private ApplicationEventPublisher events;
/** 运行时 Agent 实例缓存agentId -> BaseAgent */
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
@ -57,9 +65,26 @@ public class AgentService {
* 按工作区列出 Agent
*/
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId) {
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId)
.orderByDesc(AgentEntity::getCreateTime));
return listAgentsByWorkspace(workspaceId, null);
}
/**
* 按工作区列出 Agent可选过滤启用状态
*
* @param enabled non-null restricts the result set to agents whose
* {@code enabled} column matches the given value.
* Pass {@code true} from chat selectors so disabled
* agents disappear from the picker; the admin
* management page passes {@code null} to keep
* disabled rows visible for re-enabling.
*/
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId, Boolean enabled) {
LambdaQueryWrapper<AgentEntity> q = new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId);
if (enabled != null) {
q.eq(AgentEntity::getEnabled, enabled);
}
return agentMapper.selectList(q.orderByDesc(AgentEntity::getCreateTime));
}
public AgentEntity getAgent(Long id) {
@ -75,19 +100,100 @@ public class AgentService {
if (agent.getAgentType() == null) {
agent.setAgentType("react");
}
requireUniqueName(agent, null);
agentMapper.insert(agent);
publishLifecycle(agent, "spawned");
return agent;
}
public AgentEntity updateAgent(AgentEntity agent) {
// Detect enabled-flag flip so the lifecycle event reflects the
// intent rather than every metadata edit. Reading the prior row
// is cheap and gives us a clean diff source.
AgentEntity prior = agentMapper.selectById(agent.getId());
// Only re-validate uniqueness when the name actually changes
// a pure metadata edit (icon, prompt, ...) shouldn't pay the
// SELECT cost or risk a false positive against the row itself.
if (prior != null
&& agent.getName() != null
&& !agent.getName().equals(prior.getName())) {
// Workspace cannot be moved (Controller pins it to prior.workspaceId),
// so reuse it for the lookup even if the incoming DTO left it null.
if (agent.getWorkspaceId() == null) {
agent.setWorkspaceId(prior.getWorkspaceId());
}
requireUniqueName(agent, agent.getId());
}
agentMapper.updateById(agent);
agentInstances.remove(agent.getId());
if (prior != null && prior.getEnabled() != null
&& !prior.getEnabled().equals(agent.getEnabled())) {
publishLifecycle(agent,
Boolean.TRUE.equals(agent.getEnabled()) ? "enabled" : "disabled");
}
return agent;
}
/**
* Friendly business-code surface for the {@code (workspace_id, name)}
* unique index added in V102.
*
* <p>The wire shape is the project-wide R&lt;T&gt; envelope: HTTP status
* stays 200 (per the convention in {@code R.fail} and the axios
* interceptor in {@code mateclaw-ui/src/api/index.ts}); the 409 lives in
* the response body's {@code code} field so the front-end can branch
* without breaking on an axios error. Without this pre-check the
* duplicate save would surface as an opaque
* {@code DataIntegrityViolation} stack trace.
*
* @param excludeId when non-null, skip this row in the lookup so
* {@link #updateAgent} doesn't mistake the row for its
* own duplicate.
*/
private void requireUniqueName(AgentEntity agent, Long excludeId) {
if (agent.getName() == null || agent.getName().isBlank()) {
throw new MateClawException("err.agent.name_required", 400, "Agent 名称不能为空");
}
Long workspaceId = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
LambdaQueryWrapper<AgentEntity> q = new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId)
.eq(AgentEntity::getName, agent.getName());
if (excludeId != null) {
q.ne(AgentEntity::getId, excludeId);
}
Long count = agentMapper.selectCount(q);
if (count != null && count > 0) {
throw new MateClawException("err.agent.duplicate_name", 409,
"工作区内已存在同名 Agent: " + agent.getName());
}
}
public void deleteAgent(Long id) {
AgentEntity prior = agentMapper.selectById(id);
agentMapper.deleteById(id);
agentInstances.remove(id);
if (prior != null) publishLifecycle(prior, "terminated");
}
/**
* Best-effort publish of an {@link AgentLifecycleEvent}. A publish
* failure must never roll back the agent CRUD that just succeeded
* the agent_lifecycle trigger surface is observability, not the
* canonical record.
*/
private void publishLifecycle(AgentEntity agent, String phase) {
if (events == null || agent == null) return;
try {
events.publishEvent(new AgentLifecycleEvent(
agent.getWorkspaceId() == null ? 0L : agent.getWorkspaceId(),
agent.getId() == null ? 0L : agent.getId(),
agent.getName(),
phase,
System.currentTimeMillis()));
} catch (Exception e) {
log.warn("[AgentService] lifecycle publish failed for agent {} ({}): {}",
agent.getId(), phase, e.getMessage());
}
}
/**

View File

@ -10,7 +10,12 @@ import org.springframework.core.io.FileSystemResource;
import org.springframework.util.MimeType;
import reactor.core.publisher.Flux;
import vip.mate.approval.ApprovalPlaceholderUtil;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.routing.MediaCaptionService;
import vip.mate.llm.routing.MultimodalRouter;
import vip.mate.llm.routing.model.MultimodalRoutingDecision;
import vip.mate.llm.service.ModelCapabilityService;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.conversation.model.MessageEntity;
@ -20,6 +25,7 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
@ -85,6 +91,31 @@ public abstract class BaseAgent {
/** 构建时使用的 provider ID运行时快照 */
protected String runtimeProviderId;
/**
* Full runtime model configuration used by the multimodal router to
* decide whether the primary model can handle attachments natively.
* Set by {@code AgentGraphBuilder} alongside {@link #modelCapabilities}.
*/
protected ModelConfigEntity runtimeModelConfig;
/**
* The agent's effective tool set. Lifted from subclasses so
* {@link #buildUserMessage} can ask whether the agent has any media-capable
* tool when the primary model rejects an attachment.
*/
protected vip.mate.agent.AgentToolSet toolSet;
/**
* Optional sidecar routing services. Null when not wired (e.g. tests with
* minimal builders); the routing path then degrades to the legacy
* skip-with-text-hint behavior without any extra LLM calls.
*/
protected MultimodalRouter multimodalRouter;
protected MediaCaptionService mediaCaptionService;
/** Locale used when prompting the vision sidecar. Defaults to zh-CN when unset. */
protected java.util.Locale userLocale = java.util.Locale.SIMPLIFIED_CHINESE;
protected BaseAgent(ChatClient chatClient, ConversationService conversationService) {
this.chatClient = chatClient;
@ -206,17 +237,46 @@ public abstract class BaseAgent {
agentName, history.size(), totalCount, windowSize);
}
// ===== 识别持久化的压缩摘要从摘要位置开始跳过更早消息 =====
for (int i = 0; i < history.size(); i++) {
// ===== Slice from the LATEST compression boundary, not the first =====
// A long-running conversation can accumulate several boundaries; the
// newest one is the only relevant cut-off because every earlier
// boundary's content is already folded into the newer summary. Walking
// forward and breaking on the first boundary kept everything between
// boundaries the very redundancy compaction was supposed to remove.
boolean boundaryFoundInWindow = false;
for (int i = history.size() - 1; i >= 0; i--) {
MessageEntity msg = history.get(i);
if ("system".equals(msg.getRole()) && isCompressionSummary(msg)) {
history = new ArrayList<>(history.subList(i, history.size()));
log.info("[{}] Found compression summary, loading from index {} ({} messages)",
boundaryFoundInWindow = true;
log.info("[{}] Found latest compression boundary at index {}; loading {} messages forward",
agentName, i, history.size());
break;
}
}
// ===== Latest boundary may live OUTSIDE the recent window =====
// On a long conversation that compacted hours/days ago and has paged
// fewer than `windowSize` new messages since, `listRecentMessages`
// returns only the raw tail the boundary sat at index 0 of the
// original list and never made it into `history`. Without prepending
// it, the model would forget the original goal even though we already
// paid the LLM cost to produce a structured summary.
if (!boundaryFoundInWindow && totalCount > windowSize) {
try {
MessageEntity latestBoundary = conversationService.findLatestCompressionBoundary(conversationId);
if (latestBoundary != null) {
history = new ArrayList<>(history);
history.add(0, latestBoundary);
log.info("[{}] Prepended out-of-window compression boundary id={} so the model keeps the summary context",
agentName, latestBoundary.getId());
}
} catch (Exception e) {
log.warn("[{}] findLatestCompressionBoundary failed; loading recent window without boundary: {}",
agentName, e.getMessage());
}
}
// ===== 转换为 Spring AI Message 对象 =====
int limit = history.size();
if (limit > 0) {
@ -270,9 +330,125 @@ public abstract class BaseAgent {
while (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof UserMessage) {
messages.remove(messages.size() - 1);
}
// Head guard orphan tool-response strip.
//
// Independent of the compaction pair-safe boundary in
// ConversationWindowManager: that one protects the *compaction* cut,
// this one protects the *pagination* cut. listRecentMessages returns
// the last N rows verbatim, and the first row of that page can be a
// ToolResponseMessage whose owning AssistantMessage sat one row
// earlier i.e. outside the page. Sending such a sequence to any
// OpenAI-compatible provider returns 400 because every tool response
// must be preceded by an assistant message issuing that tool_call_id.
//
// The boundary prepend earlier inserts a SystemMessage at the head;
// the orphan, if present, sits at index 1 in that case. Skip leading
// SystemMessages and drop any leading ToolResponseMessage whose
// response ids are not all issued by a *preceding* AssistantMessage
// i.e. one we have already walked past in this scan. Provider
// validity is order-sensitive; a later same-id assistant deeper in
// the window does NOT redeem an earlier orphan. See
// stripHeadOrphanToolResponses below for the forward-scan details.
//
// Dropping is correct rather than expanding backward to fetch the
// missing assistant: if the AssistantMessage is outside the window,
// its content is already lost to the model anyway, and the boundary
// summary (if any) covers it. Keeping the orphan would just trade a
// dropped row for a 400.
stripHeadOrphanToolResponses(messages, agentName);
return messages;
}
/**
* Drop leading {@link ToolResponseMessage}s whose owning
* {@link AssistantMessage} sits <em>before</em> them in this list. Provider
* validity is order-sensitive: a tool response must follow the assistant
* that issued the tool_call_id; an unrelated later AssistantMessage that
* happens to carry the same id does not redeem an earlier orphan.
*
* <p>Algorithm: forward scan with a {@code seenIssuedIds} set. Leading
* {@link SystemMessage}s (boundary rows, system prompts) pass through
* untouched but contribute no ids. The first {@link AssistantMessage} or
* {@link UserMessage} we hit stops the repair walk by that point we're
* out of head-orphan territory. Every {@link ToolResponseMessage} we
* encounter before that stop is checked against {@code seenIssuedIds};
* if every response id is unseen, the message is dropped and the scan
* re-examines the new head. A response whose ids are all in the seen
* set (e.g. {@code [system, assistant(X), toolResponse(X), ...]} when
* the assistant fell at index 1 of the slice) is left in place.
*
* <p>Mixed responses (some ids matched, some not) inside a single
* leading {@code ToolResponseMessage} are dropped wholesale rather than
* surgically rewritten the provider would reject partially-broken
* sequences anyway, and the mixed case implies an upstream invariant
* violation that surfaces in logs.
*
* <p>Package-private + static so unit tests can drive it without standing
* up a full BaseAgent subclass.
*/
static int stripHeadOrphanToolResponses(List<Message> messages, String agentName) {
if (messages.isEmpty()) return 0;
// Built up as we walk; only assistants we've already passed count
// toward "preceding". An assistant that sits behind a head orphan is
// irrelevant: provider order-validity asks "was this tool_call id
// issued BEFORE this response?", not "anywhere in the prompt".
Set<String> seenIssuedIds = new HashSet<>();
int dropped = 0;
int i = 0;
while (i < messages.size()) {
Message m = messages.get(i);
if (m instanceof SystemMessage) {
// Boundary rows / system prompts pass through; advance and
// keep looking for orphan tool responses that sit behind them.
i++;
continue;
}
if (m instanceof AssistantMessage am) {
// Reached a preceding assistant head danger is over. The
// tool_call ids it issued are valid for any tool responses
// that follow, but we stop the repair walk here either way.
if (am.getToolCalls() != null) {
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
if (tc.id() != null && !tc.id().isEmpty()) {
seenIssuedIds.add(tc.id());
}
}
}
break;
}
if (m instanceof ToolResponseMessage trm) {
// Every response id must have been issued by a preceding
// assistant we already walked through. If any single id is
// missing from seenIssuedIds, the message is invalid in
// place. Empty / null ids don't count for or against.
boolean anyUnmatched = trm.getResponses().stream()
.map(ToolResponseMessage.ToolResponse::id)
.filter(id -> id != null && !id.isEmpty())
.anyMatch(id -> !seenIssuedIds.contains(id));
if (anyUnmatched) {
messages.remove(i);
dropped++;
continue; // re-examine the new messages[i]
}
// All ids match a preceding assistant keep, and stop the
// repair walk. Anything past here is well-formed by
// construction (provider validates each subsequent pair as
// we go).
break;
}
// UserMessage (or anything else) past the head danger. Stop.
break;
}
if (dropped > 0) {
log.info("[{}] Stripped {} leading orphan ToolResponseMessage(s) — no preceding AssistantMessage in scope",
agentName, dropped);
}
return dropped;
}
/**
* History sanitization entry point. Encapsulates *all* steps applied to a
* persisted message before it reaches an LLM prompt. Returns {@code null}
@ -510,34 +686,87 @@ public abstract class BaseAgent {
}
/**
* 构建 UserMessage支持 multimodal如果消息包含图片/视频附件直接注入 Spring AI Media 对象
* 让模型在 prompt 中直接看到媒体内容不需要再调 MCP read_media_file 工具
* Build a {@link UserMessage} for the current turn, including any image/video
* media the agent's primary model can handle natively. Returns the message
* paired with the {@link MultimodalRoutingDecision} taken so the caller can
* persist it as message metadata and emit a routing event.
*/
protected UserMessage buildUserMessage(MessageEntity message, String renderedContent) {
return buildUserMessage(message, renderedContent, true);
protected CurrentTurnUserMessage buildUserMessageForCurrentTurn(MessageEntity message, String renderedContent) {
return buildUserMessageInternal(message, renderedContent, true);
}
/**
* @param injectMedia when {@code false} (history replay), skip the Media-loading
* branch entirely and return text-only providers like Zhipu
* GLM-5V cap at 1 video per request, so re-injecting historical
* attachments on every turn breaks the call.
* History-replay variant: text-only, no media reinjected, no routing decision.
* Many providers cap at one video per request, so re-injecting old attachments
* on every replay would break the call.
*/
protected UserMessage buildUserMessage(MessageEntity message, String renderedContent) {
return buildUserMessageInternal(message, renderedContent, true).userMessage();
}
protected UserMessage buildUserMessage(MessageEntity message, String renderedContent, boolean injectMedia) {
return buildUserMessageInternal(message, renderedContent, injectMedia).userMessage();
}
private CurrentTurnUserMessage buildUserMessageInternal(MessageEntity message, String renderedContent, boolean injectMedia) {
if (!injectMedia) {
return new UserMessage(renderedContent == null ? "" : renderedContent);
return new CurrentTurnUserMessage(
new UserMessage(renderedContent == null ? "" : renderedContent),
null);
}
List<MessageContentPart> parts = conversationService.parseMessageParts(message);
// Sidecar routing runs first so caption text gets folded into finalText
// before native media injection considers the same parts again.
MultimodalRoutingDecision decision = multimodalRouter != null
? multimodalRouter.route(parts, runtimeModelConfig)
: MultimodalRoutingDecision.none();
StringBuilder textBuilder = new StringBuilder(renderedContent == null ? "" : renderedContent);
java.util.Set<String> sidecarHandledIdentifiers = new java.util.HashSet<>();
if (decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR
&& mediaCaptionService != null
&& decision.sidecarModel() != null) {
for (MessageContentPart part : parts) {
if (part == null) continue;
String contentType = part.getContentType();
boolean isImage = ("image".equals(part.getType()) || "file".equals(part.getType()))
&& contentType != null && contentType.startsWith("image/")
&& !contentType.contains("svg");
if (!isImage) continue;
MediaCaptionService.CaptionResult result = mediaCaptionService.caption(
decision.sidecarModel(), part, userLocale);
if (result.isFailure()) {
log.warn("[{}] Sidecar caption failed for {}: {}",
agentName, part.getFileName(), result.failure().getMessage());
textBuilder.append("\n\n[系统提示] 视觉模型未能解析附件 ")
.append(part.getFileName())
.append(",请稍后重试或在「设置 → 模型」检查视觉模型配置。");
continue;
}
textBuilder.append("\n\n[图片附件描述: ")
.append(part.getFileName() == null ? "image" : part.getFileName())
.append("]\n")
.append(result.description())
.append("\n[/图片附件描述]");
String identifier = identifyPart(part);
if (identifier != null) sidecarHandledIdentifiers.add(identifier);
}
}
List<Media> mediaList = new ArrayList<>();
// Reasons for attachments that the model cannot consume surfaced to the agent
// via the user message text so it does not hallucinate a tool call to read them.
// See issue #44.
List<String> skippedAttachments = new ArrayList<>();
boolean videoSupported = modelSupportsVideo();
boolean visionSupported = modelSupportsVision();
for (MessageContentPart part : parts) {
if (part == null) continue;
// Sidecar already produced text for this image; never inject the
// raw bytes the primary model would receive them and try to
// process natively, defeating the cost-saving purpose.
String identifier = identifyPart(part);
if (identifier != null && sidecarHandledIdentifiers.contains(identifier)) continue;
String partType = part.getType();
String contentType = part.getContentType();
// image 类型的 part 可能没有精确 contentType补全为 image/jpeg
@ -607,21 +836,88 @@ public abstract class BaseAgent {
}
}
String finalText = renderedContent;
if (!skippedAttachments.isEmpty()) {
finalText = (renderedContent == null ? "" : renderedContent)
+ "\n\n[系统提示] 以下附件未能传入当前模型:" + String.join("", skippedAttachments)
+ "\n请用对话语言清晰、友好地告诉用户当前模型无法处理这类附件建议切换到具备相应能力的多模态模型图片需视觉模型视频需视频理解模型后重新上传。"
+ "不要调用任何工具(包括 ffmpeg、浏览器、文件读取等尝试解析这些附件。";
textBuilder.append("\n\n[系统提示] 以下附件未能传入当前模型:")
.append(String.join("", skippedAttachments))
.append("");
// Only suggest switching models when no media-capable tool is bound
// either. With a media tool the LLM may legitimately choose to
// delegate to the tool never instruct it not to use tools.
if (!hasMediaCapableTools()) {
textBuilder.append("\n请用对话语言清晰、友好地告诉用户当前模型无法处理这类附件建议切换到具备相应能力的多模态模型或在「设置 → 模型」中配置视觉/视频模型作为旁路。");
}
}
if (mediaList.isEmpty()) {
return new UserMessage(finalText);
String finalText = textBuilder.toString();
UserMessage built = mediaList.isEmpty()
? new UserMessage(finalText)
: UserMessage.builder().text(finalText).media(mediaList).build();
return new CurrentTurnUserMessage(built, decision);
}
return UserMessage.builder()
.text(finalText)
.media(mediaList)
.build();
/**
* Stable identifier for de-duplicating parts already handled by the sidecar
* pass. Falls back across {@code path mediaId fileName} since not every
* channel populates the same field.
*/
private static String identifyPart(MessageContentPart part) {
if (part == null) return null;
if (part.getPath() != null && !part.getPath().isBlank()) return "p:" + part.getPath();
if (part.getMediaId() != null && !part.getMediaId().isBlank()) return "m:" + part.getMediaId();
if (part.getFileName() != null && !part.getFileName().isBlank()) return "f:" + part.getFileName();
return null;
}
/**
* True if the agent has at least one tool whose name or description
* suggests it can read images / video / audio. The check is intentionally
* loose false positives just mean the agent is allowed to attempt media
* processing on its own, which is the safer default.
*/
private static final Set<String> MEDIA_TOOL_KEYWORDS = Set.of(
"image", "图片", "vision", "视觉",
"video", "视频", "ffmpeg",
"ocr", "caption", "media", "audio", "音频");
private boolean hasMediaCapableTools() {
if (toolSet == null) return false;
var callbacks = toolSet.callbacks();
if (callbacks == null || callbacks.isEmpty()) return false;
return callbacks.stream().anyMatch(cb -> {
try {
String name = String.valueOf(cb.getToolDefinition().name()).toLowerCase();
String desc = String.valueOf(cb.getToolDefinition().description()).toLowerCase();
return MEDIA_TOOL_KEYWORDS.stream().anyMatch(k -> name.contains(k) || desc.contains(k));
} catch (Exception e) {
return false;
}
});
}
/**
* Pair returned from the current-turn user message build path: the assembled
* {@link UserMessage} and the routing decision the caller should persist as
* {@code metadata.routing} and surface to the SSE consumer.
*/
public record CurrentTurnUserMessage(UserMessage userMessage, MultimodalRoutingDecision routingDecision) {}
/**
* Extract a routing-decision payload from the graph input map (placed there
* by {@code buildInitialState}) and turn it into a startup
* {@link vip.mate.agent.AgentService.StreamDelta} the SSE accumulator can
* persist. Returns an empty Flux when no routing happened this turn so we
* don't emit zero-value events.
*/
@SuppressWarnings("unchecked")
public static reactor.core.publisher.Flux<vip.mate.agent.AgentService.StreamDelta> routingStartupDelta(
java.util.Map<String, Object> inputs) {
Object decision = inputs.get(vip.mate.agent.graph.state.MateClawStateKeys.ROUTING_DECISION);
if (decision instanceof java.util.Map<?, ?> map && !map.isEmpty()) {
return reactor.core.publisher.Flux.just(vip.mate.agent.AgentService.StreamDelta.event(
vip.mate.agent.GraphEventPublisher.EVENT_ROUTING_DECISION,
(java.util.Map<String, Object>) map));
}
return reactor.core.publisher.Flux.empty();
}
/**
@ -642,6 +938,17 @@ public abstract class BaseAgent {
* @return 带图片 Media UserMessage如果有图片附件否则纯文本 UserMessage
*/
protected UserMessage buildCurrentUserMessage(String conversationId, String userMessageText) {
return buildCurrentUserMessageWithRouting(conversationId, userMessageText).userMessage();
}
/**
* Same as {@link #buildCurrentUserMessage} but also returns the multimodal
* routing decision taken for this turn so the caller can persist it as
* {@code metadata.routing} and emit a SSE-side event for the chat UI.
* Returns a decision with NONE strategy when the message has no attachments
* the primary model can't already handle.
*/
protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) {
try {
List<MessageEntity> history = conversationService.listMessages(conversationId);
// 倒序取最后一条 user 消息buildInitialState saveMessage 后调用所以最后一条就是当前消息
@ -650,14 +957,14 @@ public abstract class BaseAgent {
if ("user".equals(msg.getRole())) {
// DB 中的实际内容可能包含 contentParts不用传入的 text
String content = conversationService.renderMessageContent(msg);
return buildUserMessage(msg, content != null && !content.isBlank() ? content : userMessageText);
return buildUserMessageForCurrentTurn(msg, content != null && !content.isBlank() ? content : userMessageText);
}
}
} catch (Exception e) {
log.debug("[{}] Failed to load current user message parts for multimodal: {}",
agentName, e.getMessage());
}
return new UserMessage(userMessageText);
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
}
protected Path resolveImagePath(String relativePath) {

View File

@ -45,6 +45,31 @@ public final class GraphEventPublisher {
*/
public static final String EVENT_FINISH_REASON = "finish_reason";
/**
* User-facing recovery affordances offered after a turn ends in a
* non-transient error. Carries the error type + message + a
* data-driven list of actions ({@code retry}, {@code regenerate},
* {@code report}) so the frontend can render the right buttons
* without hard-coding which categories deserve which actions.
*
* <p>Sibling to {@link #EVENT_FINISH_REASON} (which only carries the
* machine-readable reason). The two are kept separate so legacy
* consumers of {@code finish_reason} don't have to learn a new
* payload shape and so a future graph branch (e.g. evidence-
* insufficient "rerun with the listed files attached") can emit
* feedback affordances without abusing the finish_reason channel.
*/
public static final String EVENT_FEEDBACK = "feedback_event";
/**
* Multimodal sidecar routing decision for the current turn. Emitted once
* per turn before the graph starts streaming; the channel-side accumulator
* stores it under {@code metadata.routing} so the chat UI can show which
* sidecar (if any) was invoked. Underscore-prefixed name keeps it out of
* IM channel rebroadcast (see {@code ChannelMessageRouter}).
*/
public static final String EVENT_ROUTING_DECISION = "_routing_decision";
/**
* 事件记录
*/
@ -217,6 +242,30 @@ public final class GraphEventPublisher {
), ts);
}
/**
* Emit a recovery-affordance event for the frontend. {@code errorType}
* mirrors the {@code NodeStreamingChatHelper.ErrorType} value (e.g.
* {@code AUTH_ERROR}, {@code BILLING}, {@code MODEL_NOT_FOUND}, or
* the generic {@code UNKNOWN}); {@code errorMessage} is the
* user-friendly text already displayed in the bubble; {@code actions}
* is the ordered list of buttons to render. Default offering is the
* standard {@code retry / regenerate / report} triad call sites
* can narrow this if a category has limitations (e.g. AUTH_ERROR
* shouldn't offer "retry" until the key is fixed).
*/
public static GraphEvent feedback(String errorType, String errorMessage,
java.util.List<String> actions) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_FEEDBACK, Map.of(
"errorType", errorType != null ? errorType : "",
"errorMessage", errorMessage != null ? errorMessage : "",
"actions", actions != null && !actions.isEmpty()
? actions
: java.util.List.of("retry", "regenerate", "report"),
"timestamp", ts
), ts);
}
// ===== 提取方法 =====
/**

View File

@ -12,8 +12,17 @@ import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.mcp.McpSkillBridge;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillMapper;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.service.AvailableToolService;
import java.util.Collections;
import java.util.LinkedHashSet;
@ -43,16 +52,51 @@ public class AgentBindingService {
* graph when SkillRuntimeService initializes after binding.
*/
private final SkillRuntimeService skillRuntimeService;
/**
* Source of truth for what the picker can offer (built-in + MCP). Used
* by {@link #setToolBindings} to refuse new tool names that the runtime
* couldn't resolve anyway closes the gap where a UI-disabled row
* could still be saved by hitting the API directly.
*/
private final AvailableToolService availableToolService;
/**
* Direct mapper access (instead of {@code AgentService}) to look up an
* agent's workspace before binding a skill. {@code AgentService} pulls
* in {@code AgentGraphBuilder}, which itself depends on
* {@code AgentBindingService} going through the service would create a
* boot-time cycle. The mapper has no such transitive dependency.
*/
private final AgentMapper agentMapper;
/** Same reasoning as {@link #agentMapper}: skill workspace lookup. */
private final SkillMapper skillMapper;
/**
* ACP virtual skills aren't rows in {@code mate_skill}; the bridge
* synthesizes them from {@code mate_acp_endpoint}. We need this to
* answer "what workspace does this virtual id belong to?" when an
* agent tries to bind one. MCP virtual skills don't need a bridge
* reference {@link McpSkillBridge#isVirtualMcpSkillId(Long)} is a
* static range check, and MCP servers carry no workspace today, so
* binding any MCP virtual id is allowed for any agent.
*/
private final AcpSkillBridge acpSkillBridge;
@Autowired
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
AgentToolBindingMapper toolBindingMapper,
AgentProviderPreferenceMapper providerPreferenceMapper,
@Lazy SkillRuntimeService skillRuntimeService) {
@Lazy SkillRuntimeService skillRuntimeService,
AvailableToolService availableToolService,
AgentMapper agentMapper,
SkillMapper skillMapper,
AcpSkillBridge acpSkillBridge) {
this.skillBindingMapper = skillBindingMapper;
this.toolBindingMapper = toolBindingMapper;
this.providerPreferenceMapper = providerPreferenceMapper;
this.skillRuntimeService = skillRuntimeService;
this.availableToolService = availableToolService;
this.agentMapper = agentMapper;
this.skillMapper = skillMapper;
this.acpSkillBridge = acpSkillBridge;
}
// ==================== Skill Bindings ====================
@ -80,6 +124,7 @@ public class AgentBindingService {
}
public AgentSkillBinding bindSkill(Long agentId, Long skillId) {
requireSameWorkspace(agentId, skillId);
// 检查是否已绑定
AgentSkillBinding existing = skillBindingMapper.selectOne(
new LambdaQueryWrapper<AgentSkillBinding>()
@ -109,6 +154,15 @@ public class AgentBindingService {
* 批量设置 Agent skill 绑定替换模式
*/
public void setSkillBindings(Long agentId, List<Long> skillIds) {
// Validate every incoming skill BEFORE touching the binding rows;
// a half-applied save (old bindings dropped, new set rejected
// mid-loop) would leave the agent silently un-bound from skills it
// had a moment ago.
if (skillIds != null) {
for (Long skillId : skillIds) {
requireSameWorkspace(agentId, skillId);
}
}
// 删除旧绑定
skillBindingMapper.delete(
new LambdaQueryWrapper<AgentSkillBinding>()
@ -125,6 +179,78 @@ public class AgentBindingService {
}
}
/**
* Refuse to bind a skill that doesn't share the agent's workspace.
* Skills are per-workspace installable artifacts (each workspace has
* its own catalog under {@code mate_skill.workspace_id}); letting
* workspace A's agent bind workspace B's skill would leak capabilities
* and prompt content across the tenancy boundary.
*
* <p>Three skill id flavors to handle:
* <ul>
* <li><b>Real {@code mate_skill} rows</b> straight mapper lookup,
* compare {@code workspace_id} to the agent's.</li>
* <li><b>Virtual MCP-derived ids</b> ({@code >= McpSkillBridge.VIRTUAL_ID_BASE})
* pass through. MCP servers carry no workspace concept today,
* so any agent in any workspace may bind any MCP virtual skill.
* The picker (/skills/enabled) hands these out to every workspace.</li>
* <li><b>Virtual ACP-derived ids</b> ({@code AcpSkillBridge}'s range)
* resolve through the bridge so the {@link SkillEntity#getWorkspaceId()}
* comes from the backing {@code mate_acp_endpoint.workspace_id},
* then apply the same workspace comparison.</li>
* </ul>
*
* <p>Most {@code mate_skill} rows currently sit in the default workspace
* (id=1) because skill creation doesn't yet honor the
* {@code X-Workspace-Id} header; the real-skill branch is therefore
* defense-in-depth right now and flips on automatically the moment
* workspace-scoped skill creation lands. ACP enforcement is live today.
*
* @throws MateClawException 404 if the agent or skill doesn't exist;
* 403 on a workspace mismatch.
*/
private void requireSameWorkspace(Long agentId, Long skillId) {
if (agentId == null) {
throw new MateClawException("err.agent.not_found", 404, "Agent ID is required");
}
if (skillId == null) {
throw new MateClawException("err.skill.not_found", 404, "Skill ID is required");
}
AgentEntity agent = agentMapper.selectById(agentId);
if (agent == null) {
throw new MateClawException("err.agent.not_found", 404, "Agent 不存在: " + agentId);
}
// MCP virtual: no workspace on McpServerEntity globally bindable.
if (McpSkillBridge.isVirtualMcpSkillId(skillId)) {
return;
}
SkillEntity skill;
if (AcpSkillBridge.isVirtualAcpSkillId(skillId)) {
// ACP virtual: synthesize from the bridge so workspace_id flows
// through from mate_acp_endpoint. A null reply here means the
// backing endpoint was deleted or disabled between picker render
// and save same surface as a deleted real skill.
skill = acpSkillBridge.findEntityById(skillId);
if (skill == null) {
throw new MateClawException("err.skill.not_found", 404,
"ACP endpoint backing skill " + skillId + " is gone or disabled");
}
} else {
skill = skillMapper.selectById(skillId);
if (skill == null) {
throw new MateClawException("err.skill.not_found", 404, "Skill 不存在: " + skillId);
}
}
long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
long skillWs = skill.getWorkspaceId() == null ? 1L : skill.getWorkspaceId();
if (agentWs != skillWs) {
throw new MateClawException("err.skill.cross_workspace_binding", 403,
"Skill " + skillId + " (workspace=" + skillWs
+ ") cannot be bound to Agent " + agentId
+ " (workspace=" + agentWs + ")");
}
}
// ==================== Tool Bindings ====================
public List<AgentToolBinding> listToolBindings(Long agentId) {
@ -175,6 +301,19 @@ public class AgentBindingService {
* contribute nothing through this path; legacy SKILL.md prompt
* enhancement still runs separately.</li>
* </ul>
*
* <p>Auto-included on every non-null result, in addition to the bound
* tools and skill-expanded tools:
* <ul>
* <li>{@link #SYSTEM_LEVEL_TOOLS} agent-wide primitives.</li>
* <li>Every currently-bindable MCP tool (any tool with
* {@code source="mcp"} and {@code available=true} in the picker).
* MCP servers are administrator-level capabilities; once enabled
* globally they should not be silently hidden from an agent that
* happens to have any other binding. To deny a specific MCP tool
* to a specific agent, use the tool-guard deny path applied
* upstream in {@code AgentGraphBuilder}.</li>
* </ul>
*/
public Set<String> getEffectiveToolNames(Long agentId) {
Set<Long> boundSkillIds = getBoundSkillIds(agentId);
@ -216,9 +355,41 @@ public class AgentBindingService {
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
merged.addAll(SYSTEM_LEVEL_TOOLS);
// Enabled MCP server tools auto-join the allowlist for the same
// reason SYSTEM_LEVEL_TOOLS does: MCP servers are an
// administrator-enabled capability, not a per-agent opt-in. Without
// this union, an agent with any skill or built-in tool bound would
// silently lose every MCP tool users hit this when they bound one
// built-in tool, didn't tick the MCP rows, and observed "only
// built-in tools work". Operators who need to hide a specific MCP
// tool from a specific agent still have the tool-guard deny path
// (AgentGraphBuilder applies withDeniedToolsFiltered before this).
merged.addAll(getEnabledMcpToolNames());
return merged;
}
/**
* Names of every currently-bindable MCP tool, sourced from the same
* picker that the agent edit screen reads. Failures (picker outage,
* cache parse error) yield an empty set so the caller's allowlist is
* strictly narrower, never wider, than the picker never throws.
*/
private Set<String> getEnabledMcpToolNames() {
try {
return availableToolService.listAvailable().stream()
.filter(t -> "mcp".equals(t.getSource()))
.filter(AvailableToolDTO::isAvailable)
.map(AvailableToolDTO::getName)
.filter(n -> n != null && !n.isBlank())
.collect(Collectors.toCollection(LinkedHashSet::new));
} catch (Exception e) {
log.warn("AvailableToolService unavailable while computing effective tool allowlist; "
+ "MCP tools will be excluded for this resolve cycle: {}", e.getMessage());
return Collections.emptySet();
}
}
/**
* RFC-090 §11 tools that exist outside the skill scope and must
* survive any agent-level skill binding restriction.
@ -277,6 +448,11 @@ public class AgentBindingService {
"image_generate",
"music_generate",
"video_generate",
// HTML PNG rasteriser. Closes the loop for HTML-producing skills
// (architecture-diagram, infographics, dashboards) so IM channels
// can deliver the artifact as a native image instead of a file or
// a dead markdown link.
"render_html_image",
// Universal capabilities the global system prompts (SOUL.md /
// AGENTS.md / "Web Search Capability" / "File Reading Guidelines")
// explicitly tell the LLM exist. Pre-Phase-2b they were globally
@ -335,9 +511,27 @@ public class AgentBindingService {
}
/**
* 批量设置 Agent tool 绑定替换模式
* Replace the agent's tool binding set.
*
* <p>Validation rule for each incoming name:
* <ul>
* <li><b>Already in the existing binding</b> always allowed (so the
* user can keep a previously-bound tool whose upstream MCP server
* is currently stale or even removed; the client just keeps what
* it already had).</li>
* <li><b>New addition (not in existing binding)</b> must appear in
* {@link AvailableToolService#listAvailable()} with
* {@code available == true}. Names that are unknown
* (typos / legacy unprefixed MCP names / hand-crafted strings) or
* that the picker marked unavailable (hash collision, etc.) are
* rejected saving them would put a {@code mate_agent_tool} row
* in the database that the runtime can never resolve, which then
* silently drops the tool when the agent runs.</li>
* </ul>
*/
public void setToolBindings(Long agentId, List<String> toolNames) {
validateNewToolBindings(agentId, toolNames);
toolBindingMapper.delete(
new LambdaQueryWrapper<AgentToolBinding>()
.eq(AgentToolBinding::getAgentId, agentId));
@ -352,6 +546,56 @@ public class AgentBindingService {
}
}
/**
* Refuse the save when any *newly-added* tool name doesn't resolve to
* an {@code available=true} row in the picker. Names already in the
* existing binding are exempt so that subsequent edits (especially
* "remove this stale tool") still succeed even if upstream state has
* drifted.
*/
private void validateNewToolBindings(Long agentId, List<String> incoming) {
if (incoming == null || incoming.isEmpty()) {
return;
}
Set<String> existing = listToolBindings(agentId).stream()
.map(AgentToolBinding::getToolName)
.collect(Collectors.toSet());
Set<String> bindable;
try {
bindable = availableToolService.listAvailable().stream()
.filter(AvailableToolDTO::isAvailable)
.map(AvailableToolDTO::getName)
.collect(Collectors.toSet());
} catch (Exception e) {
// The picker source briefly failing must not block the user
// from saving a binding that's still in their existing set.
// Re-validate everything against just the existing set
// strictly conservative: only allow keeps, refuse adds.
log.warn("AvailableToolService unavailable during binding validation, falling back to existing-only: {}",
e.getMessage());
bindable = Set.of();
}
List<String> rejected = new java.util.ArrayList<>();
for (String name : incoming) {
if (name == null || name.isBlank()) {
rejected.add("<blank>");
continue;
}
if (existing.contains(name)) continue; // keeps are always allowed
if (!bindable.contains(name)) rejected.add(name);
}
if (!rejected.isEmpty()) {
String preview = rejected.size() <= 5
? String.join(", ", rejected)
: String.join(", ", rejected.subList(0, 5)) + " (+" + (rejected.size() - 5) + " more)";
throw new MateClawException("err.agent.tool_binding_unbindable",
"Tool name(s) cannot be bound: " + preview
+ ". Either the name is unknown or the picker marked it unavailable "
+ "(e.g. hash collision, upstream server removed).");
}
}
// ==================== Provider Preferences (RFC-009 PR-3) ====================
/** Raw rows for the agent edit form. Sorted by sort_order ascending. */

View File

@ -172,7 +172,7 @@ public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
}
if (!modelProviderService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.agent.dashscope_key_missing",
"DashScope API Key 未配置,请在模型设置中填写 dashscope 的 API Key或设置 DASHSCOPE_API_KEY 环境变量");
"DashScope API Key 未配置,请在「设置 → 模型 → 添加供应商」中为 dashscope 填写 API Key");
}
builder.apiKey(apiKey.trim());

View File

@ -14,6 +14,7 @@ import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.ToolCallback;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.stereotype.Component;
import vip.mate.agent.graph.executor.ToolResultStorage;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.config.ConversationWindowProperties;
import vip.mate.memory.spi.MemoryManager;
@ -21,6 +22,7 @@ import vip.mate.workspace.conversation.ConversationService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
@ -61,18 +63,35 @@ public class ConversationWindowManager {
/** 迭代更新:合并旧摘要 + 新轮次 */
private static final String STRUCTURED_SUMMARY_UPDATE = PromptLoader.loadPrompt("context/structured-summary-update");
/** 摘要注入前缀 */
private static final String SUMMARY_PREFIX =
/** 摘要注入前缀 (package-private for test assertions) */
static final String SUMMARY_PREFIX =
"[上下文压缩] 更早的对话轮次已被压缩为摘要以节省上下文空间。" +
"以下摘要描述了已完成的工作,当前会话状态可能已反映这些变更。" +
"请基于摘要和当前状态继续,避免重复已完成的工作:\n\n";
/**
* Marker prefix used by the first-user anchor. Lets compaction skip
* previously-injected anchors when looking for the "real" first user
* message in a subsequent round.
*
* <p>Package-private so unit tests can assert on the marker.
*/
static final String ANCHOR_PREFIX = "[Original goal]\n";
// ==================== 序列化截断参数 ====================
private static final int CONTENT_MAX = 6000;
private static final int CONTENT_HEAD = 4000;
private static final int CONTENT_TAIL = 1500;
private static final int OLD_TOOL_RESULT_SUMMARY_THRESHOLD = 500;
/**
* Minimum body size at which the duplicate-output placeholder is preferred
* over keeping the verbatim copy. Below this size the placeholder text
* (~80 chars) is comparable to the body itself, so deduplication only
* complicates the prompt without saving meaningful tokens. Above this
* size the dedup placeholder is a real win.
*/
private static final int DEDUP_MIN_CHARS = 500;
/**
* Tool names whose results must never be compacted into a one-line
@ -99,6 +118,35 @@ public class ConversationWindowManager {
private final MemoryManager memoryManager;
private final ConversationService conversationService;
/**
* Optional spill store, injected via setter so unit tests and the two
* existing 3-arg constructor callers in tests stay source-compatible.
* When {@code null}, prune falls back to "keep originals verbatim" no
* lossy summary rewrite is ever applied. Spring autowires this when
* {@link ToolResultStorage} is on the context.
*/
private ToolResultStorage toolResultStorage;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setToolResultStorage(ToolResultStorage toolResultStorage) {
this.toolResultStorage = toolResultStorage;
}
/**
* Optional stream tracker for broadcasting {@code compact_status}
* SSE events. Wired via setter so unit tests can leave it {@code null}
* without dragging in the channel layer. When present, every
* compaction emits start/skipped/summarize/done events so the
* frontend can render a boundary card and a status line in real
* time.
*/
private vip.mate.channel.web.ChatStreamTracker streamTracker;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setStreamTracker(vip.mate.channel.web.ChatStreamTracker streamTracker) {
this.streamTracker = streamTracker;
}
// ==================== 状态 ====================
/** 摘要缓存key = "conversationId:oldMessageCount" */
@ -133,7 +181,7 @@ public class ConversationWindowManager {
Integer maxInputTokens, ChatModel chatModel,
String conversationId, Long agentId) {
return fitToWindow(messages, systemPrompt, currentUserMessage,
maxInputTokens, chatModel, conversationId, agentId, null);
maxInputTokens, chatModel, conversationId, agentId, null, null);
}
/**
@ -149,10 +197,33 @@ public class ConversationWindowManager {
Integer maxInputTokens, ChatModel chatModel,
String conversationId, Long agentId,
java.util.Collection<ToolCallback> toolCallbacks) {
return fitToWindow(messages, systemPrompt, currentUserMessage,
maxInputTokens, chatModel, conversationId, agentId, toolCallbacks, null);
}
/**
* Most comprehensive overload adds {@code workspaceBasePath} so the
* pre-pass that prunes old tool results can route oversized bodies to
* the agent's workspace spill directory via {@link ToolResultStorage}.
*
* <p>When {@code workspaceBasePath} is {@code null}, spill files land in
* the configured base dir, or the JVM tmpdir as last resort (see
* {@link ToolResultStorage#resolveBaseDir(String)}). Workspace-aware
* callers should always pass the path so historical spill files stay
* grouped with the workspace that produced them.
*/
public List<Message> fitToWindow(List<Message> messages, String systemPrompt,
String currentUserMessage,
Integer maxInputTokens, ChatModel chatModel,
String conversationId, Long agentId,
java.util.Collection<ToolCallback> toolCallbacks,
String workspaceBasePath) {
if (messages == null || messages.isEmpty()) {
return messages;
}
messages = pruneOldToolResultsForModelInput(messages);
long spillsAtEntry = (toolResultStorage != null) ? toolResultStorage.getSpillCount() : 0L;
messages = pruneOldToolResultsForModelInput(messages, conversationId, workspaceBasePath);
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens();
@ -176,7 +247,7 @@ public class ConversationWindowManager {
// 可用于历史的 token 预算 = max - system - currentMsg - tools - 安全余量
int reservedTokens = systemTokens + currentMsgTokens + toolsTokens + (int) (effectiveMax * 0.05);
// RFC-025 Change 1: reserve 硬封顶到 effectiveMax 50%
// 预留 reserve 硬封顶到 effectiveMax 50%
// 小上下文模型Ollama 16K本地 8KsystemTokens + currentMsgTokens 很容易
// 接近或超过 effectiveMax不封顶会让 historyBudget 变负数导致死循环压缩
// 压缩目标比压缩前还大 压缩后又触发压缩
@ -191,7 +262,8 @@ public class ConversationWindowManager {
// 尾部保护 token 预算阈值的 20% Hermes 一致
int tailTokenBudget = (int) (triggerThreshold * 0.20);
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel, conversationId, agentId);
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel,
conversationId, agentId, totalTokens, spillsAtEntry);
}
/**
@ -207,18 +279,62 @@ public class ConversationWindowManager {
// ==================== 核心压缩逻辑 ====================
/** Broadcast a single compact_status event; silent no-op when no tracker is wired. */
private void broadcastCompactStatus(String conversationId, String status, Map<String, Object> extra) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
return;
}
try {
Map<String, Object> payload = new java.util.LinkedHashMap<>();
payload.put("status", status);
payload.put("timestamp", System.currentTimeMillis());
if (extra != null) payload.putAll(extra);
streamTracker.broadcastObject(conversationId, "compact_status", payload);
} catch (Exception e) {
log.debug("[ConversationWindow] broadcast compact_status failed: {}", e.getMessage());
}
}
private List<Message> compactMessages(List<Message> messages, int historyBudget,
int tailTokenBudget, ChatModel chatModel,
String conversationId, Long agentId) {
String conversationId, Long agentId,
int preTokens, long spillsAtEntry) {
broadcastCompactStatus(conversationId, "start", Map.of(
"preTokens", preTokens,
"messagesIn", messages.size(),
"trigger", "token_threshold"
));
// 动态计算尾部保护边界替代固定 preserveRecentPairs
int headEnd = 0; // 头部保护暂不保护system prompt 已在外部计算
int tailStart = findTailBoundary(messages, headEnd, tailTokenBudget);
if (tailStart <= headEnd) {
log.debug("[ConversationWindow] 消息数不足以拆分,跳过压缩");
broadcastCompactStatus(conversationId, "skipped",
Map.of("reason", "insufficient_messages"));
return messages;
}
// Pair safety: never split an AssistantMessage's tool_calls from its
// matching ToolResponseMessages. The cut may walk forward (i.e. the
// tail grows) until every call/response cluster lives on one side of
// the boundary. If no safe cut survives the walk, skip compaction
// a broken pair would 400 every OpenAI-compatible provider, which is
// strictly worse than letting context cross the budget by one extra
// turn.
int pairSafeCut = enforcePairSafeBoundary(messages, headEnd, tailStart);
if (pairSafeCut <= headEnd) {
broadcastCompactStatus(conversationId, "skipped",
Map.of("reason", "pair_boundary_collapsed"));
return messages;
}
if (pairSafeCut != tailStart) {
broadcastCompactStatus(conversationId, "pair_safe", Map.of(
"movedFrom", tailStart, "movedTo", pairSafeCut));
}
tailStart = pairSafeCut;
List<Message> oldMessages = new ArrayList<>(messages.subList(headEnd, tailStart));
List<Message> recentMessages = messages.subList(tailStart, messages.size());
@ -274,13 +390,20 @@ public class ConversationWindowManager {
// 计算动态摘要预算
int summaryBudget = computeSummaryBudget(forSummary);
broadcastCompactStatus(conversationId, "summarize", Map.of(
"messagesToSummarize", oldMessages.size(),
"summaryBudget", summaryBudget
));
// 检查缓存
String cacheKey = conversationId + ":" + oldMessages.size();
CachedSummary cached = summaryCache.get(cacheKey);
String summary;
boolean fromCache = false;
if (cached != null && !cached.isExpired(CACHE_TTL_MS)) {
summary = cached.summary();
fromCache = true;
log.debug("[ConversationWindow] 命中摘要缓存, conv={}", conversationId);
} else {
summary = generateSummary(forSummary, chatModel, conversationId, summaryBudget, memoryExtraContext);
@ -289,27 +412,32 @@ public class ConversationWindowManager {
int count = compressionCounts.merge(conversationId, 1, Integer::sum);
log.info("[ConversationWindow] 生成结构化摘要 ({} 字符, 第 {} 次压缩), 压缩 {} 条旧消息, conv={}",
summary.length(), count, oldMessages.size(), conversationId);
// 持久化摘要到 DB下次加载历史时可直接从摘要位置开始跳过重复压缩
if (conversationService != null) {
try {
conversationService.saveCompressionSummary(
conversationId, SUMMARY_PREFIX + summary, oldMessages.size());
} catch (Exception e) {
log.warn("[ConversationWindow] Failed to persist compression summary: {}", e.getMessage());
}
}
}
}
// 组装结果
List<Message> result = new ArrayList<>();
boolean anchored = false;
if (summary != null && !summary.isBlank()) {
result.add(new UserMessage(SUMMARY_PREFIX + summary));
// Anchor the original user goal so a long task that paged through
// dozens of turns can still see what was originally asked. Always
// as a UserMessage promoting historical user input to a
// SystemMessage would be a privilege-escalation risk.
Message anchor = buildFirstUserAnchor(oldMessages);
if (anchor != null) {
result.add(anchor);
anchored = true;
}
} else if (!oldMessages.isEmpty()) {
log.warn("[ConversationWindow] 摘要生成失败,降级为保留最近 4 条旧消息, conv={}", conversationId);
int fallbackKeep = Math.min(4, oldMessages.size());
result.addAll(oldMessages.subList(oldMessages.size() - fallbackKeep, oldMessages.size()));
broadcastCompactStatus(conversationId, "failed", Map.of(
"reason", "summary_generation_failed",
"fallbackKept", fallbackKeep
));
}
result.addAll(recentMessages);
@ -318,6 +446,48 @@ public class ConversationWindowManager {
if (resultTokens > historyBudget && result.size() > 2) {
log.warn("[ConversationWindow] 压缩后仍超预算: {} > {}, 执行二次裁剪", resultTokens, historyBudget);
result = trimToFit(result, historyBudget);
resultTokens = TokenEstimator.estimateTokens(result);
}
// Persist the boundary + announce completion only when the summary
// actually wrote a row. Failed-summary fallback already broadcast
// its own event above.
if (summary != null && !summary.isBlank() && conversationService != null && !fromCache) {
long spillsThisTurn = (toolResultStorage != null)
? Math.max(0L, toolResultStorage.getSpillCount() - spillsAtEntry)
: 0L;
Map<String, Object> boundaryMetadata = new java.util.LinkedHashMap<>();
boundaryMetadata.put("trigger", "token_threshold");
boundaryMetadata.put("preTokens", preTokens);
boundaryMetadata.put("postTokens", resultTokens);
boundaryMetadata.put("messagesSummarized", oldMessages.size());
boundaryMetadata.put("tailKept", recentMessages.size());
boundaryMetadata.put("toolResultsSpilled", spillsThisTurn);
boundaryMetadata.put("anchored", anchored);
Long summaryId = null;
try {
summaryId = conversationService.saveCompressionSummaryReturningId(
conversationId, SUMMARY_PREFIX + summary, oldMessages.size(),
boundaryMetadata);
} catch (Exception e) {
log.warn("[ConversationWindow] Failed to persist compression boundary: {}", e.getMessage());
}
if (summaryId != null) {
// Mirror the DB row's metadata: the SSE consumer needs the id
// to deep-link the boundary card without having to refetch.
boundaryMetadata.put("summaryId", summaryId);
}
broadcastCompactStatus(conversationId, "done", boundaryMetadata);
} else if (summary != null && !summary.isBlank() && fromCache) {
// Cached summary path no new DB row, but emit done so the
// frontend status bar still updates.
broadcastCompactStatus(conversationId, "done", Map.of(
"preTokens", preTokens,
"postTokens", resultTokens,
"messagesSummarized", oldMessages.size(),
"tailKept", recentMessages.size(),
"fromCache", true
));
}
return result;
@ -362,6 +532,201 @@ public class ConversationWindowManager {
return Math.max(cutIdx, headEnd + 1);
}
/**
* Adjust the candidate boundary so an {@link AssistantMessage}'s
* {@code toolCalls} are never separated from their matching
* {@link ToolResponseMessage}s.
*
* <p>Walks forward, collecting every {@code tool_call_id}'s assistant
* index and the indices of its matching responses. Whenever an
* assistant in the prefix has at least one response in the tail, the
* cut moves backward to that assistant pulling the whole cluster
* into the tail. The walk repeats until convergence because moving
* the cut can expose pairs that were previously fully in the tail.
*
* <p>The method preserves pair integrity above any other concern. If
* the cut collapses all the way to {@code headEnd}, callers must
* interpret the return as "skip compaction this turn" splitting a
* pair would produce HTTP 400 on every OpenAI-compatible provider,
* which is a worse failure mode than letting context grow by one turn.
*
* <p>An orphan {@code ToolResponseMessage} (id matching no
* assistant in scope) does not trigger movement; the upstream code
* paths should never produce one, and logging at WARN gives us a
* breadcrumb if they ever do.
*
* @return adjusted cut index, or {@code headEnd} when no pair-safe
* cut larger than {@code headEnd} can be produced.
*/
// Package-private so unit tests in the same package can drive it directly
// without standing up a ChatModel + the rest of the compactMessages pipeline.
int enforcePairSafeBoundary(List<Message> messages, int headEnd, int tailStart) {
if (tailStart <= headEnd || tailStart >= messages.size()) {
return tailStart;
}
int cut = tailStart;
int safety = messages.size() + 1; // hard guard against pathological loops
while (safety-- > 0) {
// Map: tool_call_id -> earliest assistant index that issued it.
java.util.Map<String, Integer> assistantIdxById = new java.util.HashMap<>();
// Map: tool_call_id -> max response index closing it.
java.util.Map<String, Integer> latestResponseIdxById = new java.util.HashMap<>();
for (int i = headEnd; i < messages.size(); i++) {
Message m = messages.get(i);
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
String tid = tc.id();
if (tid == null || tid.isEmpty()) continue;
// Keep the first occurrence so the cut "snaps" to the
// earliest assistant for any duplicated ids; the same
// id should never repeat anyway.
assistantIdxById.putIfAbsent(tid, i);
}
} else if (m instanceof ToolResponseMessage trm) {
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
String tid = r.id();
if (tid == null || tid.isEmpty()) continue;
latestResponseIdxById.merge(tid, i, Math::max);
}
}
}
// Find the earliest in-prefix assistant whose pair is split.
int earliestSplitAssistant = Integer.MAX_VALUE;
for (var e : assistantIdxById.entrySet()) {
String id = e.getKey();
int aIdx = e.getValue();
Integer rIdx = latestResponseIdxById.get(id);
if (rIdx == null) {
// Assistant issued a call but no response orphan call,
// would already break the provider. Not a pair-split, ignore.
continue;
}
if (aIdx < cut && rIdx >= cut && aIdx < earliestSplitAssistant) {
earliestSplitAssistant = aIdx;
}
if (aIdx >= cut && rIdx < cut) {
log.warn("[ConversationWindow] Orphan tool response in prefix without preceding assistant in tail (id={}); leaving boundary alone",
id);
}
}
if (earliestSplitAssistant == Integer.MAX_VALUE) {
break; // converged: no splits remain
}
cut = earliestSplitAssistant;
}
if (cut <= headEnd) {
log.info("[ConversationWindow] Pair-safe boundary collapsed to {} for conv: skipping compaction this turn to avoid splitting a tool_call ↔ tool_response pair",
headEnd);
return headEnd;
}
int prefixSize = cut - headEnd;
int minPrefix = Math.max(0, properties.getPairSafeMinPrefixToCompact());
if (prefixSize < minPrefix) {
log.info("[ConversationWindow] Pair-safe boundary left {} prefix message(s) (< minPrefix={}); skipping compaction",
prefixSize, minPrefix);
return headEnd;
}
if (cut != tailStart) {
log.info("[ConversationWindow] Pair-safe boundary moved {} -> {} to keep tool_call ↔ tool_response pairs intact",
tailStart, cut);
}
return cut;
}
/**
* Build an anchor message replaying the first <em>real</em> user input
* found in the compressed prefix. "Real" here excludes prior
* compaction artifacts ({@link #SUMMARY_PREFIX} / {@link #ANCHOR_PREFIX}
* messages from earlier rounds), because anchoring the previous
* summary defeats the purpose the model would just see "[Original
* goal] [上下文压缩] " pointing at compressor output, not at the user's
* actual request.
*
* <p>Sizing rules:
* <ul>
* <li> {@code firstUserAnchorMaxTokens}: keep the original text verbatim.</li>
* <li> 3× the budget: head+tail truncate to the budget so most of
* the prompt-cache benefit survives.</li>
* <li>&gt; 3× the budget: degrade to a 200-char pointer line so we
* don't blow prompt cache or the summary budget on a single
* message that was probably a pasted spec the model can re-read
* from the workspace anyway.</li>
* </ul>
*
* <p>Always returns a {@link UserMessage}. {@code null} when anchoring
* is disabled, no real first user exists in the prefix, or the body is
* blank.
*
* <p>Package-private for direct unit testing the surrounding
* {@link #compactMessages} path needs a ChatModel and the whole
* structured-summary pipeline, which the anchor logic does not.
*/
Message buildFirstUserAnchor(List<Message> oldMessages) {
if (!properties.isFirstUserAnchorEnabled()) {
return null;
}
UserMessage firstUser = null;
for (Message m : oldMessages) {
if (!(m instanceof UserMessage um)) continue;
String text = um.getText();
if (text == null) continue;
// Skip synthetic prior-round artifacts.
if (text.startsWith(SUMMARY_PREFIX) || text.startsWith(ANCHOR_PREFIX)) {
continue;
}
firstUser = um;
break;
}
if (firstUser == null) return null;
String text = firstUser.getText();
if (text == null || text.isBlank()) return null;
int maxAnchorTokens = Math.max(40, properties.getFirstUserAnchorMaxTokens());
int textTokens = TokenEstimator.estimateTokens(text);
if (textTokens <= maxAnchorTokens) {
return new UserMessage(ANCHOR_PREFIX + text);
}
// > 3× budget: cheap pointer line so we don't pay token tax for a
// gigantic pasted spec. The model still knows the original goal
// existed without seeing the full body.
if (textTokens > maxAnchorTokens * 3L) {
int pointerChars = Math.min(text.length(), 200);
String pointer = text.substring(0, pointerChars).stripTrailing()
+ (text.length() > pointerChars ? "..." : "");
log.info("[ConversationWindow] First-user anchor downgraded to pointer ({} tokens > 3× budget {})",
textTokens, maxAnchorTokens);
return new UserMessage(ANCHOR_PREFIX + pointer);
}
// Within 3× head+tail truncate to the budget. The 2 chars/token
// ratio is a deliberate over-estimate so the anchor never inflates
// past the configured budget on ASCII-heavy input.
int budgetChars = Math.max(160, maxAnchorTokens * 2);
if (budgetChars >= text.length()) {
return new UserMessage(ANCHOR_PREFIX + text);
}
int headLen = (int) (budgetChars * 0.6);
int tailLen = Math.max(40, budgetChars - headLen - 40);
if (headLen + tailLen >= text.length()) {
return new UserMessage(ANCHOR_PREFIX + text);
}
String truncated = text.substring(0, headLen)
+ "\n...[" + (text.length() - headLen - tailLen) + " chars truncated]...\n"
+ text.substring(text.length() - tailLen);
log.info("[ConversationWindow] First-user anchor head+tail truncated ({} -> ~{} chars)",
text.length(), truncated.length());
return new UserMessage(ANCHOR_PREFIX + truncated);
}
/**
* 计算摘要字数预算被压缩内容 token 20%不低于 500不超过 3000
*/
@ -374,7 +739,54 @@ public class ConversationWindowManager {
// ==================== 工具结果处理 ====================
/**
* Backwards-compatible overload older tool results that are oversized
* stay verbatim because no {@link ToolResultStorage} target is in
* scope. New call sites should use the 3-arg overload with explicit
* {@code conversationId} and {@code workspaceBasePath} so oversized
* bodies can be spilled to disk and recovered via {@code read_file}.
*/
public List<Message> pruneOldToolResultsForModelInput(List<Message> messages) {
return pruneOldToolResultsForModelInput(messages, null, null);
}
/**
* Walk the messages newest-to-oldest, keeping the latest tool response
* verbatim and applying space-saving rewrites to older ones:
*
* <ol>
* <li>Bodies already starting with {@link ToolResultStorage#SPILL_MARKER_PREFIX}
* were spilled at tool-execution time pass through untouched.</li>
* <li>If a body matches an identical body already seen in a newer turn,
* replace it with a short "duplicate tool output omitted" placeholder
* (only above {@link #DEDUP_MIN_CHARS} so we don't bloat tiny acks).</li>
* <li>Otherwise, when a {@link ToolResultStorage} is wired and a
* conversation id is available, try
* {@link ToolResultStorage#persistIfOversized} to spill the raw
* bytes to disk and replace the inline body with a preview + path
* so the model can read_file the original on demand.</li>
* <li>If none of the above apply, leave the body verbatim. Bodies
* under the spill threshold or running without a storage hook are
* preserved exactly the lossy "summarized for model context"
* single-liner that used to fire here destroyed enough context
* on long tasks to be the wrong default.</li>
* </ol>
*
* <p>The {@link #PRUNE_EXEMPT_TOOLS} set still bypasses everything:
* sub-agent delegations are not replayable, so their full transcript
* stays in context.
*
* @param messages full conversation in chronological order
* @param conversationId used to scope spill files; {@code null} disables spill
* @param workspaceBasePath used to locate the spill directory; {@code null}
* falls back through the storage's resolveBaseDir chain
*/
public List<Message> pruneOldToolResultsForModelInput(List<Message> messages,
String conversationId,
String workspaceBasePath) {
if (messages == null || messages.isEmpty()) {
return messages;
}
int latestToolResponseIndex = -1;
for (int i = messages.size() - 1; i >= 0; i--) {
if (messages.get(i) instanceof ToolResponseMessage) {
@ -386,9 +798,13 @@ public class ConversationWindowManager {
return messages;
}
boolean canSpill = toolResultStorage != null
&& conversationId != null && !conversationId.isEmpty();
List<Message> pruned = new ArrayList<>(messages);
java.util.Set<String> seenLargeOutputs = new java.util.HashSet<>();
int changed = 0;
int spilled = 0;
for (int i = pruned.size() - 1; i >= 0; i--) {
if (!(pruned.get(i) instanceof ToolResponseMessage trm)) {
continue;
@ -398,23 +814,53 @@ public class ConversationWindowManager {
boolean messageChanged = false;
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
String data = r.responseData();
boolean exempt = r.name() != null && PRUNE_EXEMPT_TOOLS.contains(r.name());
if (keepFull || exempt || data == null || data.length() <= OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
String name = r.name();
boolean exempt = name != null && PRUNE_EXEMPT_TOOLS.contains(name);
boolean alreadySpilled = data != null
&& data.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX);
// Pass through: the latest response, exempt tools, empty bodies,
// already-spilled previews none should be rewritten.
if (keepFull || exempt || data == null || data.isEmpty() || alreadySpilled) {
newResponses.add(r);
if (data != null && data.length() > OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
if (data != null && data.length() > DEDUP_MIN_CHARS) {
seenLargeOutputs.add(data);
}
continue;
}
String replacement;
if (seenLargeOutputs.contains(data)) {
replacement = "[" + r.name() + "] duplicate tool output omitted; same content appeared later.";
} else {
replacement = summarizeToolResponse(r.name(), data);
// Dedup: identical body seen in a later turn already.
if (data.length() > DEDUP_MIN_CHARS && seenLargeOutputs.contains(data)) {
String replacement = "[" + name
+ "] duplicate tool output omitted; same content appeared later.";
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), name, replacement));
messageChanged = true;
continue;
}
// Spill on demand: route oversized bodies to disk so the model
// can read_file them rather than losing them to a lossy summary.
if (canSpill) {
String candidate = toolResultStorage.persistIfOversized(
data, name, r.id(), conversationId, workspaceBasePath);
if (candidate != null
&& candidate.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)) {
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), name, candidate));
seenLargeOutputs.add(data);
messageChanged = true;
spilled++;
continue;
}
// returned unchanged: under threshold, excluded tool, or write failed.
// Fall through to "keep verbatim".
}
// Default: keep the body verbatim. Better to send a few extra
// tokens than to silently destroy data the model might need.
newResponses.add(r);
if (data.length() > DEDUP_MIN_CHARS) {
seenLargeOutputs.add(data);
}
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), replacement));
messageChanged = true;
}
if (messageChanged) {
pruned.set(i, ToolResponseMessage.builder().responses(newResponses).build());
@ -422,47 +868,43 @@ public class ConversationWindowManager {
}
}
if (changed > 0) {
log.info("[ConversationWindow] Pruned {} older tool response message(s) before model request", changed);
log.info("[ConversationWindow] Pruned {} older tool response message(s) ({} spilled to disk) before model request",
changed, spilled);
}
return changed > 0 ? pruned : messages;
}
private static String summarizeToolResponse(String toolName, String data) {
int chars = data.length();
int lines = data.isBlank() ? 0 : data.split("\\R", -1).length;
String firstLine = firstNonBlankLine(data);
if (firstLine.length() > 160) {
firstLine = firstLine.substring(0, 160) + "...";
}
StringBuilder sb = new StringBuilder();
sb.append('[').append(toolName).append("] previous tool output summarized for model context: ")
.append(chars).append(" chars, ").append(lines).append(" lines");
if (!firstLine.isBlank()) {
sb.append(". First line: ").append(firstLine);
}
return sb.toString();
}
private static String firstNonBlankLine(String data) {
for (String line : data.split("\\R")) {
String trimmed = line.trim();
if (!trimmed.isBlank()) {
return trimmed.replace('|', '/');
}
}
return "";
/**
* Spill-marker responses already point at an on-disk full copy via
* {@code path=...} in their body. Trimming, replacing, or pre-pruning
* them would destroy the very pointer the model needs to recover the
* original output with {@code read_file} which is the whole reason
* we spilled in the first place. All three compaction phases consult
* this guard before touching a response.
*/
static boolean isSpillMarker(ToolResponseMessage.ToolResponse r) {
return r != null
&& r.responseData() != null
&& r.responseData().startsWith(ToolResultStorage.SPILL_MARKER_PREFIX);
}
/**
* Phase 1 - Soft trim对工具结果做 head+tail 裁剪保留首尾各 200 字符
* <p>Spill-marker responses are left untouched so their on-disk pointer
* survives intact across compaction.
*/
private int softTrimToolResults(List<Message> messages) {
int softTrimToolResults(List<Message> messages) {
int trimmed = 0;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
List<ToolResponseMessage.ToolResponse> newResponses = new ArrayList<>();
boolean changed = false;
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (isSpillMarker(r)) {
// Pointer + preview already; trimming would lose the path.
newResponses.add(r);
continue;
}
String data = r.responseData();
if (data != null && data.length() > 500) {
String head = data.substring(0, 200);
@ -485,35 +927,56 @@ public class ConversationWindowManager {
/**
* Phase 2 - Hard clear将所有旧工具结果替换为占位符
* <p>Spill-marker responses are left untouched so the on-disk pointer
* survives a placeholder here would force the model to abandon a
* tool output it could otherwise recover via {@code read_file}.
*/
private int hardClearToolResults(List<Message> messages) {
int hardClearToolResults(List<Message> messages) {
int cleared = 0;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
List<ToolResponseMessage.ToolResponse> placeholders = trm.getResponses().stream()
.map(r -> new ToolResponseMessage.ToolResponse(r.id(), r.name(), "[tool result removed]"))
.toList();
messages.set(i, ToolResponseMessage.builder().responses(placeholders).build());
boolean changed = false;
List<ToolResponseMessage.ToolResponse> replaced = new ArrayList<>(trm.getResponses().size());
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (isSpillMarker(r)) {
replaced.add(r);
continue;
}
replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), "[tool result removed]"));
changed = true;
}
if (changed) {
messages.set(i, ToolResponseMessage.builder().responses(replaced).build());
cleared++;
}
}
}
return cleared;
}
/**
* Phase 3 Pre-prune LLM 摘要前将工具输出替换为占位符减少摘要输入 token
* <p>Spill-marker responses are left untouched so the summary input
* still has the on-disk path the model might cite back in its summary.
*/
private int prePruneForSummary(List<Message> messages) {
int prePruneForSummary(List<Message> messages) {
int pruned = 0;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
boolean hasSubstantial = trm.getResponses().stream()
.anyMatch(r -> r.responseData() != null && r.responseData().length() > 200);
.anyMatch(r -> !isSpillMarker(r)
&& r.responseData() != null
&& r.responseData().length() > 200);
if (hasSubstantial) {
List<ToolResponseMessage.ToolResponse> placeholders = trm.getResponses().stream()
.map(r -> new ToolResponseMessage.ToolResponse(r.id(), r.name(),
"[旧工具输出已清理以节省上下文空间]"))
.toList();
List<ToolResponseMessage.ToolResponse> placeholders = new ArrayList<>(trm.getResponses().size());
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (isSpillMarker(r)) {
placeholders.add(r);
continue;
}
placeholders.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(),
"[旧工具输出已清理以节省上下文空间]"));
}
messages.set(i, ToolResponseMessage.builder().responses(placeholders).build());
pruned++;
}

View File

@ -11,7 +11,13 @@ import vip.mate.channel.web.Utf8SseEmitter;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.vo.AgentCapabilitiesVO;
import vip.mate.audit.service.AuditEventService;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelCapabilityService;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.system.service.SystemSettingService;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.common.result.R;
@ -40,16 +46,22 @@ public class AgentController {
private final AuditEventService auditEventService;
private final AuthService authService;
private final WorkspaceService workspaceService;
private final ModelConfigService modelConfigService;
private final ModelCapabilityService modelCapabilityService;
private final SystemSettingService systemSettingService;
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
@Operation(summary = "获取Agent列表")
@GetMapping
@RequireWorkspaceRole("viewer")
public R<List<AgentEntity>> list(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestParam(value = "enabled", required = false) Boolean enabled) {
// header 时强制使用默认 workspace不返回全局数据
long wsId = workspaceId != null ? workspaceId : 1L;
return R.ok(agentService.listAgentsByWorkspace(wsId));
// enabled=true: chat selectors hide disabled agents.
// enabled=null: admin management page sees enabled + disabled.
return R.ok(agentService.listAgentsByWorkspace(wsId, enabled));
}
@Operation(summary = "获取Agent详情")
@ -62,6 +74,57 @@ public class AgentController {
return R.ok(agent);
}
@Operation(summary = "获取Agent当前能力modality 集合 + sidecar 配置),用于聊天页提示条")
@GetMapping("/{id}/capabilities")
@RequireWorkspaceRole("viewer")
public R<AgentCapabilitiesVO> capabilities(
@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent.getWorkspaceId(), workspaceId);
ModelConfigEntity primary;
try {
primary = modelConfigService.resolveModel(agent.getModelName());
} catch (Exception e) {
// No default model configured yet return a capabilities snapshot that
// tells the UI "we can't say anything about this agent's modalities".
return R.ok(AgentCapabilitiesVO.builder()
.agentId(id)
.modelName("")
.providerId("")
.modalities(List.of())
.build());
}
java.util.Set<ModelCapabilityService.Modality> modalities =
modelCapabilityService.resolve(primary.getModelName(), primary.getModalities());
SystemSettingsDTO settings = systemSettingService.getSettings();
Long visionId = settings.getDefaultVisionModelId();
Long videoId = settings.getDefaultVideoModelId();
return R.ok(AgentCapabilitiesVO.builder()
.agentId(id)
.modelName(primary.getModelName())
.providerId(primary.getProvider())
.modalities(modalities.stream().map(Enum::name).toList())
.defaultVisionModelId(visionId)
.defaultVisionModelLabel(resolveSidecarLabel(visionId))
.defaultVideoModelId(videoId)
.defaultVideoModelLabel(resolveSidecarLabel(videoId))
.build());
}
private String resolveSidecarLabel(Long modelId) {
if (modelId == null) return null;
try {
ModelConfigEntity m = modelConfigService.getModel(modelId);
return m == null ? null : m.getProvider() + " / " + m.getModelName();
} catch (Exception e) {
return null;
}
}
@Operation(summary = "创建Agent")
@PostMapping
@RequireWorkspaceRole("member")
@ -127,6 +190,7 @@ public class AgentController {
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
verifyAgentEnabled(agent);
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8防止中文 SSE 乱码
SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L);
@ -166,6 +230,7 @@ public class AgentController {
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
verifyAgentEnabled(agent);
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId()));
}
@ -178,6 +243,7 @@ public class AgentController {
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
verifyAgentEnabled(agent);
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId()));
}
@ -208,6 +274,21 @@ public class AgentController {
}
}
/**
* Block runtime calls against an agent flagged as disabled.
*
* <p>{@code AgentService#getOrBuildAgent} also checks the flag, but only on
* a cache miss once the {@code BaseAgent} instance is warm, a flip to
* disabled would silently keep serving requests until something else
* invalidates the cache. Enforcing here at the controller closes that gap
* for every external entry point.
*/
private void verifyAgentEnabled(AgentEntity agent) {
if (agent != null && !Boolean.TRUE.equals(agent.getEnabled())) {
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + agent.getName());
}
}
private Long resolveUserId(Authentication auth) {
if (auth == null) {
throw new MateClawException("err.auth.unauthenticated", 401, "Not authenticated");

View File

@ -0,0 +1,25 @@
package vip.mate.agent.event;
/**
* Spring application event fired when an agent's lifecycle state changes.
* The trigger module subscribes via {@code @EventListener} and forwards
* the payload through {@code TriggerEventIngestService} so triggers of
* pattern type {@code agent_lifecycle} can fan out to workflows.
*
* <p>{@code phase} matches the matcher's vocabulary: {@code spawned} for
* a fresh create, {@code enabled} / {@code disabled} for a flag flip,
* {@code terminated} for a delete. {@code crashed} is reserved for v1
* once the agent runtime grows a structured error hook.
*
* <p>The dedup key downstream is {@code phase + ":" + agentId + ":" +
* timestamp}; that's stable across retries of the same operation but
* lets the same agent flip enabled/disabled repeatedly without the
* trigger pipeline collapsing the events.
*/
public record AgentLifecycleEvent(
long workspaceId,
long agentId,
String agentName,
String phase,
long timestamp
) {}

View File

@ -1,5 +1,6 @@
package vip.mate.agent.graph;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
@ -283,6 +284,35 @@ public class NodeStreamingChatHelper {
*/
private static final int THINKING_ONLY_HARD_CAP_CHARS = 32768;
/**
* Narrow content-repetition guard fires when the buffer ends with
* the same period-sized chunk repeated {@link
* #CONTENT_REPEAT_MAX_OCCURRENCES}+ times in a row. Picked to catch
* the specific failure mode where reasoning-mode models (qwen3.6,
* deepseek-r1) get into a "Wait, I should X. → 写答案 → Wait, I
* should Y. 写同一份答案 " self-arguing loop and emit the same
* final-answer paragraph dozens of times until {@code max_tokens}
* runs out.
*
* <p>Tests probe sizes from {@link #CONTENT_REPEAT_MIN_PERIOD} up
* to {@link #CONTENT_REPEAT_MAX_PERIOD}; the smallest period that
* yields the required consecutive copies trips the guard. 4
* verbatim consecutive copies of any 24+ char unit is a near-
* impossible coincidence in real text, so false positives are very
* rare. Not as exhaustive as the previous {@code RepetitionDetector}
* (removed at 42d406ff for being brittle on legitimate long-form
* content), just the cheap specific check that catches this loop.
*/
public static final int CONTENT_REPEAT_MIN_PERIOD = 24;
public static final int CONTENT_REPEAT_MAX_PERIOD = 240;
private static final int CONTENT_REPEAT_MAX_OCCURRENCES = 4;
/**
* Re-scan every N chars of new content. Smaller = faster reaction,
* larger = less CPU. The probe loop is O(period_range × occurrences)
* char comparisons per scan cheap even at 400-char intervals.
*/
private static final int CONTENT_REPEAT_CHECK_INTERVAL = 200;
private static final int MAX_RETRIES = 5;
// RATE_LIMIT: fail fast to failover chain staying on the same
// provider during a rate-limit window wastes time without recovery.
@ -291,6 +321,8 @@ public class NodeStreamingChatHelper {
private static final long BACKOFF_BASE_MS = 3000;
private static final long BACKOFF_CAP_MS = 60_000;
private static final ObjectMapper TOOL_ARG_JSON_MAPPER = new ObjectMapper();
/**
* 判断错误是否可重试基于状态码/异常类型
*/
@ -373,11 +405,31 @@ public class NodeStreamingChatHelper {
|| msg.contains("invalid_request_error") || msg.contains("unsupported")) {
return ErrorType.CLIENT_ERROR;
}
// Server errors
// Server errors and transient TLS / socket-level network hiccups.
// Without the TLS-specific patterns, a single SSL fatal alert
// (e.g. bad_record_mac during long-running streams) falls through to
// UNKNOWN non-retryable so one transient handshake glitch surfaces
// to the user as "LLM 调用失败" with no recovery attempt. These are
// network-layer transients that almost always succeed on retry, so
// they belong in the same retryable bucket as 5xx/timeouts.
if (msg.contains("500") || msg.contains("502") || msg.contains("503") || msg.contains("504")
|| msg.contains("APITimeoutError") || msg.contains("APIConnectionError")
|| msg.contains("Connection reset") || msg.contains("Connection refused")
|| msg.contains("timeout") || msg.contains("Timeout")) {
|| msg.contains("timeout") || msg.contains("Timeout")
// TLS-layer transients: bad_record_mac (RFC 5246 §7.2.2 fatal
// alert 20), aborted handshakes, mid-stream protocol errors.
|| msg.contains("SSLException") || msg.contains("SSLHandshakeException")
|| msg.contains("SSLProtocolException") || msg.contains("bad_record_mac")
// Socket-level transients: a peer closing the TCP connection
// mid-response, or the OS reporting a half-closed pipe.
|| msg.contains("SocketException") || msg.contains("Broken pipe")
|| msg.contains("Premature close") || msg.contains("PrematureCloseException")
|| msg.contains("Connection prematurely closed")
|| msg.contains("Connection closed prematurely")
// Reactor Netty wraps the raw socket cause in WebClientRequestException;
// surface that wrapper too so retries fire even when the cause chain
// string is "WebClientRequestException ...; nested ... SSLException".
|| msg.contains("WebClientRequestException")) {
return ErrorType.SERVER_ERROR;
}
return ErrorType.UNKNOWN;
@ -718,6 +770,16 @@ public class NodeStreamingChatHelper {
// 仅保留 thinking-only 这条体积兜底处理 volcengine-plan provider
// thinking 通道堆字符不出 content 的死循环生产 trace c1eefa45
AtomicBoolean thinkingOnlyCapTriggered = new AtomicBoolean(false);
// Content-repetition guard: trips when the same paragraph-sized
// suffix appears CONTENT_REPEAT_MAX_OCCURRENCES+ times in
// contentAccum. The outer poll loop disposes the upstream
// subscription within 500ms once flipped same pattern as the
// thinking-only cap above.
AtomicBoolean contentRepeatCapTriggered = new AtomicBoolean(false);
// Last contentAccum length at which we ran the repetition scan.
// Throttles the O(n) substring scan so it runs at most once per
// CONTENT_REPEAT_CHECK_INTERVAL chars, not on every chunk.
AtomicInteger lastContentRepeatCheckLen = new AtomicInteger(0);
// Lifecycle events emitted at most once per call so consumers can
// pivot the UI between "thinking" and "drafting" without inspecting
@ -760,7 +822,7 @@ public class NodeStreamingChatHelper {
lastAssistantMessage.set(msg);
// thinking-only soft cap 已触发 跳过一切处理等外层 dispose
if (thinkingOnlyCapTriggered.get()) {
if (thinkingOnlyCapTriggered.get() || contentRepeatCapTriggered.get()) {
return;
}
@ -847,6 +909,36 @@ public class NodeStreamingChatHelper {
return;
}
// 5. Content-repetition guard. Some reasoning-mode models
// (qwen3.6, deepseek-r1) get stuck in a "Wait, I should X
// 写答案 Wait, I should Y 写同一份答案 ..." loop
// and emit the same final-answer paragraph dozens of times
// until max_tokens runs out. Without this, the user sees a
// wall of duplicated text and the bot never actually finishes.
// Throttled to one scan per CONTENT_REPEAT_CHECK_INTERVAL
// chars of new content the probe loop is cheap but no
// need to run on every chunk.
int currentLen = contentAccum.length();
int floor = CONTENT_REPEAT_MIN_PERIOD * CONTENT_REPEAT_MAX_OCCURRENCES;
if (currentLen >= floor
&& currentLen - lastContentRepeatCheckLen.get() >= CONTENT_REPEAT_CHECK_INTERVAL) {
lastContentRepeatCheckLen.set(currentLen);
if (hasRepeatingSuffix(contentAccum, CONTENT_REPEAT_MIN_PERIOD,
CONTENT_REPEAT_MAX_PERIOD,
CONTENT_REPEAT_MAX_OCCURRENCES)) {
log.warn("[{}] Content-repetition cap reached " +
"({} chars, tail repeated {}+ times) " +
"— disposing stream for conversation {}",
phase, currentLen, CONTENT_REPEAT_MAX_OCCURRENCES,
conversationId);
broadcastContentTruncated(conversationId,
"content_repetition",
currentLen);
contentRepeatCapTriggered.set(true);
return;
}
}
// 4. 提取 token usage通常最后一个 chunk 携带完整 usage
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
var usage = chatResponse.getMetadata().getUsage();
@ -884,6 +976,20 @@ public class NodeStreamingChatHelper {
// dispose latch 可能不会 countDown直接跳出
break;
}
if (contentRepeatCapTriggered.get()) {
// Same dispose pattern as thinking-only cap. The
// accumulated content is preserved (it's the looping
// text at least the user gets the FIRST occurrence
// as a partial answer instead of waiting for max_tokens).
log.warn("[{}] Stream guard tripped (content_repetition), disposing " +
"upstream subscription for conversation {}", phase, conversationId);
subscription.dispose();
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("检测到回答内容反复重复,已自动截断"));
}
break;
}
if (streamTracker.isStopRequested(conversationId)) {
// 用户主动停止 dispose 上游
subscription.dispose();
@ -979,21 +1085,26 @@ public class NodeStreamingChatHelper {
conversationId, phase, errorType);
}
// ===== 成功检查是否因 thinking-only 软上限被截断 =====
// ===== 成功检查是否因 thinking-only 软上限或内容重复被截断 =====
boolean truncatedByThinkingCap = thinkingOnlyCapTriggered.get();
boolean truncatedByContentRepeat = contentRepeatCapTriggered.get();
boolean truncated = truncatedByThinkingCap || truncatedByContentRepeat;
if (truncatedByThinkingCap) {
log.warn("[{}] LLM stream disposed: thinking-only soft cap reached for conversation {}",
phase, conversationId);
} else if (truncatedByContentRepeat) {
log.warn("[{}] LLM stream disposed: content-repetition cap reached for conversation {}",
phase, conversationId);
}
// RFC-009: guard against silent empty responses. Some providers return
// HTTP 200 with an empty body under soft-failure conditions (rate-limit
// capacity, context filter, upstream overload). Treat this as a failure
// signal so streamCallInternal can hand off to the fallback chain.
// Only fire when the thinking-only cap didn't fire (which deliberately
// produces thinking-only output) and there are no tool calls
// Only fire when neither truncation cap fired (those deliberately
// produce non-empty output) and there are no tool calls
// (tool-only responses are legitimately empty-text).
if (!truncatedByThinkingCap
if (!truncated
&& contentAccum.length() == 0
&& thinkingAccum.length() == 0
&& toolCallAccumulators.isEmpty()) {
@ -1001,11 +1112,14 @@ public class NodeStreamingChatHelper {
return buildErrorResultWithType("LLM 返回空响应", conversationId, phase, ErrorType.EMPTY_RESPONSE);
}
String truncationReason = truncatedByThinkingCap ? "thinking_only_no_content"
: truncatedByContentRepeat ? "content_repetition"
: null;
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
truncatedByThinkingCap,
truncatedByThinkingCap ? "thinking_only_no_content" : null);
truncated,
truncationReason);
}
/** 组装 stopped partial 结果(用户主动停止,有已累积内容) */
@ -1516,6 +1630,99 @@ public class NodeStreamingChatHelper {
}
}
/**
* Collapse a content buffer's trailing run of verbatim repeats to a
* single copy. Used to clean up the persisted final answer after
* {@link #hasRepeatingSuffix} fires the streamed text already
* contains the duplicates (SSE chunks can't be unsent), but the
* DB-persisted message and the IM channel reply should show ONE
* clean copy of the looping unit, not a wall.
*
* <p>Algorithm: find the smallest period in {@code [minPeriod,
* maxPeriod]} where the buffer ends with that unit repeated 2+
* times consecutively, then return everything up to (and including)
* the FIRST copy of that unit. Conservative if no period yields
* 2+ consecutive matches, returns the buffer unchanged.
*
* <p>Public for unit-testing alongside {@link #hasRepeatingSuffix}.
*/
public static String dedupTrailingRepeats(String content, int minPeriod, int maxPeriod) {
if (content == null || content.isEmpty()) return content;
int len = content.length();
if (minPeriod <= 0 || maxPeriod < minPeriod) return content;
int periodCap = Math.min(maxPeriod, len / 2);
for (int p = minPeriod; p <= periodCap; p++) {
int unitStart = len - p;
// Walk backward as far as the unit keeps matching.
int copies = 1;
int blockStart = unitStart - p;
while (blockStart >= 0
&& content.regionMatches(blockStart, content, unitStart, p)) {
copies++;
blockStart -= p;
}
if (copies >= 2) {
// Keep prefix + ONE copy. The first copy starts at
// (blockStart + p) since the loop walked back one step
// past the last match.
int firstCopyStart = blockStart + p;
int trimEnd = firstCopyStart + p;
return content.substring(0, trimEnd);
}
}
return content;
}
/**
* Detect whether {@code accum} ends with the same {@code period}-sized
* unit repeated at least {@code minOccurrences} times consecutively,
* for some {@code period} in {@code [minPeriod, maxPeriod]}. Returns
* true when the model is stuck in a "self-arguing" loop emitting the
* same final-answer chunk over and over.
*
* <p>Algorithm: probe period sizes from small to large. For each
* candidate period {@code p}, take the last {@code p} chars as the
* unit and check whether the {@code minOccurrences-1} preceding
* blocks of length {@code p} are byte-identical. The smallest period
* that yields the required consecutive copies trips the guard. We
* iterate smalllarge because tighter periods are more specific:
* a 30-char unit repeated 4× is a stronger signal than a 200-char
* unit happening to appear once.
*
* <p>Cost: O(periodRange × occurrences × period) char comparisons.
* For default thresholds (~200 × 4 × 100) that's ~80K comparisons
* per scan microseconds against an LLM call. Throttled by the
* caller via {@code lastContentRepeatCheckLen} so the scan amortizes.
*
* <p>Package-private + static for unit-testing the threshold without
* spinning up a full {@code StreamResult}.
*/
static boolean hasRepeatingSuffix(CharSequence accum, int minPeriod, int maxPeriod,
int minOccurrences) {
if (accum == null) return false;
int len = accum.length();
if (minPeriod <= 0 || minOccurrences <= 1 || maxPeriod < minPeriod) return false;
if (len < minPeriod * minOccurrences) return false;
String s = accum.toString();
int periodCap = Math.min(maxPeriod, len / minOccurrences);
for (int p = minPeriod; p <= periodCap; p++) {
// Unit = last p chars. Check prior (minOccurrences - 1)
// blocks of length p match the unit byte-for-byte.
int unitStart = len - p;
boolean allMatch = true;
for (int k = 2; k <= minOccurrences; k++) {
int blockStart = len - k * p;
if (blockStart < 0) { allMatch = false; break; }
if (!s.regionMatches(blockStart, s, unitStart, p)) {
allMatch = false;
break;
}
}
if (allMatch) return true;
}
return false;
}
/**
* Best-effort character count of the outbound prompt for the
* {@code context_prepared} event. Cheaper than tokenizing and only used
@ -1637,11 +1844,49 @@ public class NodeStreamingChatHelper {
acc.id,
acc.type != null ? acc.type : "function",
acc.name,
acc.arguments.toString()));
sanitizeToolCallArguments(acc.name, acc.arguments.toString())));
}
return result;
}
/**
* Ensure {@code function.arguments} is always a well-formed JSON string.
* <p>
* Some providers (e.g. aliyun-codingplan) reject the entire follow-up
* request with HTTP 400 when the assistant message in history carries a
* tool call whose {@code arguments} is not parseable JSON. Streaming
* accumulation can produce such payloads when:
* <ul>
* <li>The model emits zero-argument tool calls as {@code ""} instead
* of {@code "{}"}.</li>
* <li>The upstream stream is truncated mid-token, leaving a partial
* JSON fragment like {@code "{\"a\":"}.</li>
* </ul>
* Both cases are normalized to {@code "{}"} so the chat-completions
* round-trip stays valid. Tool execution downstream still re-validates
* arguments and surfaces a per-tool error if the empty payload is wrong
* for that tool.
*/
private static String sanitizeToolCallArguments(String toolName, String arguments) {
if (arguments == null || arguments.isBlank()) {
return "{}";
}
try {
TOOL_ARG_JSON_MAPPER.readTree(arguments);
return arguments;
} catch (Exception e) {
log.warn("Tool '{}' arguments are not valid JSON after stream aggregation "
+ "(len={}, head={}); replacing with empty object so the "
+ "follow-up chat-completions request stays well-formed. "
+ "Parse error: {}",
toolName,
arguments.length(),
arguments.substring(0, Math.min(80, arguments.length())),
e.getMessage());
return "{}";
}
}
private static class ToolCallAccumulator {
String id;
String type;

View File

@ -198,7 +198,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicInteger lastSoftCap = new AtomicInteger(0);
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
return compiledGraph.stream(inputs, config)
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
List<GraphEventPublisher.GraphEvent> allEvents = GraphEventPublisher.extractEvents(output);
@ -263,7 +263,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
));
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
.doOnComplete(() -> {
setState(AgentState.IDLE);
if (!sawLegitimateExit.get()) {
@ -326,7 +326,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicInteger lastSoftCap = new AtomicInteger(0);
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
return compiledGraph.stream(inputs, config)
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
// 1. 提取所有累积的事件只发送新增部分
@ -402,7 +402,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
));
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
.doOnComplete(() -> {
setState(AgentState.IDLE);
if (!sawLegitimateExit.get()) {
@ -439,12 +439,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
chatModel,
conversationId,
parsedAgentId,
toolSet != null ? toolSet.callbacks() : null);
toolSet != null ? toolSet.callbacks() : null,
workspaceBasePath);
}
List<Message> messages = new ArrayList<>(historyMessages);
// 构建当前用户消息支持 multimodal如果有图片附件直接注入 Media
messages.add(buildCurrentUserMessage(conversationId, userMessage));
// 同步获取 routing decision写入 state 供后续节点 / accumulator 读取
BaseAgent.CurrentTurnUserMessage currentTurn = buildCurrentUserMessageWithRouting(conversationId, userMessage);
messages.add(currentTurn.userMessage());
Map<String, Object> inputs = new HashMap<>();
// 输入
@ -482,6 +485,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
// Multimodal sidecar routing null when the turn carries no media or
// the primary model already covers the modalities. Stored as a Map so
// graph state stays JSON-friendly.
if (currentTurn.routingDecision() != null
&& currentTurn.routingDecision().strategy() != vip.mate.llm.routing.model.MultimodalRoutingDecision.Strategy.NONE
|| (currentTurn.routingDecision() != null && !currentTurn.routingDecision().skipped().isEmpty())) {
inputs.put(MateClawStateKeys.ROUTING_DECISION, currentTurn.routingDecision().toMap());
}
// RFC-063r §2.5: enrich the originating ChatOrigin with this agent's id
// and workspace, then write it into graph state so ActionNode +
// StepExecutionNode can forward it to ToolExecutionExecutor ToolContext.

View File

@ -79,22 +79,70 @@ public class ToolExecutionExecutor {
);
/**
* Layer 1 hard truncation cap applied to every tool result before it
* reaches ToolResultStorage (Layer 2 spill) or the LLM prompt.
* Inline hard-truncate cap for a single tool result. Acts as the fallback
* when raw-first spill cannot run (storage disabled, tool excluded, body
* already at-or-below the spill threshold, or disk write failed).
*
* <p>Two-level budget chain (RFC-008 / RFC-06 D-5):
* <p>Per-tool-result handling chain:
* <pre>
* raw tool result
* truncateToolResult(..., MAX_TOOL_RESULT_CHARS=8000) // Layer 1: hard cap
* persistIfOversized(..., perResultThresholdChars=16000) // Layer 2: spill to disk
* enforceTurnBudget(..., perTurnBudgetChars=32000) // Layer 3: per-turn aggregate
* raw tool result (full bytes)
* spillRawOrTruncate(...)
* persistIfOversized(...) tries to write the raw body to disk
* when size &gt; perResultThresholdChars and tool is not
* in the spill exclusion list. Returns a SPILL_MARKER preview
* on success, or the original string otherwise.
* if no SPILL_MARKER on the return, truncateToolResult(...)
* caps inline to MAX_TOOL_RESULT_CHARS so a multi-MB raw
* body never enters the model prompt.
* enforceTurnBudget(..., perTurnBudgetChars=32000) // per-turn aggregate
* </pre>
* Layer 1 runs first and is intentionally kept at 8000 to prevent oversized
* results from inflating the prompt. Layers 2/3 thresholds are configured in
* {@link ToolResultProperties} and application.yml.
* Spill must see the RAW result so the full output is preserved on disk
* and the model can call {@code read_file} on the spill path. Truncating
* before spilling would write a pre-shortened blob to disk, defeating the
* "ground truth on disk" guarantee. {@link ToolResultProperties} controls
* the thresholds; this constant stays in code because it is the safety
* net for the failure case and should not vary by deployment.
*/
private static final int MAX_TOOL_RESULT_CHARS = 8000;
/**
* Raw-first spill: try to write the full result to disk via the spill
* store; only fall back to inline hard-truncate when no spill marker
* comes back. Caller distinguishes spill success from "returned
* unchanged" by checking {@link ToolResultStorage#SPILL_MARKER_PREFIX}
* on the returned string otherwise an IO failure or under-threshold
* body would slip through indistinguishable from a successful spill,
* and a multi-MB raw body could end up in the model prompt.
*
* <p>Package-private + static so the spill/truncate decision is unit
* testable in isolation from the rest of the executor.
*
* @param storage spill store; {@code null} skips the spill attempt
* @param maxTruncateChars fallback inline hard cap
* @param result raw tool output (may be {@code null})
* @param toolName used in the spill preview header
* @param toolUseId unique within the conversation; becomes the file name
* @param conversationId spill files are scoped per conversation; blank/null falls back to "unknown"
* @param workspaceBasePath where the spill directory lives when set
* @return the SPILL_MARKER preview when spill succeeded, otherwise the
* original string (when threshold) or the inline-truncated string.
*/
static String spillRawOrTruncate(ToolResultStorage storage, int maxTruncateChars,
String result, String toolName, String toolUseId,
String conversationId, String workspaceBasePath) {
if (result == null) return null;
if (storage != null) {
String safeConv = conversationId != null && !conversationId.isEmpty()
? conversationId : "unknown";
String candidate = storage.persistIfOversized(
result, toolName, toolUseId, safeConv, workspaceBasePath);
if (candidate != null && candidate.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)) {
return candidate;
}
}
return truncateToolResult(result, maxTruncateChars);
}
/** 尾部错误模式检测 */
private static final java.util.regex.Pattern ERROR_TAIL_PATTERN = java.util.regex.Pattern.compile(
"(?i)\\b(error|exception|traceback|failed|fatal|panic|stack.?trace|errno)\\b");
@ -443,6 +491,17 @@ public class ToolExecutionExecutor {
}
ToolCallback callback = toolCallbackMap.get(toolName);
if (callback == null) {
SkillRedirect redirect = tryAutoRedirectSkillCall(toolName, arguments, safeOrigin);
if (redirect != null) {
// Auto-redirect succeeds with success=true on the SSE event so the
// model treats the SKILL.md content as the answer to a different,
// valid question (rather than as another failed call to recover from).
events.add(GraphEventPublisher.toolComplete(
toolCall.id(), toolName, redirect.response(), true));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, redirect.response()));
continue;
}
String msg = skillAwareNotFoundMessage(toolName);
log.warn("[ToolExecutor] {}", msg);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
@ -521,6 +580,18 @@ public class ToolExecutionExecutor {
ToolCallback callback = toolCallbackMap.get(toolName);
if (callback == null) {
// Same auto-redirect for pre-approved replays a stale skill-as-tool
// approval shouldn't dead-end the conversation either.
ChatOrigin replayOriginForRedirect = ChatOrigin.EMPTY
.withConversationId(conversationId)
.withWorkspace(null, workspaceBasePath);
SkillRedirect redirect = tryAutoRedirectSkillCall(toolName, callArguments, replayOriginForRedirect);
if (redirect != null) {
events.add(GraphEventPublisher.toolComplete(
toolCall.id(), toolName, redirect.response(), true));
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, redirect.response());
}
String msg = skillAwareNotFoundMessage(toolName);
log.warn("[ToolExecutor] Pre-approved {}", msg);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
@ -558,16 +629,13 @@ public class ToolExecutionExecutor {
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
}
// RFC-008 Layer 1 first, then Layer 2 match the non-replay path
// in executeSingleTool so behavior stays symmetric across approval
// replays. The caller-supplied conversationId scopes spill files
// into the same per-conversation directory layout.
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
if (resultStorage != null && result != null) {
String spillConv = conversationId != null && !conversationId.isEmpty() ? conversationId : "unknown";
result = resultStorage.persistIfOversized(
result, toolName, toolCall.id(), spillConv, workspaceBasePath);
}
// Raw-first spill, inline truncate as fallback. Symmetric with the
// non-replay path in executeSingleTool. The caller-supplied
// conversationId scopes spill files into the per-conversation
// directory layout. See spillRawOrTruncate javadoc for why the
// order matters.
result = spillRawOrTruncate(resultStorage, MAX_TOOL_RESULT_CHARS,
result, toolName, toolCall.id(), conversationId, workspaceBasePath);
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true));
@ -783,20 +851,16 @@ public class ToolExecutionExecutor {
}
}
// RFC-008 Layer 1: hard truncation cap to prevent oversized results
// from inflating the prompt. Runs FIRST (before spill) so the spill
// store doesn't need to handle multi-MB writes for run-of-the-mill
// greps that happen to spit out a long stdout.
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
// RFC-008 Layer 2: spill oversized results to disk and replace
// with preview + path. Falls back to truncation when spilling is
// disabled or fails. Spill preserves the full output (read_file can
// retrieve it); the Layer 1 truncation above already capped the
// inline portion, so this layer mostly catches near-cap residues.
if (resultStorage != null && result != null) {
result = resultStorage.persistIfOversized(
// Raw-first spill: write the full output to disk and replace
// with preview + path so the model can call read_file for the
// ground truth. Fall back to inline truncate only when spilling
// is disabled, the tool is on the exclusion list, the body is
// already under the spill threshold, or the disk write fails.
// Truncating before spilling would persist a pre-shortened body
// to disk and silently lose data the model could otherwise
// recover.
result = spillRawOrTruncate(resultStorage, MAX_TOOL_RESULT_CHARS,
result, toolName, pc.toolCall.id(), pc.conversationId, pc.workspaceBasePath);
}
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true));
@ -1049,6 +1113,79 @@ public class ToolExecutionExecutor {
return "Tool not found: " + toolName;
}
/**
* Holder for an auto-redirect outcome: the SKILL.md content (wrapped
* with a one-line nudge) that we substitute as the tool response when
* the LLM mistakenly calls a skill name as if it were a tool.
*
* <p>{@code success=true} on the substituted response so the model
* doesn't read it as "tool failed, try harder" semantically we
* answered a different question than the one it asked, and we want
* the model to follow the redirect rather than thrash.
*/
private record SkillRedirect(String response) {}
/**
* When the LLM calls a skill name as if it were a tool, transparently
* fetch its SKILL.md and return that as the tool response. Smaller
* models (qwen-turbo et al.) often can't act on a "not a tool — go
* read X first" hint; they keep emitting the same wrong call until the
* iteration cap. With auto-redirect, the model receives runnable
* instructions on the very first attempt and can copy the runSkillScript
* shape from SKILL.md verbatim.
*
* <p>Returns {@code null} if {@code toolName} isn't a registered skill,
* if {@code readSkillFile} isn't available in this agent's tool set, or
* if the redirect call itself errored the caller then falls through
* to the usual {@code skillAwareNotFoundMessage} hint.
*/
private SkillRedirect tryAutoRedirectSkillCall(String toolName, String originalArgs, ChatOrigin origin) {
if (skillRuntimeService == null || toolName == null || toolName.isBlank()) return null;
try {
boolean isSkill = skillRuntimeService.getActiveSkills().stream()
.anyMatch(s -> s.getName() != null && s.getName().equalsIgnoreCase(toolName));
if (!isSkill) return null;
} catch (Exception e) {
log.debug("[ToolExecutor] auto-redirect skill lookup failed: {}", e.getMessage());
return null;
}
ToolCallback readSkillFile = toolCallbackMap.get("readSkillFile");
if (readSkillFile == null) {
log.debug("[ToolExecutor] readSkillFile not bound to this agent — cannot auto-redirect '{}'", toolName);
return null;
}
String redirectArgs = "{\"skillName\":\""
+ jsonStringEscape(toolName)
+ "\",\"filePath\":\"SKILL.md\"}";
String skillMd;
try {
ToolContext ctx = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext();
skillMd = readSkillFile.call(redirectArgs, ctx);
} catch (Exception e) {
log.warn("[ToolExecutor] Auto-redirect readSkillFile failed for '{}': {}", toolName, e.getMessage());
return null;
}
log.info("[ToolExecutor] Auto-redirected skill-as-tool call '{}' → readSkillFile (returned {} chars)",
toolName, skillMd != null ? skillMd.length() : 0);
String safeArgs = originalArgs == null || originalArgs.isBlank() ? "{}" : originalArgs;
String response = String.format(
"[auto-redirect] You called '%s' as a tool, but it's a Skill (documentation package). "
+ "Its SKILL.md is loaded below — read the script invocation example, then call "
+ "`runSkillScript(skillName=\"%s\", scriptPath=\"scripts/<file from SKILL.md>\", args=[...])` "
+ "to actually run it. Your original payload was: %s%n%n---%n%s",
toolName, toolName, safeArgs, skillMd == null ? "" : skillMd);
return new SkillRedirect(response);
}
/** Minimal JSON string escaping for the synthetic readSkillFile arg payload. */
private static String jsonStringEscape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
// ==================== 内部数据类 ====================
private record PreparedToolCall(

View File

@ -40,12 +40,26 @@ public class ToolResultProperties {
private boolean enabled = true;
/**
* Layer 2 a single tool result larger than this is spilled to disk.
* The executor evaluates this against the raw result before applying the
* final inline cap, so oversized content is preserved before it is shortened
* for the model request.
* Per-result spill threshold. A single tool result larger than this is
* spilled to disk and the in-context view is replaced with a short
* preview + path so the model can call {@code read_file} on demand.
*
* <p>Aligned with {@code ToolExecutionExecutor.MAX_TOOL_RESULT_CHARS}
* (8000): the executor now tries to spill the RAW result first; only
* when spilling is disabled, the tool is on {@link #excludedTools}, the
* body is under this threshold, or the disk write fails, does it fall
* back to truncating inline to 8000 chars. Keeping the threshold equal
* to the truncate cap yields a single semantic ladder above the
* threshold means "preserved on disk", at-or-below means "stays inline
* verbatim".
*
* <p>If you want to keep more text inline before spilling, raise this
* value AND raise the executor's hard cap together; otherwise the
* 8000-char fallback truncate would silently shorten anything between
* this threshold and 8000 even when spill is disabled, defeating the
* intent.
*/
private int perResultThresholdChars = 16000; // was 4000 prevents WebSearch spill-to-disk
private int perResultThresholdChars = 8000;
/**
* Layer 3 aggregate cap on combined response size in one tool turn.
@ -83,6 +97,28 @@ public class ToolResultProperties {
*/
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
/**
* Days to retain spill files before the scheduled cleanup deletes them.
* <p><b>Default 0 means time-based cleanup is disabled</b> spill files
* stay on disk until the owning conversation is explicitly deleted (which
* fires {@code purgeConversation} via {@code ConversationService}).
* This preserves the "recoverable" invariant: a summary or preview that
* cites a spill path will keep working for the whole life of the
* conversation, no matter how long it sits dormant.
* <p>Set to a positive value if disk pressure outweighs recoverability
* for your deployment. The scheduled sweep will then delete files whose
* mtime falls outside the retention horizon.
*/
private int retentionDays = 0;
/**
* Cron expression for the spill-cleanup task. Defaults to once a day at
* 03:00 server-local time so cleanup runs during quiet hours. Set this
* to a Spring-recognised value (six-field cron) or change the bean
* wiring to disable it entirely.
*/
private String cleanupCron = "0 0 3 * * ?";
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
@ -116,6 +152,14 @@ public class ToolResultProperties {
this.excludedTools = excludedTools == null ? List.of() : excludedTools;
}
public int getRetentionDays() { return retentionDays; }
public void setRetentionDays(int retentionDays) { this.retentionDays = retentionDays; }
public String getCleanupCron() { return cleanupCron; }
public void setCleanupCron(String cleanupCron) {
this.cleanupCron = cleanupCron == null ? "" : cleanupCron;
}
/** O(1) membership test for the exclusion list, used on every tool result. */
public Set<String> excludedToolsSet() {
return Set.copyOf(excludedTools);

View File

@ -0,0 +1,54 @@
package vip.mate.agent.graph.executor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Drives {@link ToolResultStorage#cleanupExpired()} on a cron schedule so
* spill files don't accumulate forever. Kept in its own class instead of
* inlined into {@link ToolResultStorage} for two reasons:
*
* <ul>
* <li>Tests can exercise {@code cleanupExpired()} directly without
* fighting the Spring scheduler.</li>
* <li>Deployments that want to disable the schedule entirely can simply
* leave this component out of the autoconfigure path.</li>
* </ul>
*
* <p>The cron expression comes from
* {@link ToolResultProperties#getCleanupCron()} (default {@code 0 0 3 * * ?},
* i.e. once a day at 03:00 server-local time). The retention horizon comes
* from {@link ToolResultProperties#getRetentionDays()}.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ToolResultRetentionScheduler {
private final ToolResultStorage storage;
private final ToolResultProperties props;
/**
* Cron-fired hook. Failures are logged at WARN so they show up in
* standard log scrapes without aborting the scheduler thread losing
* a single sweep is fine, the next one will catch the same files.
*/
@Scheduled(cron = "${mate.agent.tool-result.cleanup-cron:0 0 3 * * ?}")
public void cleanup() {
if (props.getRetentionDays() <= 0) {
log.debug("[ToolResultRetentionScheduler] retentionDays<=0, skipping sweep");
return;
}
try {
int deleted = storage.cleanupExpired();
if (deleted > 0) {
log.info("[ToolResultRetentionScheduler] sweep deleted {} spill file(s) older than {} days",
deleted, props.getRetentionDays());
}
} catch (Exception e) {
log.warn("[ToolResultRetentionScheduler] sweep failed: {}", e.getMessage(), e);
}
}
}

View File

@ -58,6 +58,15 @@ public class ToolResultStorage {
/** D-6: monotonically increasing spill counter for observability. */
private final java.util.concurrent.atomic.AtomicLong spillCount = new java.util.concurrent.atomic.AtomicLong();
/**
* Workspace roots observed during this JVM's lifetime. Populated every
* time a successful spill resolves a base directory; consulted by the
* scheduled retention sweep and by {@link #purgeConversation} so we
* don't have to query the database for every workspace path. Cross-JVM
* orphans are not covered that is documented in the cleanup javadoc.
*/
private final java.util.Set<Path> observedRoots = java.util.concurrent.ConcurrentHashMap.newKeySet();
public ToolResultStorage(ToolResultProperties props) {
this.props = props;
this.excludedToolsSnapshot = props.excludedToolsSet();
@ -270,15 +279,162 @@ public class ToolResultStorage {
}
private Path resolveBaseDir(String workspaceBasePath) {
Path base;
if (!props.getStorageBaseDir().isEmpty()) {
return Paths.get(props.getStorageBaseDir());
}
if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
return Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
}
base = Paths.get(props.getStorageBaseDir());
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
} else {
String tmp = System.getProperty("java.io.tmpdir");
if (tmp == null || tmp.isEmpty()) return null;
return Paths.get(tmp, "mateclaw", "tool-results");
base = Paths.get(tmp, "mateclaw", "tool-results");
}
// Register so the retention sweep and conversation-delete hook can
// reach this root even when the workspace path is no longer in scope.
observedRoots.add(base);
return base;
}
/**
* Roots currently known to this instance. Exposed package-private so the
* scheduled retention sweep and unit tests can enumerate them without
* touching the underlying set directly.
*/
java.util.Set<Path> getObservedRoots() {
return java.util.Collections.unmodifiableSet(observedRoots);
}
/**
* Best-effort: delete every spill file and per-conversation directory
* older than {@link ToolResultProperties#getRetentionDays()} across all
* roots this storage has seen, plus the configured base dir and the
* tmpdir fallback. Returns the number of files deleted.
*
* <p>Workspaces that never received a spill in this JVM's lifetime are
* not covered. Persisting an observed-roots registry across restarts
* could fix that, but is intentionally out of scope the operator-side
* remedy is to run a one-off cleanup with {@code storage-base-dir}
* pointed at the historical workspace.
*/
public int cleanupExpired() {
if (props.getRetentionDays() <= 0) {
return 0;
}
long cutoffEpochMillis = System.currentTimeMillis()
- (long) props.getRetentionDays() * 24L * 60L * 60L * 1000L;
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
if (!props.getStorageBaseDir().isEmpty()) {
roots.add(Paths.get(props.getStorageBaseDir()));
}
String tmp = System.getProperty("java.io.tmpdir");
if (tmp != null && !tmp.isEmpty()) {
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
}
int deleted = 0;
for (Path root : roots) {
deleted += deleteExpiredUnder(root, cutoffEpochMillis);
}
if (deleted > 0) {
log.info("[ToolResultStorage] cleanup: {} spill files removed across {} root(s)",
deleted, roots.size());
}
return deleted;
}
private int deleteExpiredUnder(Path root, long cutoffEpochMillis) {
if (root == null || !java.nio.file.Files.isDirectory(root)) {
return 0;
}
int deleted = 0;
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.walk(root, 2)) {
for (Path p : (Iterable<Path>) stream::iterator) {
if (p.equals(root)) continue;
if (!java.nio.file.Files.isRegularFile(p)) continue;
try {
long mtime = java.nio.file.Files.getLastModifiedTime(p).toMillis();
if (mtime < cutoffEpochMillis) {
java.nio.file.Files.deleteIfExists(p);
deleted++;
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] failed to inspect spill file {}: {}", p, ioe.getMessage());
}
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] cleanup walk failed under {}: {}", root, ioe.getMessage());
return deleted;
}
// Best-effort: remove emptied per-conversation directories.
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(root)) {
for (Path child : (Iterable<Path>) stream::iterator) {
if (!java.nio.file.Files.isDirectory(child)) continue;
try (java.util.stream.Stream<Path> kids = java.nio.file.Files.list(child)) {
if (kids.findAny().isEmpty()) {
java.nio.file.Files.deleteIfExists(child);
}
} catch (java.io.IOException ignored) {
// empty-check failure is not fatal leave the directory alone
}
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] empty-dir sweep failed under {}: {}", root, ioe.getMessage());
}
return deleted;
}
/**
* Delete every spill file produced for {@code conversationId} across
* all observed roots, plus the configured base and tmpdir fallback.
* Called by {@code ConversationService.deleteConversation} so spill
* directories don't outlive the conversation that owns them.
*
* <p>Silently no-ops when nothing matches a conversation that never
* spilled, or one whose workspace root was never observed in this JVM,
* is simply left alone. Returns the number of files deleted.
*/
public int purgeConversation(String conversationId) {
if (conversationId == null || conversationId.isEmpty()) {
return 0;
}
String safeConv = sanitize(conversationId);
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
if (!props.getStorageBaseDir().isEmpty()) {
roots.add(Paths.get(props.getStorageBaseDir()));
}
String tmp = System.getProperty("java.io.tmpdir");
if (tmp != null && !tmp.isEmpty()) {
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
}
int deleted = 0;
for (Path root : roots) {
Path convDir = root.resolve(safeConv);
if (!java.nio.file.Files.isDirectory(convDir)) continue;
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(convDir)) {
for (Path p : (Iterable<Path>) stream::iterator) {
try {
if (java.nio.file.Files.isRegularFile(p)) {
java.nio.file.Files.deleteIfExists(p);
deleted++;
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] failed to delete spill file {}: {}", p, ioe.getMessage());
}
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] purge walk failed under {}: {}", convDir, ioe.getMessage());
}
try {
java.nio.file.Files.deleteIfExists(convDir);
} catch (java.io.IOException ignored) {
// non-empty after deletes (another writer raced us) fine, leave it
}
}
if (deleted > 0) {
log.info("[ToolResultStorage] purged {} spill file(s) for conversation {}", deleted, conversationId);
}
return deleted;
}
/** Strip path separators and reserved characters so user-supplied IDs cannot escape the directory. */

View File

@ -8,6 +8,7 @@ import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import vip.mate.tool.document.GeneratedFileCache;
import java.util.List;
import java.util.Map;
@ -30,6 +31,22 @@ import java.util.Map;
@Slf4j
public class FinalAnswerNode implements NodeAction {
/**
* Cache used to vet {@code /api/v1/files/generated/{id}} URLs the LLM
* may have written into the final answer. {@code null} disables the
* guard (legacy callers, narrow unit tests that don't exercise file
* outputs).
*/
private final GeneratedFileCache generatedFileCache;
public FinalAnswerNode() {
this(null);
}
public FinalAnswerNode(GeneratedFileCache generatedFileCache) {
this.generatedFileCache = generatedFileCache;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
@ -47,7 +64,7 @@ public class FinalAnswerNode implements NodeAction {
if (accessor.returnDirectTriggered()) {
List<DirectToolOutput> outputs = accessor.directToolOutputs();
if (!outputs.isEmpty()) {
String assembled = assembleDirectAnswer(outputs);
String assembled = scrubFakeUrls(assembleDirectAnswer(outputs));
String currentThinking = accessor.currentThinking();
String existingThinking = accessor.finalThinking();
String preservedThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
@ -70,7 +87,7 @@ public class FinalAnswerNode implements NodeAction {
// 审批等待路径Graph AWAITING_APPROVAL 终止保留已流式推送的内容用于持久化
if (accessor.awaitingApproval()) {
String preservedContent = accessor.streamedContent();
String preservedContent = scrubFakeUrls(accessor.streamedContent());
String preservedThinking = !accessor.streamedThinking().isEmpty()
? accessor.streamedThinking() : accessor.currentThinking();
log.info("[FinalAnswerNode] AWAITING_APPROVAL — preserving streamed content " +
@ -139,6 +156,12 @@ public class FinalAnswerNode implements NodeAction {
}
}
// Scrub hallucinated `/api/v1/files/generated/{id}` URLs whose ids
// were never inserted into the cache. Done before evidence
// validation so the validator sees the user-visible warning rather
// than treating the fake link as a "reference".
finalAnswer = scrubFakeUrls(finalAnswer);
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
finishReason = FinishReason.EVIDENCE_INSUFFICIENT;
@ -147,6 +170,24 @@ public class FinalAnswerNode implements NodeAction {
validation.unsupportedReferences());
}
// Build the event list. Always carries the finish_reason event so
// downstream consumers (memory gate, channel accumulator, message
// metadata persistence) see a machine-readable status. When the
// turn ended in a non-transient error, also attach a
// feedback_event so the frontend can render retry/regenerate/
// report affordances next to the red "[错误] ..." bubble without
// this, fatal errors leave the user staring at error text with no
// way to recover short of retyping the whole prompt.
List<GraphEventPublisher.GraphEvent> events =
new java.util.ArrayList<>(2);
events.add(GraphEventPublisher.finishReason(finishReason.getValue()));
if (finishReason == FinishReason.ERROR_FALLBACK) {
events.add(GraphEventPublisher.feedback(
"ERROR_FALLBACK",
finalAnswer,
List.of("retry", "regenerate", "report")));
}
// 不重置 CONTENT_STREAMED/THINKING_STREAMED保留上游节点的标志
var builder = MateClawStateAccessor.output()
.finalAnswer(finalAnswer)
@ -160,7 +201,7 @@ public class FinalAnswerNode implements NodeAction {
// signal. APPEND-strategy on PENDING_EVENTS means this
// composes safely with any earlier events upstream nodes
// attached.
.events(List.of(GraphEventPublisher.finishReason(finishReason.getValue())));
.events(events);
if (!finalThinking.isEmpty()) {
builder.finalThinking(finalThinking);
@ -196,6 +237,16 @@ public class FinalAnswerNode implements NodeAction {
return sb.toString();
}
/**
* Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss)
* with a user-visible warning. No-op when no cache is wired (legacy
* tests) or when the answer is empty.
*/
private String scrubFakeUrls(String text) {
if (generatedFileCache == null || text == null || text.isEmpty()) return text;
return generatedFileCache.scrubMissingReferences(text);
}
private FinishReason parseFinishReason(String reason) {
if (reason == null || reason.isEmpty()) {
return FinishReason.NORMAL;

View File

@ -348,7 +348,12 @@ public class ReasoningNode implements NodeAction {
}
if (conversationWindowManager != null) {
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
// Pass conversationId + workspaceBasePath so oversized older
// tool results can be spilled to the workspace spill directory
// (preserving the full body for read_file recovery) instead of
// being rewritten into a lossy single-line summary.
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
messages, conversationId, workspaceBasePath);
}
promptMessages.addAll(messages);
@ -489,6 +494,46 @@ public class ReasoningNode implements NodeAction {
return builder.build();
}
if (result.partial() && "content_repetition".equals(result.errorMessage())) {
// Reasoning loop: the helper disposed the stream because the
// model emitted the same paragraph 4+ times in a row (qwen3.6
// / deepseek-r1 self-arguing pattern). The streamed text
// already showed the duplicates to the user we can't unsend
// SSE chunks but the persisted finalAnswer should be ONE
// clean copy so the IM channel reply and any page-reload
// history don't show the wall of repetition. Skip
// FinalAnswerNode's evidence validation: the answer is
// already truncated, applying validateAnswer on top would
// double-stamp warnings on something the user already knows
// is incomplete.
String rawContent = result.text() != null ? result.text() : "";
String dedupedAnswer = NodeStreamingChatHelper.dedupTrailingRepeats(
rawContent,
NodeStreamingChatHelper.CONTENT_REPEAT_MIN_PERIOD,
NodeStreamingChatHelper.CONTENT_REPEAT_MAX_PERIOD);
log.warn("[ReasoningNode] Content-repetition cap hit (raw={} chars → deduped={} chars); " +
"INCOMPLETE",
rawContent.length(), dedupedAnswer.length());
var builder = reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer(dedupedAnswer.isEmpty()
? "(模型反复输出同一段内容,已自动截断。请尝试重新生成或换个问法。)"
: dedupedAnswer)
.llmCallCount(nextLlmCallCount)
.finishReason(FinishReason.INCOMPLETE)
// contentStreamed=true because the user already saw
// the looping text in their bubble; persisting again
// via streamedContent would replay it.
.contentStreamed(true)
.thinkingStreamed(result.thinking() != null && !result.thinking().isEmpty())
.mergeUsage(state, result);
if (result.thinking() != null && !result.thinking().isEmpty()) {
builder.finalThinking(result.thinking());
}
return builder.build();
}
// Fatal error直接设置 finalAnswer 为错误文案 + ERROR_FALLBACK
// 不走 LimitExceededNode后者会再发一次 LLM 调用语义不对且对认证/配额错误会再失败
// ReasoningDispatcher 看到 !needsToolCall && !shouldSummarize finalAnswerNode

View File

@ -148,7 +148,7 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
return compiledGraph.stream(inputs, config)
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
// 1. 提取事件只发送新增部分
@ -218,7 +218,7 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
));
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
.doOnComplete(() -> setState(AgentState.IDLE))
.doOnError(e -> {
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
@ -267,11 +267,13 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
chatModel,
conversationId,
parsedAgentId,
toolSet != null ? toolSet.callbacks() : null);
toolSet != null ? toolSet.callbacks() : null,
workspaceBasePath);
}
List<Message> messages = new ArrayList<>(historyMessages);
messages.add(buildCurrentUserMessage(conversationId, userMessage));
BaseAgent.CurrentTurnUserMessage currentTurn = buildCurrentUserMessageWithRouting(conversationId, userMessage);
messages.add(currentTurn.userMessage());
// 构建 working context对历史消息做受控长度摘要
String workingContext = buildWorkingContext(historyMessages, List.of());
@ -299,6 +301,12 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
if (currentTurn.routingDecision() != null
&& (currentTurn.routingDecision().strategy() != vip.mate.llm.routing.model.MultimodalRoutingDecision.Strategy.NONE
|| !currentTurn.routingDecision().skipped().isEmpty())) {
inputs.put(MateClawStateKeys.ROUTING_DECISION, currentTurn.routingDecision().toMap());
}
// RFC-063r §2.5: same as ReAct path enrich and store the ChatOrigin
// so StepExecutionNode (and any sub-graphs spawned via DelegateAgentTool)
// can read it back from state.

View File

@ -211,7 +211,11 @@ public class StepExecutionNode implements NodeAction {
ChatOptions options = oaiOpts;
if (conversationWindowManager != null) {
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
// Pass conversationId + workspaceBasePath so oversized
// older tool results can be spilled to disk instead of
// being rewritten into a lossy single-line summary.
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
messages, conversationId, workspaceBasePath);
}
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(

View File

@ -83,6 +83,15 @@ public final class MateClawStateKeys {
// ===== 事件流APPEND 策略=====
public static final String PENDING_EVENTS = "pending_events";
/**
* Multimodal routing decision for the current turn (REPLACE strategy).
* Stored as a Map ready for JSON serialization. Set by BaseAgent before
* the reasoning node runs; read back by FinalAnswerNode and (separately)
* emitted as a graph event for the SSE accumulator to write into the
* persisted message metadata under {@code metadata.routing}.
*/
public static final String ROUTING_DECISION = "routing_decision";
// ===== 阶段标记REPLACE 策略=====
public static final String CURRENT_PHASE = "current_phase";

View File

@ -30,6 +30,25 @@ public class TemplateDTO {
private String systemPrompt;
private List<WorkspaceFileTemplate> workspaceFiles;
/**
* Skill slugs (matching {@code mate_skill.name}) to pre-bind to the newly
* hired agent. Resolved against the target workspace at apply time; any
* slug whose row is missing in that workspace is logged and skipped so a
* partially-installed environment can still hire the agent. Templates ship
* with classpath-stable slugs, not numeric IDs, because skill ids vary per
* install.
*/
private List<String> defaultSkillSlugs;
/**
* Tool names to pre-bind directly (bypassing the skill layer). Filtered
* against {@code AvailableToolService.listAvailable()} at apply time
* names the picker can't resolve are dropped with a warning rather than
* aborting the hire. Use for capabilities that aren't owned by any skill,
* not for system-level tools that are already universally available.
*/
private List<String> defaultToolNames;
@Data
public static class WorkspaceFileTemplate {
private String filename;

View File

@ -1,5 +1,6 @@
package vip.mate.agent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -8,9 +9,14 @@ import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.agent.AgentService;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.model.TemplateDTO;
import vip.mate.exception.MateClawException;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillMapper;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.service.AvailableToolService;
import vip.mate.workspace.document.WorkspaceFileService;
import java.io.IOException;
@ -18,6 +24,7 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
@ -36,6 +43,9 @@ public class TemplateService {
private final AgentService agentService;
private final WorkspaceFileService workspaceFileService;
private final ObjectMapper objectMapper;
private final AgentBindingService agentBindingService;
private final SkillMapper skillMapper;
private final AvailableToolService availableToolService;
/**
* 列出所有可用模板
@ -135,9 +145,123 @@ public class TemplateService {
}
}
// 4. Pre-bind skills the template declares so a hired agent is
// usable out of the box ("数据分析师" already knows SQL, "代码审查员"
// already has the test-driven-development playbook). Resolution
// failures (slug missing in this workspace) are skipped with a
// warning; bind-service exceptions still propagate and roll back
// the @Transactional hire see helper Javadoc.
applyDefaultSkillBindings(template, created);
// 5. Pre-bind any standalone tools the template wants. Picker
// outage and unknown names are dropped with a warning; a
// setToolBindings exception still propagates (same contract as
// skills) see helper Javadoc.
applyDefaultToolBindings(template, created);
return created;
}
/**
* Resolve {@link TemplateDTO#getDefaultSkillSlugs()} to skill ids inside
* the agent's own workspace and pre-bind them.
*
* <p><b>Failure contract read carefully.</b>
* <ul>
* <li><b>Resolution failures</b> (slug not present in the agent's
* workspace, blank entries) logged and dropped. A template MUST
* stay applyable on an offline upgrade or partial-seed install
* where some bundled skills haven't landed yet.</li>
* <li><b>Service-layer failures</b>
* ({@link AgentBindingService#setSkillBindings} throws e.g. a
* race deletes the skill row between resolve and bind, or the
* workspace check rejects it) <em>propagate</em>. Because
* {@link #applyTemplate} runs under {@code @Transactional}, this
* rolls back the whole hire. That's deliberate: such a throw is
* a real wiring/race problem, and pretending the hire succeeded
* would leave the user with a half-configured agent.</li>
* </ul>
*
* <p>Reads the workspace off the just-persisted {@link AgentEntity}
* rather than a separate parameter so the lookup and the validator
* inside {@code AgentBindingService.requireSameWorkspace} can never
* disagree on which workspace they're talking about.
*/
private void applyDefaultSkillBindings(TemplateDTO template, AgentEntity created) {
List<String> slugs = template.getDefaultSkillSlugs();
if (slugs == null || slugs.isEmpty()) return;
// Mirror the fallback inside AgentBindingService.requireSameWorkspace:
// a null workspace_id on a row is treated as workspace 1, so the
// lookup needs to agree or we'd silently turn `eq(workspaceId, null)`
// into `IS NULL` and match nothing.
Long workspaceId = created.getWorkspaceId() == null ? 1L : created.getWorkspaceId();
List<Long> resolvedIds = new ArrayList<>();
for (String slug : slugs) {
if (slug == null || slug.isBlank()) continue;
SkillEntity skill = skillMapper.selectOne(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getName, slug.trim())
.eq(SkillEntity::getWorkspaceId, workspaceId));
if (skill == null) {
log.warn("[Template] template {} requested skill slug '{}' not found in workspace {}; skipping",
template.getId(), slug, workspaceId);
continue;
}
resolvedIds.add(skill.getId());
}
if (resolvedIds.isEmpty()) return;
agentBindingService.setSkillBindings(created.getId(), resolvedIds);
log.info("[Template] template {} pre-bound {} skill(s) on agent {}",
template.getId(), resolvedIds.size(), created.getId());
}
/**
* Pre-filter the template's tool names through the picker so
* {@link AgentBindingService#setToolBindings} sees only resolvable names
* its own validation would otherwise abort the call on the first
* unknown name and leave the agent with no tool bindings at all.
*
* <p>Failure contract mirrors {@link #applyDefaultSkillBindings}:
* picker outage and unknown names are dropped with a warning; an
* exception from {@code setToolBindings} itself still propagates and
* rolls back the hire.
*/
private void applyDefaultToolBindings(TemplateDTO template, AgentEntity created) {
List<String> names = template.getDefaultToolNames();
if (names == null || names.isEmpty()) return;
Set<String> bindable;
try {
bindable = availableToolService.listAvailable().stream()
.filter(AvailableToolDTO::isAvailable)
.map(AvailableToolDTO::getName)
.collect(Collectors.toSet());
} catch (Exception e) {
log.warn("[Template] picker unavailable during template {} apply; skipping tool pre-bind: {}",
template.getId(), e.getMessage());
return;
}
List<String> filtered = new ArrayList<>();
for (String name : names) {
if (name == null || name.isBlank()) continue;
String trimmed = name.trim();
if (bindable.contains(trimmed)) {
filtered.add(trimmed);
} else {
log.warn("[Template] template {} requested tool '{}' not currently bindable; skipping",
template.getId(), trimmed);
}
}
if (filtered.isEmpty()) return;
agentBindingService.setToolBindings(created.getId(), filtered);
log.info("[Template] template {} pre-bound {} tool(s) on agent {}",
template.getId(), filtered.size(), created.getId());
}
/**
* True when the raw Accept-Language header best-matches a Chinese locale.
* Implementation is intentionally simple we only need to disambiguate

View File

@ -0,0 +1,43 @@
package vip.mate.agent.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* Lightweight capability snapshot for an agent answers two questions
* the chat console needs synchronously while the user is composing a message:
*
* <ul>
* <li>What modalities does the agent's primary model support? (drives the
* attachment routing hint above the input box.)</li>
* <li>Are sidecar models configured at the system level? (drives the
* "configure a vision model" CTA when a user attaches an image to an
* agent whose primary model can't process it.)</li>
* </ul>
*
* <p>Returned by {@code GET /api/v1/agents/{id}/capabilities}. Computed on
* each request cheap because everything is cached service-side and we only
* read at most three rows. Not persisted on {@code mate_agent}; sidecar
* configuration is system-wide and {@code modelCapabilities} is derived from
* {@code mate_model_config.modalities}.
*/
@Data
@Builder
@AllArgsConstructor
public class AgentCapabilitiesVO {
private Long agentId;
private String modelName;
private String providerId;
/** Resolved modality set: any of {@code TEXT / VISION / VIDEO / AUDIO}. */
private List<String> modalities;
/** System-level vision sidecar model id, null when not configured. */
private Long defaultVisionModelId;
/** Display name of the configured vision sidecar (provider/modelName), null when not configured. */
private String defaultVisionModelLabel;
/** System-level video sidecar model id (reserved in v1; never wired). */
private Long defaultVideoModelId;
private String defaultVideoModelLabel;
}

View File

@ -92,6 +92,34 @@ public class ApprovalService {
pendingMap.remove(pendingId);
}
/**
* INTERNAL drop every map entry tied to the given conversation in one pass.
* Used by {@link ApprovalWorkflowService}'s {@code ConversationDeletedEvent}
* listener to clear residue once the {@code mate_tool_approval} rows for the
* conversation have already been deleted by the cascade. Without this, a
* still-PENDING entry (or any not yet GC'd resolved entry) would survive in
* the map until TTL eviction, and {@code findPendingByConversation} would
* keep handing out a ghost approval that points at a non-existent
* conversation row.
* <p>
* Only {@code ApprovalWorkflowService} should call this.
*
* @return number of entries removed
*/
int removeAllByConversation(String conversationId) {
if (conversationId == null) return 0;
int removed = 0;
var iter = pendingMap.entrySet().iterator();
while (iter.hasNext()) {
var entry = iter.next();
if (conversationId.equals(entry.getValue().getConversationId())) {
iter.remove();
removed++;
}
}
return removed;
}
/**
* INTERNAL register a {@link PendingApproval} reconstructed from DB during JVM startup.
* Bypasses id generation and pre-existing-entry checks; the snapshot's {@code pendingId}

View File

@ -8,8 +8,11 @@ import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -17,11 +20,13 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.approval.event.WorkflowApprovalResolvedEvent;
import vip.mate.approval.model.ToolApprovalEntity;
import vip.mate.approval.repository.ToolApprovalMapper;
import vip.mate.tool.guard.model.GuardEvaluation;
import vip.mate.tool.guard.model.GuardFinding;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.event.ConversationDeletedEvent;
import java.time.Instant;
import java.time.LocalDateTime;
@ -50,6 +55,12 @@ public class ApprovalWorkflowService implements ApplicationRunner {
private final ToolApprovalMapper approvalMapper;
private final ObjectMapper objectMapper;
private final ConversationService conversationService;
/** Optional injected only in full Spring context. The workflow
* module listens for {@link WorkflowApprovalResolvedEvent}; in tests
* that don't wire the workflow runtime this stays null and the
* publish is a no-op. */
@Autowired(required = false)
private ApplicationEventPublisher events;
/**
* GC scheduler owns the 5-minute clock for the entire approval state machine
@ -82,6 +93,26 @@ public class ApprovalWorkflowService implements ApplicationRunner {
}
}
/**
* Drop in-memory approval state for a deleted conversation. The cascade in
* {@link ConversationService#deleteConversation} already removed the
* {@code mate_tool_approval} rows; this listener clears the parallel
* {@code pendingMap} entries so {@code findPendingByConversation} cannot
* keep returning a ghost approval that points at a non-existent
* conversation row.
* <p>
* Runs after the DB cascade commits see
* {@link ConversationDeletedEvent}.
*/
@EventListener
public void onConversationDeleted(ConversationDeletedEvent event) {
int removed = approvalService.removeAllByConversation(event.conversationId());
if (removed > 0) {
log.info("[ApprovalWorkflow] Dropped {} in-memory pending entries for deleted conversation {}",
removed, event.conversationId());
}
}
/**
* Reconstruct in-memory pending approvals from DB at startup, preserving the
* original {@code pendingId} and {@code createdAt} so subsequent resolve / GC
@ -238,6 +269,78 @@ public class ApprovalWorkflowService implements ApplicationRunner {
toolCallPayload, siblingToolCalls, agentId, null);
}
/**
* Workflow-scoped approval request creates a {@code mate_tool_approval}
* row keyed to a workflow run + step instead of a conversation, so an
* {@code await_approval} step is visible in the same approval inbox the
* tool-approval flow uses. Returns the row's auto-generated long id; the
* caller (typically {@code AwaitApprovalStepAdapter}) writes that id back
* onto {@code mate_workflow_run_pause.external_approval_id} so a future
* approval-resolve callback can map "approval X resolved → resume run Y".
*
* <p>The approval row's {@code conversationId} is set to
* {@code "workflow:run:{runId}"} as a synthetic key that lets the
* existing {@link ApprovalService#findPendingByConversation} surface the
* workflow approval to operator UIs without needing a parallel query
* surface. {@code toolName} is set to {@code "workflow:{kind}"} so the
* inbox can group / filter workflow approvals from tool approvals.
*
* <p>v0 keeps the resume path through {@code WorkflowResumeController}
* with the pauseToken; this method does not yet wire a resolveresume
* callback. The approval row's purpose for v0 is operator visibility
* and a stable foreign key for the pause record.
*/
public Long requestWorkflowApproval(long workspaceId,
long runId,
Long stepId,
String approvalKind,
String approvalMessage,
java.util.List<String> approverChannels,
Integer timeoutSecs) {
try {
ToolApprovalEntity entity = new ToolApprovalEntity();
// pendingId is the string handle the existing approval pipeline
// uses for resolve / get; "wf-" prefix lets future code branch
// on workflow-scoped vs tool-scoped approvals at a glance. The
// pending_id column is VARCHAR(32) so we trim a no-dashes UUID
// down to fit ("wf-" + 24 hex chars = 27 chars; collisions of
// 24 hex chars per workflow are astronomically rare and we
// also fall back to UNIQUE-key violation handling).
String shortId = java.util.UUID.randomUUID().toString()
.replace("-", "").substring(0, 24);
entity.setPendingId("wf-" + shortId);
entity.setConversationId("workflow:run:" + runId);
String kind = approvalKind == null || approvalKind.isBlank() ? "manual" : approvalKind.trim();
entity.setToolName("workflow:" + kind);
entity.setSummary(approvalMessage == null ? "" : approvalMessage);
// Encode approver channels in tool_arguments so the inbox UI can
// render which channels were asked. Plain JSON to keep parsing
// trivial on the read path.
try {
if (approverChannels != null && !approverChannels.isEmpty()) {
entity.setToolArguments(objectMapper.writeValueAsString(
java.util.Map.of(
"runId", runId,
"stepId", stepId,
"approverChannels", approverChannels)));
}
} catch (Exception e) {
log.warn("[ApprovalWorkflow] failed to encode approverChannels: {}", e.getMessage());
}
entity.setStatus("PENDING");
entity.setCreatedAt(LocalDateTime.now());
entity.setExpireAt(LocalDateTime.now().plusSeconds(
timeoutSecs != null && timeoutSecs > 0 ? timeoutSecs : 30 * 60));
approvalMapper.insert(entity);
log.info("[ApprovalWorkflow] requested workflow approval row id={}, runId={}, workspace={}, kind={}",
entity.getId(), runId, workspaceId, kind);
return entity.getId();
} catch (Exception e) {
log.warn("[ApprovalWorkflow] requestWorkflowApproval failed: {}", e.getMessage());
return null;
}
}
/**
* Resolve a pending approval (approve / deny) following the RFC-067 §4.2 two-phase
* contract: snapshot DB UPDATE conditional on {@code status='PENDING'}
@ -484,6 +587,42 @@ public class ApprovalWorkflowService implements ApplicationRunner {
if (removeFromMap) approvalService.removeFromMap(snapshot.getPendingId());
});
// Phase 4 workflow bridge. Workflow-scoped approval rows
// (pendingId starting with "wf-") are linked to a paused workflow
// run via {@code mate_workflow_run_pause.external_approval_id}.
// Publishing the resolve here lets the workflow module's listener
// call WorkflowResumer with the matching outcome, so an operator
// approving in the inbox actually advances the workflow instead
// of leaving it paused forever. We publish AFTER commit so a tx
// rollback can't fire a stale resume; the row id is stable
// because the row already lived in DB.
if (events != null && snapshot.getPendingId() != null
&& snapshot.getPendingId().startsWith("wf-")) {
// Look up the row id since the snapshot only carries the string
// pendingId, not the long primary key. One quick equality query.
try {
ToolApprovalEntity row = approvalMapper.selectOne(
new LambdaQueryWrapper<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getPendingId, snapshot.getPendingId()));
if (row != null && row.getId() != null) {
final long rowId = row.getId();
final String pendingId = snapshot.getPendingId();
afterCommit(() -> {
try {
events.publishEvent(new WorkflowApprovalResolvedEvent(
rowId, pendingId, snapshotStatus, /* workspaceId */ null));
} catch (Exception e) {
log.warn("[ApprovalWorkflow] failed to publish workflow-resolved event for {}: {}",
pendingId, e.getMessage());
}
});
}
} catch (Exception e) {
log.warn("[ApprovalWorkflow] approval row lookup for resolve event failed for {}: {}",
snapshot.getPendingId(), e.getMessage());
}
}
boolean consumed = "consumed".equals(snapshotStatus);
ResolveOutcome outcome = consumed
? ResolveOutcome.consumed(snapshot, true, rewritten)

View File

@ -0,0 +1,36 @@
package vip.mate.approval.event;
/**
* Spring application event fired when a workflow-scoped approval row is
* resolved (approved / denied / timed out / superseded). The workflow
* module subscribes via {@code @EventListener}, looks up the pause row
* by {@code mate_workflow_run_pause.external_approval_id == approvalRowId},
* and idempotently calls {@code WorkflowResumer.resume} so the pause
* actually advances.
*
* <p>Without this bridge, an operator who clicks "approve" in the
* approval inbox would only flip the {@code mate_tool_approval} row to
* APPROVED the workflow run would stay paused forever until someone
* separately POSTed the pause token to the resume endpoint. That's the
* "approval is just a visibility surface, not an actual approval"
* trap RFC §3.4 calls out.
*
* <p>{@code approvalRowId} is the {@code mate_tool_approval.id} long key,
* NOT the {@code pendingId} string. Pause rows store the long id in
* {@code external_approval_id}, so the listener can find the right
* pause with a single equality query.
*
* <p>{@code decision} mirrors the resolve vocabulary so the listener
* can route to the right {@code WorkflowResumer.ResumeOutcome}:
* <ul>
* <li>{@code approved} / {@code consumed} APPROVED</li>
* <li>{@code denied} / {@code superseded} REJECTED</li>
* <li>{@code timeout} TIMEOUT</li>
* </ul>
*/
public record WorkflowApprovalResolvedEvent(
long approvalRowId,
String pendingId,
String decision,
Long workspaceId
) {}

View File

@ -319,6 +319,27 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
}
}
/**
* Approval notice rendering primary implementation position.
*
* <p>Subclasses that support a native card surface (WeCom
* {@code button_interaction}, DingTalk {@code ActionCard}, etc.)
* override this method and may call
* {@code super.sendApprovalNotice(...)} to fall back to the text
* path on render failure / payload-too-large / etc.
*
* <p>Lives on the abstract class rather than as an interface
* default method so the {@code super.x(...)} call from subclasses
* resolves cleanly via Java's normal class inheritance see
* RFC-32 §2.0.4 (C-4 fix).
*/
@Override
public void sendApprovalNotice(String targetId,
vip.mate.channel.notification.ApprovalNotice notice) {
sendMessage(targetId,
vip.mate.channel.notification.ApprovalNotificationService.staticBuildText(notice));
}
// ==================== 模板方法子类实现 ====================
/**

View File

@ -0,0 +1,117 @@
package vip.mate.channel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.channel.model.ChannelSessionEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.util.List;
import java.util.Set;
/**
* Forward async-task results (image / video / music / 3D) generated by tool
* pipelines to the IM channel that originated the conversation.
* <p>
* Without this dispatcher, completion bytes only land in
* {@code mate_message} + a Web SSE broadcast IM users (WeCom / DingTalk /
* Feishu / Telegram / etc.) see nothing arrive in their chat client because
* the tool pipeline doesn't know about channel adapters. This dispatcher
* closes that loop: look up the conversation's bound channel session, get
* the live adapter from {@link ChannelManager}, and call
* {@link ChannelAdapter#sendContentParts} so the same bytes ride the
* channel-native attachment protocol.
* <p>
* Web / webchat conversations are intentionally skipped because their SSE
* stream already carries the result; double-dispatching would render the
* image twice.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AsyncTaskMediaDispatcher {
private final ChannelSessionStore channelSessionStore;
private final ChannelManager channelManager;
/**
* Channel types that handle their own UX via SSE (no IM forward needed).
* Everything not in this set is treated as an IM channel and gets the
* generated parts pushed via the adapter.
*/
private static final Set<String> WEB_CHANNEL_TYPES = Set.of("web", "webchat");
/**
* Forward generated content parts to the IM channel bound to this
* conversation, if any.
* <p>
* Best-effort: missing session, missing adapter, or adapter exception
* are all logged at debug/warn and never propagate. The caller has
* already persisted the message to {@code mate_message} and broadcast
* to Web SSE before invoking this IM forwarding is additive.
*
* @param conversationId the conversation id used by the agent (e.g.
* {@code wecom:XuZhanFu}, {@code dingtalk:cid_xxx},
* {@code conv_xxx} for Web)
* @param parts assistant content parts to dispatch (typically a
* single image / video / audio / file part)
*/
public void forwardToImIfBound(String conversationId, List<MessageContentPart> parts) {
if (conversationId == null || conversationId.isBlank() || parts == null || parts.isEmpty()) {
return;
}
ChannelSessionEntity session = channelSessionStore.getSession(conversationId);
if (session == null) {
// Common case for Web-only conversations the session is never
// populated because Web doesn't write to ChannelSessionStore.
log.debug("[async-forward] No channel session for conv={}, skipping IM forward",
conversationId);
return;
}
String channelType = session.getChannelType();
if (channelType == null || WEB_CHANNEL_TYPES.contains(channelType)) {
log.debug("[async-forward] conv={} is web-class ({}), skipping IM forward",
conversationId, channelType);
return;
}
Long channelId = session.getChannelId();
if (channelId == null) {
log.debug("[async-forward] conv={} session has no channelId, skipping",
conversationId);
return;
}
ChannelAdapter adapter = channelManager.getAdapter(channelId).orElse(null);
if (adapter == null) {
log.warn("[async-forward] conv={} channelId={} has no live adapter (channel disabled?), skipping",
conversationId, channelId);
return;
}
String targetId = session.getTargetId();
if (targetId == null || targetId.isBlank()) {
log.warn("[async-forward] conv={} session has no targetId, skipping",
conversationId);
return;
}
try {
adapter.sendContentParts(targetId, parts);
log.info("[async-forward] Dispatched {} part(s) to {} adapter (conv={}, target={})",
parts.size(), channelType, conversationId, targetId);
} catch (UnsupportedOperationException uoe) {
// Adapter doesn't override sendContentParts fall through to
// text-only fallback. Most adapters that handle media (wecom /
// feishu / dingtalk) override; the rest will stay text-only
// until they implement the part dispatcher.
log.info("[async-forward] {} adapter does not implement sendContentParts, skipping (conv={})",
channelType, conversationId);
} catch (Exception e) {
log.warn("[async-forward] Failed to dispatch to {} adapter for conv={}: {}",
channelType, conversationId, e.getMessage());
}
}
}

View File

@ -95,6 +95,53 @@ public interface ChannelAdapter {
sendMessage(targetId, content);
}
/**
* Extended render-and-send overload that carries an optional
* {@link SendContext} side-channel (e.g. WeCom AI Bot
* {@code feedback.id} for like/dislike collection).
*
* <p>Default implementation ignores {@code ctx} and falls back to
* {@link #renderAndSend(String, String)}, so existing channel
* adapters and callers see no behavior change. Channels that want
* to consume {@code SendContext} fields override this overload.
*
* <p>This was introduced as part of PR-0 (RFC-32 §2.0.3) to give
* {@code ChannelMessageRouter} a way to thread the pre-allocated
* feedback id (registered against the persisted
* {@code mate_message.id}) down to the WeCom adapter without
* widening the legacy two-arg signature.
*/
default void renderAndSend(String targetId, String content, SendContext ctx) {
renderAndSend(targetId, content);
}
/**
* Render and deliver an approval notice. Channels that support a
* native interactive surface (WeCom {@code button_interaction},
* DingTalk {@code ActionCard}, etc.) override this to skip the
* text path entirely.
*
* <p><b>Primary implementation lives on
* {@link AbstractChannelAdapter}</b>, which keeps the bytewise
* fallback (markdown text {@link #sendMessage}). Adapters that
* inherit from {@code AbstractChannelAdapter} can call
* {@code super.sendApprovalNotice(...)} to fall back; the default
* here is just a safety net for adapters that, for some reason,
* implement {@link ChannelAdapter} directly.
*
* <p>Introduced in PR-0 (RFC-32 §2.0.3) so the router does not
* need to know which channel renders cards vs text:
* <pre>
* ApprovalNotice notice = approvalNotificationService.buildNotice(pending);
* adapter.sendApprovalNotice(replyTarget, notice);
* </pre>
*/
default void sendApprovalNotice(String targetId,
vip.mate.channel.notification.ApprovalNotice notice) {
sendMessage(targetId,
vip.mate.channel.notification.ApprovalNotificationService.staticBuildText(notice));
}
// ==================== 主动推送 ====================
/**
@ -152,6 +199,34 @@ public interface ChannelAdapter {
return getChannelType();
}
/**
* Whether this adapter must run on exactly one node in a multi-instance
* deployment.
*
* <p>Return {@code true} when the underlying transport rejects multiple
* concurrent connections from the same credentials e.g. a bot WebSocket
* gateway that enforces a per-app connection cap, or a long-polling
* endpoint where multiple consumers would steal updates from each other.
* The channel manager will gate {@link #start()} on a distributed lease
* so only one node connects at a time, and failover to another node when
* the lease holder dies.
*
* <p>Webhook-based channels (DingTalk, WeCom, Slack, ) should leave this
* at the default {@code false}: inbound HTTP traffic is fanned out by the
* load balancer, so every node may safely subscribe.
*
* <p>Scope: this hook is honored by the framework for <b>DB-backed
* channels</b> registered via {@code ChannelManager.startChannel}. For
* plugin-registered channels the framework can only gate the initial
* register attempt there is no follower retry, no hot-swap, and no
* disable-detection (plugins have a register/unregister lifecycle, not
* a DB-driven one). Plugin authors needing full single-leader semantics
* should depend on {@code ChannelLeaderElection} directly.
*/
default boolean requiresSingleLeader() {
return false;
}
/**
* RFC-024 Change 2 adapter 认为"多久没活动就视作 stale 需要重启"的阈值
*

View File

@ -10,6 +10,8 @@ import org.springframework.stereotype.Component;
import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
import vip.mate.channel.discord.DiscordChannelAdapter;
import vip.mate.channel.feishu.FeishuChannelAdapter;
import vip.mate.channel.leader.ChannelLeaderElection;
import vip.mate.channel.leader.LeaderLease;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.qq.QQChannelAdapter;
import vip.mate.channel.service.ChannelService;
@ -17,7 +19,9 @@ import vip.mate.channel.telegram.TelegramChannelAdapter;
import vip.mate.channel.web.WebChannelAdapter;
import vip.mate.channel.wecom.WeComChannelAdapter;
import vip.mate.channel.weixin.WeixinChannelAdapter;
import vip.mate.exception.MateClawException;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReadWriteLock;
@ -46,12 +50,91 @@ public class ChannelManager {
private final ObjectMapper objectMapper;
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
/**
* Approval notification renderer used by WeCom adapter (PR-0
* threading; PR-1 wired the WeCom override to render a
* {@code button_interaction} card via this service's card builder).
* Other adapters keep using the text path on
* {@link AbstractChannelAdapter}, which calls
* {@code ApprovalNotificationService.staticBuildText} so this
* field is currently consumed only by WeCom.
*/
private final vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService;
/**
* WeCom interactive card dispatcher (PR-1). Drives the
* {@code button_interaction} approval card render + the inbound
* {@code template_card_event} routing.
*/
private final vip.mate.channel.wecom.cards.WeComCardDispatcher weComCardDispatcher;
/**
* WeCom keepalive scheduler (PR-1). Refreshes the "🤔 思考中..."
* placeholder every 20s and force-finishes after 180s so long-
* running agent tasks don't lose their stream slot.
*/
private final vip.mate.channel.wecom.WeComKeepaliveScheduler weComKeepaliveScheduler;
/**
* Distributed leader election. Channels whose adapter reports
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
* only one node opens the upstream WebSocket / long-poll at a time.
*/
private final ChannelLeaderElection leaderElection;
/** 运行中的渠道适配器channelId -> adapter */
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
/** 插件注册的渠道适配器pluginName -> adapter */
private final Map<String, ChannelAdapter> pluginChannels = new ConcurrentHashMap<>();
/** Held leadership leases for plugin channels: pluginName -> lease */
private final Map<String, LeaderLease> pluginLeases = new ConcurrentHashMap<>();
/** Lease-extension futures for plugin channels: pluginName -> heartbeat */
private final Map<String, ScheduledFuture<?>> pluginHeartbeatFutures = new ConcurrentHashMap<>();
/**
* Serializes register / unregister / shutdown / heartbeat-loss cleanup
* for plugin channels. The three plugin maps each use
* {@code ConcurrentHashMap}, which makes individual put/remove atomic
* but not the multi-map sequences these paths run (snapshot, clear,
* stop adapter, release lease). Without this lock, a concurrent
* {@code registerPluginChannel} could insert into {@code pluginChannels}
* after {@code stopAll} snapshot-copies it but before
* {@code pluginChannels.clear()}, leaking an unstopped adapter and a
* never-released lease.
*/
private final Object pluginLifecycleLock = new Object();
/** Held leadership leases for leader-required channels: channelId -> lease */
private final Map<Long, LeaderLease> activeLeases = new HashMap<>();
/** Lease-extension futures: channelId -> heartbeat */
private final Map<Long, ScheduledFuture<?>> heartbeatFutures = new HashMap<>();
/** Follower retry futures: channelId -> retry */
private final Map<Long, ScheduledFuture<?>> followerRetryFutures = new HashMap<>();
/**
* Reconcile futures for <b>non</b>-leader-required active adapters
* (e.g. webhook-mode Feishu, polling-disabled Telegram). Without these,
* cross-node admin actions on a follower would never reach the running
* adapter on another node only leaders run reconciliation through
* their heartbeat, so a webhook-running node would otherwise be deaf
* to disable / delete / config / mode-flip changes processed elsewhere.
*/
private final Map<Long, ScheduledFuture<?>> reconcileFutures = new HashMap<>();
/**
* Last {@code update_time} the leader observed for each owned channel.
* Compared against the DB row on every heartbeat a newer value means
* another node has applied a config change, and we (the connection
* holder) need to apply it locally too. Otherwise the leader keeps
* running with stale credentials/mode after admin edits.
*/
private final Map<Long, LocalDateTime> lastSeenChannelUpdateTime = new HashMap<>();
/** 读写锁读操作getAdapter 等用读锁写操作start/stop/replace用写锁 */
private final ReadWriteLock adapterLock = new ReentrantReadWriteLock();
@ -62,9 +145,34 @@ public class ChannelManager {
return t;
});
/**
* Heartbeat + follower-retry scheduler. A small pool is enough both
* tasks are short-lived (lock extend or a single startChannel attempt).
*/
private final ScheduledExecutorService leaderScheduler = Executors.newScheduledThreadPool(2, r -> {
Thread t = new Thread(r, "channel-leader");
t.setDaemon(true);
return t;
});
/** 旧 Adapter stop() 超时时间(秒) */
private static final int STOP_TIMEOUT_SECONDS = 5;
/**
* Lease heartbeat cadence. Must be well under
* {@link ChannelLeaderElection#LOCK_AT_MOST_FOR} (60s) so a single
* missed tick doesn't lose leadership; 20s gives us three chances per
* window.
*/
private static final long HEARTBEAT_INTERVAL_SECONDS = 20L;
/**
* How often a follower retries to acquire leadership. Picked so a
* leader dying gets failed-over within (lease window + this interval)
* = ~90s in the worst case, without hammering the DB.
*/
private static final long FOLLOWER_RETRY_INTERVAL_SECONDS = 30L;
/** 支持的渠道类型 */
private static final Set<String> SUPPORTED_TYPES = Set.of(
"web", "dingtalk", "feishu", "telegram", "discord", "wecom", "qq", "weixin", "slack", "webchat"
@ -97,6 +205,7 @@ public class ChannelManager {
public void destroy() {
log.info("Shutting down ChannelManager, stopping {} active channels...", activeAdapters.size());
stopAll();
leaderScheduler.shutdownNow();
stopExecutor.shutdownNow();
messageRouter.shutdown();
}
@ -115,9 +224,25 @@ public class ChannelManager {
}
ChannelAdapter adapter = createAdapter(channel);
if (adapter.requiresSingleLeader()) {
attemptLeaderStart(channel, adapter);
} else {
adapter.start();
activeAdapters.put(channel.getId(), adapter);
lastSeenChannelUpdateTime.put(channel.getId(), channel.getUpdateTime());
// A follower retry may still be scheduled if this channel was
// previously in leader-required mode and just flipped to a
// non-leader transport (e.g. Feishu websocket webhook).
// Cancel it so we don't tick forever on a no-op startChannel.
cancelFollowerRetryLocked(channel.getId());
// Schedule cross-node reconciliation for this non-leader adapter:
// followers driven by their retry tick re-read the channel, but
// a running non-leader has neither a heartbeat nor a retry, so
// without this ticker it would never notice admin actions
// processed on a different node.
scheduleReconcileLocked(channel.getId(), channel.getName());
log.info("Channel started: {} (type={}, id={})", channel.getName(), channel.getChannelType(), channel.getId());
}
} finally {
adapterLock.writeLock().unlock();
}
@ -128,9 +253,15 @@ public class ChannelManager {
*/
public void stopChannel(Long channelId) {
ChannelAdapter oldAdapter;
LeaderLease lease;
adapterLock.writeLock().lock();
try {
oldAdapter = activeAdapters.remove(channelId);
lease = activeLeases.remove(channelId);
lastSeenChannelUpdateTime.remove(channelId);
cancelHeartbeatLocked(channelId);
cancelFollowerRetryLocked(channelId);
cancelReconcileLocked(channelId);
} finally {
adapterLock.writeLock().unlock();
}
@ -138,6 +269,371 @@ public class ChannelManager {
if (oldAdapter != null) {
stopAdapterSafely(oldAdapter, "stopChannel");
}
if (lease != null) {
try {
lease.release();
log.info("[leader] Released lease for channel id={}", channelId);
} catch (Exception e) {
log.warn("[leader] Failed to release lease for channel id={}: {}", channelId, e.getMessage());
}
}
}
// ==================== Leader election ====================
/**
* Try to become leader for {@code channel} and start its adapter on
* success. On failure (another node holds the lease) schedule a
* follower retry so we'll take over when the current leader dies.
*
* <p>Caller must hold the adapter write lock.
*/
private void attemptLeaderStart(ChannelEntity channel, ChannelAdapter adapter) {
String key = channel.getChannelType() + ":" + channel.getId();
Optional<LeaderLease> maybeLease = leaderElection.tryAcquire(key);
if (maybeLease.isEmpty()) {
log.info("[leader] Channel {} (id={}, type={}) is owned by another node — entering follower mode",
channel.getName(), channel.getId(), channel.getChannelType());
scheduleFollowerRetryLocked(channel.getId());
return;
}
LeaderLease lease = maybeLease.get();
try {
adapter.start();
activeAdapters.put(channel.getId(), adapter);
activeLeases.put(channel.getId(), lease);
lastSeenChannelUpdateTime.put(channel.getId(), channel.getUpdateTime());
scheduleHeartbeatLocked(channel.getId(), channel.getName());
cancelFollowerRetryLocked(channel.getId());
log.info("[leader] Channel started as leader: {} (type={}, id={})",
channel.getName(), channel.getChannelType(), channel.getId());
} catch (Exception e) {
log.error("[leader] Adapter start failed after acquiring lease for channel {}: {} — releasing lease",
channel.getName(), e.getMessage(), e);
lease.release();
throw e instanceof RuntimeException re ? re
: new RuntimeException("Channel start failed: " + e.getMessage(), e);
}
}
/**
* Schedule periodic heartbeat to extend the lease. Caller must hold
* the adapter write lock.
*/
private void scheduleHeartbeatLocked(Long channelId, String channelName) {
cancelHeartbeatLocked(channelId);
ScheduledFuture<?> f = leaderScheduler.scheduleAtFixedRate(
() -> heartbeat(channelId, channelName),
HEARTBEAT_INTERVAL_SECONDS, HEARTBEAT_INTERVAL_SECONDS, TimeUnit.SECONDS);
heartbeatFutures.put(channelId, f);
}
/**
* One heartbeat tick. Extends the lease, then reconciles the local
* adapter against the current DB row the leader is the only node
* holding the upstream connection, so it's the only one that can
* apply admin actions (disable / config change / delete) issued on
* a different node. Without this, e.g. a {@code /toggle?enabled=false}
* processed by a follower would never reach the actual connection.
*/
private void heartbeat(Long channelId, String channelName) {
LeaderLease lease;
adapterLock.readLock().lock();
try {
lease = activeLeases.get(channelId);
} finally {
adapterLock.readLock().unlock();
}
if (lease == null) {
return;
}
boolean stillOurs = lease.extend(ChannelLeaderElection.LOCK_AT_MOST_FOR);
if (!stillOurs) {
log.warn("[leader] Lost leadership for channel {} (id={}) — stopping local adapter and re-entering election",
channelName, channelId);
handleLeadershipLoss(channelId);
return;
}
reconcileChannel(channelId, channelName);
}
/**
* Re-read the channel from the DB and apply any admin-side changes
* (disable, delete, config update) the leader hasn't seen yet because
* the API call was processed by a different node.
*
* <p>Package-private for unit testing callers should rely on the
* heartbeat scheduler invoking this on its tick.
*/
void reconcileChannel(Long channelId, String channelName) {
ChannelEntity current;
try {
current = channelService.getChannel(channelId);
} catch (MateClawException e) {
if (e.getMsgKey() != null && e.getMsgKey().startsWith("err.channel.not_found")) {
log.info("[reconcile] Channel id={} no longer exists — stopping local adapter and releasing lease",
channelId);
stopChannel(channelId);
} else {
log.debug("[reconcile] Channel lookup failed for id={}: {}", channelId, e.getMessage());
}
return;
} catch (Exception e) {
// Transient DB issue; skip this tick and try again on the next heartbeat.
log.debug("[reconcile] Channel lookup failed for id={}: {}", channelId, e.getMessage());
return;
}
if (!Boolean.TRUE.equals(current.getEnabled())) {
log.info("[reconcile] Channel {} (id={}) is now disabled — stopping local adapter",
channelName, channelId);
stopChannel(channelId);
return;
}
LocalDateTime previousSeen;
adapterLock.readLock().lock();
try {
previousSeen = lastSeenChannelUpdateTime.get(channelId);
} finally {
adapterLock.readLock().unlock();
}
LocalDateTime currentUpdateTime = current.getUpdateTime();
if (currentUpdateTime != null && previousSeen != null
&& currentUpdateTime.isAfter(previousSeen)) {
log.info("[reconcile] Channel {} (id={}) config changed ({} → {})",
channelName, channelId, previousSeen, currentUpdateTime);
applyConfigChange(channelId, current);
}
}
/**
* Apply a detected config change to a locally-running channel.
*
* <p>The fast path is the in-place swap that preserves the lease
* but it is only valid when we are the current leader (we already
* hold {@code activeLeases[channelId]}). Without that gate, a
* non-leader node observing a {@code webhook websocket} flip
* would call {@code newAdapter.start()} directly inside the swap
* and open a duplicate upstream connection, defeating the leader
* election. Every other transition including
* {@code non-leader leader-required}, {@code leader-required
* non-leader}, and plain non-leader config updates must go
* through {@code stopChannel} + {@code startChannel} so the lease
* is correctly released or acquired and follower retry is
* scheduled when election is lost.
*
* <p>Package-private for unit testing see {@link #reconcileChannel}.
*/
void applyConfigChange(Long channelId, ChannelEntity newChannel) {
ChannelAdapter probe = createAdapter(newChannel);
boolean newRequiresLeader = probe.requiresSingleLeader();
boolean weHaveLease;
adapterLock.readLock().lock();
try {
weHaveLease = activeLeases.containsKey(channelId);
} finally {
adapterLock.readLock().unlock();
}
if (newRequiresLeader && weHaveLease) {
// Case A: same leader-required mode and we are the current leader
// preserve the lease across the adapter swap.
swapAdapterPreservingLease(channelId, newChannel);
return;
}
// All other cases: tear down local state and route through
// startChannel so leader election runs, lease is released, or both.
log.info("[reconcile] Channel {} (id={}) config change (newRequiresLeader={}, weHaveLease={}) — stop+start",
newChannel.getName(), channelId, newRequiresLeader, weHaveLease);
stopChannel(channelId);
try {
startChannel(newChannel);
} catch (Exception e) {
log.error("[reconcile] Restart after config change failed for channel {} (id={}): {}",
newChannel.getName(), channelId, e.getMessage(), e);
}
}
/**
* Swap to a freshly-built adapter using the new config, while keeping
* the leadership lease and heartbeat in place. The lease is only
* released if the new adapter fails to start, in which case we fall
* back to follower mode so another node can try.
*/
private void swapAdapterPreservingLease(Long channelId, ChannelEntity newChannel) {
ChannelAdapter oldAdapter;
adapterLock.writeLock().lock();
try {
oldAdapter = activeAdapters.remove(channelId);
} finally {
adapterLock.writeLock().unlock();
}
if (oldAdapter != null) {
stopAdapterSafely(oldAdapter, "reconcile-swap");
}
ChannelAdapter newAdapter = createAdapter(newChannel);
boolean started = false;
Exception startError = null;
try {
newAdapter.start();
started = true;
} catch (Exception e) {
startError = e;
}
LeaderLease leaseToRelease = null;
adapterLock.writeLock().lock();
try {
if (started) {
activeAdapters.put(channelId, newAdapter);
lastSeenChannelUpdateTime.put(channelId, newChannel.getUpdateTime());
} else {
leaseToRelease = activeLeases.remove(channelId);
lastSeenChannelUpdateTime.remove(channelId);
cancelHeartbeatLocked(channelId);
scheduleFollowerRetryLocked(channelId);
}
} finally {
adapterLock.writeLock().unlock();
}
if (!started) {
log.error("[reconcile] New adapter start failed for channel {} (id={}): {} — released lease, entering follower mode",
newChannel.getName(), channelId,
startError != null ? startError.getMessage() : "unknown");
if (leaseToRelease != null) {
leaseToRelease.release();
}
}
}
/**
* Drop the local adapter (without releasing the already-lost lease)
* and start follower retry so we'll attempt to reclaim leadership
* once the current owner stops renewing.
*/
private void handleLeadershipLoss(Long channelId) {
ChannelAdapter local;
adapterLock.writeLock().lock();
try {
local = activeAdapters.remove(channelId);
activeLeases.remove(channelId); // already lost; do not call release()
cancelHeartbeatLocked(channelId);
scheduleFollowerRetryLocked(channelId);
} finally {
adapterLock.writeLock().unlock();
}
if (local != null) {
stopAdapterSafely(local, "leadership-loss");
}
}
/**
* Schedule periodic follower retry. Caller must hold the adapter
* write lock.
*/
private void scheduleFollowerRetryLocked(Long channelId) {
if (followerRetryFutures.containsKey(channelId)) {
return;
}
ScheduledFuture<?> f = leaderScheduler.scheduleAtFixedRate(
() -> followerRetry(channelId),
FOLLOWER_RETRY_INTERVAL_SECONDS, FOLLOWER_RETRY_INTERVAL_SECONDS, TimeUnit.SECONDS);
followerRetryFutures.put(channelId, f);
}
/**
* One follower-retry tick. Re-reads the channel from the DB (it may
* have been disabled or deleted) and attempts to start it. The retry
* cancels itself once we successfully become leader.
*/
/** Package-private for unit testing — see {@link #reconcileChannel}. */
void followerRetry(Long channelId) {
ChannelEntity current;
try {
current = channelService.getChannel(channelId);
} catch (MateClawException e) {
// Channel was deleted on another node cancel the retry so we
// don't leak a scheduled task forever. Other exception codes
// (e.g. transient DB errors) fall through to the generic catch
// and let the retry continue.
if (e.getMsgKey() != null && e.getMsgKey().startsWith("err.channel.not_found")) {
log.info("[leader] Follower retry: channel id={} no longer exists — cancelling retry", channelId);
adapterLock.writeLock().lock();
try {
cancelFollowerRetryLocked(channelId);
} finally {
adapterLock.writeLock().unlock();
}
return;
}
log.debug("[leader] Follower retry: lookup failed for channel id={}: {}", channelId, e.getMessage());
return;
} catch (Exception e) {
log.debug("[leader] Follower retry: lookup failed for channel id={}: {}", channelId, e.getMessage());
return;
}
if (!Boolean.TRUE.equals(current.getEnabled())) {
adapterLock.writeLock().lock();
try {
cancelFollowerRetryLocked(channelId);
} finally {
adapterLock.writeLock().unlock();
}
return;
}
try {
startChannel(current);
} catch (Exception e) {
log.debug("[leader] Follower retry: startChannel failed for id={}: {}", channelId, e.getMessage());
}
}
/** Package-private for unit testing. */
boolean hasFollowerRetry(Long channelId) {
adapterLock.readLock().lock();
try {
return followerRetryFutures.containsKey(channelId);
} finally {
adapterLock.readLock().unlock();
}
}
private void cancelHeartbeatLocked(Long channelId) {
ScheduledFuture<?> f = heartbeatFutures.remove(channelId);
if (f != null) {
f.cancel(false);
}
}
private void cancelFollowerRetryLocked(Long channelId) {
ScheduledFuture<?> f = followerRetryFutures.remove(channelId);
if (f != null) {
f.cancel(false);
}
}
/**
* Schedule the cross-node reconcile ticker for a non-leader active
* adapter. Caller must hold the adapter write lock.
*/
private void scheduleReconcileLocked(Long channelId, String channelName) {
cancelReconcileLocked(channelId);
ScheduledFuture<?> f = leaderScheduler.scheduleAtFixedRate(
() -> reconcileChannel(channelId, channelName),
FOLLOWER_RETRY_INTERVAL_SECONDS, FOLLOWER_RETRY_INTERVAL_SECONDS, TimeUnit.SECONDS);
reconcileFutures.put(channelId, f);
}
private void cancelReconcileLocked(Long channelId) {
ScheduledFuture<?> f = reconcileFutures.remove(channelId);
if (f != null) {
f.cancel(false);
}
}
/**
@ -161,6 +657,50 @@ public class ChannelManager {
return;
}
// Leader-required channels: if we don't already own the local adapter
// (i.e. we are a follower, or this is a brand-new channel), there is
// nothing to hot-swap. Fall through to startChannel which handles
// lease acquisition and follower retry. Hot-swap (which briefly opens
// a second upstream connection) is also avoided here so we don't
// double-occupy the bot's connection quota during a restart.
boolean weHaveAdapter;
boolean weHaveLease;
adapterLock.readLock().lock();
try {
weHaveAdapter = activeAdapters.containsKey(channelId);
weHaveLease = activeLeases.containsKey(channelId);
} finally {
adapterLock.readLock().unlock();
}
ChannelAdapter probe = createAdapter(channel);
if (probe.requiresSingleLeader()) {
log.info("[hot-swap] Channel {} requires single-leader; stop+start instead of hot-swap (weHaveAdapter={}, weHaveLease={})",
channel.getName(), weHaveAdapter, weHaveLease);
stopChannel(channelId);
startChannel(channel);
return;
}
// Mode flip: we currently hold a lease but the new config is no
// longer leader-required (e.g. Feishu WS webhook). The lease,
// heartbeat, and lastSeenUpdateTime must all be torn down before
// the new non-leader adapter starts the in-place hot-swap path
// below would leave them behind until the next heartbeat tick
// noticed and re-restarted, causing a redundant restart and a
// window where this node is silently holding a lease nobody else
// can grab. Stop+start handles all the cleanup in one shot.
if (weHaveLease) {
log.info("[hot-swap] Channel {} flipping leader-required → non-leader; stop+start to release lease",
channel.getName());
stopChannel(channelId);
startChannel(channel);
return;
}
if (!weHaveAdapter) {
log.info("[hot-swap] No local adapter for channel {}, delegating to startChannel", channel.getName());
startChannel(channel);
return;
}
log.info("[hot-swap] Starting hot-swap for channel: {} (type={}, id={})",
channel.getName(), channel.getChannelType(), channelId);
@ -182,6 +722,10 @@ public class ChannelManager {
adapterLock.writeLock().lock();
try {
oldAdapter = activeAdapters.put(channelId, newAdapter);
// Mark the version we've now applied so the reconcile ticker
// (running for non-leader adapters) doesn't immediately fire a
// redundant swap on its next tick.
lastSeenChannelUpdateTime.put(channelId, channel.getUpdateTime());
log.info("[hot-swap] Adapter reference swapped for channel: {} (old={})",
channel.getName(), oldAdapter != null ? "present" : "none");
} finally {
@ -203,17 +747,65 @@ public class ChannelManager {
*/
public void stopAll() {
List<ChannelAdapter> adaptersToStop;
List<LeaderLease> leasesToRelease;
List<ChannelAdapter> pluginAdaptersToStop;
List<LeaderLease> pluginLeasesToRelease;
adapterLock.writeLock().lock();
try {
adaptersToStop = new ArrayList<>(activeAdapters.values());
leasesToRelease = new ArrayList<>(activeLeases.values());
activeAdapters.clear();
activeLeases.clear();
lastSeenChannelUpdateTime.clear();
heartbeatFutures.values().forEach(f -> f.cancel(false));
heartbeatFutures.clear();
followerRetryFutures.values().forEach(f -> f.cancel(false));
followerRetryFutures.clear();
reconcileFutures.values().forEach(f -> f.cancel(false));
reconcileFutures.clear();
} finally {
adapterLock.writeLock().unlock();
}
// Plugin channels live on a different map (keyed by pluginName), but
// shutdown must release their leases + cancel their heartbeats just
// like DB-backed ones. Without this, a plugin-supplied single-leader
// adapter would skip graceful release on @PreDestroy and its lease
// would stay locked until the lockAtMostFor window expired.
//
// Holding pluginLifecycleLock makes the snapshot+clear atomic
// against concurrent register / unregister / heartbeat-loss
// cleanup, so we don't leak an adapter that was registered after
// the snapshot but before the clear.
synchronized (pluginLifecycleLock) {
pluginAdaptersToStop = new ArrayList<>(pluginChannels.values());
pluginChannels.clear();
pluginLeasesToRelease = new ArrayList<>(pluginLeases.values());
pluginLeases.clear();
pluginHeartbeatFutures.values().forEach(f -> f.cancel(false));
pluginHeartbeatFutures.clear();
}
for (ChannelAdapter adapter : adaptersToStop) {
stopAdapterSafely(adapter, "stopAll");
}
for (ChannelAdapter adapter : pluginAdaptersToStop) {
stopAdapterSafely(adapter, "stopAll-plugin");
}
for (LeaderLease lease : leasesToRelease) {
try {
lease.release();
} catch (Exception e) {
log.warn("[leader] stopAll: failed to release lease '{}': {}", lease.getName(), e.getMessage());
}
}
for (LeaderLease lease : pluginLeasesToRelease) {
try {
lease.release();
} catch (Exception e) {
log.warn("[leader] stopAll: failed to release plugin lease '{}': {}", lease.getName(), e.getMessage());
}
}
}
// ==================== 查询读锁保护 ====================
@ -373,16 +965,42 @@ public class ChannelManager {
/**
* Register a channel adapter from a plugin.
*
* <p>If the adapter reports {@link ChannelAdapter#requiresSingleLeader()},
* the framework gates the local register on a distributed lease keyed by
* {@code plugin:{pluginName}}. When another node already owns the lease
* this node skips registration (its plugin instance is loaded but inert
* locally) see the scope note on {@link ChannelAdapter#requiresSingleLeader()}.
*
* @param pluginName the plugin name (used as key for unregistration)
* @param adapter the channel adapter
*/
public void registerPluginChannel(String pluginName, ChannelAdapter adapter) {
synchronized (pluginLifecycleLock) {
LeaderLease lease = null;
if (adapter.requiresSingleLeader()) {
Optional<LeaderLease> maybeLease = leaderElection.tryAcquire("plugin:" + pluginName);
if (maybeLease.isEmpty()) {
log.info("[leader] Plugin channel {} (type={}) is owned by another node — skipping local registration",
pluginName, adapter.getChannelType());
return;
}
lease = maybeLease.get();
}
try {
adapter.start();
pluginChannels.put(pluginName, adapter);
if (lease != null) {
pluginLeases.put(pluginName, lease);
schedulePluginHeartbeatLocked(pluginName);
}
log.info("Plugin channel registered: {} (type={})", pluginName, adapter.getChannelType());
} catch (Exception e) {
log.error("Failed to start plugin channel {}: {}", pluginName, e.getMessage(), e);
if (lease != null) {
lease.release();
}
}
}
}
@ -390,11 +1008,78 @@ public class ChannelManager {
* Unregister a plugin channel.
*/
public void unregisterPluginChannel(String pluginName) {
ChannelAdapter adapter = pluginChannels.remove(pluginName);
ChannelAdapter adapter;
ScheduledFuture<?> heartbeat;
LeaderLease lease;
synchronized (pluginLifecycleLock) {
adapter = pluginChannels.remove(pluginName);
heartbeat = pluginHeartbeatFutures.remove(pluginName);
lease = pluginLeases.remove(pluginName);
}
if (heartbeat != null) {
heartbeat.cancel(false);
}
if (adapter != null) {
stopAdapterSafely(adapter, "unregisterPluginChannel");
log.info("Plugin channel unregistered: {}", pluginName);
}
if (lease != null) {
try {
lease.release();
} catch (Exception e) {
log.warn("[leader] Failed to release plugin lease '{}': {}", pluginName, e.getMessage());
}
}
}
/** Caller must hold {@link #pluginLifecycleLock}. */
private void schedulePluginHeartbeatLocked(String pluginName) {
ScheduledFuture<?> existing = pluginHeartbeatFutures.remove(pluginName);
if (existing != null) {
existing.cancel(false);
}
ScheduledFuture<?> f = leaderScheduler.scheduleAtFixedRate(
() -> pluginHeartbeatTick(pluginName),
HEARTBEAT_INTERVAL_SECONDS, HEARTBEAT_INTERVAL_SECONDS, TimeUnit.SECONDS);
pluginHeartbeatFutures.put(pluginName, f);
}
/**
* One plugin heartbeat tick. The tick runs on the scheduler thread, so
* it can race with {@code registerPluginChannel} / {@code unregisterPluginChannel}
* / {@code stopAll}. Serializing the loss-handler on
* {@link #pluginLifecycleLock} keeps the three maps consistent (no
* "stop ran twice" or "lease released after register re-acquired").
*/
private void pluginHeartbeatTick(String pluginName) {
LeaderLease lease = pluginLeases.get(pluginName);
if (lease == null) {
return;
}
boolean stillOurs = lease.extend(ChannelLeaderElection.LOCK_AT_MOST_FOR);
if (stillOurs) {
return;
}
ChannelAdapter local;
ScheduledFuture<?> self;
synchronized (pluginLifecycleLock) {
// Re-check under the lock a concurrent unregister may have
// already torn everything down between the failed extend and
// our entering the locked region.
LeaderLease currentLease = pluginLeases.remove(pluginName);
if (currentLease == null) {
return;
}
local = pluginChannels.remove(pluginName);
self = pluginHeartbeatFutures.remove(pluginName);
}
log.warn("[leader] Lost plugin lease '{}' — unregistering local adapter", pluginName);
if (self != null) {
self.cancel(false);
}
if (local != null) {
stopAdapterSafely(local, "plugin-leadership-loss");
}
}
// ==================== 内部方法 ====================
@ -446,8 +1131,11 @@ public class ChannelManager {
/**
* 根据渠道实体创建对应的适配器实例
* 采用渠道注册表模式根据类型创建对应适配器
*
* <p>Package-private + non-final so unit tests can substitute a stub
* adapter without spinning up real WebSocket / HTTP clients.
*/
private ChannelAdapter createAdapter(ChannelEntity channel) {
ChannelAdapter createAdapter(ChannelEntity channel) {
String type = channel.getChannelType();
return switch (type) {
case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper);
@ -455,7 +1143,9 @@ public class ChannelManager {
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper);
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper);
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler,
generatedFileCache);
case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper);
case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper);
case "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper);

View File

@ -1,6 +1,8 @@
package vip.mate.channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService;
@ -8,6 +10,7 @@ import vip.mate.agent.context.ChatOrigin;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.ResolveOutcome;
import vip.mate.approval.PendingApproval;
import vip.mate.channel.event.ChannelMessageReceivedEvent;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.service.ChannelService;
@ -59,6 +62,11 @@ public class ChannelMessageRouter {
private final ChatStreamTracker streamTracker;
private final ChannelChatOriginFactory chatOriginFactory;
private final ChannelErrorClassifier errorClassifier;
/** Field-injected (rather than constructor) to avoid a signature
* change that would ripple through every test that constructs the
* router directly. Spring's stock publisher is always available. */
@Autowired(required = false)
private ApplicationEventPublisher events;
/** 队列条目:封装消息及其路由上下文 */
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
@ -88,8 +96,47 @@ public class ChannelMessageRouter {
/** 每个渠道的队列容量 */
private static final int QUEUE_CAPACITY = 1000;
/** 防抖等待时间(毫秒) */
private static final long DEBOUNCE_MS = 500;
/** 防抖等待时间毫秒。Package-private for unit-test access. */
static final long DEBOUNCE_MS = 500;
/**
* Extended debounce window for suspected paste-split scenarios. WeCom
* (and other IM clients) silently split a single pasted long prompt
* into 2-4 separate messages when it exceeds the per-frame limit
* (~2000 chars). The fragments arrive 0.5-2s apart, which means the
* default {@link #DEBOUNCE_MS} flushes the first fragment before the
* second one arrives the agent then sees a torn context, calls the
* LLM on a partial prompt, and gets re-triggered when the next
* fragment lands. When merged content exceeds
* {@link #LONG_TEXT_THRESHOLD} we extend the window so the merger has
* time to absorb the rest.
* <p>
* Package-private for unit-test access.
*/
static final long LONG_DEBOUNCE_MS = 2500;
/**
* Content length (chars) above which we treat the message as a likely
* paste-split fragment. 1500 sits below the typical ~2000-char IM
* client split point while staying well above any normally-typed
* message, so the long-debounce path doesn't penalize ordinary
* chatting. A short typed "hello" still flushes in 500ms.
* <p>
* Package-private for unit-test access.
*/
static final int LONG_TEXT_THRESHOLD = 1500;
/**
* Pick the debounce window: extend to {@link #LONG_DEBOUNCE_MS} when
* either the new arrival or the accumulated merged buffer looks like
* a paste-split fragment, otherwise stay at {@link #DEBOUNCE_MS}.
* <p>
* Package-private + static so tests can pin the threshold without
* spinning up the whole router (which has 12+ injected dependencies).
*/
static long pickDebounceMs(int currentMergedLength) {
return currentMergedLength > LONG_TEXT_THRESHOLD ? LONG_DEBOUNCE_MS : DEBOUNCE_MS;
}
/**
* Plan-Execute SSE events that the Web Console mirror needs to see when
@ -189,6 +236,14 @@ public class ChannelMessageRouter {
* @param channelEntity 渠道配置含关联 agentId
*/
public void enqueue(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {
// Fan out to the trigger pipeline FIRST channel_message and
// content_match triggers fire on every received message regardless
// of whether the channel has an agent attached. If we returned
// early on a missing agent below without publishing, the workflow
// side would silently lose every channel-event that doesn't also
// route to a chat agent.
publishChannelEvent(message, adapter, channelEntity);
Long agentId = channelEntity.getAgentId();
if (agentId == null) {
log.warn("Channel {} has no associated agent, ignoring message from {}",
@ -207,27 +262,101 @@ public class ChannelMessageRouter {
log.info("[{}] Enqueuing message: sender={}, conversationId={}, agentId={}",
channelType, message.getSenderId(), conversationId, agentId);
// 防抖同一会话 500ms 内的连续消息合并
// Debounce + adaptive merge: same conversation messages within the
// (500ms / 2.5s) window get concatenated into one. Adaptive: when
// the merged buffer crosses the LONG_TEXT_THRESHOLD we extend to
// LONG_DEBOUNCE_MS so paste-split fragments arrive together
// instead of triggering one agent call per piece.
synchronized (pendingMessages) {
PendingMessage existing = pendingMessages.get(conversationId);
if (existing != null) {
// 合并到已有的 pending 消息
// Sender boundary in groups: when a different user sends to the
// same group within the debounce window, merging would attribute
// both fragments to whoever sent first the LLM then loses the
// ability to tell who asked what. Flush the existing buffer
// immediately so each user's text rides its own pending window.
// Reentrant on `pendingMessages`, so the inner flushPending's
// synchronized block re-acquires safely on the same thread.
String existingSender = existing.firstMessage.getSenderId();
String incomingSender = message.getSenderId();
boolean sameSender = isSameSender(existingSender, incomingSender);
if (!sameSender) {
log.info("[{}] Sender boundary in conversation {}: flushing pending from sender={}, accepting new sender={}",
channelType, conversationId, existingSender, incomingSender);
if (existing.timer != null) {
existing.timer.cancel(false);
}
flushPending(conversationId);
// Fall through to create a fresh pending for the new sender.
} else {
// Same sender original paste-split / rapid-follow merge path.
if (existing.timer != null) {
existing.timer.cancel(false);
}
existing.appendContent(message.getContent());
int mergedLen = existing.getMergedContent().length();
long debounceMs = pickDebounceMs(mergedLen);
existing.timer = debounceScheduler.schedule(
() -> flushPending(conversationId), DEBOUNCE_MS, TimeUnit.MILLISECONDS);
log.debug("[{}] Message merged with pending (debounce): conversationId={}",
channelType, conversationId);
() -> flushPending(conversationId), debounceMs, TimeUnit.MILLISECONDS);
if (debounceMs > DEBOUNCE_MS) {
log.info("[{}] Long-text merger active: conversationId={}, mergedLen={}, debounce={}ms (paste-split suspected)",
channelType, conversationId, mergedLen, debounceMs);
} else {
log.debug("[{}] Message merged with pending (debounce {}ms): conversationId={}",
channelType, debounceMs, conversationId);
}
return;
}
}
// 首条消息创建 PendingMessage 并设定防抖定时器
// 首条消息 sender boundary 之后的新 sender创建 PendingMessage 并设定防抖定时器
PendingMessage pending = new PendingMessage(message, adapter, channelEntity);
pendingMessages.put(conversationId, pending);
int firstLen = message.getContent() != null ? message.getContent().length() : 0;
long debounceMs = pickDebounceMs(firstLen);
pending.timer = debounceScheduler.schedule(
() -> flushPending(conversationId), DEBOUNCE_MS, TimeUnit.MILLISECONDS);
() -> flushPending(conversationId), debounceMs, TimeUnit.MILLISECONDS);
if (debounceMs > DEBOUNCE_MS) {
log.info("[{}] Long-text merger armed on first message: conversationId={}, len={}, debounce={}ms",
channelType, conversationId, firstLen, debounceMs);
}
}
}
/**
* Publish a {@link ChannelMessageReceivedEvent} so the trigger module's
* bridge can fan the message out to channel_message + content_match
* triggers. Best-effort a publish failure must never block the
* primary chat-routing path. {@code messageId} is used as the dedup
* key downstream so repeated webhook deliveries can't double-fire
* the same trigger.
*/
private void publishChannelEvent(ChannelMessage message, ChannelAdapter adapter,
ChannelEntity channelEntity) {
if (events == null || message == null || adapter == null || channelEntity == null) return;
try {
long ws = channelEntity.getWorkspaceId() == null ? 0L : channelEntity.getWorkspaceId();
String channelType = adapter.getChannelType();
// messageId may be null for adapters that don't surface one;
// fall back to a sender+timestamp composite so the dedup key
// is at least deterministic-ish per webhook delivery.
String messageId = message.getMessageId();
if (messageId == null || messageId.isBlank()) {
messageId = channelType + ":" + message.getSenderId() + ":"
+ (message.getTimestamp() == null ? System.currentTimeMillis()
: message.getTimestamp());
}
events.publishEvent(new ChannelMessageReceivedEvent(
ws,
channelType,
messageId,
message.getSenderId(),
message.getSenderName(),
message.getChatId(),
message.getContent()));
} catch (Exception e) {
log.warn("[ChannelMessageRouter] event publish failed for sender {}: {}",
message.getSenderId(), e.getMessage());
}
}
@ -416,7 +545,10 @@ public class ChannelMessageRouter {
ResolveOutcome denyOutcome = approvalService.resolve(
pending.getPendingId(), message.getSenderId(), "denied");
conversationService.removeApprovalPlaceholders(conversationId);
adapter.sendMessage(replyTarget, "⛔ 已拒绝执行工具: " + pending.getToolName());
String denyHint = "⛔ 已拒绝执行工具: " + pending.getToolName();
persistAndBroadcastApprovalHint(conversationId, denyHint,
"denied", pending.getPendingId(), pending.getToolName());
adapter.sendMessage(replyTarget, denyHint);
log.info("[{}] Approval DENIED via IM command: pendingId={}, tool={}, msgRewritten={}",
adapter.getChannelType(), pending.getPendingId(), pending.getToolName(),
denyOutcome.messagesRewritten());
@ -426,7 +558,10 @@ public class ChannelMessageRouter {
// Non-approval message while a pending exists treat as implicit deny.
approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied");
conversationService.removeApprovalPlaceholders(conversationId);
adapter.sendMessage(replyTarget, "⛔ 审批已取消。将继续处理您的新消息。");
String cancelHint = "⛔ 审批已取消。将继续处理您的新消息。";
persistAndBroadcastApprovalHint(conversationId, cancelHint,
"cancelled", pending.getPendingId(), pending.getToolName());
adapter.sendMessage(replyTarget, cancelHint);
log.info("[{}] Approval auto-cancelled (non-approval message): pendingId={}",
adapter.getChannelType(), pending.getPendingId());
// Fall through to process the new message normally.
@ -454,11 +589,20 @@ public class ChannelMessageRouter {
}
// 保存用户消息 contentParts
// Group sender attribution: tag the persisted content + the
// prompt with [@sender] in groups so the LLM can disambiguate
// multiple users sharing one conversation. Single chats pass
// through unchanged (chatId is null).
List<MessageContentPart> parts = message.getContentParts();
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);
String attributedContent = applyGroupTag(message, message.getContent());
conversationService.saveMessage(conversationId, "user", attributedContent, parts);
// 构建 prompt语音输入时注入场景提示词
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
// Re-apply the tag in case the prompt was assembled from
// non-text parts (image/file) where buildPromptFromParts
// ignored `content`. Idempotent: skips when already prefixed.
promptText = applyGroupTag(message, promptText);
// 注册到 ChatStreamTracker graph 节点广播的事件phase / content_delta / tool_call_*
// 能被 ChatConsole observer 订阅到不注册 broadcast() 会因 state==null 短路丢弃
@ -511,9 +655,13 @@ public class ChannelMessageRouter {
// 检查 chat 过程中是否产生了审批 pending
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
if (newPending != null) {
// 有审批需求不保存 LLM 的审批占位回复到 DB直接从 pending 元数据构建通知
String approvalNotice = buildApprovalNotice(newPending);
adapter.renderAndSend(replyTarget, approvalNotice);
// Channel-specific approval rendering: WeCom overrides
// sendApprovalNotice to post a button_interaction card;
// every other adapter falls back to the markdown-text path
// on AbstractChannelAdapter (preserves PR-0 behavior for
// non-WeCom channels).
var notice = approvalNotificationService.buildNotice(newPending);
adapter.sendApprovalNotice(replyTarget, notice);
log.info("[{}] Approval triggered during chat, sent notice (NOT saved to DB): tool={}",
adapter.getChannelType(), newPending.getToolName());
} else {
@ -641,7 +789,11 @@ public class ChannelMessageRouter {
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
if (newPending != null) {
String replyTarget = resolveReplyTarget(message);
streamingAdapter.sendMessage(replyTarget, buildApprovalNotice(newPending));
// Same polymorphic dispatch as the non-streaming path WeCom
// renders a card, others render text. See the buildNotice +
// sendApprovalNotice pair at the non-streaming call site above.
var notice = approvalNotificationService.buildNotice(newPending);
streamingAdapter.sendApprovalNotice(replyTarget, notice);
log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}",
channelType, newPending.getToolName());
} else if (finalContent != null && !finalContent.isBlank()) {
@ -702,8 +854,14 @@ public class ChannelMessageRouter {
String replyTarget = resolveReplyTarget(triggerMessage);
Long agentId = channelEntity.getAgentId();
// 通知用户审批已通过
adapter.sendMessage(replyTarget, "✅ 已批准执行工具: " + consumed.getToolName());
// Notify the user that the approval went through. Persist + broadcast so a
// Web mirror of the same conversationId sees the resolution; otherwise this
// hint would only land in the IM channel and the Web admin console would
// show the replay reply with no preceding "approved" marker.
String approveHint = "✅ 已批准执行工具: " + consumed.getToolName();
persistAndBroadcastApprovalHint(conversationId, approveHint,
"approved", consumed.getPendingId(), consumed.getToolName());
adapter.sendMessage(replyTarget, approveHint);
// 清理 DB 中残留的审批占位消息
conversationService.removeApprovalPlaceholders(conversationId);
@ -739,7 +897,62 @@ public class ChannelMessageRouter {
adapter.getChannelType(), consumed.getToolName(), reply.length());
} catch (Exception e) {
log.error("[approval-replay] Replay failed: {}", e.getMessage(), e);
adapter.sendMessage(replyTarget, "❌ 工具执行失败: " + e.getMessage());
String errHint = "❌ 工具执行失败: " + e.getMessage();
persistAndBroadcastApprovalHint(conversationId, errHint, null, null, null);
adapter.sendMessage(replyTarget, errHint);
}
}
/**
* Persist an approval-related hint as an assistant message and best-effort
* broadcast it to any live SSE viewer of the conversation.
* <p>
* Without this, IM-driven approve/deny only reaches the originating IM
* channel via {@code adapter.sendMessage(...)} a Web mirror of the same
* conversationId has no record of the resolution because nothing lands in
* {@code mate_message} and no SSE event is emitted. The hint then "vanishes"
* from the Web admin console even though it shows up on the user's phone.
* <p>
* Persistence is the load-bearing fix (Web reload picks it up). Broadcast
* is best-effort: if no SSE stream is currently registered for the
* conversation, the broadcast no-ops silently that's the common case
* since IM-driven clicks rarely race with an active web subscriber.
*
* @param conversationId conversation owning the hint
* @param hint text to render as an assistant bubble
* @param decision "approved" / "denied" / "cancelled" / null (skips the
* structured resolved event when null, e.g. on replay error)
* @param pendingId pending approval id; null when not applicable
* @param toolName tool name for the structured event; null when not applicable
*/
private void persistAndBroadcastApprovalHint(String conversationId, String hint,
String decision, String pendingId,
String toolName) {
try {
conversationService.saveMessage(conversationId, "assistant", hint, null, "completed");
} catch (Exception e) {
log.warn("[approval-hint] saveMessage failed for conv={}: {}",
conversationId, e.getMessage());
}
try {
if (decision != null) {
streamTracker.broadcastObject(conversationId, "tool_approval_resolved", Map.of(
"pendingId", pendingId == null ? "" : pendingId,
"decision", decision,
"toolName", toolName == null ? "" : toolName,
"timestamp", System.currentTimeMillis()
));
}
streamTracker.broadcastObject(conversationId, "message_start",
Map.of("role", "assistant"));
streamTracker.broadcastObject(conversationId, "content_delta",
Map.of("delta", hint));
streamTracker.broadcastObject(conversationId, "message_complete",
Map.of("status", "completed"));
} catch (Exception e) {
// Broadcast is best-effort; a missing run state is the common case.
log.debug("[approval-hint] broadcast skipped/failed for conv={}: {}",
conversationId, e.getMessage());
}
}
@ -776,9 +989,13 @@ public class ChannelMessageRouter {
conversationService.getOrCreateConversation(conversationId, agentId, username, channelEntity.getWorkspaceId());
List<MessageContentPart> parts = message.getContentParts();
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);
// Mirror processMessage's group attribution for the streaming path
// (Web channel today; future streaming IM channels inherit it).
String attributedContent = applyGroupTag(message, message.getContent());
conversationService.saveMessage(conversationId, "user", attributedContent, parts);
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
promptText = applyGroupTag(message, promptText);
// RFC-063r §2.5: forward ChatOrigin so tools created during this
// streaming conversation inherit channel binding.
ChatOrigin origin = chatOriginFactory.from(
@ -846,6 +1063,70 @@ public class ChannelMessageRouter {
return message.getChannelType() + ":" + identifier;
}
/**
* Build a sender-attribution tag for group messages. Returns
* {@code [@senderName]} when the message is from a multi-user channel
* context (chatId is set), else {@code null} for 1:1 chats.
*
* <p>Without this tag, three users asking three different questions in
* the same group conversation collapse into an unattributed wall of
* "user:" turns and the LLM can no longer tell who is asking what
* it answers based on the most-recent text and ignores the rest.
* Single chats are unaffected because chatId is null there.
*
* <p>Prefer {@code senderName} when populated; otherwise fall back to
* {@code senderId}. WeCom currently sets both to the same opaque
* openid which is still useful for disambiguation; future channels
* (DingTalk, Slack) carry friendlier display names that flow through
* unchanged.
*
* @return sender tag like {@code [@Alice]}, or {@code null} if the
* message is not from a group context.
*/
static String buildGroupTag(ChannelMessage message) {
if (message == null) return null;
String chatId = message.getChatId();
if (chatId == null || chatId.isBlank()) return null;
String name = (message.getSenderName() != null && !message.getSenderName().isBlank())
? message.getSenderName() : message.getSenderId();
if (name == null || name.isBlank()) return null;
return "[@" + name + "]";
}
/**
* Apply {@link #buildGroupTag} to {@code content}. Idempotent: if
* {@code content} already starts with the tag (e.g. an upstream
* adapter has pre-attributed it), returns it unchanged so we don't
* double-stamp. No-op for single chats.
*/
static String applyGroupTag(ChannelMessage message, String content) {
String tag = buildGroupTag(message);
if (tag == null) return content;
// Empty content: leave empty rather than persist or prompt with a
// bare "[@Alice]" the message had no payload to attribute.
if (content == null || content.isEmpty()) return content;
if (content.startsWith(tag)) return content;
return tag + " " + content;
}
/**
* Decision helper for the debounce merger: should an incoming message
* from {@code incomingSender} merge into a pending buffer started by
* {@code existingSender}? True only when the senders match different
* senders in the same conversation (a group context) must NOT merge,
* else the second user's text gets attributed to the first.
*
* <p>Null-handling: a null {@code existingSender} means "no buffer to
* merge into" so the answer is always false; a null
* {@code incomingSender} (rare, but seen in test fixtures) is also
* not allowed to silently merge returning false routes to the
* "create new pending" branch which is safe.
*/
static boolean isSameSender(String existingSender, String incomingSender) {
if (existingSender == null || incomingSender == null) return false;
return existingSender.equals(incomingSender);
}
/**
* 确定回复目标
* 优先使用 replyToken渠道特有的回复标识其次 chatId最后 senderId

View File

@ -0,0 +1,63 @@
package vip.mate.channel;
import java.util.Map;
/**
* Per-send context used to thread optional metadata from
* {@link ChannelMessageRouter} down into channel-specific renderers
* without having to expand {@code renderAndSend} signatures every time
* a new channel needs a side-channel value.
*
* <p>Currently used by:
* <ul>
* <li>{@code feedbackId} pre-allocated by Router after the
* assistant message is persisted, so the WeCom AI Bot adapter
* can attach {@code feedback.id} to its final stream chunk for
* like/dislike collection. The id maps back to
* {@code (conversationId, savedMessageId, senderId)} in the
* channel's feedback registry.</li>
* <li>{@code savedMessageId} the persisted assistant
* {@code mate_message.id}; used by feedback registry to bridge
* a feedback event back to the originating message.</li>
* <li>{@code extra} open-ended map for future side-channel hints
* so we don't need yet another record field migration.</li>
* </ul>
*
* <p>Adapters that don't need any of this can ignore the parameter:
* the default {@code renderAndSend(targetId, content, ctx)} on
* {@link ChannelAdapter} delegates to the legacy two-arg version and
* drops {@code ctx} entirely.
*
* @param feedbackId optional like/dislike correlation id; null if
* the channel does not collect feedback or the
* assistant message could not be persisted
* @param savedMessageId persisted {@code mate_message.id} for the
* assistant reply; null in error / streaming
* passthrough paths where no row was created
* @param extra open-ended map; must never be null use
* {@link #empty()} if no extras
*/
public record SendContext(
String feedbackId,
Long savedMessageId,
Map<String, Object> extra
) {
public SendContext {
// Defensive: a null extra map breaks downstream get-or-default lookups.
if (extra == null) {
extra = Map.of();
}
}
private static final SendContext EMPTY = new SendContext(null, null, Map.of());
/**
* Reusable empty context. Adapters and callers that have no
* side-channel hints to thread should use this rather than
* allocating a fresh empty record per message.
*/
public static SendContext empty() {
return EMPTY;
}
}

View File

@ -373,6 +373,18 @@ public class DiscordChannelAdapter extends AbstractChannelAdapter {
return CHANNEL_TYPE;
}
/**
* Discord enforces a single Gateway session per bot token (any duplicate
* {@code IDENTIFY} closes the previous shard) and there is no active
* webhook fallback in this adapter the legacy webhook endpoint is
* a no-op. The leader gate ensures only one node holds the Gateway
* connection at a time.
*/
@Override
public boolean requiresSingleLeader() {
return true;
}
// ==================== Webhook 兼容保留接口不再使用 ====================
/**

View File

@ -0,0 +1,26 @@
package vip.mate.channel.event;
/**
* Spring application event fired when a channel adapter accepts an
* inbound message and hands it off to {@code ChannelMessageRouter}.
* The trigger module subscribes via {@code @EventListener} and forwards
* the payload through {@code TriggerEventIngestService} so triggers of
* pattern type {@code channel_message} or {@code content_match} can fan
* out to workflows. Going through the event bus instead of injecting
* the trigger service directly into the channel module keeps the two
* worlds decoupled and dodges the construction cycle.
*
* <p>{@code messageId} doubles as the dedup key repeated webhook
* deliveries of the same message can't double-fire downstream triggers
* because the {@code mate_trigger_event} unique constraint catches the
* second insert.
*/
public record ChannelMessageReceivedEvent(
long workspaceId,
String channelType,
String messageId,
String senderId,
String senderName,
String chatId,
String content
) {}

View File

@ -1435,4 +1435,19 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
public String getChannelType() {
return CHANNEL_TYPE;
}
/**
* WebSocket mode opens a long-lived connection to Lark's gateway, which
* caps concurrent connections per bot app (~2). In a multi-instance
* deployment every node would race for that quota and reconnect-loop on
* {@code 1000040350: the number of connections exceeded the limit}.
* The leader gate ensures only one node holds the connection at a time.
*
* <p>Webhook mode is exempt: callbacks are HTTP-fanned by the load
* balancer, so all nodes can safely subscribe.
*/
@Override
public boolean requiresSingleLeader() {
return "websocket".equals(getConfigString("connection_mode", "websocket"));
}
}

View File

@ -0,0 +1,84 @@
package vip.mate.channel.leader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.javacrumbs.shedlock.core.LockConfiguration;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.core.SimpleLock;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
/**
* Distributed leader election for channel adapters whose upstream
* service rejects multiple concurrent connections from the same bot
* credentials.
*
* <p>Typical examples are WebSocket-mode IM channels: Feishu's Lark
* SDK enforces a per-app connection cap (~2) and QQ's bot gateway
* rejects duplicate {@code IDENTIFY} sessions. Without coordination,
* every node of a multi-instance deployment trips that cap on startup
* and reconnects in a tight loop.
*
* <p>Backed by ShedLock's {@link LockProvider} (already wired for cron
* coordination), so single-node deployments incur no extra
* infrastructure the lock is acquired trivially on the only node.
*
* <p>Semantics:
* <ul>
* <li>{@link #tryAcquire(String)} returns the lease, or empty if
* another node already holds it.</li>
* <li>The owning node must call {@link LeaderLease#extend(Duration)}
* on a fixed cadence (heartbeat) shorter than the
* {@code lockAtMostFor} window, or the lease expires and another
* node may claim it.</li>
* <li>If a node dies without releasing, the lease auto-expires after
* {@code lockAtMostFor}, after which any waiting follower can
* become leader on its next retry tick.</li>
* </ul>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChannelLeaderElection {
/**
* How long the lock is held without a renewal. A failed node's lease
* stays locked for this long before another node can take over
* so longer values increase failover latency, shorter values increase
* the risk of false handover during a GC pause or DB hiccup.
*/
public static final Duration LOCK_AT_MOST_FOR = Duration.ofSeconds(60);
private final LockProvider lockProvider;
/**
* Attempt to acquire leadership for the given key.
*
* @param key a stable identifier for the resource (e.g.
* {@code "feishu:42"}). Used verbatim as the underlying
* lock name (prefixed by this class to avoid collisions
* with other lock users).
* @return an empty optional if another node already holds the lease,
* otherwise a {@link LeaderLease} that the caller is
* responsible for periodically extending and finally
* releasing.
*/
public Optional<LeaderLease> tryAcquire(String key) {
String lockName = "channel-leader:" + key;
LockConfiguration config = new LockConfiguration(
Instant.now(),
lockName,
LOCK_AT_MOST_FOR,
Duration.ZERO);
Optional<SimpleLock> lock = lockProvider.lock(config);
if (lock.isEmpty()) {
log.debug("[leader] Lock '{}' is held by another node", lockName);
return Optional.empty();
}
log.info("[leader] Acquired lease '{}'", lockName);
return Optional.of(new LeaderLease(lockName, lock.get()));
}
}

View File

@ -0,0 +1,77 @@
package vip.mate.channel.leader;
import lombok.extern.slf4j.Slf4j;
import net.javacrumbs.shedlock.core.SimpleLock;
import java.time.Duration;
import java.util.Optional;
/**
* A held leadership lease on a single resource (typically a channel id).
*
* <p>Wraps a ShedLock {@link SimpleLock} so callers don't depend on the
* underlying lock provider. The lease must be periodically extended via
* {@link #extend(Duration)} or it expires automatically, at which point
* another node can claim leadership.
*
* <p>Threading: a single lease instance is not safe for concurrent
* {@link #extend(Duration)} / {@link #release()} calls. The owning
* scheduler is expected to serialize them.
*/
@Slf4j
public class LeaderLease {
private final String name;
private volatile SimpleLock current;
private volatile boolean released;
LeaderLease(String name, SimpleLock initial) {
this.name = name;
this.current = initial;
}
public String getName() {
return name;
}
/**
* Try to extend this lease for another {@code lockAtMostFor} window.
*
* @return true if the lease is still ours; false if it has been lost
* (e.g. the previous window expired before extend ran and
* another node acquired the lock the caller should treat
* this as a leadership loss and stop the protected resource).
*/
public boolean extend(Duration lockAtMostFor) {
if (released) {
return false;
}
try {
Optional<SimpleLock> next = current.extend(lockAtMostFor, Duration.ZERO);
if (next.isPresent()) {
current = next.get();
return true;
}
log.warn("[leader] Lease '{}' extend returned empty — lock lost", name);
return false;
} catch (Exception e) {
log.warn("[leader] Lease '{}' extend threw: {}", name, e.getMessage());
return false;
}
}
/**
* Release this lease. Idempotent calling release twice is safe.
*/
public void release() {
if (released) {
return;
}
released = true;
try {
current.unlock();
} catch (Exception e) {
log.warn("[leader] Lease '{}' unlock threw: {}", name, e.getMessage());
}
}
}

View File

@ -57,29 +57,42 @@ public class ApprovalNotificationService {
}
/**
* ApprovalNotice 构建文本
* ApprovalNotice 构建文本实例方法保留供历史调用方使用
*/
public String buildApprovalText(ApprovalNotice notice) {
return staticBuildText(notice);
}
/**
* Static text renderer used by the {@code AbstractChannelAdapter}
* default {@code sendApprovalNotice} implementation, which has no
* Spring-managed reference to the service instance.
*
* <p>Logic is identical to {@link #buildApprovalText(ApprovalNotice)};
* the instance method delegates here so the two paths can never
* drift.
*/
public static String staticBuildText(ApprovalNotice notice) {
StringBuilder sb = new StringBuilder();
sb.append("🔐 **工具需要审批**\n\n");
sb.append("**工具名称**: ").append(notice.toolName()).append("\n");
// 风险等级
// Risk severity
if (notice.maxSeverity() != null) {
sb.append("**风险等级**: ").append(severityLabel(notice.maxSeverity())).append("\n");
sb.append("**风险等级**: ").append(staticSeverityLabel(notice.maxSeverity())).append("\n");
}
// 摘要
// Summary
if (notice.summary() != null && !notice.summary().isEmpty()) {
sb.append("**摘要**: ").append(notice.summary()).append("\n");
}
// 参数预览
// Args preview
if (notice.argumentsPreview() != null && !notice.argumentsPreview().isEmpty()) {
sb.append("**参数**: `").append(notice.argumentsPreview()).append("`\n");
}
// Findings 摘要最多显示 3
// Findings (top 3)
if (notice.findings() != null && !notice.findings().isEmpty()) {
sb.append("\n**发现的问题**:\n");
int shown = 0;
@ -100,6 +113,18 @@ public class ApprovalNotificationService {
return sb.toString();
}
private static String staticSeverityLabel(String severity) {
if (severity == null) return "";
return switch (severity) {
case "CRITICAL" -> "🔴 CRITICAL";
case "HIGH" -> "🟠 HIGH";
case "MEDIUM" -> "🟡 MEDIUM";
case "LOW" -> "🔵 LOW";
case "INFO" -> "⚪ INFO";
default -> severity;
};
}
/**
* 构建 Web SSE 事件数据
*/

View File

@ -199,6 +199,17 @@ public class QQChannelAdapter extends AbstractChannelAdapter {
return CHANNEL_TYPE;
}
/**
* The QQ bot gateway rejects duplicate {@code IDENTIFY} sessions for
* the same app credentials. Multiple nodes connecting simultaneously
* trip the connection cap and reconnect-loop forever. The leader gate
* ensures only one node holds the WebSocket at a time.
*/
@Override
public boolean requiresSingleLeader() {
return true;
}
// ==================== Access Token 管理 ====================
/**

View File

@ -7,6 +7,7 @@ import com.slack.api.bolt.AppConfig;
import com.slack.api.bolt.socket_mode.SocketModeApp;
import com.slack.api.methods.SlackApiException;
import com.slack.api.methods.response.chat.ChatPostMessageResponse;
import com.slack.api.methods.response.files.FilesUploadV2Response;
import com.slack.api.model.event.MessageEvent;
import lombok.extern.slf4j.Slf4j;
import vip.mate.channel.AbstractChannelAdapter;
@ -14,8 +15,16 @@ import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@ -295,4 +304,199 @@ public class SlackChannelAdapter extends AbstractChannelAdapter {
result = result.replaceAll("(?m)^#{1,6}\\s+(.+)$", "*$1*");
return result;
}
/**
* Lazily-initialised JDK HTTP client for fetching media bytes from
* fully-qualified {@code fileUrl} fields. Only used when
* {@link MessageContentPart#getPath()} isn't set.
*/
private volatile HttpClient httpClient;
private HttpClient getHttpClient() {
HttpClient hc = httpClient;
if (hc == null) {
synchronized (this) {
hc = httpClient;
if (hc == null) {
hc = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
httpClient = hc;
}
}
}
return hc;
}
/**
* Dispatch a list of {@link MessageContentPart}s to Slack. Text parts
* fall through to the existing {@link #sendMessage} chat-post path;
* media parts (image / audio / video / file / model3d) ride
* {@code filesUploadV2} so users see a native file card with thumbnail
* preview rather than an unopenable markdown link.
* <p>
* Wired by {@link vip.mate.channel.AsyncTaskMediaDispatcher} so async
* tool results (image generation / video generation / etc.) reach
* Slack channels the same way they reach WeCom / DingTalk / Feishu.
*/
@Override
public void sendContentParts(String targetId, List<MessageContentPart> parts) {
if (parts == null || parts.isEmpty()) return;
for (MessageContentPart part : parts) {
if (part == null) continue;
String type = part.getType();
try {
switch (type == null ? "" : type) {
case "text", "thinking" -> {
String text = part.getText();
if (text != null && !text.isBlank()) {
sendMessage(targetId, text);
}
}
case "refusal" -> {
String text = part.getText();
if (text != null && !text.isBlank()) {
sendMessage(targetId, "⚠️ " + text);
}
}
case "image", "audio", "video", "file", "model3d" -> uploadFilePart(targetId, part);
default -> {
// Unknown part type fall back to its text body if any.
if (part.getText() != null && !part.getText().isBlank()) {
sendMessage(targetId, part.getText());
}
}
}
} catch (Exception e) {
log.warn("[slack] Failed to send {} part to {}: {}", type, targetId, e.getMessage());
sendFallbackText(targetId, part);
}
}
}
/**
* Upload a media part to Slack via {@code files.uploadV2}. Resolves
* bytes from the part's local {@code path} first (set by image / video
* / music / 3D generation services), falling back to an HTTP fetch of
* a fully-qualified {@code fileUrl}. Returns silently after sending a
* markdown fallback if no bytes are recoverable.
*/
private void uploadFilePart(String targetId, MessageContentPart part) {
byte[] bytes = resolveBytes(part);
if (bytes == null || bytes.length == 0) {
log.warn("[slack] No bytes resolvable for {} part (path={}, fileUrl={}), falling back to text",
part.getType(), part.getPath(), part.getFileUrl());
sendFallbackText(targetId, part);
return;
}
String botToken = getConfigString("bot_token");
if (botToken == null || botToken.isBlank()) {
log.warn("[slack] Missing bot_token, cannot upload {} part", part.getType());
return;
}
String filename = part.getFileName();
if (filename == null || filename.isBlank()) {
filename = defaultFilenameFor(part.getType());
}
String threadTs = lookupThreadTs(targetId);
// Slack derives the MIME from the filename's extension; passing
// contentType here is unnecessary and not part of the V2 API.
final String finalFilename = filename;
final byte[] finalBytes = bytes;
try {
FilesUploadV2Response response = slack.methods(botToken).filesUploadV2(req -> {
var b = req
.channel(targetId)
.fileData(finalBytes)
.filename(finalFilename);
if (threadTs != null && !threadTs.isBlank()) {
b.threadTs(threadTs);
}
return b;
});
if (!response.isOk()) {
log.warn("[slack] filesUploadV2 failed for {} ({}): {}",
finalFilename, finalBytes.length, response.getError());
sendFallbackText(targetId, part);
return;
}
log.info("[slack] Uploaded {} part ({} bytes) to {}", part.getType(), finalBytes.length, targetId);
} catch (IOException | SlackApiException e) {
log.warn("[slack] filesUploadV2 error for {}: {}", finalFilename, e.getMessage());
sendFallbackText(targetId, part);
}
}
/**
* Read the part's bytes from disk first (paths set by the generation
* services + the WeCom inbound pipeline both populate this), or fetch
* via HTTP when only an external {@code fileUrl} is available. Returns
* null when neither path nor URL yields bytes.
*/
private byte[] resolveBytes(MessageContentPart part) {
String path = part.getPath();
if (path != null && !path.isBlank()) {
try {
Path p = Path.of(path);
if (Files.exists(p)) {
return Files.readAllBytes(p);
}
} catch (Exception e) {
log.debug("[slack] Reading local path failed ({}): {}", path, e.getMessage());
}
}
String url = part.getFileUrl();
if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) {
try {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<byte[]> resp = getHttpClient().send(req, HttpResponse.BodyHandlers.ofByteArray());
if (resp.statusCode() == 200) {
return resp.body();
}
log.debug("[slack] HTTP fetch returned status {} for {}", resp.statusCode(), url);
} catch (Exception e) {
log.debug("[slack] HTTP fetch failed for {}: {}", url, e.getMessage());
}
}
return null;
}
private static String defaultFilenameFor(String type) {
return switch (type == null ? "" : type) {
case "image" -> "image.png";
case "audio" -> "audio.mp3";
case "video" -> "video.mp4";
case "model3d" -> "model.glb";
default -> "file.bin";
};
}
/** Thread-aware reply: same lookup pattern as {@link #sendMessage}. */
private String lookupThreadTs(String channelId) {
for (var entry : threadTsCache.entrySet()) {
if (entry.getKey().contains(channelId)) {
return entry.getValue();
}
}
return null;
}
/** Final fallback when both upload and resolve fail keep the user
* informed instead of dropping the message silently. */
private void sendFallbackText(String targetId, MessageContentPart part) {
String url = part.getFileUrl();
String fileName = part.getFileName() != null ? part.getFileName() : part.getType();
if (url != null && !url.isBlank()) {
sendMessage(targetId, "📎 " + fileName + ": " + url);
} else {
sendMessage(targetId, "[" + fileName + "]");
}
}
}

View File

@ -165,8 +165,13 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter {
* - connection_mode=polling Polling
* - connection_mode 缺失 + webhook_url 非空 Webhook兼容旧配置
* - 其余 Polling
*
* <p>Package-private so {@link #requiresSingleLeader()} can mirror the
* same predicate the two answers must stay in lockstep, otherwise a
* single change to mode detection here would silently mis-classify the
* channel for multi-node coordination.
*/
private boolean resolveWebhookMode() {
boolean resolveWebhookMode() {
String connectionMode = getConfigString("connection_mode");
String webhookUrl = getConfigString("webhook_url");
boolean hasWebhookUrl = webhookUrl != null && !webhookUrl.isBlank();
@ -794,6 +799,21 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter {
return CHANNEL_TYPE;
}
/**
* Long-polling mode runs a {@code getUpdates(offset=)} loop that
* acknowledges each delivered update; multiple nodes polling the same
* bot token would steal updates from each other (whichever node calls
* {@code getUpdates} next consumes the queue, and the others get
* nothing). The leader gate ensures only one node polls at a time.
*
* <p>Webhook mode is exempt: Telegram POSTs to a public URL fanned
* by the load balancer, so every node may safely receive callbacks.
*/
@Override
public boolean requiresSingleLeader() {
return !resolveWebhookMode();
}
/** RFC-025 Change 4 入站文本净化上限(防止 caption 含超长二进制撑爆 prompt。 */
private static final int INBOUND_TEXT_MAX = 4096;

View File

@ -1416,6 +1416,14 @@ public class ChatController {
payload.put("status", status);
if (savedAssistant != null && savedAssistant.getId() != null) {
payload.put("assistantMessageId", savedAssistant.getId());
// Surface runtime model attribution so the chat bubble can show
// which model produced this reply without waiting for a history reload.
if (savedAssistant.getRuntimeModel() != null && !savedAssistant.getRuntimeModel().isBlank()) {
payload.put("runtimeModel", savedAssistant.getRuntimeModel());
}
if (savedAssistant.getRuntimeProvider() != null && !savedAssistant.getRuntimeProvider().isBlank()) {
payload.put("runtimeProvider", savedAssistant.getRuntimeProvider());
}
}
if (promptTokens > 0) payload.put("promptTokens", promptTokens);
if (completionTokens > 0) payload.put("completionTokens", completionTokens);
@ -1638,10 +1646,26 @@ public class ChatController {
* having to guess from text. Empty string until the event arrives.
*/
private String finishReason = "";
/**
* Recovery affordance payload from {@link
* vip.mate.agent.GraphEventPublisher#feedback}. Persisted into
* {@code metadata.feedbackEvent} so a page reload still surfaces
* the retry/regenerate/report card on the failed assistant
* bubble. Null when the turn ended cleanly.
*/
private Map<String, Object> feedbackEvent = null;
private Long planId = null;
private List<String> planSteps = List.of();
private Integer currentPlanStep = null;
private Map<String, Object> pendingApproval = null;
/**
* Multimodal sidecar routing decision for this turn (null when no
* routing happened). Captured from the {@code _routing_decision}
* event emitted before the graph stream and folded into
* {@code metadata.routing} on persistence so the chat UI can show
* which sidecar (if any) was invoked.
*/
private Map<String, Object> routingDecision = null;
synchronized void accept(AgentService.StreamDelta delta, String conversationId) {
if (delta == null) return;
@ -1675,6 +1699,22 @@ public class ChatController {
finishReason = String.valueOf(reason);
}
}
if (vip.mate.agent.GraphEventPublisher.EVENT_FEEDBACK
.equals(delta.eventType())) {
// Snapshot the affordance payload so it persists into
// message metadata. The same event is also rebroadcast
// live (via the broadcastEvent fall-through below) so
// an already-mounted UI sees it instantly without
// waiting for the message-save round trip.
feedbackEvent = delta.eventData();
}
if (vip.mate.agent.GraphEventPublisher.EVENT_ROUTING_DECISION.equals(delta.eventType())) {
// Captured at turn start; persisted under metadata.routing so the
// chat UI can render which sidecar (if any) was invoked. Internal
// event return early to skip rebroadcast on IM channels.
routingDecision = delta.eventData();
return;
}
accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId);
try {
broadcastEvent(conversationId, delta.eventType(), delta.eventData());
@ -1959,6 +1999,18 @@ public class ChatController {
// brittle text matching on the assistant content.
metadata.put("finishReason", finishReason);
}
if (feedbackEvent != null && !feedbackEvent.isEmpty()) {
// Persist the recovery-affordance payload so the
// retry/regenerate/report card survives page reload.
// Stored as-is (errorType, errorMessage, actions,
// timestamp) frontend MessageBubble reads
// metadata.feedbackEvent and renders one button per
// entry in `actions`.
metadata.put("feedbackEvent", feedbackEvent);
}
if (routingDecision != null && !routingDecision.isEmpty()) {
metadata.put("routing", routingDecision);
}
return objectMapper.writeValueAsString(metadata);
} catch (Exception e) {
log.warn("Failed to serialize metadata: {}", e.getMessage());

View File

@ -0,0 +1,167 @@
package vip.mate.channel.wecom;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* Periodically refreshes a WeCom AI Bot {@code stream} reply with the
* "🤔 思考中..." placeholder text so WeCom's server-side does not drop
* the stream slot while a long-running agent task is still computing.
*
* <p><b>Why this exists</b>: WeCom's stream slot has an undocumented TTL
* (empirically observed at ~60-120s of silence). When the slot drops,
* the eventual {@code finish=true} chunk is silently rejected the
* user sees "🤔 思考中..." stuck forever. RFC-32 §2.1.2 (R-7 / B-5).
*
* <p><b>Constants</b> (chosen empirically based on observed slot lifetime):
* <ul>
* <li>20s refresh interval well under the observed 60s minimum drop</li>
* <li>180s force-finish ceiling bound the worst-case "stuck" UX even
* if the agent task hangs forever; lets the next user reply use a
* fresh stream slot rather than reuse a closed one</li>
* </ul>
*
* <p><b>Force-finish invariant</b>: after the 180s ceiling fires, the
* scheduler calls {@link WeComChannelAdapter#invalidateReplyContext}
* to evict the {@code (frameReqId, processingStreamId)} pair from
* {@code replyContexts} so when the eventual real reply arrives,
* {@code renderAndSend} sees no context and falls through to a fresh
* {@code sendMessage} path instead of reusing the dead stream id.
* RFC-32 §2.1.2 / R-7 closes this race; the adapter's
* {@code invalidateReplyContext} is the contract.
*/
@Slf4j
@Component
public class WeComKeepaliveScheduler {
/** Refresh interval (seconds). */
static final long REFRESH_INTERVAL_SECONDS = 20;
/** Hard ceiling — after this many seconds, force-finish the stream. */
static final long MAX_DURATION_SECONDS = 180;
/** Placeholder text written on every refresh tick + on force-finish. */
static final String PROCESSING_TEXT = "🤔 思考中...";
/** One-shot state per active stream. Held by reference inside the scheduled task. */
private static final class StreamState {
final WeComChannelAdapter adapter;
final String reqId;
final String streamId;
final String replyToken;
final long startedAt;
volatile ScheduledFuture<?> future;
StreamState(WeComChannelAdapter a, String r, String s, String t) {
this.adapter = a; this.reqId = r; this.streamId = s; this.replyToken = t;
this.startedAt = System.currentTimeMillis();
}
}
private final Map<String, StreamState> states = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2, r -> {
Thread t = new Thread(r, "wecom-keepalive");
t.setDaemon(true);
return t;
});
/**
* Begin keepalive for a freshly-issued processing stream. No-op if
* called twice for the same {@code streamId} (just logs and keeps
* the existing schedule alive).
*
* @param adapter the live WeCom adapter (carries replyStream + invalidateReplyContext)
* @param reqId the inbound message frame's req_id (binds the
* outbound reply_stream chunks)
* @param streamId the same stream_id used in the initial "🤔 思考中..." chunk
* @param replyToken target id used to look up reply context on
* invalidation (typically chatId for groups,
* senderId for direct messages same value passed
* to {@code replyContexts.put})
*/
public void start(WeComChannelAdapter adapter, String reqId, String streamId, String replyToken) {
if (adapter == null || reqId == null || streamId == null
|| reqId.isBlank() || streamId.isBlank()) {
log.debug("[wecom-keepalive] start ignored — null/blank arg(s)");
return;
}
if (states.containsKey(streamId)) {
log.debug("[wecom-keepalive] start ignored — stream {} already tracked", streamId);
return;
}
StreamState st = new StreamState(adapter, reqId, streamId, replyToken);
st.future = scheduler.scheduleAtFixedRate(
() -> tick(st),
REFRESH_INTERVAL_SECONDS,
REFRESH_INTERVAL_SECONDS,
TimeUnit.SECONDS);
states.put(streamId, st);
log.debug("[wecom-keepalive] started for stream={} reqId={}", streamId, reqId);
}
/**
* Stop keepalive for a stream call this immediately before sending
* the real reply so the next refresh tick doesn't race the
* {@code finish=true} chunk on the same stream.
*/
public void stop(String streamId) {
if (streamId == null || streamId.isBlank()) return;
StreamState st = states.remove(streamId);
if (st != null && st.future != null) {
st.future.cancel(false);
log.debug("[wecom-keepalive] stopped for stream={}", streamId);
}
}
/**
* Drop every tracked stream and cancel its schedule. Called from
* {@code releaseConnectionResources} so reconnects start clean.
*/
public void shutdownAll() {
for (StreamState st : states.values()) {
if (st.future != null) st.future.cancel(false);
}
states.clear();
}
private void tick(StreamState st) {
long elapsedSec = (System.currentTimeMillis() - st.startedAt) / 1000;
if (elapsedSec >= MAX_DURATION_SECONDS) {
// Force-finish: send finish=true on the same stream so the
// server-side closes the slot cleanly, then evict the
// replyContext entry so the eventual real reply takes the
// fresh-stream path.
try {
st.adapter.replyStreamFinishForKeepalive(st.reqId, st.streamId, PROCESSING_TEXT);
} catch (Exception e) {
log.debug("[wecom-keepalive] force-finish replyStream failed for {}: {}",
st.streamId, e.getMessage());
}
try {
st.adapter.invalidateReplyContext(st.replyToken, st.streamId);
} catch (Exception e) {
log.debug("[wecom-keepalive] invalidateReplyContext failed for {}: {}",
st.streamId, e.getMessage());
}
stop(st.streamId);
log.info("[wecom-keepalive] force-finished stream {} after {}s ceiling",
st.streamId, MAX_DURATION_SECONDS);
return;
}
try {
st.adapter.replyStreamRefreshForKeepalive(st.reqId, st.streamId, PROCESSING_TEXT);
} catch (Exception e) {
log.debug("[wecom-keepalive] refresh failed for {}: {}", st.streamId, e.getMessage());
}
}
// ---- Test hooks ----
int activeStreamCount() { return states.size(); }
}

View File

@ -0,0 +1,15 @@
package vip.mate.channel.wecom.cards;
/**
* Thrown when a card payload would exceed a WeCom-imposed size limit
* (most commonly: button.key serialised JSON > 1024 bytes).
*
* <p>Catchable so that WeCom card renderers can fall back to the
* abstract-class text path on overflow rather than letting the entire
* approval flow drop. RFC-32 §2.1.1 calls this out explicitly.
*/
public class CardOversizedException extends RuntimeException {
public CardOversizedException(String message) {
super(message);
}
}

View File

@ -0,0 +1,100 @@
package vip.mate.channel.wecom.cards;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.channel.wecom.cards.tool_guard.ToolGuardCardKindFactory;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Routing-only dispatcher for WeCom interactive template cards.
*
* <p>Maintains two indexes keyed by disjoint identifiers:
* <ul>
* <li><b>Outbound</b>: {@code metadata.message_type} from the agent
* runtime {@link WeComCardKind#renderer()}. Currently the only
* direct outbound caller is {@link
* vip.mate.channel.wecom.WeComChannelAdapter#sendApprovalNotice}
* which doesn't yet read message_type but having the index lets
* us add future card kinds without touching the adapter.</li>
* <li><b>Inbound</b>: prefix of the
* {@code template_card_event.task_id} {@link WeComCardKind#handler()}.
* Card kinds <i>must</i> use disjoint prefixes; collision throws
* at registration time.</li>
* </ul>
*
* <p>The {@code @Component} is autowired; current contributors are
* collected via {@link #registerKinds()} which calls factory beans for
* each kind. Adding a new card kind: implement {@code WeComCardKind} +
* a factory bean returning it + add a line to {@link #registerKinds()}.
*/
@Slf4j
@Component
public class WeComCardDispatcher {
private final Map<String, WeComCardKind> byMessageType = new HashMap<>();
private final Map<String, WeComCardKind> byTaskIdPrefix = new HashMap<>();
private final ToolGuardCardKindFactory toolGuardFactory;
public WeComCardDispatcher(ToolGuardCardKindFactory toolGuardFactory) {
this.toolGuardFactory = toolGuardFactory;
registerKinds();
}
private void registerKinds() {
// Currently single kind. Add lines here as new card kinds land
// (poll cards / info-request cards / etc.). Order doesn't matter:
// the disjoint-prefix invariant prevents ambiguity at lookup.
register(toolGuardFactory.create());
}
private void register(WeComCardKind kind) {
if (byMessageType.containsKey(kind.messageType())) {
throw new IllegalStateException(
"duplicate card kind for messageType '" + kind.messageType()
+ "': existing=" + byMessageType.get(kind.messageType()).name()
+ ", new=" + kind.name());
}
if (byTaskIdPrefix.containsKey(kind.taskIdPrefix())) {
throw new IllegalStateException(
"duplicate card kind for taskIdPrefix '" + kind.taskIdPrefix()
+ "': existing=" + byTaskIdPrefix.get(kind.taskIdPrefix()).name()
+ ", new=" + kind.name());
}
byMessageType.put(kind.messageType(), kind);
byTaskIdPrefix.put(kind.taskIdPrefix(), kind);
log.info("[wecom-cards] Registered card kind: name={} messageType={} taskIdPrefix={}",
kind.name(), kind.messageType(), kind.taskIdPrefix());
}
/**
* Look up a card kind by outbound {@code metadata.message_type}.
*/
public Optional<WeComCardKind> lookupByMessageType(String messageType) {
if (messageType == null || messageType.isBlank()) return Optional.empty();
return Optional.ofNullable(byMessageType.get(messageType));
}
/**
* Look up a card kind by inbound {@code template_card_event.task_id}'s
* prefix. O(N) over registered kinds (N is small currently 1).
*/
public Optional<WeComCardKind> lookupByTaskId(String taskId) {
if (taskId == null || taskId.isBlank()) return Optional.empty();
for (Map.Entry<String, WeComCardKind> e : byTaskIdPrefix.entrySet()) {
if (taskId.startsWith(e.getKey())) {
return Optional.of(e.getValue());
}
}
return Optional.empty();
}
/** Visible for tests / logs. */
public List<String> registeredKindNames() {
return byMessageType.values().stream().map(WeComCardKind::name).toList();
}
}

View File

@ -0,0 +1,38 @@
package vip.mate.channel.wecom.cards;
import vip.mate.channel.wecom.WeComChannelAdapter;
import java.util.Map;
/**
* Processes an inbound WeCom {@code template_card_event} frame for one
* kind of card.
*
* <p>Implementations must respect the 5-second WeCom window: render and
* dispatch the resolved-state card update (via
* {@link WeComChannelAdapter#updateTemplateCard}) inside that window,
* THEN enqueue any agent-side command (e.g. {@code /approve <id>}).
* The window starts the moment the event frame is received, so any
* pre-update validation must be cheap (DB lookup + identity check is
* fine; LLM round-trip is not).
*/
@FunctionalInterface
public interface WeComCardHandler {
/**
* @param adapter the live WeCom adapter (provides
* {@code updateTemplateCard}, {@code messageRouter}
* for command injection, etc.)
* @param frame the raw inbound frame including {@code headers.req_id}
* needed by {@code updateTemplateCard}
* @param tce the parsed {@code event.template_card_event} sub-object
* (already extracted by the dispatcher; contains
* {@code task_id} / {@code event_key})
* @param fromBlock the {@code body.from} sub-object (carries
* {@code userid} of the clicker needed for
* identity validation against original requester)
*/
void handle(WeComChannelAdapter adapter,
Map<String, Object> frame,
Map<String, Object> tce,
Map<String, Object> fromBlock);
}

View File

@ -0,0 +1,52 @@
package vip.mate.channel.wecom.cards;
/**
* Description of one kind of interactive WeCom template card the
* dispatcher knows how to route, along with the two functional callbacks
* that handle the outbound render and the inbound click event.
*
* <p>Kept as a simple record so adding a new card type (e.g. a poll
* card, an info-request card) is just: implement renderer/handler,
* register a new {@code WeComCardKind} in
* {@link WeComCardDispatcher#registerKinds()}.
*
* @param name short human-readable name for logs
* @param messageType matches {@code metadata.message_type} on the
* outbound event coming from the agent runtime
* (drives the {@code render} dispatch)
* @param taskIdPrefix matches the prefix of the inbound
* {@code template_card_event.task_id} (drives the
* {@code handle} dispatch). Card kinds must use
* disjoint prefixes; the dispatcher rejects
* registration of a colliding prefix.
* @param renderer converts a pending business object (e.g.
* {@code ApprovalNotice}) into a WeCom template_card
* payload Map. Throws {@link CardOversizedException}
* to signal "this kind cannot render now, fall back
* to text".
* @param handler processes an inbound {@code template_card_event}
* frame: validate identity render resolved card
* enqueue any follow-up command. Implementations
* MUST complete the render-resolved-card step inside
* the 5s WeCom protocol window; the agent enqueue
* step can be slower.
*/
public record WeComCardKind(
String name,
String messageType,
String taskIdPrefix,
WeComCardRenderer renderer,
WeComCardHandler handler
) {
public WeComCardKind {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("WeComCardKind.name must not be blank");
}
if (messageType == null || messageType.isBlank()) {
throw new IllegalArgumentException("WeComCardKind.messageType must not be blank");
}
if (taskIdPrefix == null || taskIdPrefix.isBlank()) {
throw new IllegalArgumentException("WeComCardKind.taskIdPrefix must not be blank");
}
}
}

View File

@ -0,0 +1,25 @@
package vip.mate.channel.wecom.cards;
import vip.mate.channel.notification.ApprovalNotice;
import java.util.Map;
/**
* Builds a WeCom template_card payload Map from a business object.
*
* <p>Implementations may throw {@link CardOversizedException} to signal
* the caller to fall back to a non-card path (e.g. text approval
* notice). All other throw paths surface as bugs.
*
* <p>Currently parameterised on {@link ApprovalNotice} since tool-guard
* is the only card kind in PR-1; future kinds will likely accept a
* different input or take {@code Object} and self-cast.
*/
@FunctionalInterface
public interface WeComCardRenderer {
/**
* Build the template_card body Map ready to drop into
* {@code aibot_respond_msg.body.template_card}.
*/
Map<String, Object> render(ApprovalNotice notice) throws CardOversizedException;
}

View File

@ -0,0 +1,115 @@
package vip.mate.channel.wecom.cards.tool_guard;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import vip.mate.channel.wecom.cards.CardOversizedException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* JSON encode/decode helper for the {@code key} field on each
* tool-guard approval card button.
*
* <p>WeCom enforces a hard 1024-byte ceiling on each
* {@code button.key} the field is what the server echoes back as
* {@code event_key} when the user clicks. We pack the action plus the
* minimal context we need to recover the pending approval (the
* {@code pendingId} alone is enough mateclaw's
* {@code ApprovalService.findById} resolves the rest, including the
* original requester). Sender/chat context is intentionally not packed
* the inbound handler runs in-process and can do a synchronous DB
* lookup, leaving headroom in the 1024-byte budget for long tool names
* / Chinese characters.
*
* <p>Encoding is stable (LinkedHashMap consistent key order so byte-
* length is predictable). Decoding tolerates extra fields useful if
* a future change adds optional context.
*/
public final class ToolGuardButtonKey {
/** Hard byte limit for the {@code button.key} field; verified against WeCom protocol. */
public static final int MAX_KEY_BYTES = 1024;
public enum Action {
APPROVE("approve"),
DENY("deny");
public final String wireValue;
Action(String v) { this.wireValue = v; }
public static Action fromWire(String v) {
if ("approve".equalsIgnoreCase(v)) return APPROVE;
if ("deny".equalsIgnoreCase(v)) return DENY;
return null;
}
}
/** Decoded button-key payload. {@code null} if parse fails or action invalid. */
public record Decoded(Action action, String pendingId, String toolName, String severity) {}
private final ObjectMapper objectMapper;
public ToolGuardButtonKey(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* Encode an approval-button {@code key}.
*
* @throws CardOversizedException if the resulting JSON exceeds 1024
* bytes (caller must fall back to text approval path)
*/
public String encode(Action action, String pendingId, String toolName, String severity) {
// LinkedHashMap so the key order on the wire is stable across calls;
// makes byte-length predictable and snapshot-testable.
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("a", action.wireValue); // action
payload.put("rid", pendingId); // request/pending id
payload.put("tool", toolName); // for log readability when WeCom replays event_key
payload.put("sev", severity == null ? "" : severity);
try {
String json = objectMapper.writeValueAsString(payload);
int bytes = json.getBytes(StandardCharsets.UTF_8).length;
if (bytes > MAX_KEY_BYTES) {
throw new CardOversizedException(
"tool_guard button.key payload " + bytes + " bytes > limit " + MAX_KEY_BYTES);
}
return json;
} catch (CardOversizedException e) {
throw e;
} catch (Exception e) {
throw new CardOversizedException("failed to serialise button.key: " + e.getMessage());
}
}
/**
* Decode the {@code event_key} echoed back by WeCom on button click.
* Returns {@code null} if the payload is malformed or the action
* unrecognised. Callers should treat null as "ignore this event".
*/
public Decoded decode(String eventKey) {
if (eventKey == null || eventKey.isBlank()) return null;
try {
Map<String, Object> raw = objectMapper.readValue(eventKey, new TypeReference<>() {});
Action action = Action.fromWire(asString(raw.get("a")));
if (action == null) return null;
String pendingId = asString(raw.get("rid"));
if (pendingId == null || pendingId.isBlank()) return null;
return new Decoded(
action,
pendingId,
asString(raw.getOrDefault("tool", "")),
asString(raw.getOrDefault("sev", "")));
} catch (Exception e) {
// Malformed payload treat as "ignore". The caller's
// log.debug at handler entry covers visibility.
return null;
}
}
private static String asString(Object o) {
return o == null ? null : o.toString();
}
}

View File

@ -0,0 +1,225 @@
package vip.mate.channel.wecom.cards.tool_guard;
import lombok.extern.slf4j.Slf4j;
import vip.mate.approval.ApprovalService;
import vip.mate.approval.PendingApproval;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.wecom.WeComChannelAdapter;
import vip.mate.channel.wecom.cards.WeComCardHandler;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Process an inbound {@code template_card_event} frame for a tool-guard
* approval card.
*
* <p><b>Step ordering validate before render</b> (RFC-32 v2.1 / R-5):
* v2.0's draft step order was "render resolved card → inject command →
* router validates identity". That meant a non-original-requester click
* would briefly show "✅ 已批准 by 李四" on the card before the router
* silently dropped the injected command. v2.1 reorders to:
* <ol>
* <li>Decode {@code event_key} null check</li>
* <li>Look up {@code PendingApproval} by id</li>
* <li>Identity check: pending.userId vs clicker</li>
* <li>Render the appropriate resolved-state card (success / unauthorized / expired)</li>
* <li>Inject {@code /approve} or {@code /deny} command into the router
* only for authorised clicks</li>
* </ol>
*
* <p>Steps 1-4 must complete inside the WeCom 5-second window for the
* card update; step 5 can be slower.
*/
@Slf4j
public class ToolGuardCardHandler implements WeComCardHandler {
private final ApprovalService approvalService;
private final ToolGuardButtonKey buttonKey;
public ToolGuardCardHandler(ApprovalService approvalService, ToolGuardButtonKey buttonKey) {
this.approvalService = approvalService;
this.buttonKey = buttonKey;
}
@SuppressWarnings("unchecked")
@Override
public void handle(WeComChannelAdapter adapter,
Map<String, Object> frame,
Map<String, Object> tce,
Map<String, Object> fromBlock) {
String eventReqId = extractEventReqId(frame);
String taskId = (String) tce.getOrDefault("task_id", "");
String eventKey = (String) tce.getOrDefault("event_key", "");
// ---- 1. Decode event_key ----
ToolGuardButtonKey.Decoded decoded = buttonKey.decode(eventKey);
if (decoded == null) {
log.warn("[wecom-toolguard] Could not decode event_key, ignoring task_id={}", taskId);
return;
}
String pendingId = decoded.pendingId();
ToolGuardButtonKey.Action action = decoded.action();
String clickerUserId = stringOrEmpty(fromBlock.get("userid"));
// ---- 2. Look up pending approval ----
Optional<PendingApproval> opt = approvalService.getPending(pendingId);
if (opt.isEmpty() || !"pending".equals(opt.get().getStatus())) {
// Either: GC'd it / approved/denied via another path / never existed
log.info("[wecom-toolguard] Pending {} not found or already resolved (action={}, clicker={})",
pendingId, action, abbrev(clickerUserId));
renderExpired(adapter, eventReqId, taskId, decoded.toolName());
return;
}
PendingApproval pending = opt.get();
// ---- 3. Identity check ----
String originalRequester = pending.getUserId();
boolean isAuthorized = originalRequester == null
|| "system".equals(originalRequester)
|| originalRequester.equals(clickerUserId);
if (!isAuthorized) {
log.warn("[wecom-toolguard] Unauthorised click: clicker={} != requester={}, pending={}",
abbrev(clickerUserId), abbrev(originalRequester), pendingId);
renderUnauthorised(adapter, eventReqId, taskId, decoded.toolName(), originalRequester);
return;
}
// ---- 4. Render resolved card (must finish inside the 5s WeCom window) ----
renderResolved(adapter, eventReqId, taskId, decoded.toolName(), action, clickerUserId);
// ---- 5. Inject the /approve or /deny command into the message router ----
// Re-routing the click as a synthetic user message means we reuse the
// existing approval validation + state-machine path
// (ChannelMessageRouter.processMessage), so any future change to the
// approval flow keeps working without a parallel button-click code path.
String commandText = (action == ToolGuardButtonKey.Action.APPROVE ? "/approve " : "/deny ")
+ pendingId;
ChannelMessage synthetic = buildSynthetic(commandText, clickerUserId, pending, frame);
try {
adapter.injectSyntheticMessage(synthetic);
log.info("[wecom-toolguard] Injected '{}' for pending={}, clicker={}",
action == ToolGuardButtonKey.Action.APPROVE ? "/approve" : "/deny",
pendingId, abbrev(clickerUserId));
} catch (Exception e) {
// Never let an enqueue failure leave the card looking applied.
// The card already shows resolved-state, but the agent won't see
// the approve/deny operator log is the safety net.
log.error("[wecom-toolguard] Failed to inject command for pending={}: {}",
pendingId, e.getMessage(), e);
}
}
// ------------------------------------------------------------------
// Card rendering helpers
// ------------------------------------------------------------------
private static void renderResolved(WeComChannelAdapter adapter, String eventReqId,
String taskId, String toolName,
ToolGuardButtonKey.Action action, String clicker) {
String title = action == ToolGuardButtonKey.Action.APPROVE
? "✅ 已批准"
: "🚫 已拒绝";
String desc = action == ToolGuardButtonKey.Action.APPROVE
? "Tool " + toolName + " 已批准"
: "Tool " + toolName + " 已拒绝";
try {
adapter.updateTemplateCard(eventReqId,
ToolGuardCardRenderer.buildResolvedCard(taskId, title, desc));
} catch (Exception e) {
log.warn("[wecom-toolguard] update_template_card (resolved) failed: {}", e.getMessage());
}
}
private static void renderUnauthorised(WeComChannelAdapter adapter, String eventReqId,
String taskId, String toolName, String originalRequester) {
String requesterLabel = originalRequester == null ? "原请求者" : abbrev(originalRequester);
try {
adapter.updateTemplateCard(eventReqId,
ToolGuardCardRenderer.buildResolvedCard(taskId,
"❌ 仅原请求者可审批",
"请由 " + requesterLabel + " 操作"));
} catch (Exception e) {
log.warn("[wecom-toolguard] update_template_card (unauthorised) failed: {}", e.getMessage());
}
}
private static void renderExpired(WeComChannelAdapter adapter, String eventReqId,
String taskId, String toolName) {
try {
adapter.updateTemplateCard(eventReqId,
ToolGuardCardRenderer.buildResolvedCard(taskId,
"⌛ 审批已过期",
"Tool " + toolName + " 的审批已过期或被处理"));
} catch (Exception e) {
log.warn("[wecom-toolguard] update_template_card (expired) failed: {}", e.getMessage());
}
}
// ------------------------------------------------------------------
// Synthetic message construction
// ------------------------------------------------------------------
/**
* Build a {@link ChannelMessage} that looks like the clicker just sent
* "/approve <id>" (or /deny). The router's existing approval gate
* picks it up via {@code processMessage} and runs the same identity-
* check + state-machine that text commands hit.
*/
@SuppressWarnings("unchecked")
private static ChannelMessage buildSynthetic(String commandText, String clickerUserId,
PendingApproval pending,
Map<String, Object> eventFrame) {
// The conversation behind the original card is whichever WeCom chat
// the {@code template_card_event} arrived from. body.chattype +
// body.chatid let us reconstruct the same conversationId the
// original message used.
Map<String, Object> body = (Map<String, Object>) eventFrame.getOrDefault("body", Map.of());
String chatType = stringOrEmpty(body.get("chattype"));
String chatId = stringOrEmpty(body.get("chatid"));
boolean isGroup = "group".equals(chatType);
String effectiveChatId = isGroup && !chatId.isBlank() ? chatId : null;
String replyToken = isGroup && !chatId.isBlank() ? chatId : clickerUserId;
return ChannelMessage.builder()
.channelType("wecom")
.senderId(clickerUserId)
.senderName(clickerUserId)
.chatId(effectiveChatId)
.content(commandText)
.contentType("text")
.contentParts(List.of())
.inputMode("text")
.timestamp(LocalDateTime.now())
.replyToken(replyToken)
// Tag rawPayload so any downstream code that wants to
// distinguish real messages from button-click injections
// can read this flag instead of inspecting senderId.
.rawPayload(Map.of(
"wecom_button_click", true,
"wecom_pending_id", pending.getPendingId()
))
.build();
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
@SuppressWarnings("unchecked")
private static String extractEventReqId(Map<String, Object> frame) {
Map<String, Object> headers = (Map<String, Object>) frame.getOrDefault("headers", Map.of());
return stringOrEmpty(headers.get("req_id"));
}
private static String stringOrEmpty(Object v) {
return v == null ? "" : v.toString();
}
private static String abbrev(String s) {
if (s == null || s.length() <= 8) return s == null ? "" : s;
return s.substring(0, 8) + "";
}
}

View File

@ -0,0 +1,56 @@
package vip.mate.channel.wecom.cards.tool_guard;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import vip.mate.approval.ApprovalService;
import vip.mate.channel.wecom.cards.WeComCardKind;
/**
* Spring-managed factory that produces the tool-guard card kind for
* {@link vip.mate.channel.wecom.cards.WeComCardDispatcher}.
*
* <p>Plain {@code @Component} so the dispatcher can constructor-inject
* it. Each call to {@link #create()} returns a freshly constructed
* {@link WeComCardKind}; the dispatcher keeps the result and queries it
* for life of the JVM.
*/
@Component
public class ToolGuardCardKindFactory {
/**
* Same value as {@link ToolGuardCardRenderer#TASK_ID_PREFIX}. Kept
* here too so the {@link WeComCardKind#taskIdPrefix()} index can be
* declared from the factory without reaching into the renderer.
*/
public static final String TASK_ID_PREFIX = ToolGuardCardRenderer.TASK_ID_PREFIX;
/**
* Outbound metadata.message_type matched by the dispatcher when the
* agent runtime emits an approval-pending event. Currently the WeCom
* adapter's sendApprovalNotice override doesn't read message_type
* (it always renders tool-guard), but having the index lets future
* card kinds plug in cleanly.
*/
public static final String MESSAGE_TYPE = "tool_guard_approval";
private final ApprovalService approvalService;
private final ObjectMapper objectMapper;
public ToolGuardCardKindFactory(ApprovalService approvalService, ObjectMapper objectMapper) {
this.approvalService = approvalService;
this.objectMapper = objectMapper;
}
public WeComCardKind create() {
ToolGuardButtonKey buttonKey = new ToolGuardButtonKey(objectMapper);
ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonKey);
ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonKey);
return new WeComCardKind(
"tool_guard_approval",
MESSAGE_TYPE,
TASK_ID_PREFIX,
renderer,
handler
);
}
}

View File

@ -0,0 +1,147 @@
package vip.mate.channel.wecom.cards.tool_guard;
import vip.mate.channel.notification.ApprovalNotice;
import vip.mate.channel.wecom.cards.CardOversizedException;
import vip.mate.channel.wecom.cards.WeComCardRenderer;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Build the WeCom {@code button_interaction} approval card payload from
* an {@link ApprovalNotice}.
*
* <p>Card structure (matches the WeCom official protocol):
* <pre>
* {
* "card_type": "button_interaction",
* "task_id": "tg_approval_&lt;pendingId&gt;",
* "main_title": {
* "title": "🛡️ 工具审批",
* "desc": "&lt;toolName&gt; | &lt;severityLabel&gt;"
* },
* "button_list": [
* { "text": "批准", "style": 1, "key": "&lt;encoded JSON&gt;" },
* { "text": "拒绝", "style": 2, "key": "&lt;encoded JSON&gt;" }
* ]
* }
* </pre>
*
* <p>If either button.key would exceed the 1024-byte WeCom limit, the
* encoder throws {@link CardOversizedException} and the calling adapter
* falls back to the abstract-class text-approval path.
*/
public class ToolGuardCardRenderer implements WeComCardRenderer {
/**
* Prefix on the card's {@code task_id}; the inbound dispatcher matches
* this to find the right handler. Same value as
* {@link ToolGuardCardKindFactory#TASK_ID_PREFIX}.
*/
public static final String TASK_ID_PREFIX = "tg_approval_";
private final ToolGuardButtonKey buttonKey;
public ToolGuardCardRenderer(ToolGuardButtonKey buttonKey) {
this.buttonKey = buttonKey;
}
@Override
public Map<String, Object> render(ApprovalNotice notice) throws CardOversizedException {
String pendingId = notice.pendingId();
String toolName = nullSafe(notice.toolName(), "tool");
String severity = nullSafe(notice.maxSeverity(), "MEDIUM");
// Encode buttons first so a 1024-byte overflow throws BEFORE we
// build any of the cosmetic card structure. Same payload shape on
// both buttons differs only in the action wire value.
String approveKey = buttonKey.encode(
ToolGuardButtonKey.Action.APPROVE, pendingId, toolName, severity);
String denyKey = buttonKey.encode(
ToolGuardButtonKey.Action.DENY, pendingId, toolName, severity);
// Use LinkedHashMap so the JSON serialisation order is stable
// helps when log-grepping outbound frames against snapshots.
Map<String, Object> mainTitle = new LinkedHashMap<>();
mainTitle.put("title", "🛡️ 工具审批");
mainTitle.put("desc", buildSubtitle(toolName, severity));
Map<String, Object> approveBtn = new LinkedHashMap<>();
approveBtn.put("text", "批准");
approveBtn.put("style", 1);
approveBtn.put("key", approveKey);
Map<String, Object> denyBtn = new LinkedHashMap<>();
denyBtn.put("text", "拒绝");
denyBtn.put("style", 2);
denyBtn.put("key", denyKey);
Map<String, Object> card = new LinkedHashMap<>();
card.put("card_type", "button_interaction");
card.put("task_id", TASK_ID_PREFIX + pendingId);
card.put("main_title", mainTitle);
card.put("button_list", List.of(approveBtn, denyBtn));
return card;
}
/**
* Build a {@code text_notice} resolved-state card to update the
* original button card after a click. WeCom's card protocol requires
* {@code text_notice} cards to carry a {@code card_action} of type 1
* or 2 (type 0 is rejected by the bot endpoint), so we provide a
* harmless project URL.
*
* @param taskId same task_id as the original card so the
* update targets the right message
* @param title one-line headline (e.g. "✅ 已批准 by 张三")
* @param desc optional detail line; truncated to 30 chars
* to stay inside WeCom's main_title.desc limit
*/
public static Map<String, Object> buildResolvedCard(String taskId, String title, String desc) {
Map<String, Object> mainTitle = new LinkedHashMap<>();
mainTitle.put("title", title == null ? "" : title);
mainTitle.put("desc", truncate(desc == null ? "" : desc, 30));
Map<String, Object> cardAction = new LinkedHashMap<>();
cardAction.put("type", 1);
cardAction.put("url", "https://mateclaw.vip");
Map<String, Object> card = new LinkedHashMap<>();
card.put("card_type", "text_notice");
card.put("task_id", taskId);
card.put("main_title", mainTitle);
card.put("card_action", cardAction);
return card;
}
private static String buildSubtitle(String toolName, String severity) {
// Keep the subtitle short WeCom truncates aggressively. Format:
// "<tool> | <severity>". Translate severity to a single-word Chinese
// label so it reads naturally in the card.
return toolName + " | " + severityShortLabel(severity);
}
private static String severityShortLabel(String severity) {
if (severity == null) return "MEDIUM";
return switch (severity.toUpperCase()) {
case "CRITICAL" -> "🔴 极高";
case "HIGH" -> "🟠 高";
case "MEDIUM" -> "🟡 中";
case "LOW" -> "🔵 低";
case "INFO" -> "⚪ 提示";
default -> severity;
};
}
private static String truncate(String s, int max) {
if (s == null) return "";
if (s.length() <= max) return s;
if (max <= 1) return s.substring(0, max);
return s.substring(0, max - 1) + "";
}
private static String nullSafe(String v, String fallback) {
return (v == null || v.isBlank()) ? fallback : v;
}
}

View File

@ -37,4 +37,39 @@ public class ConversationWindowProperties {
/** 摘要 token 预算下限(字数) */
private int summaryBudgetFloor = 500;
/**
* Minimum prefix size (in messages) the pair-safe boundary must leave
* before compaction is allowed to run. After enforcing tool-call/response
* pair integrity the boundary may collapse so far forward that only a
* handful of messages remain in the prefix at that point the
* compaction cost (a structured-summary LLM call) outweighs any token
* savings, and we may as well skip this turn.
*
* <p>Default 2 means "at least two old messages worth condensing".
* Set to 0 to always attempt compaction whenever a pair-safe cut exists.
*/
private int pairSafeMinPrefixToCompact = 2;
/**
* After compaction, re-inject the first user message of the compressed
* prefix so the original goal stays anchored in the prompt even when a
* long task has paged through dozens of turns. Without an anchor the
* structured summary alone can drift, and the model may forget what was
* being asked. Injected as a {@link org.springframework.ai.chat.messages.UserMessage}
* (never SystemMessage) so historical user input cannot be promoted to a
* system-level instruction.
*/
private boolean firstUserAnchorEnabled = true;
/**
* Maximum tokens the anchor body is allowed to consume in the prompt.
* The first user message is often short ("write me a CLI tool that…"),
* but power users sometimes paste multi-KB specs. When the body fits
* the budget it stays verbatim; when it is up to 3× over, it is
* head+tail truncated to this budget; when it is more than 3× over,
* it degrades to a 200-char pointer line so the model still knows the
* original goal existed without blowing prompt-cache or summary budget.
*/
private int firstUserAnchorMaxTokens = 400;
}

View File

@ -204,10 +204,12 @@ public class ModelConfigController {
// ==================== Embedding 模型管理 ====================
@Operation(summary = "按类型筛选模型chat / embedding")
@Operation(summary = "按类型筛选模型chat / embedding,可选 modality 过滤")
@GetMapping("/by-type")
public R<List<ModelConfigEntity>> listByType(@RequestParam(defaultValue = "chat") String modelType) {
return R.ok(modelConfigService.listByType(modelType));
public R<List<ModelConfigEntity>> listByType(
@RequestParam(defaultValue = "chat") String modelType,
@RequestParam(required = false) String modality) {
return R.ok(modelConfigService.listByType(modelType, modality));
}
@Operation(summary = "测试 Embedding 模型连通性(嵌入一个短文本验证 API key")

View File

@ -2,8 +2,8 @@ package vip.mate.llm.embedding;
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel;
import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingOptions;
import com.alibaba.cloud.ai.dashscope.embedding.text.DashScopeEmbeddingModel;
import com.alibaba.cloud.ai.dashscope.embedding.text.DashScopeEmbeddingOptions;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.MetadataMode;

View File

@ -0,0 +1,90 @@
package vip.mate.llm.failover;
import vip.mate.llm.model.ModelProviderEntity;
import java.util.Map;
/**
* Decide which fields a {@link ModelProviderEntity} needs to be considered
* "configured", based on the row's columns rather than its protocol enum.
*
* <p>Why row-based: every OpenAI-compatible provider (OpenAI / Kimi / DeepSeek
* cloud as well as llama.cpp / lmstudio / vllm / ollama local) shares the
* single {@code OPENAI_COMPATIBLE} protocol value. A protocol-keyed lookup
* cannot distinguish "cloud, needs api key" from "local, needs base url".
* The discriminating signals all live on the row: {@code requireApiKey},
* {@code isLocal}, {@code isCustom}, {@code authType}, {@code providerId}.
*
* <p>Hint key + args (no raw text) so the frontend renders via i18n
* {@code t(key, args)} without leaking Chinese into the English locale.
*/
public final class ProviderRequirements {
/** What this provider row needs to be considered configured. */
public record Required(
boolean needsApiKey,
boolean needsBaseUrl,
String hintKey, // i18n key, null when no hint applies
Map<String, Object> hintArgs // template params for vue-i18n; never raw text
) {}
private static final Required NONE = new Required(false, false, null, Map.of());
private ProviderRequirements() {}
/**
* Compute the required-fields verdict for a provider row.
*
* Decision tree:
* - authType == "oauth" -> no api key, no base url (OAuth handled elsewhere)
* - requireApiKey == true -> needs api key
* - isLocal || isCustom -> needs base url (no sane SDK default)
* - cloud built-ins -> SDK ships hard-coded base url; no base url needed
*
* Hint key picked from a small providerId-substring map for the most common
* local providers; everything else falls back to a generic OpenAI-compatible
* hint so the user always sees an actionable example URL.
*/
public static Required of(ModelProviderEntity provider) {
if (provider == null) return NONE;
// OAuth providers store credentials elsewhere (DB column or disk). Neither
// api key nor base url applies to the configured check.
if ("oauth".equals(provider.getAuthType())) {
return NONE;
}
boolean needsApiKey = Boolean.TRUE.equals(provider.getRequireApiKey());
boolean isLocal = Boolean.TRUE.equals(provider.getIsLocal());
boolean isCustom = Boolean.TRUE.equals(provider.getIsCustom());
boolean needsBaseUrl = isLocal || isCustom;
if (!needsBaseUrl) {
return new Required(needsApiKey, false, null, Map.of());
}
// Pick a hint by providerId substring. Order matters: more specific names
// first so "lm-studio" doesn't accidentally match a generic prefix later.
String pid = provider.getProviderId() == null ? "" : provider.getProviderId().toLowerCase();
String hintKey;
Map<String, Object> hintArgs;
if (pid.contains("ollama")) {
hintKey = "provider.hint.ollamaBaseUrlExample";
hintArgs = Map.of("example", "http://127.0.0.1:11434");
} else if (pid.contains("lmstudio") || pid.contains("lm-studio") || pid.contains("lm_studio")) {
hintKey = "provider.hint.lmstudioBaseUrlExample";
hintArgs = Map.of("example", "http://127.0.0.1:1234/v1");
} else if (pid.contains("llamacpp") || pid.contains("llama-cpp") || pid.contains("llama_cpp")
|| pid.contains("llama.cpp")) {
hintKey = "provider.hint.llamacppBaseUrlExample";
hintArgs = Map.of("example", "http://127.0.0.1:8080/v1");
} else if (pid.contains("vllm")) {
hintKey = "provider.hint.vllmBaseUrlExample";
hintArgs = Map.of("example", "http://127.0.0.1:8000/v1");
} else {
hintKey = "provider.hint.openaiCompatBaseUrlExample";
hintArgs = Map.of("example", "http://127.0.0.1:8080/v1");
}
return new Required(needsApiKey, true, hintKey, hintArgs);
}
}

View File

@ -12,5 +12,6 @@ public class CreateCustomProviderRequest {
private String apiKeyPrefix;
private String protocol;
private String chatModel;
private Boolean requireApiKey;
private List<ModelInfoDTO> models;
}

View File

@ -11,6 +11,7 @@ public class ProviderConfigRequest {
private String protocol;
private String chatModel;
private Map<String, Object> generateKwargs;
private Boolean requireApiKey;
/**
* RFC-009 P3.5: provider's position in the multi-model failover chain.
* {@code 0} = excluded; positive ints define ascending try-order. When

View File

@ -3,6 +3,7 @@ package vip.mate.llm.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -41,4 +42,35 @@ public class ProviderInfoDTO {
private Long cooldownRemainingMs;
/** RFC-074: whether the user has explicitly enabled this provider. False = lives in the catalog drawer only. */
private Boolean enabled;
// Issue #81: derived fields powering the chat-console liveness-aware popup.
// All six are computed from existing columns; none are persisted.
/** Credential status: CONFIGURED / MISSING / NOT_REQUIRED / OAUTH_PENDING. */
private String authStatus;
/** Base URL completeness: null when not applicable; true/false when applicable. */
private Boolean baseUrlComplete;
/** Comma-joined missing field names ("apiKey", "baseUrl"); empty when nothing missing. */
private String missingFields;
/**
* Machine-readable next-step key. Switches the chat popup's primary button text + handler.
* Values: fill_base_url / fill_api_key / start_oauth / configure_required_fields /
* test_connection / pull_model / wait_cooldown / reprobe / none.
*/
private String suggestedAction;
/**
* i18n key for an actionable hint (e.g. "provider.hint.llamacppBaseUrlExample").
* Frontend renders via t(key, args). Null when no hint applies.
*/
private String suggestedActionHintKey;
/**
* Template parameters for {@link #suggestedActionHintKey}. Frontend passes
* this directly to vue-i18n. Empty map means no parameters.
*/
private Map<String, Object> suggestedActionHintArgs = new LinkedHashMap<>();
}

View File

@ -13,6 +13,7 @@ import org.springframework.web.client.RestClient;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.repository.ModelProviderMapper;
import vip.mate.llm.service.ModelProviderService;
import java.io.OutputStream;
import java.net.InetSocketAddress;
@ -73,9 +74,11 @@ public class OpenAIOAuthService {
private static final String SCOPES = "openid profile email offline_access";
private static final String PROVIDER_ID = "openai-chatgpt";
private static final int CALLBACK_PORT = 1455;
private static final String DEFAULT_CALLBACK_BIND_HOST = "127.0.0.1";
private final ModelProviderMapper modelProviderMapper;
private final ObjectMapper objectMapper;
private final ModelProviderService modelProviderService;
private final RestClient restClient = RestClient.create();
/** state → code_verifier 缓存 */
@ -240,15 +243,17 @@ public class OpenAIOAuthService {
// Try to bind synchronously up front so callers can detect failure.
HttpServer server;
String bindHost = resolveCallbackBindHost();
try {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", CALLBACK_PORT), 0);
server = HttpServer.create(new InetSocketAddress(bindHost, CALLBACK_PORT), 0);
} catch (java.net.BindException e) {
log.warn("OAuth callback bind failed on port {} (in-use or restricted): {}",
CALLBACK_PORT, e.getMessage());
log.warn("OAuth callback bind failed on {}:{} (in-use or restricted): {}",
bindHost, CALLBACK_PORT, e.getMessage());
pendingStates.remove(expectedState);
return false;
} catch (java.io.IOException e) {
log.warn("OAuth callback HttpServer.create IO error: {}", e.getMessage());
log.warn("OAuth callback HttpServer.create IO error on {}:{}: {}",
bindHost, CALLBACK_PORT, e.getMessage());
pendingStates.remove(expectedState);
return false;
}
@ -311,7 +316,8 @@ public class OpenAIOAuthService {
boundServer.start();
activeCallbackServer = boundServer;
log.info("OAuth 回调服务器已启动在 http://127.0.0.1:{}", CALLBACK_PORT);
log.info("OAuth 回调服务器已启动,监听 {}:{},浏览器回调地址 {}",
bindHost, CALLBACK_PORT, REDIRECT_URI);
// 3 分钟超时自动关闭
CompletableFuture.delayedExecutor(3, TimeUnit.MINUTES).execute(() -> {
@ -490,6 +496,7 @@ public class OpenAIOAuthService {
provider.setOauthAccountId(accountId);
}
modelProviderMapper.updateById(provider);
modelProviderService.activateFirstModelIfDefaultUnavailable(PROVIDER_ID);
log.info("OpenAI OAuth token 已保存expires_in={}s, accountId={}", expiresIn, accountId);
}
@ -536,6 +543,15 @@ public class OpenAIOAuthService {
}
}
String resolveCallbackBindHost() {
String configured = System.getProperty("mateclaw.oauth.openai.callback-bind-host",
System.getenv("MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST"));
if (!StringUtils.hasText(configured)) {
return DEFAULT_CALLBACK_BIND_HOST;
}
return configured.trim();
}
// ==================== PKCE 工具 ====================
private String generateCodeVerifier() {

View File

@ -0,0 +1,141 @@
package vip.mate.llm.routing;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.content.Media;
import org.springframework.core.io.FileSystemResource;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.MimeType;
import vip.mate.llm.chatmodel.ProviderChatModelFactory;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Locale;
/**
* Caption an image attachment using a configured vision model so a text-only
* primary model can still reason about it.
*
* <p>The service is the execution arm of the sidecar strategy chosen by
* {@link MultimodalRouter}: pick a vision-capable model, send a single
* structured prompt with the image attached, return the description text.
*
* <p>v1 has no caching layer every call hits the vision model. The cache
* (keyed by {@code sha256(file_bytes) + visionModelId + locale}) is reserved
* for the next iteration; the API shape exposes {@code cacheHit} so callers
* already record the field in routing metadata.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MediaCaptionService {
private final ProviderChatModelFactory chatModelFactory;
private final RetryTemplate retryTemplate;
public CaptionResult caption(ModelConfigEntity visionModel, MessageContentPart imagePart, Locale locale) {
if (visionModel == null || imagePart == null) {
return CaptionResult.failure(0, new IllegalArgumentException("vision model or image part is null"));
}
Path mediaPath = resolveMediaPath(imagePart);
if (mediaPath == null) {
return CaptionResult.failure(0, new IllegalStateException(
"Image file not found for attachment: " + imagePart.getFileName()));
}
String contentType = imagePart.getContentType();
if (contentType == null || "image/*".equals(contentType)) {
contentType = "image/jpeg";
}
long start = System.currentTimeMillis();
try {
ChatModel chatModel = chatModelFactory.buildFor(visionModel, retryTemplate);
ChatClient client = ChatClient.create(chatModel);
UserMessage userMessage = UserMessage.builder()
.text(buildPrompt(locale, imagePart.getFileName()))
.media(List.of(new Media(MimeType.valueOf(contentType), new FileSystemResource(mediaPath))))
.build();
String description = client.prompt()
.messages(userMessage)
.call()
.content();
long elapsed = System.currentTimeMillis() - start;
String trimmed = description == null ? "" : description.trim();
if (trimmed.isEmpty()) {
return CaptionResult.failure(elapsed,
new IllegalStateException("Vision model returned empty description"));
}
return CaptionResult.success(trimmed, elapsed, false);
} catch (Exception e) {
long elapsed = System.currentTimeMillis() - start;
log.warn("Caption call failed for {} via {}/{}: {}",
imagePart.getFileName(), visionModel.getProvider(), visionModel.getModelName(),
e.getMessage());
return CaptionResult.failure(elapsed, e);
}
}
/**
* Locale-aware prompt. Defaults to Chinese when the locale is null or unrecognized
* (matches the primary user base) but switches to English so vision-model output
* matches the chat language and avoids polluting English-only contexts.
*/
private String buildPrompt(Locale locale, String fileName) {
boolean english = locale != null && Locale.ENGLISH.getLanguage().equalsIgnoreCase(locale.getLanguage());
String fileHint = (fileName == null || fileName.isBlank()) ? "" : " (" + fileName + ")";
if (english) {
return "Describe this image" + fileHint
+ " concisely: list the main objects, scene, any visible text (OCR), "
+ "and notable actions or emotions. Keep the answer under 300 words. "
+ "Reply with the description only — no preamble.";
}
return "请用一段简洁的中文描述这张图片" + fileHint
+ "列出主要物体、场景、画面中可见的文字OCR、以及人物动作或情绪。"
+ "不超过 300 字。直接给出描述,不要寒暄。";
}
/**
* Mirrors {@code BaseAgent.resolveImagePath} but standalone caption service
* is reused outside the agent context (e.g. tests, future preflight endpoint).
*/
private Path resolveMediaPath(MessageContentPart part) {
Path resolved = tryResolve(part.getPath());
if (resolved != null) return resolved;
return tryResolve(part.getMediaId());
}
private Path tryResolve(String relativePath) {
if (relativePath == null || relativePath.isBlank()) return null;
Path path = Paths.get(relativePath);
if (path.isAbsolute() && Files.exists(path)) return path;
Path workdir = Paths.get(System.getProperty("user.dir")).resolve(relativePath);
if (Files.exists(workdir)) return workdir;
return null;
}
public record CaptionResult(
String description,
boolean cacheHit,
long elapsedMs,
Throwable failure
) {
public boolean isFailure() {
return failure != null;
}
public static CaptionResult success(String description, long elapsedMs, boolean cacheHit) {
return new CaptionResult(description, cacheHit, elapsedMs, null);
}
public static CaptionResult failure(long elapsedMs, Throwable failure) {
return new CaptionResult(null, false, elapsedMs, failure);
}
}
}

View File

@ -0,0 +1,166 @@
package vip.mate.llm.routing;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.routing.model.MultimodalRoutingDecision;
import vip.mate.llm.routing.model.MultimodalRoutingDecision.SkippedAttachment;
import vip.mate.llm.service.ModelCapabilityService;
import vip.mate.llm.service.ModelCapabilityService.Modality;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.system.service.SystemSettingService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
/**
* Decides how to handle attachments whose modality outruns the agent's primary model.
*
* <p>The router is a pure decision step: it inspects the parts list and the primary
* model's capability set, then returns a {@link MultimodalRoutingDecision}. Caller is
* responsible for executing the decision (e.g. invoking the caption service when
* strategy is SIDECAR).
*
* <p>v1 only supports image sidecar. Video attachments fall through to the NONE
* branch with an explanatory skip reason the next iteration will add a video
* captioning path once a strategy for frame sampling is in place.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MultimodalRouter {
private final SystemSettingService systemSettingService;
private final ModelConfigService modelConfigService;
private final ModelCapabilityService capabilityService;
public MultimodalRoutingDecision route(List<MessageContentPart> parts, ModelConfigEntity primary) {
Set<Modality> required = collectRequiredModalities(parts);
if (required.isEmpty()) return MultimodalRoutingDecision.none();
EnumSet<Modality> primaryCaps = primary == null
? EnumSet.noneOf(Modality.class)
: capabilityService.resolve(primary.getModelName(), primary.getModalities());
if (primaryCaps.containsAll(required)) return MultimodalRoutingDecision.none();
EnumSet<Modality> missing = EnumSet.copyOf(required);
missing.removeAll(primaryCaps);
List<SkippedAttachment> skipped = new ArrayList<>();
ModelConfigEntity sidecarModel = null;
// VISION sidecar: resolve configured default vision model.
if (missing.contains(Modality.VISION)) {
ModelConfigEntity candidate = resolveSidecar(Modality.VISION);
if (candidate != null) {
sidecarModel = candidate;
} else {
String reason = describeMissingSidecar(Modality.VISION);
for (MessageContentPart p : imageParts(parts)) {
skipped.add(new SkippedAttachment("image", p.getFileName(), reason));
}
}
}
// VIDEO: v1 has no sidecar implementation. Mark as skipped so the UI can
// tell the user to switch to a video-capable primary model. Reserved for
// a follow-up RFC.
if (missing.contains(Modality.VIDEO)) {
for (MessageContentPart p : videoParts(parts)) {
skipped.add(new SkippedAttachment("video", p.getFileName(),
"video_sidecar_not_supported_in_v1"));
}
}
if (sidecarModel != null) {
return MultimodalRoutingDecision.sidecar(sidecarModel, required, missing);
}
return MultimodalRoutingDecision.noneWithSkipped(required, missing, skipped);
}
private Set<Modality> collectRequiredModalities(List<MessageContentPart> parts) {
if (parts == null || parts.isEmpty()) return Set.of();
EnumSet<Modality> required = EnumSet.noneOf(Modality.class);
for (MessageContentPart part : parts) {
if (part == null) continue;
String type = part.getType();
String contentType = part.getContentType();
if (isImagePart(type, contentType)) required.add(Modality.VISION);
else if (isVideoPart(type, contentType)) required.add(Modality.VIDEO);
else if (isAudioPart(type, contentType)) required.add(Modality.AUDIO);
}
return required;
}
private boolean isImagePart(String type, String contentType) {
if ("image".equals(type)) return true;
return "file".equals(type) && contentType != null && contentType.startsWith("image/");
}
private boolean isVideoPart(String type, String contentType) {
if ("video".equals(type)) return true;
return "file".equals(type) && contentType != null && contentType.startsWith("video/");
}
private boolean isAudioPart(String type, String contentType) {
if ("audio".equals(type)) return true;
return "file".equals(type) && contentType != null && contentType.startsWith("audio/");
}
private List<MessageContentPart> imageParts(List<MessageContentPart> parts) {
return parts.stream()
.filter(p -> p != null && isImagePart(p.getType(), p.getContentType()))
.toList();
}
private List<MessageContentPart> videoParts(List<MessageContentPart> parts) {
return parts.stream()
.filter(p -> p != null && isVideoPart(p.getType(), p.getContentType()))
.toList();
}
/**
* Resolve the configured sidecar model for a modality. Returns null when:
* - the setting is empty / blank;
* - the referenced row no longer exists or has been disabled;
* - the row's resolved capability set does not actually contain the modality.
* The caller treats null as "ask the user to configure one."
*/
private ModelConfigEntity resolveSidecar(Modality modality) {
SystemSettingsDTO settings = systemSettingService.getSettings();
Long modelId = switch (modality) {
case VISION -> settings.getDefaultVisionModelId();
case VIDEO -> settings.getDefaultVideoModelId();
default -> null;
};
if (modelId == null) return null;
ModelConfigEntity model;
try {
model = modelConfigService.getModel(modelId);
} catch (Exception e) {
log.debug("Configured sidecar model id={} could not be loaded: {}", modelId, e.getMessage());
return null;
}
if (model == null || !Boolean.TRUE.equals(model.getEnabled())) return null;
if (!capabilityService.supports(model.getModelName(), model.getModalities(), modality)) {
log.warn("Configured sidecar model {}/{} does not actually support {} — ignoring",
model.getProvider(), model.getModelName(), modality);
return null;
}
return model;
}
private String describeMissingSidecar(Modality modality) {
SystemSettingsDTO settings = systemSettingService.getSettings();
Long configured = modality == Modality.VISION
? settings.getDefaultVisionModelId()
: settings.getDefaultVideoModelId();
if (configured == null) return modality.name().toLowerCase() + "_model_not_configured";
return modality.name().toLowerCase() + "_model_unavailable";
}
}

View File

@ -0,0 +1,89 @@
package vip.mate.llm.routing.model;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelCapabilityService.Modality;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Outcome of routing a single user turn that may carry image / video / audio attachments.
*
* <p>The decision is purely descriptive execution (loading captions, mutating
* the user message) happens in the caller. Treat instances as immutable; the
* static factories cover the only valid shapes.
*/
public record MultimodalRoutingDecision(
Strategy strategy,
ModelConfigEntity sidecarModel,
Set<Modality> requiredModalities,
Set<Modality> primaryMissing,
List<SkippedAttachment> skipped
) {
public enum Strategy {
/** Primary model handles the turn directly (or no attachments at all). */
NONE,
/** A separate vision/video model captions attachments; primary stays. */
SIDECAR,
/** Reserved: switch the whole turn to a multimodal model. v1 does not emit. */
NATIVE
}
public record SkippedAttachment(String type, String fileName, String reason) {}
public static MultimodalRoutingDecision none() {
return new MultimodalRoutingDecision(
Strategy.NONE, null, Set.of(), Set.of(), List.of());
}
public static MultimodalRoutingDecision noneWithSkipped(
Set<Modality> required,
Set<Modality> missing,
List<SkippedAttachment> skipped) {
return new MultimodalRoutingDecision(
Strategy.NONE, null, required, missing, skipped);
}
public static MultimodalRoutingDecision sidecar(
ModelConfigEntity sidecarModel,
Set<Modality> required,
Set<Modality> missing) {
return new MultimodalRoutingDecision(
Strategy.SIDECAR, sidecarModel, required, missing, List.of());
}
/**
* Serialize to a flat map for emission as a graph event payload.
* Only includes keys present in this decision so the resulting
* {@code metadata.routing} JSON stays compact for the chat UI.
*/
public Map<String, Object> toMap() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("strategy", strategy.name().toLowerCase());
if (sidecarModel != null) {
m.put("sidecarModelId", sidecarModel.getId());
m.put("sidecarModel", sidecarModel.getModelName());
m.put("sidecarProvider", sidecarModel.getProvider());
}
if (!requiredModalities.isEmpty()) {
m.put("requiredModalities", requiredModalities.stream().map(Enum::name).toList());
}
if (!primaryMissing.isEmpty()) {
m.put("primaryMissing", primaryMissing.stream().map(Enum::name).toList());
}
if (!skipped.isEmpty()) {
m.put("skipped", skipped.stream().map(s -> {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("type", s.type());
if (s.fileName() != null) entry.put("fileName", s.fileName());
entry.put("reason", s.reason());
return entry;
}).toList());
}
return Collections.unmodifiableMap(m);
}
}

View File

@ -23,6 +23,7 @@ public class ModelConfigService {
private final ModelConfigMapper modelConfigMapper;
private final ApplicationEventPublisher eventPublisher;
private final ModelCapabilityService modelCapabilityService;
/**
* Lazy to break circular dependency: ModelProviderService ModelConfigService.
@ -42,10 +43,10 @@ public class ModelConfigService {
public List<ModelConfigEntity> listEnabledModels() {
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
.eq(ModelConfigEntity::getEnabled, true)
.eq(ModelConfigEntity::getProvider, "dashscope")
// chat 类型排除 embeddingNULL 兼容老数据
.and(w -> w.isNull(ModelConfigEntity::getModelType)
.or().eq(ModelConfigEntity::getModelType, "chat"))
.orderByAsc(ModelConfigEntity::getProvider)
.orderByDesc(ModelConfigEntity::getIsDefault)
.orderByAsc(ModelConfigEntity::getName));
}
@ -67,18 +68,41 @@ public class ModelConfigService {
* </ul>
*/
public List<ModelConfigEntity> listByType(String modelType) {
return listByType(modelType, null);
}
/**
* Optional modality filter (case-insensitive: {@code "vision" / "video" / "audio"}).
* When non-null, only enabled rows whose resolved capability set contains the
* requested modality survive used by the multimodal sidecar settings UI to
* populate "default vision model" / "default video model" dropdowns.
*/
public List<ModelConfigEntity> listByType(String modelType, String modality) {
List<ModelConfigEntity> rows;
if ("chat".equals(modelType)) {
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
rows = modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
.and(w -> w.isNull(ModelConfigEntity::getModelType)
.or().eq(ModelConfigEntity::getModelType, "chat"))
.orderByDesc(ModelConfigEntity::getIsDefault)
.orderByAsc(ModelConfigEntity::getName));
}
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
} else {
rows = modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
.eq(ModelConfigEntity::getModelType, modelType)
.orderByDesc(ModelConfigEntity::getIsDefault)
.orderByAsc(ModelConfigEntity::getName));
}
if (modality == null || modality.isBlank()) return rows;
ModelCapabilityService.Modality required;
try {
required = ModelCapabilityService.Modality.valueOf(modality.trim().toUpperCase());
} catch (IllegalArgumentException e) {
return rows;
}
return rows.stream()
.filter(m -> Boolean.TRUE.equals(m.getEnabled()))
.filter(m -> modelCapabilityService.supports(m.getModelName(), m.getModalities(), required))
.toList();
}
/**
* 查找第一个 enabled embedding 模型WikiEmbeddingService fallback 路径
@ -107,7 +131,7 @@ public class ModelConfigService {
.and(w -> w.isNull(ModelConfigEntity::getModelType)
.or().eq(ModelConfigEntity::getModelType, "chat"))
.last("LIMIT 1"));
if (defaultMarked != null && isProviderConfigured(defaultMarked.getProvider())) {
if (defaultMarked != null && isProviderEnabledAndConfigured(defaultMarked.getProvider())) {
return defaultMarked;
}
@ -120,7 +144,7 @@ public class ModelConfigService {
.orderByDesc(ModelConfigEntity::getIsDefault)
.orderByAsc(ModelConfigEntity::getName));
for (ModelConfigEntity candidate : candidates) {
if (isProviderConfigured(candidate.getProvider())) {
if (isProviderEnabledAndConfigured(candidate.getProvider())) {
return candidate;
}
}
@ -141,12 +165,12 @@ public class ModelConfigService {
* dependency. Falls back to {@code true} when the service is not yet available
* (e.g., during early bootstrap) so we don't accidentally block startup.
*/
private boolean isProviderConfigured(String providerId) {
private boolean isProviderEnabledAndConfigured(String providerId) {
if (modelProviderService == null || providerId == null) {
return true;
}
try {
return modelProviderService.isProviderConfigured(providerId);
return modelProviderService.isProviderEnabledAndConfigured(providerId);
} catch (Exception e) {
return true; // conservative: don't filter if lookup fails
}

View File

@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
@ -13,6 +14,7 @@ import vip.mate.exception.MateClawException;
import vip.mate.llm.model.*;
import vip.mate.llm.oauth.OpenAIOAuthService;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.CompletableFuture;
@ -413,7 +415,7 @@ public class ModelDiscoveryService {
}
String apiKey = provider.getApiKey();
RestClient client = RestClient.builder()
RestClient client = openAiCompatibleClientBuilder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
@ -623,7 +625,7 @@ public class ModelDiscoveryService {
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
String completionsPath = resolveCompletionsPath(baseUrl, kwargs);
RestClient.RequestHeadersSpec<?> spec = RestClient.builder()
RestClient.RequestHeadersSpec<?> spec = openAiCompatibleClientBuilder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build()
@ -915,6 +917,23 @@ public class ModelDiscoveryService {
return normalized;
}
/**
* Build a RestClient.Builder pinned to HTTP/1.1 for self-hosted OpenAI-compatible
* servers. Java's HttpClient defaults to HTTP/2 and over cleartext attempts an
* H2C upgrade ({@code Upgrade: h2c, Connection: Upgrade, HTTP2-Settings: ...}).
* Uvicorn-based stacks (vLLM, lmstudio, llama.cpp, ollama) reject the upgrade
* by closing the socket mid-handshake surfacing as either
* "header parser received no bytes" on the chat path or, more subtly, a
* 400 with body=None on the test path because the body never makes it past
* the upgrade negotiation.
*/
private RestClient.Builder openAiCompatibleClientBuilder() {
HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.build();
return RestClient.builder().requestFactory(new JdkClientHttpRequestFactory(httpClient));
}
@SuppressWarnings("unchecked")
private void applyCustomHeaders(RestClient.RequestHeadersSpec<?> spec, Map<String, Object> kwargs) {
if (kwargs == null) {

View File

@ -14,6 +14,7 @@ import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.llm.failover.AvailableProviderPool;
import vip.mate.llm.failover.ProviderHealthTracker;
import vip.mate.llm.failover.ProviderInitProbe;
import vip.mate.llm.failover.ProviderRequirements;
import vip.mate.llm.model.*;
import vip.mate.llm.repository.ModelProviderMapper;
@ -128,6 +129,9 @@ public class ModelProviderService {
provider.setBaseUrl(request.getBaseUrl());
provider.setChatModel(ModelProtocol.resolveChatModel(request.getProtocol(), request.getChatModel()));
provider.setGenerateKwargs(writeJson(request.getGenerateKwargs()));
if (request.getRequireApiKey() != null) {
provider.setRequireApiKey(request.getRequireApiKey());
}
// RFC-009 P3.5: only update fallback priority when the caller explicitly
// sends a value. null leaves it untouched (existing chain unchanged).
if (request.getFallbackPriority() != null) {
@ -168,7 +172,7 @@ public class ModelProviderService {
provider.setSupportModelDiscovery(false);
provider.setSupportConnectionCheck(false);
provider.setFreezeUrl(false);
provider.setRequireApiKey(true);
provider.setRequireApiKey(request.getRequireApiKey() == null || Boolean.TRUE.equals(request.getRequireApiKey()));
modelProviderMapper.insert(provider);
if (request.getModels() != null) {
@ -234,18 +238,31 @@ public class ModelProviderService {
public boolean isProviderAvailable(String providerId) {
ModelProviderEntity provider = getProvider(providerId);
return isProviderConfigured(provider) && hasModels(providerId);
return isProviderEnabledAndConfigured(provider) && hasModels(providerId);
}
public String getProviderUnavailableReason(String providerId) {
ModelProviderEntity provider = getProvider(providerId);
if (!isProviderConfigured(provider)) {
if (Boolean.TRUE.equals(provider.getRequireApiKey())) {
return "Provider 未配置有效的 API Key";
if (!Boolean.TRUE.equals(provider.getEnabled())) {
return "Provider 未启用";
}
if (Boolean.TRUE.equals(provider.getIsCustom()) || !Boolean.TRUE.equals(provider.getIsLocal())) {
if (!isProviderConfigured(provider)) {
// Issue #81: emit a precise reason based on which row-level fields are
// missing, rather than the previous protocol-blind heuristic. The new
// frontend reads suggestedActionHintKey/Args; this string remains for
// logs and legacy callers.
ProviderRequirements.Required req = ProviderRequirements.of(provider);
boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
boolean hasApiKey = hasUsableApiKey(provider.getApiKey());
if (req.needsBaseUrl() && !hasBaseUrl && req.needsApiKey() && !hasApiKey) {
return "Provider 未配置 Base URL 和 API Key";
}
if (req.needsBaseUrl() && !hasBaseUrl) {
return "Provider 未配置 Base URL";
}
if (req.needsApiKey() && !hasApiKey) {
return "Provider 未配置 API Key";
}
return "Provider 未完成配置";
}
if (!hasModels(providerId)) {
@ -316,7 +333,7 @@ public class ModelProviderService {
}
private void tryAutoActivateModel(String providerId, ModelProviderEntity provider) {
if (!isProviderConfigured(provider)) {
if (!isProviderEnabledAndConfigured(provider)) {
return;
}
List<ModelConfigEntity> providerModels = modelConfigService.listModelsByProvider(providerId);
@ -327,7 +344,7 @@ public class ModelProviderService {
try {
ModelConfigEntity currentDefault = modelConfigService.getDefaultModel();
ModelProviderEntity defaultProvider = modelProviderMapper.selectById(currentDefault.getProvider());
if (!isProviderConfigured(defaultProvider)) {
if (!isProviderEnabledAndConfigured(defaultProvider)) {
shouldAutoActivate = true;
}
} catch (MateClawException e) {
@ -339,6 +356,16 @@ public class ModelProviderService {
}
}
/**
* OAuth/device-code completion updates credentials outside the normal provider
* config endpoint. Reuse the same default-model promotion logic so a freshly
* connected OAuth provider is immediately selectable by chat.
*/
public void activateFirstModelIfDefaultUnavailable(String providerId) {
ModelProviderEntity provider = getProvider(providerId);
tryAutoActivateModel(providerId, provider);
}
private ModelProviderEntity getProvider(String providerId) {
ModelProviderEntity provider = modelProviderMapper.selectById(providerId);
if (provider == null) {
@ -416,9 +443,85 @@ public class ModelProviderService {
}
dto.setModels(builtinModels);
dto.setExtraModels(extraModels);
applySuggestedAction(dto, provider, providerLiveness);
return dto;
}
/**
* Issue #81: derive the chat-popup recovery hint from row + liveness, so the
* frontend can render a precise "next step" instead of a generic
* "model unavailable" toast. Six fields populated:
* - authStatus: CONFIGURED / MISSING / NOT_REQUIRED / OAUTH_PENDING
* - baseUrlComplete: null when not applicable, true/false otherwise
* - missingFields: comma-joined ("apiKey", "baseUrl") for required-field UX
* - suggestedAction: machine-readable next-step key (frontend switches on this)
* - suggestedActionHintKey + suggestedActionHintArgs: i18n key/args, no raw text
*/
private void applySuggestedAction(ProviderInfoDTO dto, ModelProviderEntity provider, Liveness liveness) {
ProviderRequirements.Required req = ProviderRequirements.of(provider);
boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
boolean hasApiKey = hasUsableApiKey(provider.getApiKey());
boolean hasModels = (dto.getModels() != null && !dto.getModels().isEmpty())
|| (dto.getExtraModels() != null && !dto.getExtraModels().isEmpty());
// 1. authStatus
if ("oauth".equals(provider.getAuthType())) {
dto.setAuthStatus(Boolean.TRUE.equals(dto.getOauthConnected()) ? "CONFIGURED" : "OAUTH_PENDING");
} else if (req.needsApiKey()) {
dto.setAuthStatus(hasApiKey ? "CONFIGURED" : "MISSING");
} else {
dto.setAuthStatus("NOT_REQUIRED");
}
// 2. baseUrlComplete: null when this provider doesn't need a base URL.
dto.setBaseUrlComplete(req.needsBaseUrl() ? hasBaseUrl : null);
// 3. missingFields
java.util.List<String> missing = new ArrayList<>();
if (req.needsApiKey() && !hasApiKey) missing.add("apiKey");
if (req.needsBaseUrl() && !hasBaseUrl) missing.add("baseUrl");
dto.setMissingFields(String.join(",", missing));
// 4. suggestedAction
String action;
if (liveness == Liveness.UNCONFIGURED) {
if ("oauth".equals(provider.getAuthType())) {
action = "start_oauth";
} else if (missing.size() == 1 && missing.get(0).equals("baseUrl")) {
action = "fill_base_url";
} else if (missing.size() == 1 && missing.get(0).equals("apiKey")) {
action = "fill_api_key";
} else {
action = "configure_required_fields";
}
} else if (liveness == Liveness.REMOVED) {
action = "reprobe";
} else if (liveness == Liveness.COOLDOWN) {
action = "wait_cooldown";
} else if (liveness == Liveness.UNPROBED) {
action = "reprobe";
} else if (liveness == Liveness.LIVE && !hasModels) {
action = Boolean.TRUE.equals(provider.getSupportModelDiscovery())
? "pull_model"
: "configure_required_fields";
} else {
action = "none";
}
dto.setSuggestedAction(action);
// 5. hint key + args (NOT raw text). Frontend renders via t(key, args).
// Only emit hint when it actually applies to the action; suppress for
// REMOVED / COOLDOWN / UNPROBED to keep the popup clean.
if ("fill_base_url".equals(action) || "configure_required_fields".equals(action)) {
dto.setSuggestedActionHintKey(req.hintKey());
dto.setSuggestedActionHintArgs(req.hintArgs() == null ? new java.util.LinkedHashMap<>()
: new java.util.LinkedHashMap<>(req.hintArgs()));
} else {
dto.setSuggestedActionHintKey(null);
dto.setSuggestedActionHintArgs(new java.util.LinkedHashMap<>());
}
}
private boolean hasModels(String providerId) {
return !modelConfigService.listModelsByProvider(providerId).isEmpty();
}
@ -427,13 +530,11 @@ public class ModelProviderService {
if (provider == null) {
return false;
}
if (Boolean.TRUE.equals(provider.getIsLocal())) {
return true;
}
// OAuth 认证的 provider检查 OAuth token 是否存在
// OAuth providers store credentials elsewhere (DB column or disk for
// Claude Code). Resolve them via the OAuth service rather than the
// base-URL / api-key columns.
if ("oauth".equals(provider.getAuthType())) {
// Claude Code OAuth (RFC-062) token lives on disk, not in DB.
if (CLAUDE_CODE_PROVIDER_ID.equals(provider.getProviderId())) {
ClaudeCodeOAuthService svc = claudeCodeOAuthServiceProvider.getIfAvailable();
return svc != null && svc.isLoggedIn();
@ -441,16 +542,21 @@ public class ModelProviderService {
return StringUtils.hasText(provider.getOauthAccessToken());
}
boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
boolean hasApiKey = hasUsableApiKey(provider.getApiKey());
if (Boolean.TRUE.equals(provider.getIsCustom())) {
return hasBaseUrl && (!Boolean.TRUE.equals(provider.getRequireApiKey()) || hasApiKey);
// Issue #81: decide required fields from the provider row, not from the
// protocol enum. Every OpenAI-compatible provider (cloud or local) shares
// OPENAI_COMPATIBLE, so a protocol-keyed table cannot tell OpenAI cloud
// (needs api_key, no base url) apart from llama.cpp local (no api_key,
// needs base url). Without this, isLocal=true short-circuited to true
// for llama.cpp regardless of an empty Base URL, hiding the real cause
// behind a confusing REMOVED state.
ProviderRequirements.Required req = ProviderRequirements.of(provider);
if (req.needsApiKey() && !hasUsableApiKey(provider.getApiKey())) {
return false;
}
if (Boolean.FALSE.equals(provider.getRequireApiKey())) {
return hasBaseUrl;
if (req.needsBaseUrl() && !StringUtils.hasText(provider.getBaseUrl())) {
return false;
}
return hasApiKey;
return true;
}
public boolean hasUsableApiKey(String apiKey) {
@ -458,11 +564,30 @@ public class ModelProviderService {
return false;
}
String normalized = apiKey.trim();
// Reject masked display values (the UI sends "********" when the user
// didn't re-type the key) and known placeholder sentinels without this
// check, the chat / embedding fallback chain happily forwards the
// placeholder to the LLM endpoint, which then returns a 401 at request
// time. "configure-in-admin-ui" is the application.yml default that
// keeps DashScopeChatAutoConfiguration happy at startup when no env var
// is set; "your-*-api-key-here" are legacy sentinels from earlier
// .env.example / application.yml versions.
return !normalized.contains("*")
&& !"configure-in-admin-ui".equalsIgnoreCase(normalized)
&& !"your-dashscope-api-key-here".equalsIgnoreCase(normalized)
&& !"your-api-key-here".equalsIgnoreCase(normalized);
}
public boolean isProviderEnabledAndConfigured(String providerId) {
return isProviderEnabledAndConfigured(getProvider(providerId));
}
private boolean isProviderEnabledAndConfigured(ModelProviderEntity provider) {
return provider != null
&& Boolean.TRUE.equals(provider.getEnabled())
&& isProviderConfigured(provider);
}
public Map<String, Object> readProviderGenerateKwargs(ModelProviderEntity provider) {
return readJson(provider != null ? provider.getGenerateKwargs() : null);
}

View File

@ -1,32 +0,0 @@
package vip.mate.memory.fact.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entity reference for multi-hop graph queries on facts.
*
* @author MateClaw Team
*/
@Data
@TableName("mate_fact_entity_ref")
public class FactEntityRefEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long factId;
private String entityName;
/** person, tool, project, concept */
private String entityType;
/** subject | object */
private String role;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}

View File

@ -6,7 +6,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.memory.fact.model.FactContradictionEntity;
import vip.mate.memory.fact.model.FactEntity;
import vip.mate.memory.fact.model.FactEntityRefEntity;
import vip.mate.memory.fact.repository.FactMapper;
import java.time.LocalDateTime;
@ -24,7 +23,6 @@ import java.util.List;
public class FactQueryService {
private final FactMapper factMapper;
private final vip.mate.memory.fact.repository.FactEntityRefMapper refMapper;
private final vip.mate.memory.fact.repository.FactContradictionMapper contradictionMapper;
/**
@ -41,27 +39,6 @@ public class FactQueryService {
.last("LIMIT 20"));
}
/**
* Find related facts via entity references (multi-hop).
*/
public List<FactEntity> related(Long agentId, String entity, int hops) {
// Find fact IDs that reference this entity
List<FactEntityRefEntity> refs = refMapper.selectList(
new LambdaQueryWrapper<FactEntityRefEntity>()
.like(FactEntityRefEntity::getEntityName, entity)
.last("LIMIT 50"));
List<Long> factIds = refs.stream().map(FactEntityRefEntity::getFactId).distinct().toList();
if (factIds.isEmpty()) return List.of();
return factMapper.selectList(
new LambdaQueryWrapper<FactEntity>()
.eq(FactEntity::getAgentId, agentId)
.eq(FactEntity::getDeleted, 0)
.in(FactEntity::getId, factIds)
.orderByDesc(FactEntity::getTrust)
.last("LIMIT 20"));
}
/**
* List unresolved contradictions for an agent.
*/

View File

@ -1,9 +0,0 @@
package vip.mate.memory.fact.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.memory.fact.model.FactEntityRefEntity;
@Mapper
public interface FactEntityRefMapper extends BaseMapper<FactEntityRefEntity> {
}

View File

@ -43,24 +43,6 @@ public class FactQueryTool {
.collect(Collectors.joining("\n"));
}
@Tool(description = "Find facts related to an entity via entity references (multi-hop graph query).")
public String fact_related(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Entity name") String entity,
@ToolParam(description = "Number of hops (1-3)") int hops) {
if (!properties.getFact().isProjectionEnabled()) {
return "Fact projection is disabled.";
}
List<FactEntity> facts = queryService.related(agentId, entity, Math.min(hops, 3));
if (facts.isEmpty()) return "No related facts found for: " + entity;
queryService.bumpUseCount(facts.stream().map(FactEntity::getId).toList());
return facts.stream()
.map(f -> String.format("- %s %s %s (trust=%.2f)", f.getSubject(), f.getPredicate(), f.getObjectValue(), f.getTrust()))
.collect(Collectors.joining("\n"));
}
@Tool(description = "List unresolved fact contradictions detected during Dream consolidation.")
public String fact_list_contradictions(
@ToolParam(description = "Agent ID") Long agentId) {

View File

@ -301,6 +301,12 @@ public class AcpSkillBridge {
s.setEnabled(Boolean.TRUE.equals(ep.getEnabled()));
s.setBuiltin(Boolean.TRUE.equals(ep.getBuiltin()));
s.setTags("acp");
// Carry the backing endpoint's workspace through to the virtual
// SkillEntity so binding-time tenancy checks can compare it against
// the agent's workspace. Without this the bridge synthesizes rows
// with workspaceId = null and an agent in any workspace could bind
// any ACP endpoint regardless of where the endpoint was provisioned.
s.setWorkspaceId(ep.getWorkspaceId());
s.setSecurityScanStatus("PASSED"); // ACP endpoints are user-configured external CLIs, not skill scripts
s.setConfigJson(buildConfigJson(ep));
s.setManifestJson(serializeManifest(buildManifest(ep)));

View File

@ -23,6 +23,7 @@ import vip.mate.skill.synthesis.SkillSynthesisService;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.skill.workspace.BundledSkillSyncer;
import vip.mate.skill.workspace.SkillFileSyncer;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import java.util.ArrayList;
@ -49,6 +50,7 @@ public class SkillController {
private final SkillRuntimeService skillRuntimeService;
private final SkillWorkspaceManager workspaceManager;
private final BundledSkillSyncer bundledSkillSyncer;
private final SkillFileSyncer skillFileSyncer;
private final SkillSynthesisService synthesisService;
private final SkillDependencyChecker dependencyChecker;
private final SkillLessonsService lessonsService;
@ -249,13 +251,88 @@ public class SkillController {
@Operation(summary = "重新扫描单个技能RFC-042 §2.3.4")
@PostMapping("/{id}/rescan")
public R<SkillEntity> rescan(@PathVariable Long id) {
rejectVirtualSkillMutation(id);
return R.ok(skillService.rescanSecurity(id));
}
@Operation(summary = "Re-sync this skill's bundle files from DB → local workspace cache",
description = "Use after an out-of-band scripts/ change or to recover a missing local cache " +
"in a multi-instance deployment. Pulls every mate_skill_file row owned by the skill " +
"down to disk; if no rows exist yet but local files do, ingests them into the canonical store.")
@PostMapping("/{id}/sync-files")
public R<Map<String, Object>> syncFiles(@PathVariable Long id) {
rejectVirtualSkillMutation(id);
SkillEntity skill = skillService.getSkill(id);
var report = skillFileSyncer.syncOne(skill);
Map<String, Object> body = new LinkedHashMap<>();
body.put("skillId", id);
body.put("name", skill.getName());
body.put("filesMaterialized", report.filesMaterialized());
body.put("filesAlreadyCurrent", report.filesAlreadyCurrent());
body.put("filesBackfilledFromDisk", report.filesBackfilledFromDisk());
body.put("backfilledFromDisk", report.didBackfillFromDisk());
return R.ok(body);
}
@Operation(summary = "Re-sync every skill's bundle files (admin)",
description = "Bulk variant of /sync-files; primarily for ops debugging when you suspect " +
"the local workspace is out of sync with the canonical store.")
@PostMapping("/sync-files")
public R<Map<String, Object>> syncAllFiles() {
var report = skillFileSyncer.syncAll();
Map<String, Object> body = new LinkedHashMap<>();
body.put("skillsConsidered", report.skillsConsidered());
body.put("skillsBackfilled", report.skillsBackfilled());
body.put("filesMaterialized", report.filesMaterialized());
body.put("filesAlreadyCurrent", report.filesAlreadyCurrent());
body.put("filesBackfilledFromDisk", report.filesBackfilledFromDisk());
return R.ok(body);
}
/**
* Mutation paths refuse virtual MCP/ACP skill ids upfront. The bridge
* synthesizes those rows on the fly from the upstream connection
* config; persisting an update against {@code mate_skill} would
* either silently no-op (no row to update) or as users have hit
* throw "技能不存在" because the lookup precedes the update. Sending
* a clear 4xx with a redirect hint is the right shape: the user
* wants the icon / display name / etc. to stick, and the only place
* those fields persist for an MCP entry is the MCP connection page.
*/
private void rejectVirtualSkillMutation(Long id) {
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)
|| vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) {
throw new vip.mate.exception.MateClawException(
"err.skill.virtual_readonly",
"MCP/ACP 衍生技能不可在此编辑——请到 Settings ▸ MCP/ACP 连接页修改");
}
}
@Operation(summary = "获取已启用技能列表")
@GetMapping("/enabled")
public R<List<SkillEntity>> listEnabled() {
return R.ok(skillService.listEnabledSkills());
// Mirror the merging the paginated /skills endpoint does so the agent
// edit picker (which calls this endpoint) sees MCP- and ACP-derived
// virtual skills alongside the persisted ones. The shadow base must
// include all real skill names including disabled ones so a
// disabled real skill correctly suppresses its same-named virtual
// twin, matching /skills and /counts.
List<SkillEntity> result = new ArrayList<>(skillService.listEnabledSkills());
Set<String> realNames = realSkillNames();
try {
result.addAll(filterShadowedVirtualSkills(
mcpSkillBridge.listMcpDerivedSkillEntities(), realNames));
} catch (Exception e) {
// Bridge failure must not 500 the picker same defensive stance as /counts.
}
try {
result.addAll(filterShadowedVirtualSkills(
acpSkillBridge.listAcpDerivedSkillEntities(), realNames));
} catch (Exception e) {
// Bridge failure must not 500 the picker same defensive stance as /counts.
}
return R.ok(result);
}
@Operation(summary = "按类型获取技能列表")
@ -300,6 +377,7 @@ public class SkillController {
@Operation(summary = "更新技能")
@PutMapping("/{id}")
public R<SkillEntity> update(@PathVariable Long id, @RequestBody SkillEntity skill) {
rejectVirtualSkillMutation(id);
skill.setId(id);
return R.ok(skillService.updateSkill(skill));
}
@ -316,6 +394,7 @@ public class SkillController {
@Operation(summary = "硬删除技能 (admin only — 物理删除 + 工作区清空)")
@DeleteMapping("/{id}")
public R<Void> delete(@PathVariable Long id) {
rejectVirtualSkillMutation(id);
skillService.hardDeleteSkill(id);
return R.ok();
}
@ -323,6 +402,7 @@ public class SkillController {
@Operation(summary = "启用/禁用技能")
@PutMapping("/{id}/toggle")
public R<SkillEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled) {
rejectVirtualSkillMutation(id);
return R.ok(skillService.toggleSkill(id, enabled));
}

View File

@ -309,7 +309,16 @@ public class BuiltinSkillSeedService implements ApplicationRunner {
row.setDescription(nullIfBlank(parsed.getDescription()));
row.setSkillType(SKILL_TYPE_BUILTIN);
row.setBuiltin(true);
row.setEnabled(true);
// Frontmatter `optional: true` flips the initial seed to enabled=false,
// so heavyweight bundled skills (paid CLI dependencies, external OAuth,
// niche integrations) ship dark the user opts in from the Skills page
// when they actually want them. The default (frontmatter absent or
// false) preserves the historical "all bundled skills active" behavior.
// {@link #mergeIntoExisting} deliberately does NOT touch `enabled`, so
// once a user activates an optional skill, subsequent boots keep their
// choice and a downgrade in frontmatter never silently disables it.
boolean optional = booleanFromFrontmatter(parsed, "optional", false);
row.setEnabled(!optional);
row.setSkillContent(content);
row.setVersion(stringFromFrontmatter(parsed, "version", DEFAULT_VERSION));
row.setIcon(stringFromFrontmatter(parsed, "icon", DEFAULT_ICON));
@ -406,6 +415,24 @@ public class BuiltinSkillSeedService implements ApplicationRunner {
return dirty;
}
/**
* Read a boolean frontmatter key, tolerant of the YAML / casual-string
* forms the parser might surface ({@code true} / {@code "true"} /
* {@code "yes"} / {@code "1"}). Anything else falls back to the supplied
* default so a typo doesn't silently flip behavior.
*/
private boolean booleanFromFrontmatter(SkillFrontmatterParser.ParsedSkillMd parsed,
String key, boolean fallback) {
Map<String, Object> fm = parsed.getFrontmatter();
if (fm == null) return fallback;
Object value = fm.get(key);
if (value == null) return fallback;
if (value instanceof Boolean b) return b;
String s = value.toString().trim().toLowerCase();
if (s.isEmpty()) return fallback;
return s.equals("true") || s.equals("yes") || s.equals("1") || s.equals("on");
}
@SuppressWarnings("unchecked")
private String stringFromFrontmatter(SkillFrontmatterParser.ParsedSkillMd parsed,
String key, String fallback) {

View File

@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import vip.mate.skill.installer.model.*;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.service.SkillFileService;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.SkillWorkspaceEvent;
import vip.mate.skill.workspace.SkillWorkspaceManager;
@ -36,6 +37,7 @@ public class SkillInstaller {
private final SkillHubClient skillHubClient;
private final SkillWorkspaceManager workspaceManager;
private final SkillService skillService;
private final SkillFileService skillFileService;
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
@ -146,79 +148,35 @@ public class SkillInstaller {
return CompletableFuture.completedFuture(null);
}
// 4. 写入 workspace 目录
// overwrite 时先清理旧 references/ scripts/防止残留过期文件
if (exists) {
workspaceManager.cleanWorkspaceDataDirs(skillName);
}
// 重装时 (exists=true) 覆写 SKILL.md否则保留已有内容向后兼容首次创建语义
// 4. Materialize SKILL.md (overwrite on reinstall, keep on first create).
workspaceManager.initWorkspace(skillName, bundle.content(), exists);
// 写入 references/
if (bundle.references() != null) {
for (var entry : bundle.references().entrySet()) {
workspaceManager.writeWorkspaceFile(skillName, "references/" + entry.getKey(), entry.getValue());
}
}
// 写入 scripts/
if (bundle.scripts() != null) {
for (var entry : bundle.scripts().entrySet()) {
workspaceManager.writeWorkspaceFile(skillName, "scripts/" + entry.getKey(), entry.getValue());
}
}
// cancel check: 文件已落盘但数据库尚未写入 归档已写入的目录后退出
if (task.isCancelRequested()) {
workspaceManager.archiveWorkspace(skillName);
task.markCancelled();
return CompletableFuture.completedFuture(null);
}
// 5. 注册/更新数据库
SkillEntity skillEntity;
if (exists) {
// 更新已有记录
skillEntity = skillService.listSkills().stream()
.filter(s -> s.getName().equals(skillName))
.findFirst().orElseThrow();
skillEntity.setSkillContent(bundle.content());
skillEntity.setDescription(bundle.description());
skillEntity.setVersion(bundle.version());
skillEntity.setAuthor(bundle.author());
skillEntity.setIcon(bundle.icon());
skillEntity.setConfigJson(buildConfigJson(bundle));
if (Boolean.TRUE.equals(request.getEnable())) {
skillEntity.setEnabled(true);
}
skillService.updateSkill(skillEntity);
} else {
// 创建新记录
skillEntity = new SkillEntity();
skillEntity.setName(skillName);
skillEntity.setDescription(bundle.description());
skillEntity.setSkillType("dynamic");
skillEntity.setVersion(bundle.version());
skillEntity.setAuthor(bundle.author());
skillEntity.setIcon(bundle.icon());
skillEntity.setSkillContent(bundle.content());
skillEntity.setConfigJson(buildConfigJson(bundle));
skillEntity.setEnabled(Boolean.TRUE.equals(request.getEnable()));
skillService.createSkill(skillEntity);
}
// 5. Register/update the skill row first so we have an id for the file rows.
SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists,
Boolean.TRUE.equals(request.getEnable()));
// cancel check: DB 已写入此时取消不再回滚数据库但标记任务为 cancelled
if (task.isCancelRequested()) {
task.markCancelled();
return CompletableFuture.completedFuture(null);
}
// 6. 发布事件
// 6. Persist bundle files: DB is canonical, FS is the materialized cache.
// Empty-bundle guard protects both sides from a malformed bundle
// silently wiping pre-existing scripts/references.
boolean force = Boolean.TRUE.equals(request.getForcePrune());
persistBundleFiles(skillEntity, bundle, force, "url");
// 7. Publish event for runtime refresh / sibling-node materialization.
eventPublisher.publishEvent(new SkillWorkspaceEvent(
skillName, SkillWorkspaceEvent.Type.INSTALLED,
workspaceManager.resolveConventionPath(skillName)));
// 7. 完成
task.markCompleted(InstallResult.builder()
.name(skillName)
.enabled(Boolean.TRUE.equals(request.getEnable()))
@ -254,28 +212,37 @@ public class SkillInstaller {
"Skill '" + skillName + "' already exists. Enable overwrite to replace.");
}
// 写入 workspace
if (exists) {
workspaceManager.cleanWorkspaceDataDirs(skillName);
}
workspaceManager.initWorkspace(skillName, bundle.content());
// Materialize SKILL.md (always overwrite on reinstall path).
workspaceManager.initWorkspace(skillName, bundle.content(), exists);
if (bundle.references() != null) {
for (var entry : bundle.references().entrySet()) {
String key = entry.getKey();
if (!key.startsWith("references/")) key = "references/" + key;
workspaceManager.writeWorkspaceFile(skillName, key, entry.getValue());
}
}
if (bundle.scripts() != null) {
for (var entry : bundle.scripts().entrySet()) {
String key = entry.getKey();
if (!key.startsWith("scripts/")) key = "scripts/" + key;
workspaceManager.writeWorkspaceFile(skillName, key, entry.getValue());
}
// Register/update skill row first so we have an id to anchor the file rows.
SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable);
// DB-canonical, FS-cache. Empty-bundle guard on both sides.
persistBundleFiles(skillEntity, bundle, false, "zip");
eventPublisher.publishEvent(new SkillWorkspaceEvent(
skillName, SkillWorkspaceEvent.Type.INSTALLED,
workspaceManager.resolveConventionPath(skillName)));
int filesCount = (bundle.references() != null ? bundle.references().size() : 0)
+ (bundle.scripts() != null ? bundle.scripts().size() : 0) + 1;
log.info("Skill '{}' installed from ZIP (v{}, {} files)", skillName, bundle.version(), filesCount);
return Map.of(
"skillId", skillEntity.getId(),
"name", skillName,
"version", bundle.version() != null ? bundle.version() : "",
"filesCount", filesCount
);
}
// 注册/更新 DB
/**
* Insert or update the {@code mate_skill} row from a bundle. Returns the
* persisted entity so callers have its id for downstream file writes.
*/
private SkillEntity upsertSkillRow(SkillBundle bundle, String skillName, boolean exists, boolean enable) {
SkillEntity skillEntity;
if (exists) {
skillEntity = skillService.listSkills().stream()
@ -302,22 +269,40 @@ public class SkillInstaller {
skillEntity.setEnabled(enable);
skillService.createSkill(skillEntity);
}
return skillEntity;
}
eventPublisher.publishEvent(new SkillWorkspaceEvent(
skillName, SkillWorkspaceEvent.Type.INSTALLED,
workspaceManager.resolveConventionPath(skillName)));
/**
* Write bundle files to both DB (canonical) and FS (cache) using the
* same prefixed-key map. Logs a single combined summary so multi-instance
* deployments can see what each node persisted vs preserved.
*/
private void persistBundleFiles(SkillEntity skillEntity, SkillBundle bundle, boolean force, String origin) {
Map<String, String> combined = new LinkedHashMap<>();
if (bundle.references() != null) {
for (var e : bundle.references().entrySet()) {
String key = e.getKey().startsWith("references/") ? e.getKey() : "references/" + e.getKey();
combined.put(key, e.getValue());
}
}
if (bundle.scripts() != null) {
for (var e : bundle.scripts().entrySet()) {
String key = e.getKey().startsWith("scripts/") ? e.getKey() : "scripts/" + e.getKey();
combined.put(key, e.getValue());
}
}
int filesCount = (bundle.references() != null ? bundle.references().size() : 0)
+ (bundle.scripts() != null ? bundle.scripts().size() : 0) + 1;
var dbApply = skillFileService.applyBundleFiles(skillEntity.getId(), combined, force);
var fsApply = workspaceManager.applyBundleFiles(skillEntity.getName(),
bundle.references(), bundle.scripts(), force);
log.info("Skill '{}' installed from ZIP (v{}, {} files)", skillName, bundle.version(), filesCount);
return Map.of(
"skillId", skillEntity.getId(),
"name", skillName,
"version", bundle.version() != null ? bundle.version() : "",
"filesCount", filesCount
);
log.info("Persisted bundle for '{}' ({}): db(write={}, prune={}, preservedScripts={}, preservedRefs={}) " +
"fs(refs write={}, prune={}, preserved={} | scripts write={}, prune={}, preserved={})",
skillEntity.getName(), origin,
dbApply.rowsWritten(), dbApply.rowsPruned(),
dbApply.scriptsPreservedDueToEmptyBundle(), dbApply.referencesPreservedDueToEmptyBundle(),
fsApply.referencesWritten(), fsApply.referencesPruned(), fsApply.referencesPreservedDueToEmptyBundle(),
fsApply.scriptsWritten(), fsApply.scriptsPruned(), fsApply.scriptsPreservedDueToEmptyBundle());
}
// ==================== 工具方法 ====================

View File

@ -9,15 +9,18 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* Parses a ZIP-packaged Skill into a {@link SkillBundle}.
* <p>
* Used by both the upload endpoint (MultipartFile) and the ClawHub install
* Used by both the upload endpoint (MultipartFile) and the marketplace install
* path (downloaded ZIP bytes). Hardened against:
* <ul>
* <li>Zip Slip path traversal</li>
@ -25,6 +28,12 @@ import java.util.zip.ZipInputStream;
* <li>Only SKILL.md / references/ / scripts/ entries are kept</li>
* </ul>
*
* <p>Extraction is two-pass: the entire archive is buffered in memory first
* (cap-protected), then SKILL.md is located and the common parent prefix is
* stripped from every other entry. This keeps classification correct
* regardless of the order zip tools write entries earlier single-pass logic
* silently dropped {@code scripts/*} entries that streamed before SKILL.md.
*
* @author MateClaw Team
*/
@Slf4j
@ -35,15 +44,39 @@ public class ZipSkillFetcher {
private static final String SKILL_MD = "SKILL.md";
private static final String SKILL_MD_LOWER = "skill.md";
/**
* Lowercase file extensions that should be treated as runnable scripts
* when they appear next to SKILL.md without an explicit {@code scripts/}
* prefix. Real-world zips from third parties (e.g. the official
* tencent-meeting-mcp package) put {@code setup.sh} at the package
* root; without this fallback, those files get logged as "unclassified"
* and the skill ships with an empty scripts/ directory.
*/
private static final Set<String> SCRIPT_EXTENSIONS = Set.of(
".sh", ".bash", ".zsh", ".py", ".js", ".mjs", ".ts",
".rb", ".pl", ".php", ".bat", ".cmd", ".ps1");
/**
* Lowercase file extensions that are documentation / data alongside
* SKILL.md and should default to {@code references/} when not nested
* under an explicit prefix.
*/
private static final Set<String> REFERENCE_EXTENSIONS = Set.of(
".md", ".txt", ".json", ".yaml", ".yml", ".csv", ".tsv",
".html", ".htm", ".xml", ".toml");
/**
* Holds the in-memory result of decompressing a ZIP. Used by callers
* that want to enrich the SkillBundle with metadata (e.g. ClawHub author
* / icon) that isn't carried inside SKILL.md.
* that want to enrich the SkillBundle with metadata (e.g. marketplace
* author / icon) that isn't carried inside SKILL.md.
*/
public record ExtractedSkill(String skillMdContent,
Map<String, String> references,
Map<String, String> scripts) {}
/** Buffered raw zip entry, awaiting classification once SKILL.md prefix is known. */
private record RawEntry(String name, String content) {}
/**
* Parse an uploaded ZIP file into a SkillBundle. Source type is "zip"
* and source URL is the original filename.
@ -93,12 +126,18 @@ public class ZipSkillFetcher {
/**
* Decompress a ZIP stream into in-memory SKILL.md + references + scripts.
* Throws {@link IllegalArgumentException} if no SKILL.md is present.
*
* <p>Two-pass: the first pass buffers every text entry (subject to size
* caps) and remembers where SKILL.md lives. The second pass strips the
* SKILL.md parent prefix from each buffered entry and routes it into
* {@code references} / {@code scripts}. Anything that doesn't match
* either bucket is logged at WARN level so packaging mistakes surface
* instead of being silently dropped.
*/
public static ExtractedSkill extract(InputStream zipStream) throws IOException {
List<RawEntry> raws = new ArrayList<>();
String skillMdContent = null;
String skillMdPrefix = "";
Map<String, String> references = new HashMap<>();
Map<String, String> scripts = new HashMap<>();
long totalSize = 0;
try (ZipInputStream zis = new ZipInputStream(zipStream, StandardCharsets.UTF_8)) {
@ -138,28 +177,20 @@ public class ZipSkillFetcher {
}
String content = new String(bytes, StandardCharsets.UTF_8);
String normalizedName = entryPath.toString().replace('\\', '/');
String fileName = entryPath.getFileName().toString();
// First match wins for SKILL.md so we lock onto the shallowest one.
if (skillMdContent == null && (SKILL_MD.equals(fileName) || SKILL_MD_LOWER.equals(fileName))) {
skillMdContent = content;
int slashIdx = entryName.lastIndexOf('/');
skillMdPrefix = slashIdx > 0 ? entryName.substring(0, slashIdx + 1) : "";
log.info("[ZipSkillFetcher] Found SKILL.md at: {}", entryName);
int slashIdx = normalizedName.lastIndexOf('/');
skillMdPrefix = slashIdx > 0 ? normalizedName.substring(0, slashIdx + 1) : "";
log.info("[ZipSkillFetcher] Found SKILL.md at: {}", normalizedName);
} else {
raws.add(new RawEntry(normalizedName, content));
}
zis.closeEntry();
String normalizedName = entryPath.toString().replace('\\', '/');
String relativeName = normalizedName;
if (!skillMdPrefix.isEmpty() && normalizedName.startsWith(skillMdPrefix)) {
relativeName = normalizedName.substring(skillMdPrefix.length());
}
if (relativeName.startsWith("references/")) {
references.put(relativeName.substring("references/".length()), content);
} else if (relativeName.startsWith("scripts/")) {
scripts.put(relativeName.substring("scripts/".length()), content);
}
}
}
@ -167,6 +198,61 @@ public class ZipSkillFetcher {
throw new IllegalArgumentException("ZIP does not contain SKILL.md");
}
Map<String, String> references = new HashMap<>();
Map<String, String> scripts = new HashMap<>();
for (RawEntry raw : raws) {
String relative = raw.name();
if (!skillMdPrefix.isEmpty() && relative.startsWith(skillMdPrefix)) {
relative = relative.substring(skillMdPrefix.length());
}
if (relative.startsWith("references/")) {
references.put(relative.substring("references/".length()), raw.content());
} else if (relative.startsWith("scripts/")) {
scripts.put(relative.substring("scripts/".length()), raw.content());
} else if (!relative.contains("/")) {
// Sibling of SKILL.md (post-prefix-strip). Some real-world
// packagers notably the official tencent-meeting-mcp.zip
// put setup.sh at the package root instead of under scripts/.
// Fall back to extension-based classification so those zips
// install cleanly without forcing the user to repackage.
String classified = classifyRootFile(relative);
if ("scripts".equals(classified)) {
scripts.put(relative, raw.content());
log.info("[ZipSkillFetcher] Classified root-level entry '{}' as script by extension", relative);
} else if ("references".equals(classified)) {
references.put(relative, raw.content());
log.info("[ZipSkillFetcher] Classified root-level entry '{}' as reference by extension", relative);
} else {
log.warn("[ZipSkillFetcher] Ignoring root-level entry with unknown extension: {}", raw.name());
}
} else {
log.warn("[ZipSkillFetcher] Ignoring entry outside references/ or scripts/: {} (skill prefix={})",
raw.name(), skillMdPrefix.isEmpty() ? "<root>" : skillMdPrefix);
}
}
return new ExtractedSkill(skillMdContent, references, scripts);
}
/**
* Classify a root-level file (sibling of SKILL.md, no directory prefix)
* by extension. Returns {@code "scripts"} / {@code "references"} for
* recognized extensions, {@code null} for everything else.
*
* <p>Only invoked for entries that are NOT already nested under
* {@code scripts/} or {@code references/}, so well-formed packages
* are unaffected.
*/
private static String classifyRootFile(String fileName) {
if (fileName == null) return null;
String lower = fileName.toLowerCase();
int dot = lower.lastIndexOf('.');
if (dot < 0) return null;
String ext = lower.substring(dot);
if (SCRIPT_EXTENSIONS.contains(ext)) return "scripts";
if (REFERENCE_EXTENSIONS.contains(ext)) return "references";
return null;
}
}

View File

@ -24,4 +24,13 @@ public class InstallRequest {
/** 若同名 skill 已存在,是否覆盖 */
private Boolean overwrite = false;
/**
* Bypass the empty-bundle prune guard. Default {@code false} keeps
* existing scripts/references when the new bundle has zero entries
* for that bucket protects against malformed uploads. Set to
* {@code true} only when you really want to clear out a bucket via
* an intentionally empty bundle.
*/
private Boolean forcePrune = false;
}

View File

@ -10,6 +10,7 @@ import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.tool.mcp.model.McpServerEntity;
import vip.mate.tool.mcp.runtime.McpClientManager;
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
import vip.mate.tool.mcp.service.McpServerService;
import java.util.ArrayList;
@ -134,8 +135,7 @@ public class McpSkillBridge {
s.setId(virtualIdFor(server));
s.setName(slugify(server.getName()));
s.setNameEn(displayName(server));
s.setNameZh(server.getDescription() != null && !server.getDescription().isBlank()
? displayName(server) : null);
s.setNameZh(displayName(server));
s.setDescription(buildDescription(server));
s.setSkillType("mcp");
s.setIcon(iconFor(server));
@ -146,12 +146,18 @@ public class McpSkillBridge {
s.setTags("mcp");
s.setSecurityScanStatus("PASSED"); // MCP servers don't go through SkillSecurityService
s.setConfigJson(buildConfigJson(server));
s.setManifestJson(serializeManifest(buildManifest(server)));
s.setManifestJson(serializeManifest(buildManifestFrom(server, readToolRawNames(server))));
return s;
}
private ResolvedSkill serverToResolved(McpServerEntity server) {
SkillManifest manifest = buildManifest(server);
List<String> rawNames = readToolRawNames(server);
Map<String, String> toolDisplayNames = new LinkedHashMap<>();
for (String raw : rawNames) {
String prefixed = McpToolNameResolver.prefixedName(server.getId(), raw);
toolDisplayNames.put(prefixed, prefixed + " (" + raw + ")");
}
SkillManifest manifest = buildManifestFrom(server, rawNames);
boolean connected = "connected".equalsIgnoreCase(nullSafe(server.getLastStatus()));
boolean errored = "error".equalsIgnoreCase(nullSafe(server.getLastStatus()))
|| (server.getLastError() != null && !server.getLastError().isBlank());
@ -192,27 +198,33 @@ public class McpSkillBridge {
.manifest(manifest)
.featureStatuses(featureStatuses)
.activeFeatures(active)
.toolDisplayNames(toolDisplayNames)
.build();
}
/**
* Auto-generate the §10.2 Q2 minimal manifest from the live MCP
* server. Tool list is the union of discovered MCP tools; one
* synthetic feature {@code default} carries them so the standard
* features-aware gate light up correctly.
* Auto-generate the minimal manifest from the MCP server's most-recent
* tool snapshot. The tool list is sourced in priority order:
* <ol>
* <li>{@code mate_mcp_server.tools_cache_json} present whenever the
* server has connected at least once. Lets the picker stay
* populated through brief disconnects.</li>
* <li>The runtime in-memory cache (current connection's
* {@code listTools()} result).</li>
* </ol>
*
* <p>Tool names emitted into {@code manifest.allowedTools} go through
* {@link McpToolNameResolver#prefixedName(long, String)} so they match
* the runtime callback names registered by
* {@link McpClientManager#getAllToolCallbacks()}. Without this, a
* resolved skill's effective allowlist would carry raw names that
* don't appear in any agent's callbacks at chat time, and the LLM
* would see no MCP tools even though the bindings were saved.
*/
private SkillManifest buildManifest(McpServerEntity server) {
List<String> toolNames = new ArrayList<>();
try {
List<McpSchema.Tool> discovered = mcpClientManager.getServerTools(server.getId());
for (McpSchema.Tool t : discovered) {
if (t == null) continue;
String n = t.name();
if (n != null && !n.isBlank()) toolNames.add(n);
}
} catch (Exception e) {
log.debug("MCP bridge manifest build: getServerTools({}) failed: {}",
server.getId(), e.getMessage());
private SkillManifest buildManifestFrom(McpServerEntity server, List<String> rawNames) {
List<String> toolNames = new ArrayList<>(rawNames.size());
for (String raw : rawNames) {
toolNames.add(McpToolNameResolver.prefixedName(server.getId(), raw));
}
SkillManifest.FeatureDef defaultFeature = SkillManifest.FeatureDef.builder()
@ -253,6 +265,58 @@ public class McpSkillBridge {
.build();
}
/**
* Resolve the raw tool name list for a server with cache-first / live-fallback
* semantics. Returns an empty list (never null) so the manifest builder
* stays simple.
*/
private List<String> readToolRawNames(McpServerEntity server) {
List<String> fromCache = parseCachedToolNames(server.getToolsCacheJson());
if (!fromCache.isEmpty()) {
return fromCache;
}
try {
List<McpSchema.Tool> discovered = mcpClientManager.getServerTools(server.getId());
List<String> names = new ArrayList<>(discovered.size());
for (McpSchema.Tool t : discovered) {
if (t == null) continue;
String n = t.name();
if (n != null && !n.isBlank()) names.add(n);
}
return names;
} catch (Exception e) {
log.debug("MCP bridge manifest build: getServerTools({}) failed: {}",
server.getId(), e.getMessage());
return List.of();
}
}
/**
* Parse the {@code tools_cache_json} column written by
* {@code McpServerService} after each successful connect. Returns an
* empty list if the column is null/blank/malformed the bridge is
* required to keep working when the cache hasn't been populated yet
* (e.g. first-ever connect just succeeded a moment ago).
*/
private List<String> parseCachedToolNames(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
cn.hutool.json.JSONArray arr = cn.hutool.json.JSONUtil.parseArray(json);
List<String> out = new ArrayList<>(arr.size());
for (Object obj : arr) {
if (!(obj instanceof cn.hutool.json.JSONObject jo)) continue;
String name = jo.getStr("name");
if (name != null && !name.isBlank()) out.add(name);
}
return out;
} catch (Exception e) {
log.debug("MCP bridge: failed to parse tools_cache_json: {}", e.getMessage());
return List.of();
}
}
private String slugify(String raw) {
if (raw == null) return "";
return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-");

View File

@ -97,7 +97,17 @@ public class SkillEntity {
/** 标签(逗号分隔) */
private String tags;
/** RFC-023来源对话 IDAgent 自治合成时记录) */
/**
* Owning workspace. The DB column has existed since the baseline schema
* (default = 1) but the field was missing from the entity, so MyBatis
* Plus silently ignored both reads and writes. Surfacing it here lets
* binding-time tenancy checks see the value; default behavior on insert
* remains "fall through to the column DEFAULT" because the field stays
* {@code null} in the no-arg create path.
*/
private Long workspaceId;
/** 来源对话 IDAgent 自治合成时记录) */
private String sourceConversationId;
/**

View File

@ -0,0 +1,53 @@
package vip.mate.skill.model;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* One file inside a skill bundle (an entry under {@code scripts/} or
* {@code references/}).
* <p>
* The database is the canonical store. {@code SkillFileSyncer} mirrors
* each row to the local workspace cache so {@code SkillScriptTool} and
* other directory-aware consumers see the file on disk regardless of
* which node accepted the original upload.
*
* @author MateClaw Team
*/
@Data
@TableName("mate_skill_file")
public class SkillFileEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** Owning skill (FK to {@code mate_skill.id}). */
private Long skillId;
/**
* Path relative to the skill workspace root, always starting with
* {@code scripts/} or {@code references/}. Forward slashes only.
*/
private String filePath;
/** UTF-8 text content. Per-file size bounded by ZipSkillFetcher (1MB). */
private String content;
/** Length of {@link #content} in bytes — kept so listings can sort/audit without loading the blob. */
private Integer contentSize;
/** SHA-256 of {@link #content}; used by the syncer to skip no-op writes. */
private String sha256;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,20 @@
package vip.mate.skill.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import vip.mate.skill.model.SkillFileEntity;
/**
* Mapper for {@link SkillFileEntity}.
*
* @author MateClaw Team
*/
@Mapper
public interface SkillFileMapper extends BaseMapper<SkillFileEntity> {
/** Drop every file row owned by the given skill — used on hard-delete. */
@Delete("DELETE FROM mate_skill_file WHERE skill_id = #{skillId}")
int deleteBySkillId(@Param("skillId") Long skillId);
}

View File

@ -335,6 +335,7 @@ public class SkillPackageResolver {
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.createTime(entity.getCreateTime())
.build();
}
@ -375,6 +376,7 @@ public class SkillPackageResolver {
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.createTime(entity.getCreateTime())
.build();
}

View File

@ -354,16 +354,20 @@ public class SkillRuntimeService {
Long agentId) {
List<ResolvedSkill> activeSkills;
if (boundSkillIds != null) {
// Per-agent 过滤从全局 enabled skills 中按 ID 过滤RFC-090
// §14.1 must use the same features-aware gate as
// refreshActiveSkills() so legacy dependencyReady drift
// doesn't silently let setup-needed manifest skills through
// (or hide partially-ready features that should be visible).
List<SkillEntity> enabledSkills = skillService.listEnabledSkills();
activeSkills = enabledSkills.stream()
.filter(s -> boundSkillIds.contains(s.getId()))
.map(packageResolver::resolve)
.filter(SkillRuntimeService::passesActiveGate)
// Per-agent filter: pick the agent's bound subset from the
// already-merged active set (real + MCP/ACP virtual). Using
// getActiveSkills() instead of a fresh
// skillService.listEnabledSkills() walk is what makes bound
// virtual skills surface in the prompt catalog. The earlier
// implementation only looked at mate_skill rows, so a user
// who explicitly checked an MCP/ACP card in the agent picker
// got its tools (via AgentBindingService.getEffectiveToolNames)
// but lost the corresponding `## Skills` catalog row, which
// confused the LLM when it tried to dispatch by skill name.
// Cache-backed get + same passesActiveGate semantics, so this
// is strictly additive for real skills.
activeSkills = getActiveSkills().stream()
.filter(s -> s.getId() != null && boundSkillIds.contains(s.getId()))
.collect(java.util.stream.Collectors.toList());
} else {
activeSkills = getActiveSkills();
@ -391,10 +395,17 @@ public class SkillRuntimeService {
int descLimit = promptDescriptionLimit(maxInputTokens);
Set<String> recentNames = usageService.recentLoadedSkillNames(agentId, 8);
Set<String> frequentNames = usageService.frequentlyLoadedSkillNames(8);
// Boost freshly installed skills for a short window so a skill the
// user *just* added is visible in the compact catalog before it has
// any usage history. Without this, qwen-turbo-style 8-entry budgets
// hide new skills behind 40+ existing ones, and the LLM tells the
// user "no such skill" minutes after they uploaded it.
java.time.LocalDateTime recencyCutoff = java.time.LocalDateTime.now().minus(NEW_SKILL_BOOST_WINDOW);
List<ResolvedSkill> sorted = SkillCatalogSorter.sortResolved(visibleSkills, SkillCatalogSort.RECOMMENDED)
.stream()
.sorted(java.util.Comparator
.comparingInt((ResolvedSkill s) -> recentNames.contains(s.getName()) ? 0 : 1)
.comparingInt((ResolvedSkill s) -> isRecentlyInstalled(s, recencyCutoff) ? 0 : 1)
.thenComparingInt(s -> recentNames.contains(s.getName()) ? 0 : 1)
.thenComparingInt(s -> frequentNames.contains(s.getName()) ? 0 : 1)
.thenComparing(SkillCatalogSorter.resolvedComparator(SkillCatalogSort.RECOMMENDED)))
.toList();
@ -412,7 +423,12 @@ public class SkillRuntimeService {
sb.append("\n\n## Skills\n");
sb.append("This is a compact catalog. If a listed skill matches the task, ");
sb.append("first call `readSkillFile(skillName=<name>, filePath=\"SKILL.md\")` and follow its instructions. ");
sb.append("If none of these skills match, call `listAvailableSkills()` to inspect the broader catalog. ");
sb.append("If none of these skills match, call `listAvailableSkills()` to inspect the broader catalog ");
sb.append("(it accepts `keyword=<part of name>` and `limit=` up to 50 — use them to search by topic ");
sb.append("when the default page is truncated). ");
sb.append("If the user names a specific skill that isn't in this table, ");
sb.append("call `readSkillFile(skillName=\"<exact-name>\", filePath=\"SKILL.md\")` directly — ");
sb.append("the catalog above is intentionally compact and doesn't list every active skill. ");
sb.append("Skills are documentation packages — calling a skill name as a tool will fail. ");
sb.append("Skills with a `scripts/` directory expose `runSkillScript`; SKILL.md will name the script when needed.\n\n");
sb.append("| Skill | Status | Description |\n");
@ -454,6 +470,28 @@ public class SkillRuntimeService {
return tools == null || tools.isEmpty() || effectiveToolNames.containsAll(tools);
}
/**
* Treat skills installed within this window as "new" for the prompt
* catalog ranker. Long enough that a user who installs on Friday and
* comes back Monday still sees the boost; short enough that the
* catalog reverts to usage-based ordering before the boost slot
* crowds out genuinely useful skills.
*/
public static final java.time.Duration NEW_SKILL_BOOST_WINDOW = java.time.Duration.ofDays(7);
/**
* Returns true if the skill's row was created after {@code cutoff}.
* Builtins and virtual MCP/ACP skills typically have no createTime;
* they are not boosted (the user didn't just install them). Public so
* the user-facing {@code listAvailableSkills} catalog can apply the
* same boost as the prompt enhancement.
*/
public static boolean isRecentlyInstalled(ResolvedSkill skill, java.time.LocalDateTime cutoff) {
if (skill == null || skill.getCreateTime() == null) return false;
if (skill.isBuiltin()) return false;
return skill.getCreateTime().isAfter(cutoff);
}
private static int promptCatalogEntryLimit(Integer maxInputTokens) {
int max = maxInputTokens != null && maxInputTokens > 0 ? maxInputTokens : 8192;
if (max <= 8192) return 8;

View File

@ -6,6 +6,7 @@ import lombok.Data;
import vip.mate.skill.manifest.SkillManifest;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@ -72,6 +73,16 @@ public class ResolvedSkill {
@Builder.Default
private boolean builtin = false;
/**
* Skill row create timestamp, copied from {@code mate_skill.create_time}.
* Used by the prompt-catalog ranker to surface freshly installed skills
* before they accumulate any usage stats without this, a brand-new
* skill stays invisible behind the recent/frequent/alphabetical sort
* and the LLM ends up replying "no such skill" right after the user
* installed it. Null for virtual MCP/ACP skills that don't own a row.
*/
private LocalDateTime createTime;
// ==================== 安全扫描状态 ====================
/** 是否被安全扫描阻断 */
@ -130,6 +141,25 @@ public class ResolvedSkill {
@Builder.Default
private Set<String> activeFeatures = Set.of();
/**
* Per-tool display-name decoration table, keyed by the prefixed callback
* name and valued by the human-readable form (e.g.
* {@code "mcp_4_fs_a1b2c3"} {@code "mcp_4_fs_a1b2c3 (read_file)"}).
*
* <p>Populated by skill source providers that have a recoverable raw
* name (currently MCP-bridged skills); other sources leave it empty,
* in which case {@link #getEffectiveAllowedToolsDisplay()} falls
* through to the prefixed names unchanged.
*
* <p>Held internally rather than serialized: the wire shape exposes
* the decorated set via the derived getter, which keeps the
* source-of-truth (the feature filter in
* {@link #getEffectiveAllowedTools()}) in one place.
*/
@JsonIgnore
@Builder.Default
private Map<String, String> toolDisplayNames = Map.of();
/** RFC-090 §14.1 — replacement filter for {@code dependencyReady}. */
public boolean hasAnyActiveFeature() {
return activeFeatures != null && !activeFeatures.isEmpty();
@ -200,6 +230,26 @@ public class ResolvedSkill {
return out;
}
/**
* Display-friendly companion to {@link #getEffectiveAllowedTools()}.
* Each prefixed callback name is replaced by its decorated form (e.g.
* {@code "mcp_4_fs_a1b2c3 (read_file)"}) when {@link #toolDisplayNames}
* carries an entry for it; names without a decoration entry are kept
* verbatim. Feature-filter semantics match the prefixed getter, so a
* tool that is hidden by a SETUP_NEEDED feature stays hidden here too.
*/
public Set<String> getEffectiveAllowedToolsDisplay() {
Set<String> base = getEffectiveAllowedTools();
if (base.isEmpty() || toolDisplayNames == null || toolDisplayNames.isEmpty()) {
return base;
}
Set<String> out = new LinkedHashSet<>(base.size());
for (String name : base) {
out.add(toolDisplayNames.getOrDefault(name, name));
}
return out;
}
// ==================== 综合状态 ====================
/**

View File

@ -0,0 +1,178 @@
package vip.mate.skill.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.skill.model.SkillFileEntity;
import vip.mate.skill.repository.SkillFileMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Persistence layer for skill bundle files.
* <p>
* Treated as the canonical store: every install writes the full set of
* scripts/references rows here, and {@code SkillFileSyncer} mirrors them
* to the local workspace cache on every node so script execution works
* across a multi-instance deployment that shares one database.
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SkillFileService {
private final SkillFileMapper mapper;
/** All file rows owned by a skill. */
public List<SkillFileEntity> listBySkillId(Long skillId) {
if (skillId == null) return List.of();
QueryWrapper<SkillFileEntity> q = new QueryWrapper<>();
q.eq("skill_id", skillId);
return mapper.selectList(q);
}
/** Compute SHA-256 hex of a UTF-8 string (used for idempotent diffs). */
public static String sha256Hex(String content) {
if (content == null) content = "";
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable on this JVM", e);
}
}
/**
* Replace the skill's full file set with {@code newFiles}, using
* write-then-prune semantics to mirror the on-disk applyBundleFiles.
*
* <p>Empty-bundle guard: if {@code newFiles} contains zero entries
* for a bucket (scripts/ or references/) and there are existing rows
* for that bucket, the rows are preserved unless {@code force=true}.
* This blocks the same data-loss scenario that tripped up the FS path.
*
* @param skillId owning skill id
* @param newFiles new full file set, keyed by path under workspace root
* (e.g. {@code "scripts/run.py"})
* @param force bypass empty-bundle guard
*/
@Transactional
public ApplyResult applyBundleFiles(Long skillId, Map<String, String> newFiles, boolean force) {
if (skillId == null) {
return new ApplyResult(0, 0, false, false);
}
Map<String, String> incoming = newFiles == null ? Map.of() : newFiles;
boolean newHasScripts = bucketHasEntries(incoming, "scripts/");
boolean newHasRefs = bucketHasEntries(incoming, "references/");
List<SkillFileEntity> existing = listBySkillId(skillId);
boolean existingHasScripts = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("scripts/"));
boolean existingHasRefs = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("references/"));
boolean preserveScripts = !newHasScripts && existingHasScripts && !force;
boolean preserveRefs = !newHasRefs && existingHasRefs && !force;
Map<String, SkillFileEntity> existingByPath = new HashMap<>();
for (SkillFileEntity e : existing) existingByPath.put(e.getFilePath(), e);
Set<String> keepPaths = new HashSet<>();
if (preserveScripts) {
for (SkillFileEntity e : existing) {
if (e.getFilePath() != null && e.getFilePath().startsWith("scripts/")) {
keepPaths.add(e.getFilePath());
}
}
}
if (preserveRefs) {
for (SkillFileEntity e : existing) {
if (e.getFilePath() != null && e.getFilePath().startsWith("references/")) {
keepPaths.add(e.getFilePath());
}
}
}
keepPaths.addAll(incoming.keySet());
int written = 0;
LocalDateTime now = LocalDateTime.now();
for (var entry : incoming.entrySet()) {
String path = entry.getKey();
String content = entry.getValue() == null ? "" : entry.getValue();
String hash = sha256Hex(content);
int size = content.getBytes(StandardCharsets.UTF_8).length;
SkillFileEntity prior = existingByPath.get(path);
if (prior == null) {
SkillFileEntity row = new SkillFileEntity();
row.setSkillId(skillId);
row.setFilePath(path);
row.setContent(content);
row.setContentSize(size);
row.setSha256(hash);
row.setCreateTime(now);
row.setUpdateTime(now);
mapper.insert(row);
written++;
} else if (!hash.equals(prior.getSha256())) {
prior.setContent(content);
prior.setContentSize(size);
prior.setSha256(hash);
prior.setUpdateTime(now);
mapper.updateById(prior);
written++;
}
}
int pruned = 0;
for (SkillFileEntity e : existing) {
if (!keepPaths.contains(e.getFilePath())) {
mapper.deleteById(e.getId());
pruned++;
}
}
if (preserveScripts) {
log.warn("Refused to prune scripts/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId);
}
if (preserveRefs) {
log.warn("Refused to prune references/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId);
}
return new ApplyResult(written, pruned, preserveScripts, preserveRefs);
}
/** Drop every file row for a skill (used on hard-delete). */
@Transactional
public int deleteAllForSkill(Long skillId) {
if (skillId == null) return 0;
return mapper.deleteBySkillId(skillId);
}
private boolean bucketHasEntries(Map<String, String> files, String prefix) {
for (String key : files.keySet()) {
if (key != null && key.startsWith(prefix)) return true;
}
return false;
}
/** Outcome of {@link #applyBundleFiles}. */
public record ApplyResult(int rowsWritten,
int rowsPruned,
boolean scriptsPreservedDueToEmptyBundle,
boolean referencesPreservedDueToEmptyBundle) {}
}

View File

@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.exception.MateClawException;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillFileMapper;
import vip.mate.skill.repository.SkillMapper;
import vip.mate.skill.runtime.SkillCatalogSort;
import vip.mate.skill.runtime.SkillCatalogSorter;
@ -42,6 +43,7 @@ import java.util.stream.Collectors;
public class SkillService {
private final SkillMapper skillMapper;
private final SkillFileMapper skillFileMapper;
private final SkillWorkspaceManager workspaceManager;
private final SkillWorkspaceProperties workspaceProperties;
private final SkillSecretService skillSecretService;
@ -300,6 +302,29 @@ public class SkillService {
* </ul>
* 仍不允许name / version / author / skillType / builtin 这些是身份字段
* 改动会破坏绑定与解析
*
* <p>The UI sends a partial body that only contains the fields the
* user edited (Identity edit {@code nameZh/nameEn/description/tags/icon};
* Body edit {@code skillContent}, plus optional {@code sourceCode}).
* Every other field on the deserialized entity is {@code null}.
*
* <p>{@link SkillEntity} declares several
* {@code @TableField(updateStrategy = FieldStrategy.ALWAYS)} columns
* {@code name_zh}, {@code name_en}, {@code config_json},
* {@code source_code}, {@code skill_content}, {@code manifest_json},
* {@code security_scan_result}. Calling
* {@code skillMapper.updateById(partial)} would tell MyBatis Plus to
* write {@code NULL} into every ALWAYS column missing from the
* partial, wiping perfectly valid content on every save. The earlier
* #45 fix only protected the resolver's scan write-back; this path
* was still exposed (and surfaced as issue #93 when a partial PUT
* also took the workspace-sync branch with a {@code null} name and
* NPE'd inside {@code sanitizeName}).
*
* <p>Fix: merge non-null fields from the partial onto a copy of the
* existing row, then persist the merged entity. Same shape as the
* builtin branch above, just with a wider whitelist for dynamic
* skills.
*/
public SkillEntity updateSkill(SkillEntity skill) {
SkillEntity existing = getSkill(skill.getId());
@ -307,7 +332,7 @@ public class SkillService {
if (Boolean.TRUE.equals(existing.getBuiltin())) {
// Functional fields
existing.setEnabled(skill.getEnabled() != null ? skill.getEnabled() : existing.getEnabled());
existing.setConfigJson(skill.getConfigJson());
if (skill.getConfigJson() != null) existing.setConfigJson(skill.getConfigJson());
existing.setDescription(skill.getDescription() != null ? skill.getDescription() : existing.getDescription());
if (skill.getSkillContent() != null) {
existing.setSkillContent(skill.getSkillContent());
@ -335,20 +360,36 @@ public class SkillService {
return existing;
}
// 非内置技能允许修改所有字段但不允许改为 builtin
skill.setBuiltin(false);
skillMapper.updateById(skill);
log.info("Updated skill: {}", skill.getName());
// 非内置技能merge non-null fields from the partial onto the
// existing row. name / skillType / builtin stay locked because
// they're identity fields whose change would orphan bindings
// and break the resolver.
if (skill.getDescription() != null) existing.setDescription(skill.getDescription());
if (skill.getIcon() != null) existing.setIcon(skill.getIcon());
if (skill.getVersion() != null && !skill.getVersion().isBlank()) existing.setVersion(skill.getVersion());
if (skill.getAuthor() != null) existing.setAuthor(skill.getAuthor());
if (skill.getEnabled() != null) existing.setEnabled(skill.getEnabled());
if (skill.getTags() != null) existing.setTags(skill.getTags());
if (skill.getNameZh() != null) existing.setNameZh(skill.getNameZh());
if (skill.getNameEn() != null) existing.setNameEn(skill.getNameEn());
if (skill.getConfigJson() != null) existing.setConfigJson(skill.getConfigJson());
if (skill.getSourceCode() != null) existing.setSourceCode(skill.getSourceCode());
if (skill.getSkillContent() != null) existing.setSkillContent(skill.getSkillContent());
if (skill.getManifestJson() != null) existing.setManifestJson(skill.getManifestJson());
existing.setBuiltin(false);
skillMapper.updateById(existing);
log.info("Updated skill: {}", existing.getName());
// skillContent 变更且约定工作区存在同步 SKILL.md
syncSkillContentToWorkspace(skill);
syncSkillContentToWorkspace(existing);
// 刷新 runtime cache
if (runtimeService != null) {
runtimeService.refreshActiveSkills();
}
return skill;
return existing;
}
/**
@ -401,6 +442,10 @@ public class SkillService {
"内置技能不可硬删除: " + skill.getName());
}
skillMapper.hardDeleteById(id); // bypass the logical-delete flag
int filesDropped = skillFileMapper.deleteBySkillId(id);
if (filesDropped > 0) {
log.info("Hard-deleted {} bundle file row(s) for skill {}", filesDropped, skill.getName());
}
log.info("Hard-deleted skill (physical delete + purge): {}", skill.getName());
// RFC-091 settings bridge purge any per-skill secrets so a

View File

@ -0,0 +1,209 @@
package vip.mate.skill.workspace;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.model.SkillFileEntity;
import vip.mate.skill.service.SkillFileService;
import vip.mate.skill.service.SkillService;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Mirrors canonical {@code mate_skill_file} rows down to each node's local
* workspace cache so {@code scripts/} and {@code references/} files exist
* on disk wherever the skill might run.
*
* <p>Also runs a one-time backfill: for any skill that has on-disk files
* but no DB rows (typically pre-V112 installs), the local files are read
* up into the DB so the canonical store catches up to reality. Backfill
* is content-hash idempotent and safe to invoke repeatedly.
*
* <p>Triggered:
* <ul>
* <li>At startup, after the bundled-skill syncer (see
* {@link SkillWorkspaceBootstrapRunner}).</li>
* <li>On-demand via the admin endpoint {@code POST /api/v1/skills/{id}/sync-files}.</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SkillFileSyncer {
private final SkillService skillService;
private final SkillFileService skillFileService;
private final SkillWorkspaceManager workspaceManager;
/** Aggregate counters for one full sync pass. */
public record SyncReport(int skillsConsidered,
int skillsBackfilled,
int filesMaterialized,
int filesAlreadyCurrent,
int filesBackfilledFromDisk) {}
/** Sync every active skill once. Idempotent. */
public SyncReport syncAll() {
List<SkillEntity> skills = skillService.listSkills();
int considered = 0;
int backfilled = 0;
int materialized = 0;
int current = 0;
int diskBackfilled = 0;
for (SkillEntity skill : skills) {
if (skill.getId() == null || skill.getName() == null) continue;
considered++;
var per = syncOne(skill);
materialized += per.filesMaterialized();
current += per.filesAlreadyCurrent();
diskBackfilled += per.filesBackfilledFromDisk();
if (per.didBackfillFromDisk()) backfilled++;
}
if (considered > 0) {
log.info("SkillFileSyncer pass: skills={}, materialized={}, current={}, " +
"backfilledFromDisk(skills={}, files={})",
considered, materialized, current, backfilled, diskBackfilled);
}
return new SyncReport(considered, backfilled, materialized, current, diskBackfilled);
}
/** Per-skill sync outcome. */
public record PerSkillReport(int filesMaterialized,
int filesAlreadyCurrent,
int filesBackfilledFromDisk,
boolean didBackfillFromDisk) {}
/**
* Sync a single skill: backfill DB from FS if DB is empty and FS has
* files, then materialize DB rows down to FS so any missing/stale files
* are restored.
*/
public PerSkillReport syncOne(SkillEntity skill) {
Path workspaceDir = workspaceManager.resolveConventionPath(skill.getName());
List<SkillFileEntity> dbFiles = skillFileService.listBySkillId(skill.getId());
boolean didBackfill = false;
int backfilled = 0;
if (dbFiles.isEmpty()) {
backfilled = backfillFromDiskIfNeeded(skill, workspaceDir);
if (backfilled > 0) {
didBackfill = true;
dbFiles = skillFileService.listBySkillId(skill.getId());
}
}
int materialized = 0;
int alreadyCurrent = 0;
for (SkillFileEntity row : dbFiles) {
switch (materializeOne(workspaceDir, row)) {
case WROTE -> materialized++;
case CURRENT -> alreadyCurrent++;
case SKIPPED -> {
/* unsafe path / IO failure already logged */
}
}
}
return new PerSkillReport(materialized, alreadyCurrent, backfilled, didBackfill);
}
private enum MaterializeOutcome { WROTE, CURRENT, SKIPPED }
private MaterializeOutcome materializeOne(Path workspaceDir, SkillFileEntity row) {
String relative = row.getFilePath();
if (relative == null || relative.isBlank()) return MaterializeOutcome.SKIPPED;
if (!relative.startsWith("references/") && !relative.startsWith("scripts/")) {
log.warn("Skipping skill_file row {} — path outside scripts/ or references/: {}",
row.getId(), relative);
return MaterializeOutcome.SKIPPED;
}
if (relative.contains("..")) {
log.warn("Skipping skill_file row {} — suspicious path: {}", row.getId(), relative);
return MaterializeOutcome.SKIPPED;
}
Path target = workspaceDir.resolve(relative).normalize();
if (!target.startsWith(workspaceDir.normalize())) {
log.warn("Skipping skill_file row {} — escapes workspace: {}", row.getId(), relative);
return MaterializeOutcome.SKIPPED;
}
try {
String content = row.getContent() == null ? "" : row.getContent();
if (Files.exists(target)) {
String onDisk = Files.readString(target, StandardCharsets.UTF_8);
if (SkillFileService.sha256Hex(onDisk).equals(row.getSha256())) {
return MaterializeOutcome.CURRENT;
}
}
Files.createDirectories(target.getParent());
Files.writeString(target, content, StandardCharsets.UTF_8);
return MaterializeOutcome.WROTE;
} catch (IOException e) {
log.warn("Failed to materialize skill_file {} → {}: {}", row.getId(), target, e.getMessage());
return MaterializeOutcome.SKIPPED;
}
}
/**
* One-time ingestion of pre-V112 on-disk files into the canonical
* {@code mate_skill_file} table. Only runs when the skill has zero
* file rows; subsequent installs go through the installer's normal
* write-to-both-stores path.
*/
private int backfillFromDiskIfNeeded(SkillEntity skill, Path workspaceDir) {
if (!Files.exists(workspaceDir) || !Files.isDirectory(workspaceDir)) return 0;
List<Path> roots = new ArrayList<>(2);
Path scripts = workspaceDir.resolve("scripts");
Path references = workspaceDir.resolve("references");
if (Files.isDirectory(scripts)) roots.add(scripts);
if (Files.isDirectory(references)) roots.add(references);
if (roots.isEmpty()) return 0;
java.util.Map<String, String> ingested = new java.util.LinkedHashMap<>();
Set<String> seen = new HashSet<>();
for (Path root : roots) {
String prefix = workspaceDir.relativize(root).toString().replace('\\', '/') + "/";
try (var stream = Files.walk(root)) {
List<Path> files = stream.filter(Files::isRegularFile).toList();
for (Path f : files) {
String relative = workspaceDir.relativize(f).toString().replace('\\', '/');
if (!relative.startsWith(prefix)) continue;
if (!seen.add(relative)) continue;
try {
String content = Files.readString(f, StandardCharsets.UTF_8);
ingested.put(relative, content);
} catch (IOException e) {
log.warn("Backfill skipped {} (read failed: {})", f, e.getMessage());
}
}
} catch (IOException e) {
log.warn("Backfill walk failed for {}: {}", root, e.getMessage());
}
}
if (ingested.isEmpty()) return 0;
skillFileService.applyBundleFiles(skill.getId(), ingested, false);
log.info("Backfilled {} bundle file(s) into mate_skill_file for skill '{}' (id={})",
ingested.size(), skill.getName(), skill.getId());
// Touch the workspace event so other observers (e.g. runtime cache) refresh.
// Use a synthetic event type INSTALLED is the closest existing match.
skill.setUpdateTime(LocalDateTime.now());
return ingested.size();
}
}

View File

@ -10,36 +10,51 @@ import org.springframework.stereotype.Component;
import java.util.List;
/**
* Skill 工作区启动初始化
* <p>
* 1. 确保 workspace root 目录存在
* 2. classpath 下预置技能同步到 workspace
* - 首次创建并同步
* - 后续比对 SKILL.md frontmatter 中的 version 字段
* bundled version 更高时归档旧版本并覆盖升级
* <p>
* Order(195) DatabaseBootstrapRunner(200) 之前执行
* Skill workspace bootstrap.
* <ol>
* <li>Ensure the workspace root exists.</li>
* <li>Sync classpath-bundled skills into the workspace
* (first install creates them; later starts upgrade only when the
* bundled SKILL.md frontmatter version is strictly newer).</li>
* <li>Materialize {@code mate_skill_file} rows down to each node's
* local cache so multi-instance deployments share the same
* scripts/references regardless of which node accepted the upload.
* Also backfills any pre-V112 on-disk-only skill files into the
* canonical store.</li>
* </ol>
*
* <p>Order(210) runs after {@code DatabaseBootstrapRunner}(200) so the
* skill rows the syncer needs to read are already loaded.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@Order(195)
@Order(210)
@RequiredArgsConstructor
public class SkillWorkspaceBootstrapRunner implements ApplicationRunner {
private final SkillWorkspaceManager workspaceManager;
private final BundledSkillSyncer bundledSkillSyncer;
private final SkillFileSyncer skillFileSyncer;
@Override
public void run(ApplicationArguments args) {
var root = workspaceManager.getWorkspaceRoot();
log.info("Skill workspace root ready: {}", root);
// 同步 classpath 下预置技能到 workspace
List<String> synced = bundledSkillSyncer.sync();
if (!synced.isEmpty()) {
log.info("Synced {} bundled skill(s) to workspace: {}", synced.size(), synced);
}
// Pull canonical bundle files from DB local cache (and one-time
// backfill of pre-V112 disk-only skills back into the DB).
var report = skillFileSyncer.syncAll();
log.info("Skill file sync: skills={}, materialized={}, current={}, " +
"diskBackfilled(skills={}, files={})",
report.skillsConsidered(), report.filesMaterialized(),
report.filesAlreadyCurrent(),
report.skillsBackfilled(), report.filesBackfilledFromDisk());
}
}

View File

@ -239,6 +239,158 @@ public class SkillWorkspaceManager {
cleanDirectoryContents(workspaceDir.resolve("scripts"));
}
/**
* Outcome of {@link #applyBundleFiles}, exposing per-bucket counters so
* the installer can log a meaningful summary and the admin UI can show
* what actually changed.
*/
public record ApplyBundleResult(
int referencesWritten,
int referencesPruned,
boolean referencesPreservedDueToEmptyBundle,
int scriptsWritten,
int scriptsPruned,
boolean scriptsPreservedDueToEmptyBundle
) {}
/**
* Apply a bundle's references/ + scripts/ to the workspace using
* write-then-prune semantics:
* <ol>
* <li>Write every entry from the bundle (overwrites same paths).</li>
* <li>Delete any pre-existing file under references/ or scripts/ that
* is NOT in the bundle.</li>
* </ol>
*
* <p>Empty-bundle safety: if the bundle has zero entries for a bucket
* AND the workspace already has files in that bucket, the bucket is
* left untouched (no pruning) unless {@code force=true}. This protects
* against malformed uploads, network truncation, and parser bugs that
* would otherwise wipe a user's scripts on reinstall the same class
* of regression that an earlier patch fixed for SKILL.md.
*
* @param skillName workspace owner
* @param references new bundle's references map (key = path under references/)
* @param scripts new bundle's scripts map (key = path under scripts/)
* @param force bypass the empty-bundle guard (admin-only switch)
* @return per-bucket apply summary (never null)
*/
public ApplyBundleResult applyBundleFiles(String skillName,
Map<String, String> references,
Map<String, String> scripts,
boolean force) {
Path workspaceDir = resolveConventionPath(skillName);
try {
Files.createDirectories(workspaceDir.resolve("references"));
Files.createDirectories(workspaceDir.resolve("scripts"));
} catch (IOException e) {
log.warn("Failed to ensure data dirs for skill '{}': {}", skillName, e.getMessage());
}
int refsWritten = applyBucket(skillName, "references/", references);
int scriptsWritten = applyBucket(skillName, "scripts/", scripts);
var refsPrune = pruneBucket(workspaceDir.resolve("references"),
normalizeKeys(references), force, skillName, "references");
var scriptsPrune = pruneBucket(workspaceDir.resolve("scripts"),
normalizeKeys(scripts), force, skillName, "scripts");
return new ApplyBundleResult(
refsWritten, refsPrune.deleted(), refsPrune.preservedDueToEmpty(),
scriptsWritten, scriptsPrune.deleted(), scriptsPrune.preservedDueToEmpty()
);
}
private int applyBucket(String skillName, String bucketPrefix, Map<String, String> entries) {
if (entries == null || entries.isEmpty()) return 0;
int written = 0;
for (var e : entries.entrySet()) {
String key = e.getKey();
String relative = key.startsWith(bucketPrefix) ? key : (bucketPrefix + key);
try {
writeWorkspaceFile(skillName, relative, e.getValue());
written++;
} catch (RuntimeException ex) {
log.warn("Failed to write {} for skill '{}': {}", relative, skillName, ex.getMessage());
}
}
return written;
}
/** Strip a leading "<bucket>/" prefix so the key matches the path relative to the bucket dir. */
private Set<String> normalizeKeys(Map<String, String> entries) {
if (entries == null || entries.isEmpty()) return Collections.emptySet();
Set<String> out = new HashSet<>(entries.size() * 2);
for (String key : entries.keySet()) {
String k = key.replace('\\', '/');
int firstSlash = k.indexOf('/');
if (firstSlash > 0 && (k.startsWith("references/") || k.startsWith("scripts/"))) {
out.add(k.substring(firstSlash + 1));
} else {
out.add(k);
}
}
return out;
}
private record PruneOutcome(int deleted, boolean preservedDueToEmpty) {}
private PruneOutcome pruneBucket(Path bucketDir, Set<String> keep, boolean force,
String skillName, String bucketLabel) {
if (!Files.exists(bucketDir) || !Files.isDirectory(bucketDir)) {
return new PruneOutcome(0, false);
}
// Empty-bundle guard: if the new bundle has nothing for this bucket
// and there's at least one file on disk, refuse to prune unless the
// caller explicitly asked for it. Logged so the operator can see why
// their "clean install" didn't actually clean.
if (keep.isEmpty() && !force) {
try (var stream = Files.walk(bucketDir)) {
boolean hasAny = stream.filter(Files::isRegularFile).findFirst().isPresent();
if (hasAny) {
log.warn("Refusing to prune {}/{}/ — new bundle is empty and would wipe existing files. " +
"Pass force=true to override.", skillName, bucketLabel);
return new PruneOutcome(0, true);
}
} catch (IOException e) {
log.warn("Failed to inspect {}/{}/: {}", skillName, bucketLabel, e.getMessage());
return new PruneOutcome(0, false);
}
}
int deleted = 0;
try (var stream = Files.walk(bucketDir)) {
List<Path> files = stream.filter(Files::isRegularFile).toList();
for (Path file : files) {
String relative = bucketDir.relativize(file).toString().replace('\\', '/');
if (!keep.contains(relative)) {
try {
Files.delete(file);
deleted++;
} catch (IOException e) {
log.warn("Failed to prune {}/{}/{}: {}", skillName, bucketLabel, relative, e.getMessage());
}
}
}
// Best-effort: tidy up emptied subdirs (leave the bucket root in place).
try (var dirs = Files.walk(bucketDir)) {
dirs.sorted(java.util.Comparator.reverseOrder())
.filter(p -> Files.isDirectory(p) && !p.equals(bucketDir))
.forEach(p -> {
try (var children = Files.list(p)) {
if (children.findAny().isEmpty()) Files.delete(p);
} catch (IOException ignored) {
/* leave non-empty / locked dirs in place */
}
});
}
} catch (IOException e) {
log.warn("Failed to prune {}/{}/: {}", skillName, bucketLabel, e.getMessage());
}
return new PruneOutcome(deleted, false);
}
/**
* 验证写入路径安全性防止路径逃逸
*

View File

@ -0,0 +1,46 @@
package vip.mate.stt;
/**
* Issue #76: protocol-family abstraction for STT.
*
* <p>The original {@link SttProvider} bundled "which vendor is this" with
* "how does its wire protocol work", forcing every new vendor to ship a
* dedicated Java class even when the wire protocol is identical to an
* existing one. {@code SttTransport} is the protocol-only half: it knows how
* to send a request and parse a response, but doesn't care whether the
* endpoint is OpenAI cloud, FunASR self-hosted, SiliconFlow, Groq, or
* Together anything that speaks the same protocol can plug in.
*
* <p>Two transports cover ~99% of the market today:
* <ul>
* <li>OpenAI Whisper compatible HTTP multipart (this transport)</li>
* <li>DashScope realtime WebSocket (kept inline in
* {@code DashScopeSttProvider} for now its own transport class
* can be carved out the same way when a second WebSocket-based
* vendor lands)</li>
* </ul>
*
* <p>Identity (display name, baseUrl defaults, language bias, ...) is
* declared by a future {@code SttProviderProfile} layer (Phase 2 of the
* refactor). Phase 1 keeps {@link SttProvider} as the public SPI but
* delegates the wire work to a transport so swapping the credential row
* doesn't require changing the provider class.
*/
public interface SttTransport {
/**
* Stable id of the protocol family this transport speaks. Profiles
* pick a transport by matching against this e.g.
* {@code "openai_compatible_audio"} for any OpenAI Whisper-shaped
* endpoint.
*/
String apiMode();
/**
* Run a transcription against the resolved endpoint. Returns a typed
* success/failure result; transport implementations must NOT throw
* caller relies on the failure path to keep the {@link SttProvider}
* fallback chain alive.
*/
SttResult transcribe(SttRequest request, SttTransportConfig config);
}

View File

@ -0,0 +1,19 @@
package vip.mate.stt;
/**
* Issue #76: resolved endpoint config passed to an {@link SttTransport}.
*
* <p>Decoupling the transport from {@code ModelProviderService} lookups makes
* tests trivial (no Spring context) and lets the same transport serve any
* credential row OpenAI cloud, FunASR self-hosted, SiliconFlow, Groq, etc.
*
* @param baseUrl fully-qualified provider base URL ({@code https://api.openai.com}
* or {@code http://10.0.0.5:9999/v1}). Trailing slash optional;
* transports normalize it.
* @param apiKey bearer token. May be blank when the provider doesn't require
* authentication (some self-hosted FunASR deployments).
* @param model the model id sent in the multipart "model" field
* (whisper-1 / paraformer-large / FunAudioLLM-Whisper / ...).
*/
public record SttTransportConfig(String baseUrl, String apiKey, String model) {
}

View File

@ -1,23 +1,40 @@
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;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelProviderEntity;
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;
import vip.mate.stt.SttTransportConfig;
import vip.mate.stt.transport.OpenAiCompatibleSttTransport;
import vip.mate.system.model.SystemSettingsDTO;
/**
* OpenAI STT Provider Whisper / gpt-4o-mini-transcribe
* <p>
* 复用模型管理中的 OpenAI API Key
* OpenAI Whisper / OpenAI-compatible STT provider thin wrapper.
*
* <p>Issue #76: this used to bake the {@code id="openai"} credential row + the
* {@code https://api.openai.com} base URL + Whisper-1 directly into the
* transport call, so the only way to point STT at FunASR / SiliconFlow / Groq
* was to hand-edit the OpenAI provider row's baseUrl (lossy + side-effects on
* chat). After this refactor:
*
* <ul>
* <li>Wire protocol lives in {@link OpenAiCompatibleSttTransport}.</li>
* <li>Credential row is selected by {@code SystemSettingsDTO.sttOpenAiCompatProviderId}
* (defaults to {@code "openai"} for backwards compatibility).</li>
* <li>Model is selected by {@code SystemSettingsDTO.sttOpenAiCompatModel}
* (defaults to {@code "whisper-1"}).</li>
* </ul>
*
* <p>The provider id stays {@code "openai"} because settings UI / fallback
* registry / per-language ordering all key off it. Phase 2 of the refactor
* will replace this single provider with a profile-driven registry; until
* then, swapping the credential row is the path forward for new vendors.
*/
@Slf4j
@Component
@ -25,12 +42,13 @@ import vip.mate.system.model.SystemSettingsDTO;
public class OpenAiSttProvider implements SttProvider {
private final ModelProviderService modelProviderService;
private final ObjectMapper objectMapper;
private final OpenAiCompatibleSttTransport transport;
private static final String DEFAULT_MODEL = "whisper-1";
private static final String LEGACY_DEFAULT_PROVIDER_ID = "openai";
private static final String LEGACY_DEFAULT_MODEL = "whisper-1";
@Override public String id() { return "openai"; }
@Override public String label() { return "OpenAI Whisper"; }
@Override public String label() { return "OpenAI / OpenAI-compatible (Whisper)"; }
@Override public boolean requiresCredential() { return true; }
@Override public int autoDetectOrder() { return 100; }
@ -56,7 +74,8 @@ public class OpenAiSttProvider implements SttProvider {
@Override
public boolean isAvailable(SystemSettingsDTO config) {
try {
return modelProviderService.isProviderConfigured("openai");
String providerId = resolveProviderId(config);
return modelProviderService.isProviderConfigured(providerId);
} catch (Exception e) {
log.warn("[OpenAI STT] availability check failed: {}", e.getMessage());
return false;
@ -65,40 +84,39 @@ public class OpenAiSttProvider implements SttProvider {
@Override
public SttResult transcribe(SttRequest request, SystemSettingsDTO config) {
String providerId = resolveProviderId(config);
ModelProviderEntity provider;
try {
String apiKey = modelProviderService.getProviderConfig("openai").getApiKey();
String baseUrl = modelProviderService.getProviderConfig("openai").getBaseUrl();
if (apiKey == null) return SttResult.failure("OpenAI API Key 未配置");
provider = modelProviderService.getProviderConfig(providerId);
} catch (MateClawException e) {
return SttResult.failure("STT 凭证 provider 未找到: " + providerId);
}
String url = (baseUrl != null ? baseUrl : "https://api.openai.com") + "/v1/audio/transcriptions";
String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL;
// 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());
String apiKey = provider.getApiKey();
String baseUrl = StringUtils.hasText(provider.getBaseUrl())
? provider.getBaseUrl()
: "https://api.openai.com";
HttpResponse response = HttpRequest.post(url)
.header("Authorization", "Bearer " + apiKey)
.form("model", model)
.form("file", request.getAudioData(), fileName)
.timeout(60_000)
.execute();
// Allow blank apiKey for self-hosted / no-auth setups (FunASR is the
// typical case). The transport will only attach the Authorization
// header when apiKey is present.
boolean requiresKey = Boolean.TRUE.equals(provider.getRequireApiKey());
if (requiresKey && (apiKey == null || apiKey.isBlank())) {
return SttResult.failure("STT 凭证 provider 未配置 API Key: " + providerId);
}
if (response.getStatus() == 200) {
JsonNode result = objectMapper.readTree(response.body());
String text = result.path("text").asText("");
log.info("[OpenAI STT] Transcribed {} chars (model={})", text.length(), model);
return SttResult.success(text);
} else {
log.warn("[OpenAI STT] Failed: HTTP {} - {}", response.getStatus(), response.body());
return SttResult.failure("OpenAI STT 失败: HTTP " + response.getStatus());
}
} catch (Exception e) {
log.error("[OpenAI STT] Error: {}", e.getMessage(), e);
return SttResult.failure("OpenAI STT 异常: " + e.getMessage());
String model = resolveModel(config);
SttTransportConfig transportConfig = new SttTransportConfig(baseUrl, apiKey, model);
return transport.transcribe(request, transportConfig);
}
private String resolveProviderId(SystemSettingsDTO config) {
String configured = config != null ? config.getSttOpenAiCompatProviderId() : null;
return StringUtils.hasText(configured) ? configured.trim() : LEGACY_DEFAULT_PROVIDER_ID;
}
private String resolveModel(SystemSettingsDTO config) {
String configured = config != null ? config.getSttOpenAiCompatModel() : null;
return StringUtils.hasText(configured) ? configured.trim() : LEGACY_DEFAULT_MODEL;
}
}

View File

@ -0,0 +1,128 @@
package vip.mate.stt.transport;
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;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.stt.AudioMimeTypes;
import vip.mate.stt.SttRequest;
import vip.mate.stt.SttResult;
import vip.mate.stt.SttTransport;
import vip.mate.stt.SttTransportConfig;
/**
* Issue #76: protocol family transport for the OpenAI Whisper-shaped HTTP
* audio endpoint. Identical request format covers OpenAI itself, FunASR with
* the openai-compat shim, SiliconFlow, Groq Whisper, Together, Volcano,
* and roughly every other paid + self-hosted ASR vendor available today.
*
* <p>Wire shape:
* <ul>
* <li>{@code POST {baseUrl}/v1/audio/transcriptions}
* (or {@code {baseUrl}/audio/transcriptions} when baseUrl already
* carries a {@code /vN} suffix)</li>
* <li>multipart/form-data with {@code model} field + {@code file} field
* carrying the audio bytes named after the detected mime type
* (Hutool infers the multipart Content-Type from the extension
* {@link AudioMimeTypes#resolveFileName} is what makes that work).</li>
* <li>Optional {@code Authorization: Bearer <api_key>} when the caller
* supplies one. Self-hosted FunASR commonly skips auth entirely.</li>
* </ul>
*
* <p>Response: {@code { "text": "..." }} the only field we read.
*
* <p>The transport intentionally does NOT touch {@code ModelProviderService}
* or {@code SystemSettingsDTO}: the caller resolves credentials and hands
* them in via {@link SttTransportConfig}. This keeps the transport reusable
* across any number of credential rows and trivially unit-testable.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OpenAiCompatibleSttTransport implements SttTransport {
public static final String API_MODE = "openai_compatible_audio";
private final ObjectMapper objectMapper;
@Override
public String apiMode() {
return API_MODE;
}
@Override
public SttResult transcribe(SttRequest request, SttTransportConfig config) {
try {
String baseUrl = normalizeBaseUrl(config.baseUrl());
if (baseUrl == null) {
return SttResult.failure("STT 端点 base URL 未配置");
}
String url = baseUrl + resolveAudioPath(baseUrl);
String model = effectiveModel(request, config);
// 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());
HttpRequest http = HttpRequest.post(url)
.form("model", model)
.form("file", request.getAudioData(), fileName)
.timeout(60_000);
String apiKey = config.apiKey();
if (apiKey != null && !apiKey.isBlank()) {
http.header("Authorization", "Bearer " + apiKey.trim());
}
HttpResponse response = http.execute();
if (response.getStatus() == 200) {
JsonNode result = objectMapper.readTree(response.body());
String text = result.path("text").asText("");
log.info("[OpenAI-compat STT] Transcribed {} chars (model={}, baseUrl={})",
text.length(), model, baseUrl);
return SttResult.success(text);
}
log.warn("[OpenAI-compat STT] Failed: HTTP {} - {}", response.getStatus(), response.body());
return SttResult.failure("STT 失败: HTTP " + response.getStatus());
} catch (Exception e) {
log.error("[OpenAI-compat STT] Error: {}", e.getMessage(), e);
return SttResult.failure("STT 异常: " + e.getMessage());
}
}
/**
* Pick the audio path to append. If baseUrl already ends in a {@code /vN}
* version segment (lmstudio-style), append only {@code /audio/transcriptions}.
* Otherwise append {@code /v1/audio/transcriptions}. Mirrors the resolver
* pattern used by the chat-models probe so user-set baseUrls behave
* consistently across endpoints.
*/
static String resolveAudioPath(String baseUrl) {
if (baseUrl != null && baseUrl.matches(".*/v\\d{1,2}$")) {
return "/audio/transcriptions";
}
return "/v1/audio/transcriptions";
}
static String normalizeBaseUrl(String raw) {
if (raw == null) return null;
String trimmed = raw.trim();
if (trimmed.isEmpty()) return null;
return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
}
private static String effectiveModel(SttRequest request, SttTransportConfig config) {
if (request.getModel() != null && !request.getModel().isBlank()) {
return request.getModel();
}
if (config.model() != null && !config.model().isBlank()) {
return config.model();
}
return "whisper-1";
}
}

Some files were not shown because too many files have changed in this diff Show More