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 743b8888..78f1e889 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java @@ -39,7 +39,13 @@ public class PluginContextImpl implements PluginContext { private final MemoryManager memoryManager; private final ModelProviderService modelProviderService; private final SearchProviderRegistry searchProviderRegistry; - private final Map configMap; + /** + * Live config view: replaced wholesale by {@link #refreshConfig} when an admin + * saves new values, so {@link #getConfig} reflects updates without a plugin + * restart. Volatile reference to an immutable map — readers either see the old + * snapshot or the new one, never a torn state. + */ + private volatile Map configMap; private final Logger logger; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -62,6 +68,15 @@ public class PluginContextImpl implements PluginContext { this.configMap = parseConfig(configJson); } + /** + * Re-parse and swap the live config after {@code PluginManager.updateConfig} + * persists new values, so the running plugin's {@code getConfig} calls pick up + * the change immediately instead of serving load-time values until a restart. + */ + void refreshConfig(String configJson) { + this.configMap = parseConfig(configJson); + } + @SuppressWarnings("unchecked") private Map parseConfig(String configJson) { if (configJson == null || configJson.isBlank()) { 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 e4c0a076..9ef69cc5 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java @@ -419,14 +419,49 @@ public class PluginManager { } /** - * Update a plugin's configuration. + * 反查某个 search provider id 是由哪个已加载插件注册的(供设置页 catalog 用)。 + * + * @return 插件名(manifest 的 name),找不到返回 {@code null} */ + public String getPluginNameForSearchProvider(String searchProviderId) { + return plugins.values().stream() + .filter(p -> p.getRegisteredSearchProviders().contains(searchProviderId)) + .map(p -> p.getManifest().getName()) + .findFirst() + .orElse(null); + } + + /** + * Update a plugin's configuration. + *

+ * Merges the incoming (possibly partial) {@code config} over the existing stored + * config rather than replacing it wholesale. The plugin config dialog intentionally + * omits unchanged secret fields from its save payload — the frontend never receives + * plaintext secret values back from the backend (they're redacted), so it has no way + * to "resubmit unchanged"; omission is the only privacy-safe way to say "leave this + * as-is". If we treated the incoming map as the complete new config, every omitted + * field — including previously-configured secrets — would be silently deleted on + * every save. + */ + @SuppressWarnings("unchecked") public void updateConfig(String name, Map config) { PluginEntity entity = findByName(name); if (entity == null) { throw new PluginException("Plugin not found: " + name); } + // Parse the existing stored config so omitted keys can be carried forward. + Map mergedConfig = new LinkedHashMap<>(); + try { + if (entity.getConfigJson() != null && !entity.getConfigJson().isBlank()) { + mergedConfig.putAll(objectMapper.readValue(entity.getConfigJson(), Map.class)); + } + } catch (Exception e) { + log.warn("Plugin {} has unparsable stored config, discarding it: {}", name, e.getMessage()); + } + // Incoming values win for provided keys; everything else survives from the old config. + mergedConfig.putAll(config); + // Validate config keys against manifest if plugin is loaded LoadedPlugin loaded = plugins.get(name); if (loaded != null && loaded.getManifest().getConfig() != null) { @@ -436,17 +471,35 @@ public class PluginManager { log.warn("Plugin {} config: unknown key '{}' (not in manifest schema)", name, key); } } - // Check required fields + // Check required fields against the MERGED result — a required field that was + // already configured and is simply omitted from this save must not be treated + // as missing. Blank strings count as "not actually set", consistent with how + // SystemSettingService treats blank secret values as absent. for (Map.Entry schemaEntry : schema.entrySet()) { - if (schemaEntry.getValue().isRequired() && !config.containsKey(schemaEntry.getKey())) { - throw new PluginException("Missing required config field: " + schemaEntry.getKey()); + if (schemaEntry.getValue().isRequired()) { + Object value = mergedConfig.get(schemaEntry.getKey()); + boolean missing = !mergedConfig.containsKey(schemaEntry.getKey()) + || value == null + || (value instanceof String s && s.isBlank()); + if (missing) { + throw new PluginException("Missing required config field: " + schemaEntry.getKey()); + } } } } try { - entity.setConfigJson(objectMapper.writeValueAsString(config)); + String mergedJson = objectMapper.writeValueAsString(mergedConfig); + entity.setConfigJson(mergedJson); pluginMapper.updateById(entity); + // Push the new values into the RUNNING plugin's context too — configMap is + // parsed once at load time, so without this refresh the plugin would keep + // serving stale values from getConfig() until a disable/enable cycle, + // making the config dialog's "save" silently ineffective. + if (loaded != null && loaded.getContext() != null) { + loaded.getContext().refreshConfig(mergedJson); + log.info("Plugin config refreshed in running instance: {}", name); + } log.info("Plugin config updated: {}", name); } catch (PluginException e) { throw e; 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 index 813218f6..5d8bb1bb 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java @@ -1,5 +1,6 @@ package vip.mate.plugin.bridge; +import lombok.extern.slf4j.Slf4j; import vip.mate.plugin.api.search.PluginSearchProvider; import vip.mate.plugin.api.search.PluginSearchQuery; import vip.mate.plugin.api.search.PluginSearchResult; @@ -18,40 +19,64 @@ import java.util.List; * 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. + *

+ * Fault isolation: {@code search()} may throw (the caller's provider-fallback + * chain handles that), but everything consulted on unguarded paths is insulated + * from plugin code here — metadata ({@code id/label/requiresCredential/autoDetectOrder}) + * is snapshotted once at registration time (where a throw is caught and rolled + * back by the plugin loader), because it is later read inside {@code allSorted()}'s + * sort comparator and the settings catalog with no per-provider guard; and + * {@code isAvailable()} degrades to {@code false} on any plugin exception, because + * it runs inside {@code resolve()} on every web_search call — a throwing + * availability check must not take every other provider down with it. * * @author MateClaw Team */ +@Slf4j public class PluginSearchBridge implements SearchProvider { private final PluginSearchProvider delegate; + private final String id; + private final String label; + private final boolean requiresCredential; + private final int autoDetectOrder; public PluginSearchBridge(PluginSearchProvider delegate) { this.delegate = delegate; + this.id = delegate.id(); + this.label = delegate.label(); + this.requiresCredential = delegate.requiresCredential(); + this.autoDetectOrder = delegate.autoDetectOrder(); } @Override public String id() { - return delegate.id(); + return id; } @Override public String label() { - return delegate.label(); + return label; } @Override public boolean requiresCredential() { - return delegate.requiresCredential(); + return requiresCredential; } @Override public int autoDetectOrder() { - return delegate.autoDetectOrder(); + return autoDetectOrder; } @Override public boolean isAvailable(SystemSettingsDTO config) { - return delegate.isAvailable(); + try { + return delegate.isAvailable(); + } catch (Exception e) { + log.warn("插件搜索提供商 {} 的 isAvailable() 抛出异常,按不可用处理: {}", id, e.getMessage()); + return false; + } } @Override @@ -79,7 +104,7 @@ public class PluginSearchBridge implements SearchProvider { .snippet(r.snippet()) .source(r.source()) .date(r.date()) - .providerId(delegate.id()) + .providerId(id) .build()); } return results; diff --git a/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java b/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java index 35a5a452..ac1c1382 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java +++ b/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java @@ -6,6 +6,7 @@ import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; +import vip.mate.system.model.SearchProviderCatalogResponse; import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.service.SystemSettingService; import vip.mate.workspace.core.annotation.RequireGlobalAdmin; @@ -33,6 +34,13 @@ public class SystemSettingController { return R.ok(systemSettingService.saveSettings(dto)); } + @Operation(summary = "获取搜索 provider catalog(内置 + 插件),及当前实际生效的 provider") + @GetMapping("/search-providers") + @RequireWorkspaceRole("admin") + public R getSearchProviders() { + return R.ok(systemSettingService.getSearchProviderCatalog()); + } + @Operation(summary = "获取当前语言") @GetMapping("/language") public R getLanguage() { diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogEntry.java b/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogEntry.java new file mode 100644 index 00000000..46ed7772 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogEntry.java @@ -0,0 +1,24 @@ +package vip.mate.system.model; + +/** + * One row in the search-provider catalog exposed to the settings UI. + * + * @param id provider id (matches {@code SearchProvider.id()}) + * @param label display label + * @param builtin {@code true} for the four shipped providers, {@code false} for plugin-registered ones + * @param requiresCredential whether the provider needs an API key/credential + * @param available whether it's currently usable under the active config + * @param pluginName owning plugin's manifest name for plugin-registered providers; + * {@code null} for builtin providers, and also possibly {@code null} + * for a plugin provider if the owning plugin was unregistered + * concurrently with this lookup + */ +public record SearchProviderCatalogEntry( + String id, + String label, + boolean builtin, + boolean requiresCredential, + boolean available, + String pluginName +) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogResponse.java b/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogResponse.java new file mode 100644 index 00000000..f28c8ab2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogResponse.java @@ -0,0 +1,17 @@ +package vip.mate.system.model; + +import java.util.List; + +/** + * Response payload for {@code GET /api/v1/settings/search-providers}. + * + * @param providers all registered providers (builtin + plugin), sorted by autoDetectOrder + * @param resolvedId the id of the provider that would actually be used right now; {@code null} if none available + * @param resolvedSource why it was picked: "configured" / "auto-detect" / "keyless-fallback"; {@code null} when resolvedId is null + */ +public record SearchProviderCatalogResponse( + List providers, + String resolvedId, + String resolvedSource +) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index 8384afa8..d21431fc 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -1,14 +1,20 @@ package vip.mate.system.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import vip.mate.plugin.PluginManager; +import vip.mate.system.model.SearchProviderCatalogEntry; +import vip.mate.system.model.SearchProviderCatalogResponse; import vip.mate.system.model.SystemSettingEntity; import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.repository.SystemSettingMapper; +import vip.mate.tool.search.SearchProvider; +import vip.mate.tool.search.SearchProviderRegistry; + +import java.util.List; @Service -@RequiredArgsConstructor public class SystemSettingService { private static final String LANGUAGE_KEY = "language"; @@ -77,6 +83,29 @@ public class SystemSettingService { private static final String MINIMAX_REGION_KEY = "minimaxRegion"; private final SystemSettingMapper systemSettingMapper; + private final SearchProviderRegistry searchProviderRegistry; + + /** + * {@code PluginManager} is injected lazily because the bean graph is + * cyclic: {@code pluginManager → toolRegistry → i18nService → + * systemSettingService}. It is only consulted from + * {@link #toEntry} at request time (never at construction), so a lazy + * proxy is safe and breaks the cycle cleanly. Note: {@code @Lazy} must be + * applied via an explicit constructor (not {@code @RequiredArgsConstructor}) + * — Lombok does not copy field-level annotations onto the generated + * constructor parameter, so a Lombok-only {@code @Lazy} silently has no + * effect and Spring still resolves the bean eagerly. + */ + @Lazy + private final PluginManager pluginManager; + + public SystemSettingService(SystemSettingMapper systemSettingMapper, + SearchProviderRegistry searchProviderRegistry, + @Lazy PluginManager pluginManager) { + this.systemSettingMapper = systemSettingMapper; + this.searchProviderRegistry = searchProviderRegistry; + this.pluginManager = pluginManager; + } /** * Resolve the SearXNG base URL: DB value takes priority; fall back to the @@ -206,6 +235,37 @@ public class SystemSettingService { return dto; } + /** + * 搜索 provider catalog:内置 + 插件注册的全部 provider,标注是否可用、 + * 属于哪个插件,以及当前实际会被 resolve() 选中的是哪一个。 + */ + public SearchProviderCatalogResponse getSearchProviderCatalog() { + SystemSettingsDTO config = getSearchSettings(); + + List entries = searchProviderRegistry.allSorted().stream() + .map(p -> toEntry(p, config)) + .toList(); + + SearchProviderRegistry.ResolvedProvider resolved = searchProviderRegistry.resolve(config); + String resolvedId = resolved != null ? resolved.provider().id() : null; + String resolvedSource = resolved != null ? resolved.source() : null; + + return new SearchProviderCatalogResponse(entries, resolvedId, resolvedSource); + } + + private SearchProviderCatalogEntry toEntry(SearchProvider provider, SystemSettingsDTO config) { + boolean builtin = !searchProviderRegistry.isPluginProvider(provider.id()); + String pluginName = builtin ? null : pluginManager.getPluginNameForSearchProvider(provider.id()); + return new SearchProviderCatalogEntry( + provider.id(), + provider.label(), + builtin, + provider.requiresCredential(), + provider.isAvailable(config), + pluginName + ); + } + public SystemSettingsDTO saveSettings(SystemSettingsDTO dto) { saveValue(LANGUAGE_KEY, dto.getLanguage(), "当前界面语言"); saveValue(STREAM_ENABLED_KEY, String.valueOf(Boolean.TRUE.equals(dto.getStreamEnabled())), "是否开启流式响应"); 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 6522bfa6..fb624797 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 @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.stream.Collectors; @@ -34,6 +35,13 @@ public class SearchProviderRegistry { /** 插件注册的 provider(运行时可变),与 Spring 注入的内置 provider 合并成完整视图 */ private final ConcurrentHashMap pluginProviders = new ConcurrentHashMap<>(); + /** + * 注册写锁:大小写不敏感的冲突检测是"先检查后插入",两个并发注册大小写变体 + * ("Foo"/"foo")可能双双通过检查后各自落入不同 key——写路径必须原子化。 + * 读路径(getById/allSorted/resolve)仍走无锁的 ConcurrentHashMap。 + */ + private final Object registrationLock = new Object(); + public SearchProviderRegistry(List providers) { this.sortedProviders = providers.stream() .sorted(Comparator.comparingInt(SearchProvider::autoDetectOrder)) @@ -47,24 +55,39 @@ public class SearchProviderRegistry { /** * 注册一个插件提供的 provider。 * - * @throws IllegalArgumentException id 为空,或与内置/已注册插件 provider 冲突 + *

id 规则:不允许为空或含首尾空白(拒绝而非 trim——注册键必须与 + * {@code provider.id()} 完全一致,反注册才能对得上);存储与查找大小写敏感, + * 但冲突检测大小写不敏感,防止 "Serper" 这类变体在 UI 上与内置 "serper" 混淆。 + * + * @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)) { + if (!id.equals(id.trim())) { throw new IllegalArgumentException( - "Search provider id conflicts with a built-in provider: " + id); + "Search provider id must not contain leading/trailing whitespace: '" + id + "'"); } - if (pluginProviders.putIfAbsent(id, provider) != null) { - throw new IllegalArgumentException( - "Search provider id already registered by another plugin: " + id); + synchronized (registrationLock) { + if (containsIgnoreCase(providerMap.keySet(), id)) { + throw new IllegalArgumentException( + "Search provider id conflicts with a built-in provider: " + id); + } + if (containsIgnoreCase(pluginProviders.keySet(), id)) { + throw new IllegalArgumentException( + "Search provider id already registered by another plugin: " + id); + } + pluginProviders.put(id, provider); } log.info("插件搜索提供商已注册: {} (order={})", id, provider.autoDetectOrder()); } + private static boolean containsIgnoreCase(Set ids, String candidate) { + return ids.stream().anyMatch(existing -> existing.equalsIgnoreCase(candidate)); + } + /** 反注册插件 provider(disable / rollback 路径调用;id 不存在时静默) */ public void unregisterPluginProvider(String id) { if (pluginProviders.remove(id) != null) { @@ -72,6 +95,11 @@ public class SearchProviderRegistry { } } + /** 判断某个 id 是否由插件注册(而非内置 Spring bean) */ + public boolean isPluginProvider(String id) { + return pluginProviders.containsKey(id); + } + /** 按 ID 获取指定 provider(内置优先,其次插件注册区) */ public SearchProvider getById(String id) { SearchProvider builtin = providerMap.get(id); diff --git a/mateclaw-server/src/test/java/vip/mate/ApplicationContextSmokeTest.java b/mateclaw-server/src/test/java/vip/mate/ApplicationContextSmokeTest.java new file mode 100644 index 00000000..c3f7add6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/ApplicationContextSmokeTest.java @@ -0,0 +1,25 @@ +package vip.mate; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Guards against circular bean dependencies and other wiring mistakes that + * only surface when Spring actually constructs the full application context — + * invisible to Mockito-based unit tests, which never build the real bean graph. + *

+ * Added after a regression slipped through the unit suite: a circular dependency + * (SystemSettingService → PluginManager → ToolRegistry → I18nService → + * SystemSettingService) was introduced and went undetected by the full + * per-class unit test suite until a manual {@code spring-boot:run} smoke check. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +class ApplicationContextSmokeTest { + + @Test + void contextLoads() { + // Intentionally empty: if the ApplicationContext fails to start + // (missing bean, circular dependency, bad property, etc.), this + // test fails during Spring's context setup before the test body runs. + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerSearchLookupTest.java b/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerSearchLookupTest.java new file mode 100644 index 00000000..d4e2951b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerSearchLookupTest.java @@ -0,0 +1,112 @@ +package vip.mate.plugin; + +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.MateClawPlugin; +import vip.mate.plugin.api.PluginContext; +import vip.mate.plugin.api.PluginManifest; +import vip.mate.plugin.repository.PluginMapper; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.search.SearchProviderRegistry; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.lang.reflect.Field; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; + +/** + * PluginManager#getPluginNameForSearchProvider: reverse-lookup which loaded + * plugin registered a given search provider id, used by the settings catalog + * endpoint to show "managed by plugin X". + */ +class PluginManagerSearchLookupTest { + + private PluginManager manager() { + // Constructor param order MUST match PluginManager's field declaration order + // (Lombok @RequiredArgsConstructor): pluginProperties, pluginMapper, toolRegistry, + // channelManager, memoryManager, modelProviderService, searchProviderRegistry, workspaceService. + return new PluginManager( + mock(PluginProperties.class), + mock(PluginMapper.class), + mock(ToolRegistry.class), + mock(ChannelManager.class), + mock(MemoryManager.class), + mock(ModelProviderService.class), + new SearchProviderRegistry(List.of()), + Optional.empty()); + } + + private LoadedPlugin loadedPluginWithSearchIds(String name, String... searchIds) { + PluginManifest manifest = new PluginManifest(); + manifest.setName(name); + 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 loaded = new LoadedPlugin(manifest, plugin, + new URLClassLoader(new URL[0], getClass().getClassLoader())); + loaded.getRegisteredSearchProviders().addAll(List.of(searchIds)); + return loaded; + } + + @SuppressWarnings("unchecked") + private void seedPlugins(PluginManager manager, LoadedPlugin... loaded) throws Exception { + Field f = PluginManager.class.getDeclaredField("plugins"); + f.setAccessible(true); + Map map = (Map) f.get(manager); + for (LoadedPlugin l : loaded) { + map.put(l.getManifest().getName(), l); + } + } + + @Test + @DisplayName("finds the plugin name that registered the given search provider id") + void findsOwningPlugin() throws Exception { + PluginManager manager = manager(); + seedPlugins(manager, loadedPluginWithSearchIds("plugin-a", "my-search")); + + assertEquals("plugin-a", manager.getPluginNameForSearchProvider("my-search")); + } + + @Test + @DisplayName("discriminates between multiple loaded plugins, returning only the one that owns the id") + void discriminatesAmongMultiplePlugins() throws Exception { + PluginManager manager = manager(); + seedPlugins(manager, + loadedPluginWithSearchIds("plugin-a", "other-search"), + loadedPluginWithSearchIds("plugin-b", "my-search")); + + assertEquals("plugin-b", manager.getPluginNameForSearchProvider("my-search")); + } + + @Test + @DisplayName("returns null when no loaded plugin registered that id") + void returnsNullWhenNotFound() throws Exception { + PluginManager manager = manager(); + seedPlugins(manager, loadedPluginWithSearchIds("plugin-a", "other-search")); + + assertNull(manager.getPluginNameForSearchProvider("my-search")); + } + + @Test + @DisplayName("returns null for a built-in id no plugin ever registered") + void returnsNullForBuiltinId() throws Exception { + PluginManager manager = manager(); + + assertNull(manager.getPluginNameForSearchProvider("serper")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerUpdateConfigTest.java b/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerUpdateConfigTest.java new file mode 100644 index 00000000..56b6f068 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerUpdateConfigTest.java @@ -0,0 +1,184 @@ +package vip.mate.plugin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.channel.ChannelManager; +import vip.mate.llm.service.ModelProviderService; +import vip.mate.memory.spi.MemoryManager; +import vip.mate.plugin.api.MateClawPlugin; +import vip.mate.plugin.api.PluginContext; +import vip.mate.plugin.api.PluginException; +import vip.mate.plugin.api.PluginManifest; +import vip.mate.plugin.model.PluginEntity; +import vip.mate.plugin.repository.PluginMapper; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.search.SearchProviderRegistry; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.lang.reflect.Field; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * PluginManager#updateConfig must MERGE the incoming partial config onto the existing + * stored config, not overwrite it wholesale — the new Plugins.vue config dialog + * intentionally omits unchanged secret fields (it never receives plaintext secrets + * back from the backend to resubmit them), so "omitted" must mean "keep the old + * value", not "delete it". + */ +class PluginManagerUpdateConfigTest { + + private static final String PLUGIN_NAME = "plugin-a"; + private static final String ORIGINAL_CONFIG_JSON = + "{\"baseUrl\":\"https://example.com\",\"apiKey\":\"secret123\"}"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private PluginMapper pluginMapper; + + private PluginManager manager() { + // Constructor param order MUST match PluginManager's field declaration order + // (Lombok @RequiredArgsConstructor): pluginProperties, pluginMapper, toolRegistry, + // channelManager, memoryManager, modelProviderService, searchProviderRegistry, workspaceService. + pluginMapper = mock(PluginMapper.class); + return new PluginManager( + mock(PluginProperties.class), + pluginMapper, + mock(ToolRegistry.class), + mock(ChannelManager.class), + mock(MemoryManager.class), + mock(ModelProviderService.class), + new SearchProviderRegistry(List.of()), + Optional.empty()); + } + + private PluginEntity fixtureEntity(String configJson) { + PluginEntity entity = new PluginEntity(); + entity.setName(PLUGIN_NAME); + entity.setConfigJson(configJson); + entity.setEnabled(true); + return entity; + } + + private LoadedPlugin loadedPluginWithRequiredField(String name, String requiredKey) { + PluginManifest manifest = new PluginManifest(); + manifest.setName(name); + manifest.setVersion("1.0.0"); + manifest.setType("search"); + manifest.setEntrypoint("x.Y"); + + PluginManifest.ConfigField field = new PluginManifest.ConfigField(); + field.setType("string"); + field.setRequired(true); + field.setSecret(true); + manifest.setConfig(Map.of(requiredKey, field)); + + MateClawPlugin plugin = new MateClawPlugin() { + @Override public void onLoad(PluginContext ctx) { } + @Override public void onEnable() { } + @Override public void onDisable() { } + }; + return new LoadedPlugin(manifest, plugin, + new URLClassLoader(new URL[0], getClass().getClassLoader())); + } + + @SuppressWarnings("unchecked") + private void seedPlugins(PluginManager manager, LoadedPlugin... loaded) throws Exception { + Field f = PluginManager.class.getDeclaredField("plugins"); + f.setAccessible(true); + Map map = (Map) f.get(manager); + for (LoadedPlugin l : loaded) { + map.put(l.getManifest().getName(), l); + } + } + + @Test + @DisplayName("merges new values over existing config, preserving omitted keys") + void mergesNewValuesOverExistingConfig() throws Exception { + PluginManager manager = manager(); + when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON)); + + manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://new.example.com")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PluginEntity.class); + verify(pluginMapper).updateById(captor.capture()); + + Map persisted = objectMapper.readValue(captor.getValue().getConfigJson(), Map.class); + assertEquals("https://new.example.com", persisted.get("baseUrl")); + assertEquals("secret123", persisted.get("apiKey"), "omitted secret must be retained, not deleted"); + } + + @Test + @DisplayName("overwrites a key when explicitly provided, keeping other stored keys untouched") + void overwritesAKeyWhenExplicitlyProvided() throws Exception { + PluginManager manager = manager(); + when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON)); + + manager.updateConfig(PLUGIN_NAME, Map.of("apiKey", "newSecret456")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PluginEntity.class); + verify(pluginMapper).updateById(captor.capture()); + + Map persisted = objectMapper.readValue(captor.getValue().getConfigJson(), Map.class); + assertEquals("newSecret456", persisted.get("apiKey")); + assertEquals("https://example.com", persisted.get("baseUrl")); + } + + @Test + @DisplayName("required-field check passes when omitted but already present in stored config") + void requiredFieldCheckPassesWhenOmittedButAlreadyStoredFromBefore() throws Exception { + PluginManager manager = manager(); + seedPlugins(manager, loadedPluginWithRequiredField(PLUGIN_NAME, "apiKey")); + when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON)); + + assertDoesNotThrow(() -> + manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://only-this-changed.com"))); + } + + @Test + @DisplayName("required-field check still fails when the field has never been configured") + void requiredFieldCheckStillFailsWhenNeverConfigured() throws Exception { + PluginManager manager = manager(); + seedPlugins(manager, loadedPluginWithRequiredField(PLUGIN_NAME, "apiKey")); + when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity("{}")); + + assertThrows(PluginException.class, () -> + manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://only-this-changed.com"))); + } + + @Test + @DisplayName("running plugin's context sees the new config immediately after save (no restart needed)") + void runningPluginContextIsRefreshedAfterSave() throws Exception { + PluginManager manager = manager(); + LoadedPlugin loaded = loadedPluginWithRequiredField(PLUGIN_NAME, "apiKey"); + PluginContextImpl context = new PluginContextImpl( + loaded, loaded.getManifest(), + mock(ToolRegistry.class), mock(ChannelManager.class), + mock(MemoryManager.class), mock(ModelProviderService.class), + new SearchProviderRegistry(List.of()), + ORIGINAL_CONFIG_JSON); + loaded.setContext(context); + seedPlugins(manager, loaded); + when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON)); + + assertEquals("https://example.com", context.getConfig("baseUrl", String.class)); + + manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://new.example.com")); + + assertEquals("https://new.example.com", context.getConfig("baseUrl", String.class), + "getConfig must serve the saved value without a disable/enable cycle"); + assertEquals("secret123", context.getConfig("apiKey", String.class), + "omitted secret must survive the refresh via merge semantics"); + } +} 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 index 3856bc4a..baf6582e 100644 --- a/mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java +++ b/mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java @@ -10,9 +10,11 @@ import vip.mate.tool.search.SearchQuery; import vip.mate.tool.search.SearchResult; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -102,6 +104,50 @@ class PluginSearchBridgeTest { () -> bridge.search(SearchQuery.of("kw"), new SystemSettingsDTO())); } + @Test + @DisplayName("metadata is snapshotted at construction — plugin code never runs on sort/catalog reads") + void metadataSnapshottedAtConstruction() { + AtomicInteger metadataCalls = new AtomicInteger(); + PluginSearchProvider delegate = new PluginSearchProvider() { + @Override public String id() { metadataCalls.incrementAndGet(); return "snap-search"; } + @Override public String label() { metadataCalls.incrementAndGet(); return "Snap Search"; } + @Override public boolean requiresCredential() { metadataCalls.incrementAndGet(); return true; } + @Override public int autoDetectOrder() { metadataCalls.incrementAndGet(); return 500; } + @Override public boolean isAvailable() { return true; } + @Override public List search(PluginSearchQuery query) { return List.of(); } + }; + + PluginSearchBridge bridge = new PluginSearchBridge(delegate); + int callsAfterConstruction = metadataCalls.get(); + + // Repeated reads (what resolve()'s sort comparator and the catalog do) must + // serve the snapshot, not re-enter plugin code. + for (int i = 0; i < 3; i++) { + assertEquals("snap-search", bridge.id()); + assertEquals("Snap Search", bridge.label()); + assertTrue(bridge.requiresCredential()); + assertEquals(500, bridge.autoDetectOrder()); + } + assertEquals(callsAfterConstruction, metadataCalls.get(), + "metadata getters must not invoke plugin code after construction"); + } + + @Test + @DisplayName("a throwing isAvailable() degrades to unavailable instead of breaking resolve()") + void throwingIsAvailableDegradesToFalse() { + PluginSearchProvider delegate = new PluginSearchProvider() { + @Override public String id() { return "broken-search"; } + @Override public String label() { return "Broken Search"; } + @Override public boolean isAvailable() { throw new IllegalStateException("availability boom"); } + @Override public List search(PluginSearchQuery query) { return List.of(); } + }; + + PluginSearchBridge bridge = new PluginSearchBridge(delegate); + + assertFalse(bridge.isAvailable(new SystemSettingsDTO()), + "isAvailable runs inside resolve() on every web_search call with no per-provider guard — a plugin exception must degrade to false, not propagate"); + } + // ---- helpers ---- private interface SearchFn { diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java index 6aaffcdb..a4c6f55c 100644 --- a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java +++ b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java @@ -10,13 +10,18 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.plugin.PluginManager; import vip.mate.system.model.SystemSettingEntity; import vip.mate.system.repository.SystemSettingMapper; +import vip.mate.tool.search.SearchProviderRegistry; + +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -41,7 +46,7 @@ class SystemSettingBoolApiTest { @BeforeEach void setUp() { - service = new SystemSettingService(mapper); + service = new SystemSettingService(mapper, new SearchProviderRegistry(List.of()), mock(PluginManager.class)); } private SystemSettingEntity row(String value) { diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java new file mode 100644 index 00000000..999b2d3f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java @@ -0,0 +1,150 @@ +package vip.mate.system.service; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.plugin.PluginManager; +import vip.mate.system.model.SearchProviderCatalogResponse; +import vip.mate.system.model.SystemSettingEntity; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.system.repository.SystemSettingMapper; +import vip.mate.tool.search.SearchProvider; +import vip.mate.tool.search.SearchProviderRegistry; +import vip.mate.tool.search.SearchResult; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * SystemSettingService#getSearchProviderCatalog: aggregates SearchProviderRegistry + * (builtin + plugin providers) with PluginManager (owning-plugin lookup) into the + * catalog payload the settings UI renders. + */ +@ExtendWith(MockitoExtension.class) +class SystemSettingServiceCatalogTest { + + @Mock private SystemSettingMapper mapper; + @Mock private PluginManager pluginManager; + + private SystemSettingService service; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + SystemSettingEntity.class); + } + + 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 + "-label"; } + @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(); } + }; + } + + @BeforeEach + void setUp() { + when(mapper.selectOne(any())).thenReturn(null); // no DB rows -> defaults used by getSearchSettings() + } + + @Test + @DisplayName("marks builtin providers as builtin=true with no pluginName") + void builtinEntry() { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false))); + service = new SystemSettingService(mapper, registry, pluginManager); + + SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); + + assertEquals(1, catalog.providers().size()); + var entry = catalog.providers().get(0); + assertEquals("serper", entry.id()); + assertTrue(entry.builtin()); + assertNull(entry.pluginName()); + assertFalse(entry.available()); // not configured + } + + @Test + @DisplayName("marks plugin-registered providers as builtin=false with the owning pluginName") + void pluginEntry() { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of()); + registry.registerPluginProvider(stub("my-search", 500, true, true)); + when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin"); + service = new SystemSettingService(mapper, registry, pluginManager); + + SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); + + var entry = catalog.providers().get(0); + assertEquals("my-search", entry.id()); + assertFalse(entry.builtin()); + assertEquals("my-plugin", entry.pluginName()); + assertTrue(entry.available()); + } + + @Test + @DisplayName("mixed catalog: builtin and plugin providers both appear, correctly labeled and ordered") + void mixedBuiltinAndPluginCatalog() { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of( + stub("serper", 300, true, true), + stub("duckduckgo", 100, false, true))); + registry.registerPluginProvider(stub("my-search", 200, true, true)); + when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin"); + service = new SystemSettingService(mapper, registry, pluginManager); + + SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); + + assertEquals(3, catalog.providers().size()); + // Sorted by autoDetectOrder ascending: duckduckgo(100), my-search(200), serper(300) + assertEquals("duckduckgo", catalog.providers().get(0).id()); + assertTrue(catalog.providers().get(0).builtin()); + assertNull(catalog.providers().get(0).pluginName()); + + assertEquals("my-search", catalog.providers().get(1).id()); + assertFalse(catalog.providers().get(1).builtin()); + assertEquals("my-plugin", catalog.providers().get(1).pluginName()); + + assertEquals("serper", catalog.providers().get(2).id()); + assertTrue(catalog.providers().get(2).builtin()); + assertNull(catalog.providers().get(2).pluginName()); + } + + @Test + @DisplayName("surfaces the resolved provider id and source alongside the catalog") + void resolvedSurfaced() { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("duckduckgo", 100, false, true))); + service = new SystemSettingService(mapper, registry, pluginManager); + + SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); + + assertEquals("duckduckgo", catalog.resolvedId()); + assertEquals("keyless-fallback", catalog.resolvedSource()); + } + + @Test + @DisplayName("resolvedId/resolvedSource are null when no provider is available at all") + void resolvedNullWhenNothingAvailable() { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false))); + service = new SystemSettingService(mapper, registry, pluginManager); + + SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); + + assertNull(catalog.resolvedId()); + assertNull(catalog.resolvedSource()); + } +} 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 index 5ce10a78..ededf670 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java @@ -5,11 +5,15 @@ import org.junit.jupiter.api.Test; import vip.mate.system.model.SystemSettingsDTO; import java.util.List; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Plugin-provider mutability of {@link SearchProviderRegistry}: @@ -93,6 +97,85 @@ class SearchProviderRegistryPluginTest { () -> registry.registerPluginProvider(stub(null, 500, true, true))); } + @Test + @DisplayName("id with leading/trailing whitespace is rejected, not trimmed") + void paddedIdRejected() { + SearchProviderRegistry registry = registryWithBuiltins(); + + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub(" my-search", 500, true, true))); + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub("my-search ", 500, true, true))); + } + + @Test + @DisplayName("case-variant of a built-in id is rejected (no visual spoofing)") + void caseVariantOfBuiltinRejected() { + SearchProviderRegistry registry = registryWithBuiltins(); + + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub("Serper", 500, true, true))); + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub("DUCKDUCKGO", 500, true, true))); + } + + @Test + @DisplayName("case-variant of an already-registered plugin id is rejected") + void caseVariantOfPluginIdRejected() { + SearchProviderRegistry registry = registryWithBuiltins(); + registry.registerPluginProvider(stub("my-search", 500, true, true)); + + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub("My-Search", 501, true, true))); + } + + @Test + @DisplayName("concurrent registration of case-variants admits exactly one (no TOCTOU bypass)") + void concurrentCaseVariantRegistrationAdmitsExactlyOne() throws Exception { + // Plugins may call registerSearchProvider from arbitrary threads, so the + // case-insensitive conflict check must be atomic with the insert: without + // the registration lock, two threads registering "Foo"/"foo" could both + // pass the pre-check and land in different map keys. + for (int round = 0; round < 20; round++) { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of()); + var barrier = new CyclicBarrier(2); + var successes = new AtomicInteger(); + Runnable register = () -> { + String id = Thread.currentThread().getName().endsWith("-a") ? "Race-Search" : "race-search"; + try { + barrier.await(); + registry.registerPluginProvider(stub(id, 500, true, true)); + successes.incrementAndGet(); + } catch (IllegalArgumentException expected) { + // the loser — expected + } catch (Exception e) { + throw new IllegalStateException(e); + } + }; + Thread t1 = new Thread(register, "race-" + round + "-a"); + Thread t2 = new Thread(register, "race-" + round + "-b"); + t1.start(); + t2.start(); + t1.join(); + t2.join(); + + assertEquals(1, successes.get(), + "exactly one of the case-variant registrations may win (round " + round + ")"); + } + } + + @Test + @DisplayName("isPluginProvider distinguishes built-in ids from plugin-registered ids") + void isPluginProviderDistinguishesSource() { + SearchProviderRegistry registry = registryWithBuiltins(); + registry.registerPluginProvider(stub("my-search", 500, true, true)); + + assertTrue(registry.isPluginProvider("my-search")); + assertFalse(registry.isPluginProvider("serper")); + assertFalse(registry.isPluginProvider("duckduckgo")); + assertFalse(registry.isPluginProvider("does-not-exist")); + } + @Test @DisplayName("resolve honours an explicitly configured plugin provider") void resolvePicksConfiguredPluginProvider() { diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 158b7144..5321165d 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -675,6 +675,7 @@ export const settingsApi = { // sidestep JS Number precision loss on 19-digit Snowflake IDs. updateSidecar: (data: { defaultVisionModelId: number | string | null; defaultVideoModelId: number | string | null }) => http.put('/settings/sidecar', data), + getSearchProviders: () => http.get('/settings/search-providers'), } // ==================== Global outbound proxy ==================== diff --git a/mateclaw-ui/src/composables/__tests__/useSearchProviderCatalog.test.ts b/mateclaw-ui/src/composables/__tests__/useSearchProviderCatalog.test.ts new file mode 100644 index 00000000..a0e94527 --- /dev/null +++ b/mateclaw-ui/src/composables/__tests__/useSearchProviderCatalog.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + buildProviderOptions, + builtinFallbackCatalog, + resolveDefaultExpandedId, + resolveSourceLabelKey, +} from '../useSearchProviderCatalog' +import type { SearchProviderCatalog } from '@/types' + +const catalog: SearchProviderCatalog = { + providers: [ + { id: 'serper', label: 'Serper (Google)', builtin: true, requiresCredential: true, available: false, pluginName: null }, + { id: 'duckduckgo', label: 'DuckDuckGo', builtin: true, requiresCredential: false, available: true, pluginName: null }, + { id: 'my-search', label: 'My Search', builtin: false, requiresCredential: true, available: true, pluginName: 'my-plugin' }, + ], + resolvedId: 'duckduckgo', + resolvedSource: 'keyless-fallback', +} + +describe('buildProviderOptions', () => { + it('prepends an auto option with empty-string value', () => { + const options = buildProviderOptions(catalog, 'auto-label') + expect(options[0]).toEqual({ value: '', label: 'auto-label' }) + expect(options).toHaveLength(4) + }) + + it('maps each catalog entry to a value/label pair preserving order', () => { + const options = buildProviderOptions(catalog, 'auto-label') + expect(options.slice(1)).toEqual([ + { value: 'serper', label: 'Serper (Google)' }, + { value: 'duckduckgo', label: 'DuckDuckGo' }, + { value: 'my-search', label: 'My Search' }, + ]) + }) + + it('returns just the auto option when the catalog is empty', () => { + const options = buildProviderOptions({ providers: [], resolvedId: null, resolvedSource: null }, 'auto-label') + expect(options).toEqual([{ value: '', label: 'auto-label' }]) + }) + + it('appends the saved provider id when it is missing from the catalog', () => { + const options = buildProviderOptions(catalog, 'auto-label', 'vanished-plugin-search') + expect(options[options.length - 1]).toEqual({ value: 'vanished-plugin-search', label: 'vanished-plugin-search' }) + }) + + it('does not duplicate the saved provider id when it is already in the catalog', () => { + const options = buildProviderOptions(catalog, 'auto-label', 'serper') + expect(options.filter((o) => o.value === 'serper')).toHaveLength(1) + }) + + it('does not append anything for an empty or null saved value', () => { + expect(buildProviderOptions(catalog, 'auto-label', '')).toHaveLength(4) + expect(buildProviderOptions(catalog, 'auto-label', null)).toHaveLength(4) + }) +}) + +describe('builtinFallbackCatalog', () => { + it('contains exactly the four built-in providers, all marked builtin with no resolution', () => { + const fallback = builtinFallbackCatalog() + expect(fallback.providers.map((p) => p.id)).toEqual(['searxng', 'duckduckgo', 'serper', 'tavily']) + expect(fallback.providers.every((p) => p.builtin && p.pluginName === null)).toBe(true) + expect(fallback.resolvedId).toBeNull() + expect(fallback.resolvedSource).toBeNull() + }) +}) + +describe('resolveDefaultExpandedId', () => { + it('expands the resolved provider when present', () => { + expect(resolveDefaultExpandedId(catalog)).toBe('duckduckgo') + }) + + it('falls back to the first provider when nothing is resolved', () => { + const noneResolved = { ...catalog, resolvedId: null, resolvedSource: null } + expect(resolveDefaultExpandedId(noneResolved)).toBe('serper') + }) + + it('returns null when the catalog has no providers at all', () => { + expect(resolveDefaultExpandedId({ providers: [], resolvedId: null, resolvedSource: null })).toBeNull() + }) +}) + +describe('resolveSourceLabelKey', () => { + it('maps "configured" to "configured"', () => { + expect(resolveSourceLabelKey('configured')).toBe('configured') + }) + + it('maps "auto-detect" to "autoDetect"', () => { + expect(resolveSourceLabelKey('auto-detect')).toBe('autoDetect') + }) + + it('falls back to "keylessFallback" for "keyless-fallback"', () => { + expect(resolveSourceLabelKey('keyless-fallback')).toBe('keylessFallback') + }) + + it('falls back to "keylessFallback" for null or unrecognized values', () => { + expect(resolveSourceLabelKey(null)).toBe('keylessFallback') + expect(resolveSourceLabelKey('something-new')).toBe('keylessFallback') + }) +}) diff --git a/mateclaw-ui/src/composables/useSearchProviderCatalog.ts b/mateclaw-ui/src/composables/useSearchProviderCatalog.ts new file mode 100644 index 00000000..c3ead147 --- /dev/null +++ b/mateclaw-ui/src/composables/useSearchProviderCatalog.ts @@ -0,0 +1,84 @@ +import type { SearchProviderCatalog } from '@/types' + +export interface ProviderOption { + value: string + label: string +} + +/** + * Turns a catalog into still shows the real stored value. Without it the + * dropdown would render blank and a subsequent save would silently rewrite the + * setting to '' (auto) even though the admin never chose to change it. + */ +export function buildProviderOptions( + catalog: SearchProviderCatalog, + autoLabel: string, + currentId?: string | null, +): ProviderOption[] { + const options: ProviderOption[] = [{ value: '', label: autoLabel }] + for (const entry of catalog.providers) { + options.push({ value: entry.id, label: entry.label }) + } + if (currentId && !options.some((opt) => opt.value === currentId)) { + options.push({ value: currentId, label: currentId }) + } + return options +} + +/** Which provider card should be expanded by default: the currently-resolved one, else the first. */ +export function resolveDefaultExpandedId(catalog: SearchProviderCatalog): string | null { + if (catalog.resolvedId) return catalog.resolvedId + return catalog.providers.length > 0 ? catalog.providers[0].id : null +} + +/** + * Maps the backend's resolvedSource value to the i18n key suffix used under + * settings.searchResolvedSource.*. Falls back to 'keylessFallback' for any + * unrecognized or null value — but that fallback is now a named, visible + * decision here rather than an implicit template ternary. + */ +export function resolveSourceLabelKey(source: string | null): string { + if (source === 'configured') return 'configured' + if (source === 'auto-detect') return 'autoDetect' + return 'keylessFallback' +} + +/** + * Static stand-in for the four built-in providers, used when the catalog endpoint + * fails: the built-in config forms (which bind to plain SystemSettings fields and + * never depended on the catalog) stay reachable instead of the whole search section + * silently vanishing. `available` is unknown in this mode — callers should hide + * status badges rather than show a guessed state. + * + * ⚠ DRIFT COUPLING — these entries are a hand-maintained mirror of the backend's + * built-in providers and MUST be kept in sync when the backend changes them: + * - Source of truth: mateclaw-server/.../tool/search/*SearchProvider.java + * (id() + autoDetectOrder()). + * - id/label/requiresCredential must match each provider exactly. + * - Order must match ascending autoDetectOrder (searxng=50, duckduckgo=100, + * serper=300, tavily=400 today), because resolveDefaultExpandedId() falls + * back to providers[0] and the UI's default-expanded card should be the + * highest-priority keyless one. + * - If a built-in is added/removed/renamed, update BOTH this list AND the + * @@ -339,6 +446,70 @@ onMounted(() => { } .plugin-error svg { flex-shrink: 0; margin-top: 1px; } +.plugin-actions { + display: flex; + justify-content: flex-end; +} + +.btn-configure { padding: 6px 12px; font-size: 12px; } + +/* Config modal */ +.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; } +.modal { + background: var(--mc-surface, #fff); + border-radius: 12px; + width: 480px; + max-width: 100%; + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; +} +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--mc-border); +} +.modal-header h2 { font-size: 16px; margin: 0; color: var(--mc-text-primary); } +.modal-close { + background: none; border: none; cursor: pointer; font-size: 20px; line-height: 1; + color: var(--mc-text-tertiary); padding: 4px; +} +.modal-close:hover { color: var(--mc-text-primary); } +.modal-body { padding: 16px 20px; overflow-y: auto; } +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 20px; + border-top: 1px solid var(--mc-border); +} + +.config-field { margin: 14px 0; } +.config-field:first-child { margin-top: 0; } +.config-field label { display: block; font-weight: 600; margin-bottom: 4px; color: var(--mc-text-primary); font-size: 13px; } +.config-field-desc { font-size: 12px; color: var(--mc-text-tertiary); margin: 2px 0 6px; } +.config-field .form-input { + width: 100%; + border: 1px solid var(--mc-border, #e5e7eb); + border-radius: 8px; + padding: 8px 10px; + font-size: 13px; + box-sizing: border-box; + background: var(--mc-surface, #fff); + color: var(--mc-text-primary); +} +.required-mark { font-size: 12px; color: var(--mc-accent, #6366f1); font-weight: 400; margin-left: 6px; } + +.btn-primary { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; + background: var(--mc-accent, #6366f1); color: #fff; border: none; cursor: pointer; transition: 0.15s; +} +.btn-primary:hover { opacity: 0.9; } + /* Toggle switch (reuse pattern from Tools.vue) */ .toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; } .toggle-switch input { opacity: 0; width: 0; height: 0; } diff --git a/mateclaw-ui/src/views/Settings/System/index.vue b/mateclaw-ui/src/views/Settings/System/index.vue index f805e8b3..4674884a 100644 --- a/mateclaw-ui/src/views/Settings/System/index.vue +++ b/mateclaw-ui/src/views/Settings/System/index.vue @@ -73,12 +73,27 @@

+
+
+
⚠ {{ t('settings.searchCatalogError') }}
+
+
+ +
+
+
+ ✓ {{ t('settings.searchResolvedLabel') }}: + {{ providerCatalog.providers.find(p => p.id === providerCatalog.resolvedId)?.label }} + ({{ t('settings.searchResolvedSource.' + (resolveSourceLabelKey(providerCatalog.resolvedSource))) }}) +
+
+
+
{{ t('settings.fields.searchFallbackEnabled') }}
@@ -92,101 +107,124 @@
- -
-
-
{{ t('settings.fields.serperApiKey') }}
-
{{ t('settings.hints.serperApiKey') }}
-
-
- -
-
+
+ -
-
-
{{ t('settings.fields.serperBaseUrl') }}
-
{{ t('settings.hints.serperBaseUrl') }}
-
-
- -
-
+
+ +

+ {{ t('settings.searchPluginManaged', { plugin: entry.pluginName }) }} + {{ t('settings.searchGoToPlugins') }} +

- -
-
-
{{ t('settings.fields.tavilyApiKey') }}
-
{{ t('settings.hints.tavilyApiKey') }}
-
-
- -
-
- -
-
-
{{ t('settings.fields.tavilyBaseUrl') }}
-
{{ t('settings.hints.tavilyBaseUrl') }}
-
-
- -
-
- - -
-
-
{{ t('settings.fields.duckduckgoEnabled') }}
-
{{ t('settings.hints.duckduckgoEnabled') }}
-
-
- -
-
- -
-
-
{{ t('settings.fields.searxngBaseUrl') }}
-
{{ t('settings.hints.searxngBaseUrl') }}
-
-
- + + + + +
@@ -201,12 +239,13 @@