From 0a58b3fb355665a049155540d651d21d827ff3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Fri, 3 Jul 2026 16:57:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E6=8F=92=E4=BB=B6=E5=8C=96?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=20Provider=20=E2=80=94=20PluginType.SEARCH?= =?UTF-8?q?=20+=20PluginSearchProvider=20SPI=20(#477)=20(#479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../2026-07-03-plugin-search-provider-pr1.md | 1288 +++++++++++++++++ ...026-07-03-plugin-search-provider-design.md | 144 ++ .../vip/mate/plugin/api/PluginContext.java | 14 + .../java/vip/mate/plugin/api/PluginType.java | 5 +- .../api/search/PluginSearchProvider.java | 53 + .../plugin/api/search/PluginSearchQuery.java | 22 + .../plugin/api/search/PluginSearchResult.java | 24 + mateclaw-plugin-search-sample/pom.xml | 50 + .../sample/search/SimpleSearchPlugin.java | 124 ++ .../src/main/resources/mateclaw-plugin.json | 24 + .../java/vip/mate/plugin/LoadedPlugin.java | 3 + .../vip/mate/plugin/PluginContextImpl.java | 19 + .../java/vip/mate/plugin/PluginManager.java | 18 +- .../plugin/bridge/PluginSearchBridge.java | 87 ++ .../vip/mate/plugin/model/PluginInfo.java | 3 + .../tool/search/SearchProviderRegistry.java | 57 +- .../main/resources/docs/en/architecture.md | 21 + .../main/resources/docs/zh/architecture.md | 21 + .../plugin/PluginContextImplSearchTest.java | 103 ++ .../plugin/bridge/PluginSearchBridgeTest.java | 121 ++ .../SearchProviderRegistryPluginTest.java | 141 ++ pom.xml | 1 + 22 files changed, 2333 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-03-plugin-search-provider-pr1.md create mode 100644 docs/superpowers/specs/2026-07-03-plugin-search-provider-design.md create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java create mode 100644 mateclaw-plugin-search-sample/pom.xml create mode 100644 mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java create mode 100644 mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java create mode 100644 mateclaw-server/src/test/java/vip/mate/plugin/PluginContextImplSearchTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java diff --git a/docs/superpowers/plans/2026-07-03-plugin-search-provider-pr1.md b/docs/superpowers/plans/2026-07-03-plugin-search-provider-pr1.md new file mode 100644 index 00000000..5841e2df --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-plugin-search-provider-pr1.md @@ -0,0 +1,1288 @@ +# 插件化搜索 Provider(PR-1:SDK + 桥接 + Registry)实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 第三方以独立 jar 实现 `PluginSearchProvider` 并丢进插件目录,即可为 `web_search` 工具新增搜索源,mateclaw-server 源码零改动。 + +**Architecture:** 在 `mateclaw-plugin-api` 新增 SEARCH 插件类型与自包含 SDK 接口;server 侧用 `PluginSearchBridge` 把插件接口适配成核心 `SearchProvider`,`SearchProviderRegistry` 从 immutable 改为"Spring bean 基底 + 插件区合并视图";生命周期(注册/disable 反注册/加载失败 rollback)与现有 TOOL/CHANNEL/MEMORY/PROVIDER 四类完全对称。 + +**Tech Stack:** Java 21 / Spring Boot 3.5 / Maven 多模块 reactor(`mateclaw-plugin-api` → `mateclaw-server` → samples)/ JUnit 5 + Mockito。 + +**上游 issue:** https://github.com/mateaix/mateclaw/issues/477(已获认可后动工) +**设计文档:** `docs/superpowers/specs/2026-07-03-plugin-search-provider-design.md` + +**背景速览(给零上下文的执行者):** +- 核心接口 `vip.mate.tool.search.SearchProvider`(server 模块):`id()/label()/requiresCredential()/autoDetectOrder()/isAvailable(SystemSettingsDTO)/search(SearchQuery, SystemSettingsDTO)`。内置 4 个实现(serper/tavily/searxng/duckduckgo)是 `@Component`。 +- `SearchProviderRegistry`(`mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java`)构造器注入 `List`,`resolve(config)` 三分支:显式配置 → 按 `autoDetectOrder` 找有 credential 的 → keyless 兜底。 +- 插件系统:`PluginManager`(`@RequiredArgsConstructor`,字段即依赖)从文件系统加载 jar(`URLClassLoader`),`PluginContextImpl` 是插件看到的平台 API,`LoadedPlugin` 记录插件注册了什么以便 disable 时反注册。 +- 构建命令均从仓库根执行;server 单测在 `mateclaw-server/` 下执行。 + +--- + +### Task 1: 建分支并提交设计文档 + +**Files:** +- 已存在(未提交): `docs/superpowers/specs/2026-07-03-plugin-search-provider-design.md` +- 本文件: `docs/superpowers/plans/2026-07-03-plugin-search-provider-pr1.md` + +- [ ] **Step 1: 从 dev 建分支** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw +git fetch upstream dev 2>/dev/null || git fetch origin dev +git checkout -b feat/plugin-search-provider $(git rev-parse --verify upstream/dev 2>/dev/null || git rev-parse origin/dev) +``` + +预期:新分支 `feat/plugin-search-provider`,基于最新 dev。 + +- [ ] **Step 2: 提交 spec 与 plan** + +```bash +git add docs/superpowers/ +git commit -m "docs: add plugin search provider design spec and plan (#477)" +``` + +--- + +### Task 2: SDK — PluginType.SEARCH + search 包(纯新增,无行为变化) + +**Files:** +- Modify: `mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java` +- Create: `mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java` +- Create: `mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java` +- Create: `mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java` + +注意:**本任务不改 `PluginContext`**(接口加方法会导致 server 模块的 `PluginContextImpl` 编译失败;那一步放在 Task 5 与实现同 commit,保证每个 commit 可编译)。 + +- [ ] **Step 1: PluginType 加枚举值** + +`PluginType.java` 的 `MEMORY` 之后追加: + +```java + /** Register new memory providers */ + MEMORY, + + /** Register new web-search providers for the web_search tool */ + SEARCH +``` + +(`PluginManifest.validate()` 用 `PluginType.valueOf(type.toUpperCase())` 校验,枚举加值后 manifest `"type": "search"` 自动合法,无需改 validate。) + +- [ ] **Step 2: 新建 PluginSearchQuery** + +```java +package vip.mate.plugin.api.search; + +/** + * Search query passed from the platform to a plugin search provider. + *

+ * 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 +) { +} +``` + +- [ ] **Step 3: 新建 PluginSearchResult** + +```java +package vip.mate.plugin.api.search; + +/** + * A single search result returned by a plugin search provider. + *

+ * 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 +) { +} +``` + +- [ ] **Step 4: 新建 PluginSearchProvider** + +```java +package vip.mate.plugin.api.search; + +import java.util.List; + +/** + * SPI for plugin-provided web-search providers. + *

+ * 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). + *

+ * 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 search(PluginSearchQuery query); +} +``` + +- [ ] **Step 5: 编译 plugin-api 并安装到本地仓库** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw +mvn -q -pl mateclaw-plugin-api install -DskipTests +``` + +预期:BUILD SUCCESS。 + +- [ ] **Step 6: Commit** + +```bash +git add mateclaw-plugin-api/ +git commit -m "feat(plugin-api): add SEARCH plugin type and PluginSearchProvider SPI (#477)" +``` + +--- + +### Task 3: SearchProviderRegistry 可变化(TDD) + +**Files:** +- Test: `mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java`(新建,目录也新建) +- Modify: `mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java` + +- [ ] **Step 1: 写失败测试** + +```java +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 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 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("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()); + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw/mateclaw-server +mvn test -Dtest=SearchProviderRegistryPluginTest +``` + +预期:编译失败,`registerPluginProvider`/`unregisterPluginProvider` 方法不存在。 + +- [ ] **Step 3: 修改 SearchProviderRegistry** + +对 `SearchProviderRegistry.java` 做如下修改(保留现有 javadoc 与日志,只列出变化): + +新增 import: + +```java +import java.util.ArrayList; +import java.util.concurrent.ConcurrentHashMap; +``` + +新增字段(`providerMap` 之后): + +```java + /** 插件注册的 provider(运行时可变),与 Spring 注入的内置 provider 合并成完整视图 */ + private final ConcurrentHashMap pluginProviders = new ConcurrentHashMap<>(); +``` + +新增方法(`getById` 之前): + +```java + /** + * 注册一个插件提供的 provider(issue #477)。 + * + * @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()); + } + + /** 反注册插件 provider(disable / rollback 路径调用;id 不存在时静默) */ + public void unregisterPluginProvider(String id) { + if (pluginProviders.remove(id) != null) { + log.info("插件搜索提供商已反注册: {}", id); + } + } +``` + +`getById` 改为查合并视图: + +```java + /** 按 ID 获取指定 provider(内置优先,其次插件注册区) */ + public SearchProvider getById(String id) { + SearchProvider builtin = providerMap.get(id); + return builtin != null ? builtin : pluginProviders.get(id); + } +``` + +`allSorted` 改为合并视图(provider 总数 <10,每次读时排序无性能顾虑): + +```java + /** 获取按 autoDetectOrder 排序的全部 provider(内置 + 插件) */ + public List allSorted() { + if (pluginProviders.isEmpty()) { + return sortedProviders; + } + List merged = new ArrayList<>(sortedProviders); + merged.addAll(pluginProviders.values()); + merged.sort(Comparator.comparingInt(SearchProvider::autoDetectOrder)); + return merged; + } +``` + +`resolve` 内部两处改为走合并视图:第 1 分支 `providerMap.get(configuredId)` → `getById(configuredId)`;第 2 分支 `for (SearchProvider p : sortedProviders)` → `for (SearchProvider p : allSorted())`。 + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw/mateclaw-server +mvn test -Dtest=SearchProviderRegistryPluginTest +``` + +预期:7 个测试全 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw +git add mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java \ + mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java +git commit -m "feat(search): make SearchProviderRegistry accept runtime plugin providers (#477)" +``` + +--- + +### Task 4: PluginSearchBridge(TDD) + +**Files:** +- Test: `mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java`(新建,目录也新建) +- Create: `mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java` + +- [ ] **Step 1: 写失败测试** + +```java +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 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 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 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 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 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 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 search(PluginSearchQuery query) { + return fn.apply(query); + } + }; + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw/mateclaw-server +mvn test -Dtest=PluginSearchBridgeTest +``` + +预期:编译失败,`PluginSearchBridge` 不存在。 + +- [ ] **Step 3: 实现 PluginSearchBridge** + +```java +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). + *

+ * 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 search(String query, SystemSettingsDTO config) { + return search(SearchQuery.of(query), config); + } + + @Override + public List search(SearchQuery searchQuery, SystemSettingsDTO config) { + PluginSearchQuery pluginQuery = new PluginSearchQuery( + searchQuery.query(), + searchQuery.freshness(), + searchQuery.language(), + searchQuery.resolvedCount() + ); + List pluginResults = delegate.search(pluginQuery); + if (pluginResults == null) { + return List.of(); + } + List 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; + } +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw/mateclaw-server +mvn test -Dtest=PluginSearchBridgeTest +``` + +预期:5 个测试全 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw +git add mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java \ + mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java +git commit -m "feat(plugin): bridge PluginSearchProvider to the core SearchProvider chain (#477)" +``` + +--- + +### Task 5: 注册入口 + 生命周期(PluginContext / PluginContextImpl / LoadedPlugin / PluginManager / PluginInfo) + +**Files:** +- Modify: `mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java` +- Modify: `mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java` +- Modify: `mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java` +- Modify: `mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java`(字段 + 构造点 + rollback + disable + listPlugins) +- Modify: `mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java` +- Test: `mateclaw-server/src/test/java/vip/mate/plugin/PluginContextImplSearchTest.java`(新建) + +接口加方法会让 `PluginContextImpl` 编译失败,因此本任务的接口与实现**必须同一 commit**。 + +- [ ] **Step 1: 写失败测试** + +```java +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 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()); + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw/mateclaw-server +mvn test -Dtest=PluginContextImplSearchTest +``` + +预期:编译失败(`registerSearchProvider` / `getRegisteredSearchProviders` / 7 参构造器不存在)。 + +- [ ] **Step 3: PluginContext 接口加方法** + +`PluginContext.java` 新增 import `vip.mate.plugin.api.search.PluginSearchProvider;`,在 `registerMemoryProvider` 之后加: + +```java + /** + * Register a web-search provider that joins the platform's search provider + * chain used by the {@code web_search} tool. + *

+ * 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); +``` + +- [ ] **Step 4: LoadedPlugin 加记录字段** + +`LoadedPlugin.java` 在 `registeredChannels` 之后加: + +```java + /** Search provider ids registered by this plugin */ + private final List registeredSearchProviders = new ArrayList<>(); +``` + +- [ ] **Step 5: PluginContextImpl 实现** + +新增 import: + +```java +import vip.mate.plugin.api.search.PluginSearchProvider; +import vip.mate.plugin.bridge.PluginSearchBridge; +import vip.mate.tool.search.SearchProviderRegistry; +``` + +字段区加 `private final SearchProviderRegistry searchProviderRegistry;`,构造器在 `modelProviderService` 参数后加 `SearchProviderRegistry searchProviderRegistry` 并赋值(保持 `configJson` 为最后一个参数)。 + +`registerMemoryProvider` 之后加实现: + +```java + @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()); + } + loadedPlugin.getRegisteredSearchProviders().add(provider.id()); + } +``` + +- [ ] **Step 6: PluginManager 注入 registry + 生命周期反注册** + +新增 import `vip.mate.tool.search.SearchProviderRegistry;`;字段区(`modelProviderService` 之后)加: + +```java + private final SearchProviderRegistry searchProviderRegistry; +``` + +(`@RequiredArgsConstructor` 自动进构造器。) + +`new PluginContextImpl(...)` 构造点(约 L211)在 `modelProviderService` 后传入 `searchProviderRegistry`。 + +`rollbackRegistrations`(约 L251)末尾加: + +```java + for (String searchId : loaded.getRegisteredSearchProviders()) { + try { searchProviderRegistry.unregisterPluginProvider(searchId); } catch (Exception e) { /* best effort */ } + } +``` + +`disablePlugin`(约 L269)在 memory/provider 反注册段之后、`loaded.setEnabled(false)` 之前加: + +```java + for (String searchId : loaded.getRegisteredSearchProviders()) { + searchProviderRegistry.unregisterPluginProvider(searchId); + } + int searchRemoved = loaded.getRegisteredSearchProviders().size(); +``` + +并把结尾的 log.info 扩展一个占位: + +```java + log.info("Plugin disabled: {} (tools={}, channels={}, provider={}, memory={}, search={})", + name, toolsRemoved, channelsRemoved, + providerRemoved != null ? providerRemoved : "none", + memoryRemoved != null ? memoryRemoved : "none", + searchRemoved); +``` + +`listPlugins()` 内存分支 builder(约 L355)加 `.registeredSearchProviders(List.copyOf(loaded.getRegisteredSearchProviders()))`;DB-only 分支(约 L377)加 `.registeredSearchProviders(List.of())`。 + +- [ ] **Step 7: PluginInfo 加字段** + +`PluginInfo.java` 在 `registeredMemoryProvider` 之后加: + +```java + /** Search provider ids registered by this plugin */ + private List registeredSearchProviders; +``` + +- [ ] **Step 8: 全链编译 + 跑测试确认通过** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw +mvn -q -pl mateclaw-plugin-api install -DskipTests +cd mateclaw-server +mvn test -Dtest='PluginContextImplSearchTest,SearchProviderRegistryPluginTest,PluginSearchBridgeTest' +``` + +预期:全 PASS。 + +- [ ] **Step 9: Commit** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw +git add mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java \ + mateclaw-server/src/main/java/vip/mate/plugin/ \ + mateclaw-server/src/test/java/vip/mate/plugin/PluginContextImplSearchTest.java +git commit -m "feat(plugin): registerSearchProvider lifecycle — register, disable, rollback (#477)" +``` + +--- + +### Task 6: 参考实现 — mateclaw-plugin-search-sample 模块 + +**Files:** +- Modify: `pom.xml`(根,`` 加一行) +- Create: `mateclaw-plugin-search-sample/pom.xml` +- Create: `mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json` +- Create: `mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java` + +设计说明:spec §3.6 说"sample 模块增加参考实现",但一个插件 jar 只有一个 manifest(一个 type/entrypoint),往现有 `mateclaw-plugin-sample`(type=tool 的 HelloPlugin)里塞 search 会破坏它的演示语义,因此新建独立 sample 模块。实现刻意最小:调一个可配 `baseUrl` 的 JSON 搜索端点(约定响应 `{"results":[{"title","url","snippet"}]}`),JSON 解析用 Jackson(provided,平台父 ClassLoader 提供),HTTP 用 JDK `java.net.http`,零额外依赖。 + +- [ ] **Step 1: 根 pom 加 module** + +`pom.xml` 的 `` 中 `mateclaw-plugin-sample` 之后加: + +```xml + mateclaw-plugin-search-sample +``` + +- [ ] **Step 2: 模块 pom** + +```xml + + + 4.0.0 + + + vip.mate + mateclaw + ${revision} + ../pom.xml + + + mateclaw-plugin-search-sample + jar + + MateClaw Search Provider Sample Plugin + Sample plugin registering a custom web-search provider via the MateClaw Plugin SDK + + + + + vip.mate + mateclaw-plugin-api + provided + + + + + org.springframework.ai + spring-ai-model + provided + + + + + com.fasterxml.jackson.core + jackson-databind + provided + + + + + org.slf4j + slf4j-api + provided + + + +``` + +- [ ] **Step 3: manifest(含 config schema,驱动 PR-2 的配置表单)** + +`src/main/resources/mateclaw-plugin.json`: + +```json +{ + "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" + } + } +} +``` + +- [ ] **Step 4: 插件实现** + +```java +package vip.mate.plugin.sample.search; + +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 search(PluginSearchQuery query) { + String baseUrl = context.getConfig("baseUrl", String.class); + String apiKey = context.getConfig("apiKey", String.class); + + 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 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 parse(String body) throws Exception { + List 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; + } + } +} +``` + +- [ ] **Step 5: 全量构建(reactor 三模块 + 新 sample)** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw +mvn -q -pl mateclaw-plugin-search-sample -am package -DskipTests +ls mateclaw-plugin-search-sample/target/*.jar +``` + +预期:BUILD SUCCESS,产出 `mateclaw-plugin-search-sample-1.7.0-SNAPSHOT.jar`。 + +- [ ] **Step 6: Commit** + +```bash +git add pom.xml mateclaw-plugin-search-sample/ +git commit -m "feat(plugin): add search provider sample plugin module (#477)" +``` + +--- + +### Task 7: 回归 + 手工端到端验证 + +- [ ] **Step 1: server 全量测试回归** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw/mateclaw-server +mvn test +``` + +预期:全绿(重点确认 `WebSearchTool`/`WebSearchService`/registry 相关既有测试无回归)。 + +- [ ] **Step 2: 手工端到端(jar 落盘加载)** + +```bash +mkdir -p ~/.mateclaw/plugins +cp /Users/connor/workspace/ai-lab/mateclaw/mateclaw-plugin-search-sample/target/mateclaw-plugin-search-sample-*.jar ~/.mateclaw/plugins/ +cd /Users/connor/workspace/ai-lab/mateclaw/mateclaw-server +mvn spring-boot:run +``` + +启动日志验证三点: +1. `Plugin loaded: mateclaw-plugin-search-demo v1.0.0 (type=search, ...)` +2. `插件搜索提供商已注册: demo-search (order=500)` +3. 无 id 冲突/rollback 报错。 + +再验证 disable 路径:登录(admin/admin123)→ 插件页关掉该插件 → 日志出现 `插件搜索提供商已反注册: demo-search` 与 `Plugin disabled: ... search=1`。 + +验证完清理: + +```bash +rm ~/.mateclaw/plugins/mateclaw-plugin-search-sample-*.jar +``` + +- [ ] **Step 3: 若有问题修复后补 commit;全部通过则进入 Task 8** + +--- + +### Task 8: 推分支 + 开 PR + +- [ ] **Step 1: 推到 origin(ncw1992120/mateclaw)** + +```bash +cd /Users/connor/workspace/ai-lab/mateclaw +git push -u origin feat/plugin-search-provider +``` + +- [ ] **Step 2: 开 PR 到 mateaix:dev** + +```bash +gh pr create --repo mateaix/mateclaw --base dev \ + --title "feat(plugin): 插件化搜索 Provider — PluginType.SEARCH + PluginSearchProvider SPI (#477)" \ + --body "$(cat <<'EOF' +Closes 部分 #477(PR-1:SDK + 桥接 + registry;PR-2 的 catalog 接口与设置页重构另行提交)。 + +## 改动 +- `mateclaw-plugin-api`: `PluginType.SEARCH` + `vip.mate.plugin.api.search` 包(`PluginSearchProvider`/`PluginSearchQuery`/`PluginSearchResult`,自包含、不依赖 server 类)+ `PluginContext.registerSearchProvider()` +- `mateclaw-server`: `PluginSearchBridge`(适配核心 `SearchProvider`);`SearchProviderRegistry` 支持运行时注册/反注册插件 provider(合并视图,id 冲突拒绝);`PluginManager` disable/rollback 反注册与现有四类对称;`PluginInfo` 暴露 `registeredSearchProviders` +- 新模块 `mateclaw-plugin-search-sample`:type=search 的最小参考实现(可配 baseUrl/apiKey 的 JSON 端点) + +## 测试 +- `SearchProviderRegistryPluginTest`(7):合并排序 / getById / 内置与插件 id 冲突 / resolve 三分支含插件项 / 反注册回退 +- `PluginSearchBridgeTest`(5):query/result 转换、count 钳制、null 归一、异常透传 +- `PluginContextImplSearchTest`(3):注册记录、冲突转 PluginException、空 id 拒绝 +- 手工端到端:sample jar 落盘 → 启动加载注册 → disable 反注册,日志均符合预期 + +无破坏性改动:接口新增方法/枚举值对存量插件透明;`GET/PUT /api/v1/settings` 未动。 +EOF +)" +``` + +- [ ] **Step 2 后**:PR 链接贴回 issue #477。 + +--- + +## 范围外(本计划不做) + +- PR-2:`GET /api/v1/settings/search-providers` catalog 接口、搜索设置页分组折叠、`Plugins.vue` schema 配置表单 —— PR-1 合并后单独出计划。 +- 面向插件作者的开发文档(仓库内暂无 plugin dev docs 先例,是否新增由上游在 PR 评审中定夺)。 diff --git a/docs/superpowers/specs/2026-07-03-plugin-search-provider-design.md b/docs/superpowers/specs/2026-07-03-plugin-search-provider-design.md new file mode 100644 index 00000000..0afd5b14 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-plugin-search-provider-design.md @@ -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` 收集 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 下拉菜单是写死的两个 `

+ * 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. * diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java index e6287ec1..e2de252e 100644 --- a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java @@ -17,5 +17,8 @@ public enum PluginType { CHANNEL, /** Register new memory providers */ - MEMORY + MEMORY, + + /** Register new web-search providers for the web_search tool */ + SEARCH } diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java new file mode 100644 index 00000000..ba67e2b2 --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java @@ -0,0 +1,53 @@ +package vip.mate.plugin.api.search; + +import java.util.List; + +/** + * SPI for plugin-provided web-search providers. + *

+ * 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). + *

+ * 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 search(PluginSearchQuery query); +} diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java new file mode 100644 index 00000000..68a84fd0 --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java @@ -0,0 +1,22 @@ +package vip.mate.plugin.api.search; + +/** + * Search query passed from the platform to a plugin search provider. + *

+ * 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 +) { +} diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java new file mode 100644 index 00000000..b10749d3 --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java @@ -0,0 +1,24 @@ +package vip.mate.plugin.api.search; + +/** + * A single search result returned by a plugin search provider. + *

+ * 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 +) { +} diff --git a/mateclaw-plugin-search-sample/pom.xml b/mateclaw-plugin-search-sample/pom.xml new file mode 100644 index 00000000..6959beee --- /dev/null +++ b/mateclaw-plugin-search-sample/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + + vip.mate + mateclaw + ${revision} + ../pom.xml + + + mateclaw-plugin-search-sample + jar + + MateClaw Search Provider Sample Plugin + Sample plugin registering a custom web-search provider via the MateClaw Plugin SDK + + + + + vip.mate + mateclaw-plugin-api + provided + + + + + org.springframework.ai + spring-ai-model + provided + + + + + com.fasterxml.jackson.core + jackson-databind + provided + + + + + org.slf4j + slf4j-api + provided + + + diff --git a/mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java b/mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java new file mode 100644 index 00000000..306e4918 --- /dev/null +++ b/mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java @@ -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 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 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 parse(String body) throws JsonProcessingException { + List 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; + } + } +} diff --git a/mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json b/mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json new file mode 100644 index 00000000..221ca6bd --- /dev/null +++ b/mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json @@ -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" + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java b/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java index 2c455883..6f2b9605 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java @@ -29,6 +29,9 @@ public class LoadedPlugin { /** Channel types registered by this plugin */ private final List registeredChannels = new ArrayList<>(); + /** Search provider ids registered by this plugin */ + private final List registeredSearchProviders = new ArrayList<>(); + /** Provider ID registered by this plugin (null if none) */ private String registeredProvider; diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java b/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java index 554f4db5..743b8888 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java @@ -12,9 +12,12 @@ import vip.mate.plugin.api.PluginException; import vip.mate.plugin.api.PluginManifest; import vip.mate.plugin.api.channel.PluginChannelAdapter; 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.PluginMemoryBridge; +import vip.mate.plugin.bridge.PluginSearchBridge; import vip.mate.tool.ToolRegistry; +import vip.mate.tool.search.SearchProviderRegistry; import com.fasterxml.jackson.databind.ObjectMapper; @@ -35,6 +38,7 @@ public class PluginContextImpl implements PluginContext { private final ChannelManager channelManager; private final MemoryManager memoryManager; private final ModelProviderService modelProviderService; + private final SearchProviderRegistry searchProviderRegistry; private final Map configMap; private final Logger logger; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -45,6 +49,7 @@ public class PluginContextImpl implements PluginContext { ChannelManager channelManager, MemoryManager memoryManager, ModelProviderService modelProviderService, + SearchProviderRegistry searchProviderRegistry, String configJson) { this.loadedPlugin = loadedPlugin; this.manifest = manifest; @@ -52,6 +57,7 @@ public class PluginContextImpl implements PluginContext { this.channelManager = channelManager; this.memoryManager = memoryManager; this.modelProviderService = modelProviderService; + this.searchProviderRegistry = searchProviderRegistry; this.logger = LoggerFactory.getLogger("plugin." + manifest.getName()); this.configMap = parseConfig(configJson); } @@ -105,6 +111,19 @@ public class PluginContextImpl implements PluginContext { 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 @SuppressWarnings("unchecked") public T getConfig(String key, Class type) { diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java b/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java index 0976c26f..e4c0a076 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java @@ -19,6 +19,7 @@ import vip.mate.plugin.model.PluginEntity; import vip.mate.plugin.model.PluginInfo; import vip.mate.plugin.repository.PluginMapper; import vip.mate.tool.ToolRegistry; +import vip.mate.tool.search.SearchProviderRegistry; import vip.mate.workspace.core.model.WorkspaceEntity; import vip.mate.workspace.core.service.WorkspaceService; @@ -57,6 +58,7 @@ public class PluginManager { private final ChannelManager channelManager; private final MemoryManager memoryManager; private final ModelProviderService modelProviderService; + private final SearchProviderRegistry searchProviderRegistry; private final Optional workspaceService; private final Map plugins = new ConcurrentHashMap<>(); @@ -211,6 +213,7 @@ public class PluginManager { PluginContextImpl context = new PluginContextImpl( loadedPlugin, manifest, toolRegistry, channelManager, memoryManager, modelProviderService, + searchProviderRegistry, configJson ); loadedPlugin.setContext(context); @@ -261,6 +264,9 @@ public class PluginManager { if (loaded.getRegisteredProvider() != null) { 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(); } + for (String searchId : loaded.getRegisteredSearchProviders()) { + searchProviderRegistry.unregisterPluginProvider(searchId); + } + int searchRemoved = loaded.getRegisteredSearchProviders().size(); + loaded.setEnabled(false); plugins.remove(name); 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, 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())) .registeredProvider(loaded.getRegisteredProvider()) .registeredMemoryProvider(loaded.getRegisteredMemoryProvider()) + .registeredSearchProviders(List.copyOf(loaded.getRegisteredSearchProviders())) .configSchema(buildConfigSchema(m)) .currentConfig(buildRedactedConfig(loaded)) .build()); @@ -387,6 +400,7 @@ public class PluginManager { .jarPath(entity.getJarPath()) .registeredTools(List.of()) .registeredChannels(List.of()) + .registeredSearchProviders(List.of()) .build()); } } diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java new file mode 100644 index 00000000..84b1b6ef --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java @@ -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). + *

+ * 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 search(String query, SystemSettingsDTO config) { + return search(SearchQuery.of(query), config); + } + + @Override + public List search(SearchQuery searchQuery, SystemSettingsDTO config) { + PluginSearchQuery pluginQuery = new PluginSearchQuery( + searchQuery.query(), + searchQuery.freshness(), + searchQuery.language(), + searchQuery.resolvedCount() + ); + List pluginResults = delegate.search(pluginQuery); + if (pluginResults == null) { + return List.of(); + } + List 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java index 817d5592..b576bb5e 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java @@ -38,6 +38,9 @@ public class PluginInfo { /** Memory provider ID registered by this plugin (null if none) */ private String registeredMemoryProvider; + /** Search provider ids registered by this plugin */ + private List registeredSearchProviders; + /** Plugin config schema (from manifest) */ private Map configSchema; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java index 3ef10131..fa8cd632 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java @@ -4,9 +4,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import vip.mate.system.model.SystemSettingsDTO; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.stream.Collectors; @@ -29,6 +31,9 @@ public class SearchProviderRegistry { private final List sortedProviders; private final Map providerMap; + /** 插件注册的 provider(运行时可变),与 Spring 注入的内置 provider 合并成完整视图 */ + private final ConcurrentHashMap pluginProviders = new ConcurrentHashMap<>(); + public SearchProviderRegistry(List providers) { this.sortedProviders = providers.stream() .sorted(Comparator.comparingInt(SearchProvider::autoDetectOrder)) @@ -39,14 +44,52 @@ public class SearchProviderRegistry { sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList()); } - /** 按 ID 获取指定 provider */ - public SearchProvider getById(String id) { - return providerMap.get(id); + /** + * 注册一个插件提供的 provider(issue #477)。 + * + * @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(内置 + 插件)。 + *

有插件注册时每次调用重新合并排序——provider 总数 <10,无需缓存。 + */ public List allSorted() { - return sortedProviders; + if (pluginProviders.isEmpty()) { + return sortedProviders; + } + List 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 String configuredId = config.getSearchProvider(); if (configuredId != null && !configuredId.isBlank()) { - SearchProvider configured = providerMap.get(configuredId); + SearchProvider configured = getById(configuredId); if (configured != null && configured.isAvailable(config)) { return new ResolvedProvider(configured, "configured"); } @@ -73,7 +116,7 @@ public class SearchProviderRegistry { // 2. 按优先级遍历,先找有 credential 的 SearchProvider keylessFallback = null; - for (SearchProvider p : sortedProviders) { + for (SearchProvider p : allSorted()) { if (!p.requiresCredential()) { // 记住第一个可用的 keyless provider if (keylessFallback == null && p.isAvailable(config)) { diff --git a/mateclaw-server/src/main/resources/docs/en/architecture.md b/mateclaw-server/src/main/resources/docs/en/architecture.md index 319f94ec..4bb206d9 100644 --- a/mateclaw-server/src/main/resources/docs/en/architecture.md +++ b/mateclaw-server/src/main/resources/docs/en/architecture.md @@ -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). +### 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 Bundle instructions + tools + optional scripts in a `SKILL.md`. Upload via the UI or API. Agents can invoke them at runtime. See [Skills](./skills). diff --git a/mateclaw-server/src/main/resources/docs/zh/architecture.md b/mateclaw-server/src/main/resources/docs/zh/architecture.md index 977f4fd8..80688f76 100644 --- a/mateclaw-server/src/main/resources/docs/zh/architecture.md +++ b/mateclaw-server/src/main/resources/docs/zh/architecture.md @@ -248,6 +248,27 @@ public interface ChannelAdapter { 通过 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)。 diff --git a/mateclaw-server/src/test/java/vip/mate/plugin/PluginContextImplSearchTest.java b/mateclaw-server/src/test/java/vip/mate/plugin/PluginContextImplSearchTest.java new file mode 100644 index 00000000..44b7eda0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/plugin/PluginContextImplSearchTest.java @@ -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 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()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java b/mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java new file mode 100644 index 00000000..3856bc4a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java @@ -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 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 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 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 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 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 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 search(PluginSearchQuery query) { + return fn.apply(query); + } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java b/mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java new file mode 100644 index 00000000..19b93055 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java @@ -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 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 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()); + } +} diff --git a/pom.xml b/pom.xml index f461901d..5d56af46 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ mateclaw-plugin-api mateclaw-server mateclaw-plugin-sample + mateclaw-plugin-search-sample