mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
release: v1.3.0
This commit is contained in:
parent
d994be3d04
commit
f47cf8c6be
19
.env.example
19
.env.example
@ -50,6 +50,25 @@ MATECLAW_BROWSER_CDP_URL=
|
||||
MATECLAW_BROWSER_CHROME_PATH=
|
||||
MATECLAW_BROWSER_CHANNEL=
|
||||
|
||||
# ==================== OpenAI OAuth(Docker,可选) ====================
|
||||
#
|
||||
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code,
|
||||
# 不需要自定义 client secret。
|
||||
#
|
||||
# 默认留空即可。后端会根据访问 Host 自动选择:
|
||||
# - localhost / 127.0.0.1 / ::1 → LOCAL(PKCE 回调)
|
||||
# - 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
4
.gitignore
vendored
@ -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 |
@ -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 模式默认保持 auto:localhost 访问走 LOCAL,IP/域名访问走 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
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<T> 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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// ===== 提取方法 =====
|
||||
|
||||
/**
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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、本地 8K)下,systemTokens + 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>> 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++;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
@ -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 small→large 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;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 > 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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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. */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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 resolve→resume
|
||||
* 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)
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
// ==================== 模板方法(子类实现) ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 需要重启"的阈值。
|
||||
*
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 兼容(保留接口,不再使用) ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 事件数据
|
||||
*/
|
||||
|
||||
@ -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 管理 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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 + "]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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(); }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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) + "…";
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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_<pendingId>",
|
||||
* "main_title": {
|
||||
* "title": "🛡️ 工具审批",
|
||||
* "desc": "<toolName> | <severityLabel>"
|
||||
* },
|
||||
* "button_list": [
|
||||
* { "text": "批准", "style": 1, "key": "<encoded JSON>" },
|
||||
* { "text": "拒绝", "style": 2, "key": "<encoded JSON>" }
|
||||
* ]
|
||||
* }
|
||||
* </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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -12,5 +12,6 @@ public class CreateCustomProviderRequest {
|
||||
private String apiKeyPrefix;
|
||||
private String protocol;
|
||||
private String chatModel;
|
||||
private Boolean requireApiKey;
|
||||
private List<ModelInfoDTO> models;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<>();
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 类型(排除 embedding),NULL 兼容老数据
|
||||
.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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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> {
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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)));
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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_-]", "-");
|
||||
|
||||
@ -97,7 +97,17 @@ public class SkillEntity {
|
||||
/** 标签(逗号分隔) */
|
||||
private String tags;
|
||||
|
||||
/** RFC-023:来源对话 ID(Agent 自治合成时记录) */
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/** 来源对话 ID(Agent 自治合成时记录) */
|
||||
private String sourceConversationId;
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
// ==================== 综合状态 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证写入路径安全性,防止路径逃逸
|
||||
*
|
||||
|
||||
46
mateclaw-server/src/main/java/vip/mate/stt/SttTransport.java
Normal file
46
mateclaw-server/src/main/java/vip/mate/stt/SttTransport.java
Normal 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);
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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 未配置");
|
||||
|
||||
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());
|
||||
|
||||
HttpResponse response = HttpRequest.post(url)
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.form("model", model)
|
||||
.form("file", request.getAudioData(), fileName)
|
||||
.timeout(60_000)
|
||||
.execute();
|
||||
|
||||
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());
|
||||
provider = modelProviderService.getProviderConfig(providerId);
|
||||
} catch (MateClawException e) {
|
||||
return SttResult.failure("STT 凭证 provider 未找到: " + providerId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[OpenAI STT] Error: {}", e.getMessage(), e);
|
||||
return SttResult.failure("OpenAI STT 异常: " + e.getMessage());
|
||||
|
||||
String apiKey = provider.getApiKey();
|
||||
String baseUrl = StringUtils.hasText(provider.getBaseUrl())
|
||||
? provider.getBaseUrl()
|
||||
: "https://api.openai.com";
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user