feat(plugin): 插件化搜索 Provider — PluginType.SEARCH + PluginSearchProvider SPI (#477) (#479)

* docs: add plugin search provider design spec and plan (#477)

Claude-Session: https://claude.ai/code/session_013uyvXEazZkhNw27geRtakK

* feat(plugin-api): add SEARCH plugin type and PluginSearchProvider SPI (#477)

Claude-Session: https://claude.ai/code/session_013uyvXEazZkhNw27geRtakK

* feat(search): make SearchProviderRegistry accept runtime plugin providers (#477)

Claude-Session: https://claude.ai/code/session_013uyvXEazZkhNw27geRtakK

* test(search): cover blank plugin provider id rejection (#477)

Claude-Session: https://claude.ai/code/session_013uyvXEazZkhNw27geRtakK

* feat(plugin): bridge PluginSearchProvider to the core SearchProvider chain (#477)

Claude-Session: https://claude.ai/code/session_013uyvXEazZkhNw27geRtakK

* feat(plugin): registerSearchProvider lifecycle — register, disable, rollback (#477)

Claude-Session: https://claude.ai/code/session_013uyvXEazZkhNw27geRtakK

* fix(plugin): preserve cause when wrapping registry conflict as PluginException (#477)

Claude-Session: https://claude.ai/code/session_013uyvXEazZkhNw27geRtakK

* feat(plugin): add search provider sample plugin module (#477)

Claude-Session: https://claude.ai/code/session_013uyvXEazZkhNw27geRtakK

* docs(plugin): note unused query params and narrow parse exception in search sample (#477)

Claude-Session: https://claude.ai/code/session_013uyvXEazZkhNw27geRtakK

* docs(architecture): document the standalone-jar plugin system and SEARCH type (#477)

Claude-Session: https://claude.ai/code/session_013uyvXEazZkhNw27geRtakK
This commit is contained in:
倪程伟 2026-07-03 16:57:27 +08:00 committed by GitHub
parent dcc5382bf9
commit 0a58b3fb35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2333 additions and 10 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,144 @@
# 插件化搜索 Provider + 搜索设置页重构 设计文档
日期2026-07-03
状态:待评审
相关:`vip.mate.tool.search`(现有搜索 provider 链)、`mateclaw-plugin-api`(插件 SDK、`/settings/system` 搜索设置区块
## 1. 背景与问题
### 1.1 自定义搜索 provider 没有插件化路径
当前 `SearchProviderRegistry` 通过 Spring 构造器注入 `List<SearchProvider>` 收集 provider只认同一 `ApplicationContext` 里的 bean。要新增一个搜索源唯一办法是**在 `vip.mate.tool.search` 源码树里加 `@Component` 类并重新编译部署整个 server**。
而项目已有一套真正的运行时插件系统(`mateclaw-plugin-api` + `PluginManager`):独立 jar 丢进 `~/.mateclaw/plugins/` 或工作区 `plugins/``URLClassLoader` 隔离加载,支持运行时 enable/disable配置走 manifest 声明的 schema`mateclaw-plugin.json` 的 `config` 字段)+ `plugin``config_json` 持久化 + `PUT /api/v1/plugins/{name}/config` 接口。但 `PluginType` 只有 `TOOL / PROVIDER(LLM) / CHANNEL / MEMORY` 四类,**没有 SEARCH**`PluginContext` 也没有对应注册方法。
LLM provider 已有"内置 `@Component` 链 + 插件注册表"双轨并存的先例(`ModelProviderService.pluginChatModels`),搜索 provider 缺的就是同构的第二轨。
### 1.2 搜索设置 UI 平铺、下拉菜单硬编码
`/settings/system` 的搜索区块把 4 个 provider 的开关/key/url 共 9 个配置项拍平在一个列表里;主 provider 下拉菜单是写死的两个 `<option>`serper/tavily`searxng`/`duckduckgo` 无法显式选中,只能靠后端自动探测兜底;管理员也无法看到"当前实际生效的是哪个 provider"。
### 1.3 插件配置表单缺失(前端)
后端 `PluginInfo` 已返回 `configSchema`(来自 manifest和脱敏后的 `currentConfig``updateConfig()` 已有 schema 白名单 + required 校验,前端 `pluginApi.updateConfig` 客户端也已存在——但 `Plugins.vue` 没有任何配置编辑 UI这条链路在前端是死代码。所有类型的插件目前都无法在界面上配置。
## 2. 目标 / 非目标
**目标**
1. 第三方以独立 jar 形式提供搜索 provider实现 SDK 接口 + manifest 声明,丢进 plugins 目录即用,**mateclaw-server 源码零改动**。
2. 搜索设置页:主 provider 选择动态化(含插件 provider 与"自动选择")、按 provider 分组折叠、显示当前实际生效的 provider。
3. 补上 schema 驱动的插件配置表单(服务所有插件类型,不只 search
**非目标**
- 不改内置 4 个 provider 的配置存储方式(继续走 `SystemSettingsDTO` / `mate_system_setting`)。
- 不删除、不重命名 `GET/PUT /api/v1/settings` 现有字段(无破坏性改动)。
- 不做搜索结果聚合/多 provider 并发查询。
## 3. 设计
### 3.1 SDK 侧(`mateclaw-plugin-api`
新增 `vip.mate.plugin.api.search` 包,接口**不依赖任何 server 类**jar 隔离加载下的硬约束;对比核心 `SearchProvider` 依赖 `SystemSettingsDTO`SDK 版必须自包含):
```java
public interface PluginSearchProvider {
String id(); // 全局唯一,如 "my-search"
String label(); // 显示名
default boolean requiresCredential() { return true; }
default int autoDetectOrder() { return 500; } // 默认排在内置 provider50~400之后
boolean isAvailable(); // 插件自查:如 context.getConfig 拿 key 判空
List<PluginSearchResult> search(PluginSearchQuery query);
}
public record PluginSearchQuery(String query, String freshness, String language, Integer count) {}
public record PluginSearchResult(String title, String url, String snippet, String source, String date) {}
```
- `PluginType` 增加 `SEARCH`
- `PluginContext` 增加 `void registerSearchProvider(PluginSearchProvider provider);`
(接口新增方法对已编译的存量插件无影响——它们不调用即可。)
- 插件的配置API key 等)**不进搜索设置页**走插件系统自己的机制manifest `config` 声明 schema运行时 `context.getConfig(key, type)` 读取。职责天然分离:搜索设置页只管"选谁",插件页管"配它"。
### 3.2 Server 桥接侧
**`bridge/PluginSearchBridge.java`**(模式照抄 `PluginChannelBridge`):把 `PluginSearchProvider` 适配成核心 `SearchProvider`
- `search(SearchQuery, SystemSettingsDTO)` → 转调插件 `search(PluginSearchQuery)`,忽略 DTO
- 结果转核心 `SearchResult``providerId` 填插件 provider id
- `isAvailable(SystemSettingsDTO)` → 委托插件无参 `isAvailable()`
- 插件抛出的异常原样上抛(`WebSearchService.tryProvider()` 已有 catch-and-fallback 语义)。
**`SearchProviderRegistry` 可变化**:从"构造时定死的 immutable list"改为两层合并视图:
- 基底Spring 注入的内置 provider不变
- 插件区:`ConcurrentHashMap<String, SearchProvider>`,新增 `registerPluginProvider(SearchProvider)` / `unregisterPluginProvider(String id)`
- `allSorted()` / `getById()` / `resolve()` 全部查合并视图,排序仍按 `autoDetectOrder`
- **id 冲突拒绝注册**(插件 id 与内置或已注册插件 id 重复时抛 `PluginException`,不允许顶掉 serper 等内置项)。
**生命周期**(与现有四类完全对称):
- `PluginContextImpl.registerSearchProvider()` → 包 bridge 后调 registry 注册,记录到 `LoadedPlugin`
- `disablePlugin()` 与加载失败 rollback 路径各加一个 `searchProviderRegistry.unregisterPluginProvider(...)`best-effort同现有风格
- 插件被 disable 后,若它正是 `searchProvider` 显式指定项,`resolve()` 因 `getById()` 查不到而自动落入 auto-detect 分支——行为安全,无需额外处理。
### 3.3 动态 provider catalog 接口
`GET /api/v1/settings/search-providers``SystemSettingController``@RequireWorkspaceRole("admin")`),只读:
```json
{
"providers": [
{ "id": "serper", "label": "Serper (Google)", "builtin": true, "requiresCredential": true, "available": false },
{ "id": "my-search","label": "My Search", "builtin": false, "requiresCredential": true, "available": true,
"pluginName": "my-search-plugin" }
],
"resolved": { "id": "my-search", "source": "configured" }
}
```
- 数据源:`SearchProviderRegistry.allSorted()`(合并视图,插件 provider 自动出现)+ `resolve(config)`(暴露"当前实际生效"与原因:`configured` / `auto-detect` / `keyless-fallback`)。
- `pluginName` 供前端渲染"去插件页配置"跳转。
- 不含任何敏感值。
### 3.4 搜索设置页重构(`views/Settings/System/index.vue`
- **主 provider 选择**:选项从 catalog 接口动态渲染,新增首项"自动选择(推荐)"——对应 `searchProvider=""`(后端 `resolve()` 对空值本就走 auto-detect无需引入 `"auto"` 特殊值)。下方常驻一行状态提示:`✓ 当前实际生效: Xxx原因`。
- **分组折叠卡片**:每个 provider 一张可折叠卡片,标题行 = 名称 + 徽标(已配置/未配置/生效中),默认只展开"当前生效"的那张。
- 内置 provider卡片内是现有的 key/url 输入框(字段与保存逻辑不变,仍走 `PUT /api/v1/settings`
- 插件 provider卡片内不放表单显示"该 Provider 由插件 {pluginName} 提供,请在插件页配置" + 跳转链接。
- 现有保存语义不变API key 仅在用户输入新值时提交)。
### 3.5 插件配置表单(`views/Plugins.vue`,纯前端)
插件卡片增加"配置"入口(有 `configSchema` 时显示),弹出 schema 驱动的通用表单:
- 按 `configSchema` 渲染字段:`secret=true` → password 输入框placeholder 显示脱敏值,留空表示不修改);其余按 `type` 渲染 text/number/boolean`required` 标星并做前端必填校验(后端已有兜底校验);`description` 作为字段提示。
- 提交走已存在的 `pluginApi.updateConfig`;保存后刷新列表。
- 该表单对所有 `PluginType` 通用,非 search 专属。
- 注意manifest `ConfigField.type` 是自由字符串,前端对未知 type 一律降级为 text 输入。
### 3.6 参考实现(`mateclaw-plugin-sample`
sample 模块增加一个最小 `PluginSearchProvider` 实现(如包装一个可配 baseUrl+apiKey 的通用 HTTP 搜索 APImanifest 声明 `type: "search"` + config schema——同时充当文档示例与集成测试素材。
## 4. 交付拆分(遵循上游单一关注点规范)
- **上游 issue 先行**:动手前在 mateaix/mateclaw 提 issue 说明设计(本文档摘要),获认可后实施。
- **PR-1后端 + SDK**`PluginType.SEARCH` + SDK 接口/record + `PluginSearchBridge` + registry 可变化 + `PluginContextImpl`/`PluginManager` 生命周期 + sample 参考实现 + 单测。
- **PR-2接口 + 前端)**catalog 接口 + 搜索设置页分组折叠重构 + Plugins.vue schema 配置表单。PR-2 不依赖 PR-1 合并catalog 对纯内置 provider 同样成立),但先后合并时插件 provider 自动出现在下拉中。
## 5. 测试
**PR-1**
- registry注册/反注册/合并排序/`resolve()` 三分支含插件项/id 冲突拒绝。
- bridge`SearchQuery`↔`PluginSearchQuery`、`SearchResult` 转换、异常透传。
- 生命周期disable 后 registry 查不到该 id显式指定的插件 provider 被 disable 后 resolve 落回 auto-detect。
- sample 插件 jar 端到端:打包 → 放插件目录 → 启动加载 → `getAllToolCallbacks` 路径外单独验证 `web_search` 走插件 provider。
**PR-2**
- catalog 接口:内置/插件混合列表、resolved 三种 source、无敏感值泄露。
- 前端:下拉动态渲染、"自动选择"存空串、折叠展开状态、secret 字段留空不覆盖。
## 6. 兼容性与风险
- 存量插件:`PluginType` 加枚举值 + `PluginContext` 加方法,均为增量,不影响已编译插件。
- `GET/PUT /api/v1/settings` 字段不动,旧前端/脚本不受影响。
- `SearchProviderRegistry` 由不可变转可变:并发读多写少,`ConcurrentHashMap` + 每次读时合并排序provider 总数 <10无性能顾虑)。
- 插件 provider 质量不可控:`WebSearchService` 现有 15s 超时属于各 provider 自身实现插件侧超时由插件自负catch-and-fallback 链保证坏插件不拖垮搜索功能(最多浪费一次尝试)。
- 安全:插件 jar 本身即任意代码执行现有插件系统的既定信任模型本设计不扩大攻击面catalog 接口仅 admin 可见。

View File

@ -5,6 +5,7 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallback;
import vip.mate.plugin.api.channel.PluginChannelAdapter; import vip.mate.plugin.api.channel.PluginChannelAdapter;
import vip.mate.plugin.api.memory.PluginMemoryProvider; import vip.mate.plugin.api.memory.PluginMemoryProvider;
import vip.mate.plugin.api.search.PluginSearchProvider;
import java.util.function.Supplier; import java.util.function.Supplier;
@ -60,6 +61,19 @@ public interface PluginContext {
*/ */
void registerMemoryProvider(PluginMemoryProvider provider); void registerMemoryProvider(PluginMemoryProvider provider);
/**
* Register a web-search provider that joins the platform's search provider
* chain used by the {@code web_search} tool.
* <p>
* The provider id must be globally unique registration fails with a
* {@link PluginException} if it clashes with a built-in provider
* (serper / tavily / searxng / duckduckgo) or another plugin's provider.
*
* @param provider the search provider
* @throws PluginException if the id is blank or already taken
*/
void registerSearchProvider(PluginSearchProvider provider);
/** /**
* Read a configuration value from the plugin's config. * Read a configuration value from the plugin's config.
* *

View File

@ -17,5 +17,8 @@ public enum PluginType {
CHANNEL, CHANNEL,
/** Register new memory providers */ /** Register new memory providers */
MEMORY MEMORY,
/** Register new web-search providers for the web_search tool */
SEARCH
} }

View File

@ -0,0 +1,53 @@
package vip.mate.plugin.api.search;
import java.util.List;
/**
* SPI for plugin-provided web-search providers.
* <p>
* Implementations are registered via {@code PluginContext#registerSearchProvider}
* and appear in the platform's search provider chain alongside the built-in
* providers (serper / tavily / searxng / duckduckgo).
* <p>
* Configuration (API keys, base URLs, ...) is NOT passed in plugins read their
* own config declared in {@code mateclaw-plugin.json} via
* {@code PluginContext#getConfig(String, Class)}.
*
* @author MateClaw Team
*/
public interface PluginSearchProvider {
/** Globally unique provider id, e.g. "my-search". Must not clash with built-in ids. */
String id();
/** Human-readable display name. */
String label();
/** Whether this provider needs a credential (affects auto-detect priority). */
default boolean requiresCredential() {
return true;
}
/**
* Auto-detect ordering (ascending). Built-in providers occupy 50-400;
* plugin providers default to 500 (after built-ins) but may override.
*/
default int autoDetectOrder() {
return 500;
}
/**
* Whether the provider is currently usable typically: required config present.
* Called on every provider resolution; keep it cheap (no network I/O).
*/
boolean isAvailable();
/**
* Execute the search.
*
* @param query the query (never null)
* @return results; empty list if nothing found. Must not return null.
* Throw on failure the platform falls back to the next provider.
*/
List<PluginSearchResult> search(PluginSearchQuery query);
}

View File

@ -0,0 +1,22 @@
package vip.mate.plugin.api.search;
/**
* Search query passed from the platform to a plugin search provider.
* <p>
* Self-contained SDK type must not depend on any mateclaw-server class,
* because plugin JARs are compiled only against mateclaw-plugin-api.
*
* @param query search keywords (never null/blank)
* @param freshness time-range filter: day / week / month / year (nullable)
* @param language language preference, e.g. zh-CN / en (nullable)
* @param count max results 1-10, already clamped by the platform (never null)
*
* @author MateClaw Team
*/
public record PluginSearchQuery(
String query,
String freshness,
String language,
Integer count
) {
}

View File

@ -0,0 +1,24 @@
package vip.mate.plugin.api.search;
/**
* A single search result returned by a plugin search provider.
* <p>
* Self-contained SDK type mirrors the platform's internal SearchResult
* (title/url/snippet/source/date) without depending on server classes.
*
* @param title result title
* @param url result link
* @param snippet short excerpt
* @param source source domain, e.g. "reuters.com" (nullable)
* @param date published date as raw string (nullable)
*
* @author MateClaw Team
*/
public record PluginSearchResult(
String title,
String url,
String snippet,
String source,
String date
) {
}

View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-plugin-search-sample</artifactId>
<packaging>jar</packaging>
<name>MateClaw Search Provider Sample Plugin</name>
<description>Sample plugin registering a custom web-search provider via the MateClaw Plugin SDK</description>
<dependencies>
<!-- MateClaw Plugin API -->
<dependency>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- Spring AI (provided by the platform) — PluginContext method signatures
reference ToolCallback/ChatModel, so it must be resolvable at compile time -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
<scope>provided</scope>
</dependency>
<!-- Jackson (provided by the platform parent classloader) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>provided</scope>
</dependency>
<!-- SLF4J (provided by the platform) -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,124 @@
package vip.mate.plugin.sample.search;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import vip.mate.plugin.api.MateClawPlugin;
import vip.mate.plugin.api.PluginContext;
import vip.mate.plugin.api.search.PluginSearchProvider;
import vip.mate.plugin.api.search.PluginSearchQuery;
import vip.mate.plugin.api.search.PluginSearchResult;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Sample plugin demonstrating {@code PluginType.SEARCH}: registers a search
* provider that queries a configurable JSON endpoint. Expected response shape:
* {@code {"results":[{"title":"...","url":"...","snippet":"..."}]}}
*
* @author MateClaw Team
*/
public class SimpleSearchPlugin implements MateClawPlugin {
private Logger log;
@Override
public void onLoad(PluginContext context) {
this.log = context.getLogger();
context.registerSearchProvider(new DemoSearchProvider(context));
log.info("SimpleSearchPlugin loaded, search provider registered");
}
@Override
public void onEnable() {
if (log != null) log.info("SimpleSearchPlugin enabled");
}
@Override
public void onDisable() {
if (log != null) log.info("SimpleSearchPlugin disabled");
}
static class DemoSearchProvider implements PluginSearchProvider {
private static final Duration TIMEOUT = Duration.ofSeconds(15);
private final PluginContext context;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
private final ObjectMapper objectMapper = new ObjectMapper();
DemoSearchProvider(PluginContext context) {
this.context = context;
}
@Override
public String id() {
return "demo-search";
}
@Override
public String label() {
return "Demo Search";
}
@Override
public boolean isAvailable() {
String baseUrl = context.getConfig("baseUrl", String.class);
return baseUrl != null && !baseUrl.isBlank();
}
@Override
public List<PluginSearchResult> search(PluginSearchQuery query) {
String baseUrl = context.getConfig("baseUrl", String.class);
String apiKey = context.getConfig("apiKey", String.class);
// Minimal demo: only q/count are wired. query.freshness() and query.language()
// are also available see the built-in SearXNGSearchProvider for how to map them.
String url = baseUrl + (baseUrl.contains("?") ? "&" : "?")
+ "q=" + URLEncoder.encode(query.query(), StandardCharsets.UTF_8)
+ "&count=" + query.count();
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url))
.timeout(TIMEOUT)
.GET();
if (apiKey != null && !apiKey.isBlank()) {
req.header("Authorization", "Bearer " + apiKey);
}
try {
HttpResponse<String> resp = http.send(req.build(), HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
throw new IllegalStateException("Search endpoint returned HTTP " + resp.statusCode());
}
return parse(resp.body());
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException("Search request failed: " + e.getMessage(), e);
}
}
private List<PluginSearchResult> parse(String body) throws JsonProcessingException {
List<PluginSearchResult> results = new ArrayList<>();
JsonNode items = objectMapper.readTree(body).path("results");
for (JsonNode item : items) {
results.add(new PluginSearchResult(
item.path("title").asText(null),
item.path("url").asText(null),
item.path("snippet").asText(null),
null,
null));
}
return results;
}
}
}

View File

@ -0,0 +1,24 @@
{
"name": "mateclaw-plugin-search-demo",
"version": "1.0.0",
"type": "search",
"displayName": "Demo Search Provider",
"description": "Registers a custom web-search provider backed by a configurable JSON search endpoint.",
"entrypoint": "vip.mate.plugin.sample.search.SimpleSearchPlugin",
"minPlatformVersion": "1.1.0",
"author": "MateClaw Team",
"config": {
"baseUrl": {
"type": "string",
"required": true,
"secret": false,
"description": "Search endpoint returning {\"results\":[{\"title\",\"url\",\"snippet\"}]}"
},
"apiKey": {
"type": "string",
"required": false,
"secret": true,
"description": "Optional bearer token sent as Authorization header"
}
}
}

View File

@ -29,6 +29,9 @@ public class LoadedPlugin {
/** Channel types registered by this plugin */ /** Channel types registered by this plugin */
private final List<String> registeredChannels = new ArrayList<>(); private final List<String> registeredChannels = new ArrayList<>();
/** Search provider ids registered by this plugin */
private final List<String> registeredSearchProviders = new ArrayList<>();
/** Provider ID registered by this plugin (null if none) */ /** Provider ID registered by this plugin (null if none) */
private String registeredProvider; private String registeredProvider;

View File

@ -12,9 +12,12 @@ import vip.mate.plugin.api.PluginException;
import vip.mate.plugin.api.PluginManifest; import vip.mate.plugin.api.PluginManifest;
import vip.mate.plugin.api.channel.PluginChannelAdapter; import vip.mate.plugin.api.channel.PluginChannelAdapter;
import vip.mate.plugin.api.memory.PluginMemoryProvider; import vip.mate.plugin.api.memory.PluginMemoryProvider;
import vip.mate.plugin.api.search.PluginSearchProvider;
import vip.mate.plugin.bridge.PluginChannelBridge; import vip.mate.plugin.bridge.PluginChannelBridge;
import vip.mate.plugin.bridge.PluginMemoryBridge; import vip.mate.plugin.bridge.PluginMemoryBridge;
import vip.mate.plugin.bridge.PluginSearchBridge;
import vip.mate.tool.ToolRegistry; import vip.mate.tool.ToolRegistry;
import vip.mate.tool.search.SearchProviderRegistry;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@ -35,6 +38,7 @@ public class PluginContextImpl implements PluginContext {
private final ChannelManager channelManager; private final ChannelManager channelManager;
private final MemoryManager memoryManager; private final MemoryManager memoryManager;
private final ModelProviderService modelProviderService; private final ModelProviderService modelProviderService;
private final SearchProviderRegistry searchProviderRegistry;
private final Map<String, Object> configMap; private final Map<String, Object> configMap;
private final Logger logger; private final Logger logger;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@ -45,6 +49,7 @@ public class PluginContextImpl implements PluginContext {
ChannelManager channelManager, ChannelManager channelManager,
MemoryManager memoryManager, MemoryManager memoryManager,
ModelProviderService modelProviderService, ModelProviderService modelProviderService,
SearchProviderRegistry searchProviderRegistry,
String configJson) { String configJson) {
this.loadedPlugin = loadedPlugin; this.loadedPlugin = loadedPlugin;
this.manifest = manifest; this.manifest = manifest;
@ -52,6 +57,7 @@ public class PluginContextImpl implements PluginContext {
this.channelManager = channelManager; this.channelManager = channelManager;
this.memoryManager = memoryManager; this.memoryManager = memoryManager;
this.modelProviderService = modelProviderService; this.modelProviderService = modelProviderService;
this.searchProviderRegistry = searchProviderRegistry;
this.logger = LoggerFactory.getLogger("plugin." + manifest.getName()); this.logger = LoggerFactory.getLogger("plugin." + manifest.getName());
this.configMap = parseConfig(configJson); this.configMap = parseConfig(configJson);
} }
@ -105,6 +111,19 @@ public class PluginContextImpl implements PluginContext {
loadedPlugin.setRegisteredMemoryProvider(provider.id()); loadedPlugin.setRegisteredMemoryProvider(provider.id());
} }
@Override
public void registerSearchProvider(PluginSearchProvider provider) {
if (provider == null || provider.id() == null || provider.id().isBlank()) {
throw new PluginException("Search provider id must not be blank");
}
try {
searchProviderRegistry.registerPluginProvider(new PluginSearchBridge(provider));
} catch (IllegalArgumentException e) {
throw new PluginException(e.getMessage(), e);
}
loadedPlugin.getRegisteredSearchProviders().add(provider.id());
}
@Override @Override
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <T> T getConfig(String key, Class<T> type) { public <T> T getConfig(String key, Class<T> type) {

View File

@ -19,6 +19,7 @@ import vip.mate.plugin.model.PluginEntity;
import vip.mate.plugin.model.PluginInfo; import vip.mate.plugin.model.PluginInfo;
import vip.mate.plugin.repository.PluginMapper; import vip.mate.plugin.repository.PluginMapper;
import vip.mate.tool.ToolRegistry; import vip.mate.tool.ToolRegistry;
import vip.mate.tool.search.SearchProviderRegistry;
import vip.mate.workspace.core.model.WorkspaceEntity; import vip.mate.workspace.core.model.WorkspaceEntity;
import vip.mate.workspace.core.service.WorkspaceService; import vip.mate.workspace.core.service.WorkspaceService;
@ -57,6 +58,7 @@ public class PluginManager {
private final ChannelManager channelManager; private final ChannelManager channelManager;
private final MemoryManager memoryManager; private final MemoryManager memoryManager;
private final ModelProviderService modelProviderService; private final ModelProviderService modelProviderService;
private final SearchProviderRegistry searchProviderRegistry;
private final Optional<WorkspaceService> workspaceService; private final Optional<WorkspaceService> workspaceService;
private final Map<String, LoadedPlugin> plugins = new ConcurrentHashMap<>(); private final Map<String, LoadedPlugin> plugins = new ConcurrentHashMap<>();
@ -211,6 +213,7 @@ public class PluginManager {
PluginContextImpl context = new PluginContextImpl( PluginContextImpl context = new PluginContextImpl(
loadedPlugin, manifest, loadedPlugin, manifest,
toolRegistry, channelManager, memoryManager, modelProviderService, toolRegistry, channelManager, memoryManager, modelProviderService,
searchProviderRegistry,
configJson configJson
); );
loadedPlugin.setContext(context); loadedPlugin.setContext(context);
@ -261,6 +264,9 @@ public class PluginManager {
if (loaded.getRegisteredProvider() != null) { if (loaded.getRegisteredProvider() != null) {
try { modelProviderService.unregisterPluginChatModel(loaded.getRegisteredProvider()); } catch (Exception e) { /* best effort */ } try { modelProviderService.unregisterPluginChatModel(loaded.getRegisteredProvider()); } catch (Exception e) { /* best effort */ }
} }
for (String searchId : loaded.getRegisteredSearchProviders()) {
try { searchProviderRegistry.unregisterPluginProvider(searchId); } catch (Exception e) { /* best effort */ }
}
} }
/** /**
@ -300,14 +306,20 @@ public class PluginManager {
providerRemoved = loaded.getRegisteredProvider(); providerRemoved = loaded.getRegisteredProvider();
} }
for (String searchId : loaded.getRegisteredSearchProviders()) {
searchProviderRegistry.unregisterPluginProvider(searchId);
}
int searchRemoved = loaded.getRegisteredSearchProviders().size();
loaded.setEnabled(false); loaded.setEnabled(false);
plugins.remove(name); plugins.remove(name);
updateStatus(name, false, "DISABLED", null); updateStatus(name, false, "DISABLED", null);
log.info("Plugin disabled: {} (tools={}, channels={}, provider={}, memory={})", log.info("Plugin disabled: {} (tools={}, channels={}, provider={}, memory={}, search={})",
name, toolsRemoved, channelsRemoved, name, toolsRemoved, channelsRemoved,
providerRemoved != null ? providerRemoved : "none", providerRemoved != null ? providerRemoved : "none",
memoryRemoved != null ? memoryRemoved : "none"); memoryRemoved != null ? memoryRemoved : "none",
searchRemoved);
} }
/** /**
@ -365,6 +377,7 @@ public class PluginManager {
.registeredChannels(List.copyOf(loaded.getRegisteredChannels())) .registeredChannels(List.copyOf(loaded.getRegisteredChannels()))
.registeredProvider(loaded.getRegisteredProvider()) .registeredProvider(loaded.getRegisteredProvider())
.registeredMemoryProvider(loaded.getRegisteredMemoryProvider()) .registeredMemoryProvider(loaded.getRegisteredMemoryProvider())
.registeredSearchProviders(List.copyOf(loaded.getRegisteredSearchProviders()))
.configSchema(buildConfigSchema(m)) .configSchema(buildConfigSchema(m))
.currentConfig(buildRedactedConfig(loaded)) .currentConfig(buildRedactedConfig(loaded))
.build()); .build());
@ -387,6 +400,7 @@ public class PluginManager {
.jarPath(entity.getJarPath()) .jarPath(entity.getJarPath())
.registeredTools(List.of()) .registeredTools(List.of())
.registeredChannels(List.of()) .registeredChannels(List.of())
.registeredSearchProviders(List.of())
.build()); .build());
} }
} }

View File

@ -0,0 +1,87 @@
package vip.mate.plugin.bridge;
import vip.mate.plugin.api.search.PluginSearchProvider;
import vip.mate.plugin.api.search.PluginSearchQuery;
import vip.mate.plugin.api.search.PluginSearchResult;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.tool.search.SearchProvider;
import vip.mate.tool.search.SearchQuery;
import vip.mate.tool.search.SearchResult;
import java.util.ArrayList;
import java.util.List;
/**
* Bridge that wraps a plugin's {@link PluginSearchProvider} into the platform's
* internal {@link SearchProvider} interface (issue #477).
* <p>
* The platform-side {@link SystemSettingsDTO} is intentionally ignored plugin
* providers read their own config via {@code PluginContext#getConfig}, keeping
* the SDK free of server types.
*
* @author MateClaw Team
*/
public class PluginSearchBridge implements SearchProvider {
private final PluginSearchProvider delegate;
public PluginSearchBridge(PluginSearchProvider delegate) {
this.delegate = delegate;
}
@Override
public String id() {
return delegate.id();
}
@Override
public String label() {
return delegate.label();
}
@Override
public boolean requiresCredential() {
return delegate.requiresCredential();
}
@Override
public int autoDetectOrder() {
return delegate.autoDetectOrder();
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
return delegate.isAvailable();
}
@Override
public List<SearchResult> search(String query, SystemSettingsDTO config) {
return search(SearchQuery.of(query), config);
}
@Override
public List<SearchResult> search(SearchQuery searchQuery, SystemSettingsDTO config) {
PluginSearchQuery pluginQuery = new PluginSearchQuery(
searchQuery.query(),
searchQuery.freshness(),
searchQuery.language(),
searchQuery.resolvedCount()
);
List<PluginSearchResult> pluginResults = delegate.search(pluginQuery);
if (pluginResults == null) {
return List.of();
}
List<SearchResult> results = new ArrayList<>(pluginResults.size());
for (PluginSearchResult r : pluginResults) {
results.add(SearchResult.builder()
.title(r.title())
.url(r.url())
.snippet(r.snippet())
.source(r.source())
.date(r.date())
.providerId(delegate.id())
.build());
}
return results;
}
}

View File

@ -38,6 +38,9 @@ public class PluginInfo {
/** Memory provider ID registered by this plugin (null if none) */ /** Memory provider ID registered by this plugin (null if none) */
private String registeredMemoryProvider; private String registeredMemoryProvider;
/** Search provider ids registered by this plugin */
private List<String> registeredSearchProviders;
/** Plugin config schema (from manifest) */ /** Plugin config schema (from manifest) */
private Map<String, Object> configSchema; private Map<String, Object> configSchema;

View File

@ -4,9 +4,11 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.model.SystemSettingsDTO;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -29,6 +31,9 @@ public class SearchProviderRegistry {
private final List<SearchProvider> sortedProviders; private final List<SearchProvider> sortedProviders;
private final Map<String, SearchProvider> providerMap; private final Map<String, SearchProvider> providerMap;
/** 插件注册的 provider运行时可变与 Spring 注入的内置 provider 合并成完整视图 */
private final ConcurrentHashMap<String, SearchProvider> pluginProviders = new ConcurrentHashMap<>();
public SearchProviderRegistry(List<SearchProvider> providers) { public SearchProviderRegistry(List<SearchProvider> providers) {
this.sortedProviders = providers.stream() this.sortedProviders = providers.stream()
.sorted(Comparator.comparingInt(SearchProvider::autoDetectOrder)) .sorted(Comparator.comparingInt(SearchProvider::autoDetectOrder))
@ -39,14 +44,52 @@ public class SearchProviderRegistry {
sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList()); sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList());
} }
/** 按 ID 获取指定 provider */ /**
public SearchProvider getById(String id) { * 注册一个插件提供的 providerissue #477
return providerMap.get(id); *
* @throws IllegalArgumentException id 为空或与内置/已注册插件 provider 冲突
*/
public void registerPluginProvider(SearchProvider provider) {
String id = provider.id();
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Search provider id must not be blank");
}
if (providerMap.containsKey(id)) {
throw new IllegalArgumentException(
"Search provider id conflicts with a built-in provider: " + id);
}
if (pluginProviders.putIfAbsent(id, provider) != null) {
throw new IllegalArgumentException(
"Search provider id already registered by another plugin: " + id);
}
log.info("插件搜索提供商已注册: {} (order={})", id, provider.autoDetectOrder());
} }
/** 获取按 autoDetectOrder 排序的全部 provider 列表 */ /** 反注册插件 providerdisable / rollback 路径调用id 不存在时静默) */
public void unregisterPluginProvider(String id) {
if (pluginProviders.remove(id) != null) {
log.info("插件搜索提供商已反注册: {}", id);
}
}
/** 按 ID 获取指定 provider内置优先其次插件注册区 */
public SearchProvider getById(String id) {
SearchProvider builtin = providerMap.get(id);
return builtin != null ? builtin : pluginProviders.get(id);
}
/**
* 获取按 autoDetectOrder 排序的全部 provider内置 + 插件
* <p>有插件注册时每次调用重新合并排序provider 总数 &lt;10无需缓存
*/
public List<SearchProvider> allSorted() { public List<SearchProvider> allSorted() {
return sortedProviders; if (pluginProviders.isEmpty()) {
return sortedProviders;
}
List<SearchProvider> merged = new ArrayList<>(sortedProviders);
merged.addAll(pluginProviders.values());
merged.sort(Comparator.comparingInt(SearchProvider::autoDetectOrder));
return merged;
} }
/** /**
@ -65,7 +108,7 @@ public class SearchProviderRegistry {
// 1. 用户显式配置的 primary provider // 1. 用户显式配置的 primary provider
String configuredId = config.getSearchProvider(); String configuredId = config.getSearchProvider();
if (configuredId != null && !configuredId.isBlank()) { if (configuredId != null && !configuredId.isBlank()) {
SearchProvider configured = providerMap.get(configuredId); SearchProvider configured = getById(configuredId);
if (configured != null && configured.isAvailable(config)) { if (configured != null && configured.isAvailable(config)) {
return new ResolvedProvider(configured, "configured"); return new ResolvedProvider(configured, "configured");
} }
@ -73,7 +116,7 @@ public class SearchProviderRegistry {
// 2. 按优先级遍历先找有 credential // 2. 按优先级遍历先找有 credential
SearchProvider keylessFallback = null; SearchProvider keylessFallback = null;
for (SearchProvider p : sortedProviders) { for (SearchProvider p : allSorted()) {
if (!p.requiresCredential()) { if (!p.requiresCredential()) {
// 记住第一个可用的 keyless provider // 记住第一个可用的 keyless provider
if (keylessFallback == null && p.isAvailable(config)) { if (keylessFallback == null && p.isAvailable(config)) {

View File

@ -248,6 +248,27 @@ Implement `vip.mate.memory.spi.MemoryProvider` to plug in a custom memory backen
Connect external tool servers over stdio, streamable_http, or sse. Their tools appear in the tool registry automatically — Agent code doesn't know they're external. See [MCP](./mcp). Connect external tool servers over stdio, streamable_http, or sse. Their tools appear in the tool registry automatically — Agent code doesn't know they're external. See [MCP](./mcp).
### Standalone-jar plugins (`mateclaw-plugin-api`)
All of the above require your code to be compiled into `mateclaw-server` itself. If you want to extend capabilities by dropping in an independent jar — no core source changes — use the `mateclaw-plugin-api` SDK: implement `MateClawPlugin`, declare `type` and a `config` schema in `mateclaw-plugin.json`, package it, and drop it into a workspace `plugins/` directory or the user-level `~/.mateclaw/plugins/`. `PluginManager` loads it at startup in an isolated `URLClassLoader` and supports runtime enable/disable. Five `PluginType`s are currently supported: `TOOL`, `PROVIDER` (LLM), `CHANNEL`, `MEMORY`, and `SEARCH` (a search source for the `web_search` tool, `1.7.0+`).
```java
public class MySearchPlugin implements MateClawPlugin {
@Override
public void onLoad(PluginContext context) {
context.registerSearchProvider(new MySearchProvider(context));
}
@Override public void onEnable() {}
@Override public void onDisable() {}
}
class MySearchProvider implements PluginSearchProvider {
// id() / label() / isAvailable() / search(PluginSearchQuery) — see the mateclaw-plugin-search-sample module
}
```
Reference implementations: `mateclaw-plugin-sample` (TOOL) and `mateclaw-plugin-search-sample` (SEARCH).
### Skill packages ### Skill packages
Bundle instructions + tools + optional scripts in a `SKILL.md`. Upload via the UI or API. Agents can invoke them at runtime. See [Skills](./skills). Bundle instructions + tools + optional scripts in a `SKILL.md`. Upload via the UI or API. Agents can invoke them at runtime. See [Skills](./skills).

View File

@ -248,6 +248,27 @@ public interface ChannelAdapter {
通过 stdio、streamable_http、sse 连接外部工具服务。它们的工具自动出现在工具注册表里——Agent 代码**不知道它们是外部的**。见 [MCP 协议](./mcp)。 通过 stdio、streamable_http、sse 连接外部工具服务。它们的工具自动出现在工具注册表里——Agent 代码**不知道它们是外部的**。见 [MCP 协议](./mcp)。
### 独立 jar 插件(`mateclaw-plugin-api`
以上都要求代码编译进 `mateclaw-server` 本体。如果你想**不碰核心源码**、丢一个独立 jar 就扩展能力,用 `mateclaw-plugin-api` SDK实现 `MateClawPlugin`,在 `mateclaw-plugin.json` 里声明 `type``config` schema打包后放进工作区 `plugins/` 或用户级 `~/.mateclaw/plugins/``PluginManager` 用隔离的 `URLClassLoader` 在启动时加载,支持运行时 enable/disable。当前支持 5 种 `PluginType``TOOL`、`PROVIDER`LLM、`CHANNEL`、`MEMORY`、`SEARCH``web_search` 工具的搜索源,`1.7.0+`)。
```java
public class MySearchPlugin implements MateClawPlugin {
@Override
public void onLoad(PluginContext context) {
context.registerSearchProvider(new MySearchProvider(context));
}
@Override public void onEnable() {}
@Override public void onDisable() {}
}
class MySearchProvider implements PluginSearchProvider {
// id() / label() / isAvailable() / search(PluginSearchQuery) — 详见 mateclaw-plugin-search-sample 模块
}
```
参考实现见 `mateclaw-plugin-sample`TOOL`mateclaw-plugin-search-sample`SEARCH
### 技能包 ### 技能包
把指令 + 工具 + 可选脚本打包进一个 `SKILL.md`。通过 UI 或 API 上传。Agent 在运行时可以调用它们。见 [技能系统](./skills)。 把指令 + 工具 + 可选脚本打包进一个 `SKILL.md`。通过 UI 或 API 上传。Agent 在运行时可以调用它们。见 [技能系统](./skills)。

View File

@ -0,0 +1,103 @@
package vip.mate.plugin;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.channel.ChannelManager;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.memory.spi.MemoryManager;
import vip.mate.plugin.api.PluginException;
import vip.mate.plugin.api.PluginManifest;
import vip.mate.plugin.api.MateClawPlugin;
import vip.mate.plugin.api.PluginContext;
import vip.mate.plugin.api.search.PluginSearchProvider;
import vip.mate.plugin.api.search.PluginSearchQuery;
import vip.mate.plugin.api.search.PluginSearchResult;
import vip.mate.tool.ToolRegistry;
import vip.mate.tool.search.SearchProviderRegistry;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
/**
* PluginContextImpl#registerSearchProvider: wraps the plugin SPI in a bridge,
* registers it into SearchProviderRegistry, and records the id on LoadedPlugin
* so disable/rollback can unregister it (issue #477).
*/
class PluginContextImplSearchTest {
private SearchProviderRegistry registry;
private PluginContextImpl context;
private LoadedPlugin loadedPlugin;
@BeforeEach
void setUp() {
registry = new SearchProviderRegistry(List.of());
PluginManifest manifest = new PluginManifest();
manifest.setName("test-plugin");
manifest.setVersion("1.0.0");
manifest.setType("search");
manifest.setEntrypoint("x.Y");
MateClawPlugin plugin = new MateClawPlugin() {
@Override public void onLoad(PluginContext ctx) { }
@Override public void onEnable() { }
@Override public void onDisable() { }
};
loadedPlugin = new LoadedPlugin(manifest, plugin,
new URLClassLoader(new URL[0], getClass().getClassLoader()));
context = new PluginContextImpl(
loadedPlugin, manifest,
mock(ToolRegistry.class), mock(ChannelManager.class),
mock(MemoryManager.class), mock(ModelProviderService.class),
registry,
null);
}
private static PluginSearchProvider provider(String id) {
return new PluginSearchProvider() {
@Override public String id() { return id; }
@Override public String label() { return id; }
@Override public boolean isAvailable() { return true; }
@Override public List<PluginSearchResult> search(PluginSearchQuery query) {
return List.of();
}
};
}
@Test
@DisplayName("registers into the registry and records the id on LoadedPlugin")
void registersAndRecords() {
context.registerSearchProvider(provider("my-search"));
assertNotNull(registry.getById("my-search"));
assertEquals(List.of("my-search"), loadedPlugin.getRegisteredSearchProviders());
}
@Test
@DisplayName("id conflict surfaces as PluginException and is not recorded")
void conflictBecomesPluginException() {
context.registerSearchProvider(provider("my-search"));
assertThrows(PluginException.class,
() -> context.registerSearchProvider(provider("my-search")));
assertEquals(1, loadedPlugin.getRegisteredSearchProviders().size());
}
@Test
@DisplayName("blank id is rejected with PluginException")
void blankIdRejected() {
assertThrows(PluginException.class,
() -> context.registerSearchProvider(provider(" ")));
assertTrue(loadedPlugin.getRegisteredSearchProviders().isEmpty());
}
}

View File

@ -0,0 +1,121 @@
package vip.mate.plugin.bridge;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.plugin.api.search.PluginSearchProvider;
import vip.mate.plugin.api.search.PluginSearchQuery;
import vip.mate.plugin.api.search.PluginSearchResult;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.tool.search.SearchQuery;
import vip.mate.tool.search.SearchResult;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@link PluginSearchBridge} adapts the self-contained plugin SPI
* ({@code PluginSearchProvider}) to the platform's {@code SearchProvider}
* without leaking server types into plugin land.
*/
class PluginSearchBridgeTest {
@Test
@DisplayName("query fields pass through and results are converted with the plugin's providerId")
void convertsQueryAndResults() {
AtomicReference<PluginSearchQuery> received = new AtomicReference<>();
PluginSearchProvider plugin = new PluginSearchProvider() {
@Override public String id() { return "my-search"; }
@Override public String label() { return "My Search"; }
@Override public boolean isAvailable() { return true; }
@Override public List<PluginSearchResult> search(PluginSearchQuery query) {
received.set(query);
return List.of(new PluginSearchResult(
"T1", "https://example.com/a", "snippet-1", "example.com", "2026-07-01"));
}
};
PluginSearchBridge bridge = new PluginSearchBridge(plugin);
List<SearchResult> results = bridge.search(
new SearchQuery("kw", "week", "zh-CN", 3), new SystemSettingsDTO());
assertEquals("kw", received.get().query());
assertEquals("week", received.get().freshness());
assertEquals("zh-CN", received.get().language());
assertEquals(3, received.get().count());
assertEquals(1, results.size());
SearchResult r = results.get(0);
assertEquals("T1", r.getTitle());
assertEquals("https://example.com/a", r.getUrl());
assertEquals("snippet-1", r.getSnippet());
assertEquals("example.com", r.getSource());
assertEquals("2026-07-01", r.getDate());
assertEquals("my-search", r.getProviderId());
}
@Test
@DisplayName("count is clamped via SearchQuery.resolvedCount before reaching the plugin")
void countIsClamped() {
AtomicReference<PluginSearchQuery> received = new AtomicReference<>();
PluginSearchBridge bridge = new PluginSearchBridge(stub(q -> {
received.set(q);
return List.of();
}));
bridge.search(new SearchQuery("kw", null, null, 99), new SystemSettingsDTO());
assertEquals(10, received.get().count()); // MAX_COUNT
bridge.search(new SearchQuery("kw", null, null, null), new SystemSettingsDTO());
assertEquals(5, received.get().count()); // DEFAULT_COUNT
}
@Test
@DisplayName("delegates id/label/order/credential and maps isAvailable() ignoring the DTO")
void delegatesMetadata() {
PluginSearchBridge bridge = new PluginSearchBridge(stub(q -> List.of()));
assertEquals("stub-search", bridge.id());
assertEquals("Stub Search", bridge.label());
assertTrue(bridge.requiresCredential());
assertEquals(500, bridge.autoDetectOrder());
assertTrue(bridge.isAvailable(new SystemSettingsDTO()));
}
@Test
@DisplayName("a null result list from a sloppy plugin is normalised to empty")
void nullResultListNormalised() {
PluginSearchBridge bridge = new PluginSearchBridge(stub(q -> null));
List<SearchResult> results = bridge.search(SearchQuery.of("kw"), new SystemSettingsDTO());
assertTrue(results.isEmpty());
}
@Test
@DisplayName("plugin exceptions propagate so WebSearchService's fallback chain can react")
void exceptionsPropagate() {
PluginSearchBridge bridge = new PluginSearchBridge(stub(q -> {
throw new IllegalStateException("plugin boom");
}));
assertThrows(IllegalStateException.class,
() -> bridge.search(SearchQuery.of("kw"), new SystemSettingsDTO()));
}
// ---- helpers ----
private interface SearchFn {
List<PluginSearchResult> apply(PluginSearchQuery q);
}
private static PluginSearchProvider stub(SearchFn fn) {
return new PluginSearchProvider() {
@Override public String id() { return "stub-search"; }
@Override public String label() { return "Stub Search"; }
@Override public boolean isAvailable() { return true; }
@Override public List<PluginSearchResult> search(PluginSearchQuery query) {
return fn.apply(query);
}
};
}
}

View File

@ -0,0 +1,141 @@
package vip.mate.tool.search;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.system.model.SystemSettingsDTO;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Plugin-provider mutability of {@link SearchProviderRegistry} (issue #477):
* plugin JARs register/unregister providers at runtime; the registry must merge
* them with the Spring-injected built-ins and reject id conflicts.
*/
class SearchProviderRegistryPluginTest {
/** Minimal stub standing in for both built-in and plugin-bridged providers. */
private static SearchProvider stub(String id, int order, boolean credentialed, boolean available) {
return new SearchProvider() {
@Override public String id() { return id; }
@Override public String label() { return id; }
@Override public boolean requiresCredential() { return credentialed; }
@Override public int autoDetectOrder() { return order; }
@Override public boolean isAvailable(SystemSettingsDTO config) { return available; }
@Override public List<SearchResult> search(String query, SystemSettingsDTO config) { return List.of(); }
};
}
private static SearchProviderRegistry registryWithBuiltins() {
// Mirrors the real built-in landscape: one credentialed, one keyless.
return new SearchProviderRegistry(List.of(
stub("serper", 300, true, false), // credentialed but NOT configured
stub("duckduckgo", 100, false, true) // keyless, available
));
}
@Test
@DisplayName("registered plugin provider shows up in allSorted, ordered by autoDetectOrder")
void pluginProviderAppearsInMergedSortedView() {
SearchProviderRegistry registry = registryWithBuiltins();
SearchProvider plugin = stub("my-search", 500, true, true);
registry.registerPluginProvider(plugin);
List<SearchProvider> all = registry.allSorted();
assertEquals(3, all.size());
assertEquals("duckduckgo", all.get(0).id()); // order 100
assertEquals("serper", all.get(1).id()); // order 300
assertSame(plugin, all.get(2)); // order 500
}
@Test
@DisplayName("getById finds plugin providers")
void getByIdFindsPluginProvider() {
SearchProviderRegistry registry = registryWithBuiltins();
SearchProvider plugin = stub("my-search", 500, true, true);
registry.registerPluginProvider(plugin);
assertSame(plugin, registry.getById("my-search"));
}
@Test
@DisplayName("plugin id clashing with a built-in id is rejected")
void builtinIdConflictRejected() {
SearchProviderRegistry registry = registryWithBuiltins();
assertThrows(IllegalArgumentException.class,
() -> registry.registerPluginProvider(stub("serper", 500, true, true)));
}
@Test
@DisplayName("plugin id clashing with an already-registered plugin id is rejected")
void pluginIdConflictRejected() {
SearchProviderRegistry registry = registryWithBuiltins();
registry.registerPluginProvider(stub("my-search", 500, true, true));
assertThrows(IllegalArgumentException.class,
() -> registry.registerPluginProvider(stub("my-search", 501, true, true)));
}
@Test
@DisplayName("blank or null plugin id is rejected")
void blankIdRejected() {
SearchProviderRegistry registry = registryWithBuiltins();
assertThrows(IllegalArgumentException.class,
() -> registry.registerPluginProvider(stub(" ", 500, true, true)));
assertThrows(IllegalArgumentException.class,
() -> registry.registerPluginProvider(stub(null, 500, true, true)));
}
@Test
@DisplayName("resolve honours an explicitly configured plugin provider")
void resolvePicksConfiguredPluginProvider() {
SearchProviderRegistry registry = registryWithBuiltins();
SearchProvider plugin = stub("my-search", 500, true, true);
registry.registerPluginProvider(plugin);
SystemSettingsDTO config = new SystemSettingsDTO();
config.setSearchProvider("my-search");
SearchProviderRegistry.ResolvedProvider resolved = registry.resolve(config);
assertSame(plugin, resolved.provider());
assertEquals("configured", resolved.source());
}
@Test
@DisplayName("resolve auto-detects an available credentialed plugin provider")
void resolveAutoDetectsPluginProvider() {
SearchProviderRegistry registry = registryWithBuiltins();
SearchProvider plugin = stub("my-search", 500, true, true);
registry.registerPluginProvider(plugin);
// No explicit provider configured; serper (credentialed) is unavailable,
// so auto-detect must reach the plugin provider before keyless fallback.
SearchProviderRegistry.ResolvedProvider resolved = registry.resolve(new SystemSettingsDTO());
assertSame(plugin, resolved.provider());
assertEquals("auto-detect", resolved.source());
}
@Test
@DisplayName("after unregister, an explicitly configured plugin id falls back to auto-detect")
void unregisteredConfiguredProviderFallsBackToAutoDetect() {
SearchProviderRegistry registry = registryWithBuiltins();
registry.registerPluginProvider(stub("my-search", 500, true, true));
registry.unregisterPluginProvider("my-search");
assertNull(registry.getById("my-search"));
SystemSettingsDTO config = new SystemSettingsDTO();
config.setSearchProvider("my-search");
SearchProviderRegistry.ResolvedProvider resolved = registry.resolve(config);
// Plugin gone; keyless duckduckgo is the only available provider left.
assertEquals("duckduckgo", resolved.provider().id());
assertEquals("keyless-fallback", resolved.source());
}
}

View File

@ -17,6 +17,7 @@
<module>mateclaw-plugin-api</module> <module>mateclaw-plugin-api</module>
<module>mateclaw-server</module> <module>mateclaw-server</module>
<module>mateclaw-plugin-sample</module> <module>mateclaw-plugin-sample</module>
<module>mateclaw-plugin-search-sample</module>
</modules> </modules>
<properties> <properties>