mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
* 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:
parent
dcc5382bf9
commit
0a58b3fb35
1288
docs/superpowers/plans/2026-07-03-plugin-search-provider-pr1.md
Normal file
1288
docs/superpowers/plans/2026-07-03-plugin-search-provider-pr1.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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; } // 默认排在内置 provider(50~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 搜索 API),manifest 声明 `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 可见。
|
||||||
@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
50
mateclaw-plugin-search-sample/pom.xml
Normal file
50
mateclaw-plugin-search-sample/pom.xml
Normal 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>
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
* 注册一个插件提供的 provider(issue #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 列表 */
|
/** 反注册插件 provider(disable / 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 总数 <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)) {
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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)。
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
1
pom.xml
1
pom.xml
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user