feat(plugin/settings): search-provider catalog endpoint + grouped settings UI + plugin config form

Adds a read-only GET /api/v1/settings/search-providers catalog (admin-gated, no secrets), grouped collapsible provider cards, and a schema-driven plugin config form. Breaks a SystemSettingService<->PluginManager circular dependency via parameter @Lazy (with a context smoke test), and fixes PluginManager.updateConfig to merge instead of overwrite so omitted/blank secret fields are preserved. Hardens plugin search-provider id validation (reject-not-trim, case-insensitive conflict) and insulates the provider bridge hot path from throwing plugin code.
This commit is contained in:
倪程伟 2026-07-04 12:06:33 +08:00 committed by GitHub
parent 9fb3550e91
commit 53a12ee18d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1458 additions and 121 deletions

View File

@ -39,7 +39,13 @@ public class PluginContextImpl implements PluginContext {
private final MemoryManager memoryManager;
private final ModelProviderService modelProviderService;
private final SearchProviderRegistry searchProviderRegistry;
private final Map<String, Object> 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<String, Object> 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<String, Object> parseConfig(String configJson) {
if (configJson == null || configJson.isBlank()) {

View File

@ -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.
* <p>
* 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<String, Object> 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<String, Object> 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<String, PluginManifest.ConfigField> 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;

View File

@ -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.
* <p>
* 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;

View File

@ -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<SearchProviderCatalogResponse> getSearchProviders() {
return R.ok(systemSettingService.getSearchProviderCatalog());
}
@Operation(summary = "获取当前语言")
@GetMapping("/language")
public R<String> getLanguage() {

View File

@ -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
) {
}

View File

@ -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<SearchProviderCatalogEntry> providers,
String resolvedId,
String resolvedSource
) {
}

View File

@ -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<SearchProviderCatalogEntry> 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())), "是否开启流式响应");

View File

@ -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<String, SearchProvider> pluginProviders = new ConcurrentHashMap<>();
/**
* 注册写锁大小写不敏感的冲突检测是"先检查后插入"两个并发注册大小写变体
* "Foo"/"foo"可能双双通过检查后各自落入不同 key写路径必须原子化
* 读路径getById/allSorted/resolve仍走无锁的 ConcurrentHashMap
*/
private final Object registrationLock = new Object();
public SearchProviderRegistry(List<SearchProvider> providers) {
this.sortedProviders = providers.stream()
.sorted(Comparator.comparingInt(SearchProvider::autoDetectOrder))
@ -47,24 +55,39 @@ public class SearchProviderRegistry {
/**
* 注册一个插件提供的 provider
*
* @throws IllegalArgumentException id 为空或与内置/已注册插件 provider 冲突
* <p>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<String> ids, String candidate) {
return ids.stream().anyMatch(existing -> existing.equalsIgnoreCase(candidate));
}
/** 反注册插件 providerdisable / 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);

View File

@ -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.
* <p>
* 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.
}
}

View File

@ -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.<WorkspaceService>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<String, LoadedPlugin> map = (Map<String, LoadedPlugin>) 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"));
}
}

View File

@ -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.<WorkspaceService>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<String, LoadedPlugin> map = (Map<String, LoadedPlugin>) 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<PluginEntity> captor = ArgumentCaptor.forClass(PluginEntity.class);
verify(pluginMapper).updateById(captor.capture());
Map<String, Object> 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<PluginEntity> captor = ArgumentCaptor.forClass(PluginEntity.class);
verify(pluginMapper).updateById(captor.capture());
Map<String, Object> 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");
}
}

View File

@ -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<PluginSearchResult> 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<PluginSearchResult> 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 {

View File

@ -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) {

View File

@ -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<SearchResult> 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());
}
}

View File

@ -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() {

View File

@ -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 ====================

View File

@ -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')
})
})

View File

@ -0,0 +1,84 @@
import type { SearchProviderCatalog } from '@/types'
export interface ProviderOption {
value: string
label: string
}
/**
* Turns a catalog into <select> options, with a synthetic "auto" option prepended (value '').
*
* When `currentId` (the currently-saved provider id) is set but missing from the
* catalog catalog fetch failed, or the owning plugin was disabled an option for
* it is appended so the <select> 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
* <template v-if="entry.id === '...'"> form blocks in Settings/System/index.vue.
* These ids are NOT a stable public contract they are internal keys that have
* just never changed. There is a matching unit test (builtinFallbackCatalog)
* that pins the current set, so an accidental local edit will fail tests; it
* cannot catch a backend-only change, hence this comment.
*/
export function builtinFallbackCatalog(): SearchProviderCatalog {
return {
providers: [
{ id: 'searxng', label: 'SearXNG', builtin: true, requiresCredential: false, available: false, pluginName: null },
{ id: 'duckduckgo', label: 'DuckDuckGo', builtin: true, requiresCredential: false, available: false, pluginName: null },
{ id: 'serper', label: 'Serper (Google)', builtin: true, requiresCredential: true, available: false, pluginName: null },
{ id: 'tavily', label: 'Tavily', builtin: true, requiresCredential: true, available: false, pluginName: null },
],
resolvedId: null,
resolvedSource: null,
}
}

View File

@ -1008,6 +1008,7 @@ export default {
tavilyBaseUrl: 'Tavily Base URL',
duckduckgoEnabled: 'DuckDuckGo (Keyless)',
searxngBaseUrl: 'SearXNG Base URL',
searchProviderAuto: 'Auto (recommended)',
sttEnabled: 'Enable Speech Recognition',
sttProvider: 'Preferred STT Provider',
sttFallbackEnabled: 'Provider Fallback',
@ -1056,6 +1057,7 @@ export default {
tavilyBaseUrl: 'Usually no need to change unless using a custom proxy.',
duckduckgoEnabled: 'Free keyless search fallback. No API key required. Enabled by default as a zero-config fallback.',
searxngBaseUrl: 'Self-hosted SearXNG instance URL. Auto-configured when deploying via Docker.',
searchProviderAuto: 'Let the system auto-pick the first configured provider by priority.',
sttEnabled: 'Enable speech-to-text for voice messages. Both providers reuse existing API keys.',
sttProvider: 'Select preferred STT provider. Auto mode picks the first available one.',
sttFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.',
@ -1106,6 +1108,19 @@ export default {
},
searchTitle: 'Search Service',
searchDesc: 'Configure the built-in search tool provider and API credentials',
searchCatalogError: 'Failed to load the search provider catalog — showing built-in provider configuration only; plugin providers and status info are unavailable.',
searchResolvedLabel: 'Currently active',
searchResolvedSource: {
configured: 'manually configured',
autoDetect: 'auto-detected',
keylessFallback: 'keyless fallback',
},
searchStatusConfigured: 'Configured',
searchStatusNotConfigured: 'Not configured',
searchStatusActive: 'Active',
searchStatusNoCredential: 'No credential needed',
searchPluginManaged: 'Provided by plugin "{plugin}" — configure it on the Plugins page',
searchGoToPlugins: 'Go to Plugins →',
proxyTitle: 'Network Proxy',
proxyDesc: 'Configure a global HTTP / SOCKS proxy for all outbound requests (LLM APIs, web search, channel bridging, etc.). For environments that cannot reach overseas APIs directly or require a unified egress.',
proxy: {
@ -2131,6 +2146,15 @@ export default {
channels: 'Channels',
provider: 'Provider',
memoryProvider: 'Memory',
searchProviders: 'Search providers',
configure: 'Configure',
configTitle: 'Plugin configuration',
configSave: 'Save',
configCancel: 'Cancel',
configSaved: 'Configuration saved',
configFailed: 'Failed to save plugin configuration',
configRequired: 'required',
configSecretPlaceholder: '(leave blank to keep unchanged)',
noDescription: 'No description',
emptyTitle: 'No plugins installed',
emptyHint: 'Place plugin JAR files in ~/.mateclaw/plugins/ and restart the server',

View File

@ -870,6 +870,7 @@ export default {
tavilyBaseUrl: 'Tavily 接口地址',
duckduckgoEnabled: 'DuckDuckGo免 Key',
searxngBaseUrl: 'SearXNG 地址',
searchProviderAuto: '自动选择(推荐)',
// STT 语音识别
sttEnabled: '启用语音识别',
sttProvider: '首选 STT 提供商',
@ -924,6 +925,7 @@ export default {
tavilyBaseUrl: '通常无需修改,除非使用自定义代理地址。',
duckduckgoEnabled: '免费搜索兜底,无需 API Key。默认开启作为零配置下的搜索降级方案。',
searxngBaseUrl: '自部署 SearXNG 实例地址。Docker 部署时自动配置。',
searchProviderAuto: '让系统按优先级自动挑选一个已配置好的 provider。',
// STT 语音识别
sttEnabled: '开启后支持语音消息转文字。OpenAI Whisper 和 DashScope Paraformer 均复用已有 Key。',
sttProvider: '选择首选 STT 提供商auto 模式自动选择可用的提供商。',
@ -980,6 +982,19 @@ export default {
},
searchTitle: '搜索服务',
searchDesc: '配置内置搜索工具的提供商与 API 凭证',
searchCatalogError: '无法加载搜索提供商目录,仅显示内置提供商配置;插件提供商与状态信息暂不可用。',
searchResolvedLabel: '当前实际生效',
searchResolvedSource: {
configured: '手动指定',
autoDetect: '自动探测',
keylessFallback: '免 Key 兜底',
},
searchStatusConfigured: '已配置',
searchStatusNotConfigured: '未配置',
searchStatusActive: '生效中',
searchStatusNoCredential: '无需配置',
searchPluginManaged: '该 Provider 由插件「{plugin}」提供,请在插件页配置',
searchGoToPlugins: '前往插件页 →',
proxyTitle: '网络代理',
proxyDesc: '为所有出站请求LLM API、网页搜索、频道桥接等配置全局 HTTP / SOCKS 代理。适用于无法直连海外 API、或要求统一出口的网络环境。',
proxy: {
@ -2005,6 +2020,15 @@ export default {
channels: '渠道',
provider: '提供商',
memoryProvider: '记忆',
searchProviders: '搜索提供商',
configure: '配置',
configTitle: '插件配置',
configSave: '保存',
configCancel: '取消',
configSaved: '配置已保存',
configFailed: '保存插件配置失败',
configRequired: '必填',
configSecretPlaceholder: '(留空表示不修改)',
noDescription: '暂无描述',
emptyTitle: '暂无已安装插件',
emptyHint: '将插件 JAR 文件放入 ~/.mateclaw/plugins/ 目录后重启服务',

View File

@ -830,6 +830,21 @@ export interface SystemSettings {
klingSecretKeyMasked?: string
}
export interface SearchProviderCatalogEntry {
id: string
label: string
builtin: boolean
requiresCredential: boolean
available: boolean
pluginName: string | null
}
export interface SearchProviderCatalog {
providers: SearchProviderCatalogEntry[]
resolvedId: string | null
resolvedSource: string | null
}
export interface ProviderModelInfo {
id: string
name: string

View File

@ -82,6 +82,17 @@
<span class="capability-label">{{ t('plugins.memoryProvider') }}:</span>
<span class="capability-tag">{{ plugin.registeredMemoryProvider }}</span>
</div>
<div class="capability-section" v-if="plugin.registeredSearchProviders?.length">
<span class="capability-label">{{ t('plugins.searchProviders') }}:</span>
<span class="capability-tag" v-for="sp in plugin.registeredSearchProviders" :key="sp">{{ sp }}</span>
</div>
</div>
<!-- Plugin actions -->
<div class="plugin-actions" v-if="plugin.configSchema && Object.keys(plugin.configSchema).length > 0">
<button type="button" class="btn-secondary btn-configure" @click="openConfigDialog(plugin)">
{{ t('plugins.configure') }}
</button>
</div>
<!-- Error message -->
@ -105,19 +116,65 @@
<p class="empty-title">{{ t('plugins.emptyTitle') }}</p>
<p class="empty-hint">{{ t('plugins.emptyHint') }}</p>
</div>
<!-- Config modal -->
<div v-if="configDialogPlugin" class="modal-overlay" @click.self="closeConfigDialog">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>{{ t('plugins.configTitle') }} {{ configDialogPlugin.displayName || configDialogPlugin.name }}</h2>
<button type="button" class="modal-close" @click="closeConfigDialog" aria-label="close">&times;</button>
</div>
<div class="modal-body">
<div v-for="(field, key) in configDialogPlugin.configSchema" :key="key" class="config-field">
<label :for="`plugin-config-${key}`">
{{ key }}
<span v-if="field.required" class="required-mark">*{{ t('plugins.configRequired') }}</span>
</label>
<p v-if="field.description" class="config-field-desc">{{ field.description }}</p>
<input
v-if="field.secret"
:id="`plugin-config-${key}`"
type="password"
v-model="configDraft[key]"
class="form-input"
:placeholder="t('plugins.configSecretPlaceholder')"
autocomplete="off"
/>
<input
v-else
:id="`plugin-config-${key}`"
type="text"
v-model="configDraft[key]"
class="form-input"
/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" @click="closeConfigDialog">{{ t('plugins.configCancel') }}</button>
<button type="button" class="btn-primary" @click="saveConfigDialog">{{ t('plugins.configSave') }}</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { pluginApi } from '@/api'
const { t } = useI18n()
interface PluginConfigField {
type: string
required?: boolean
secret?: boolean
description?: string
}
interface PluginInfo {
name: string
version: string
@ -133,6 +190,9 @@ interface PluginInfo {
registeredChannels?: string[]
registeredProvider?: string
registeredMemoryProvider?: string
registeredSearchProviders?: string[]
configSchema?: Record<string, PluginConfigField>
currentConfig?: Record<string, any>
}
const plugins = ref<PluginInfo[]>([])
@ -179,12 +239,59 @@ function hasCapabilities(plugin: PluginInfo): boolean {
plugin.registeredTools?.length ||
plugin.registeredChannels?.length ||
plugin.registeredProvider ||
plugin.registeredMemoryProvider
plugin.registeredMemoryProvider ||
plugin.registeredSearchProviders?.length
)
}
const configDialogPlugin = ref<PluginInfo | null>(null)
const configDraft = ref<Record<string, string>>({})
function openConfigDialog(plugin: PluginInfo) {
configDialogPlugin.value = plugin
const draft: Record<string, string> = {}
for (const key of Object.keys(plugin.configSchema || {})) {
// secret fields always start blank (never echo plaintext); non-secret fields echo the current value
draft[key] = plugin.configSchema![key].secret ? '' : (plugin.currentConfig?.[key] ?? '')
}
configDraft.value = draft
}
function closeConfigDialog() {
configDialogPlugin.value = null
configDraft.value = {}
}
async function saveConfigDialog() {
if (!configDialogPlugin.value) return
// Only submit non-blank fields: blank secret means "keep unchanged"; non-secret fields may submit an empty string to overwrite.
const payload: Record<string, string> = {}
for (const [key, value] of Object.entries(configDraft.value)) {
const schema = configDialogPlugin.value.configSchema![key]
if (schema.secret && !value) continue
payload[key] = value
}
try {
await pluginApi.updateConfig(configDialogPlugin.value.name, payload)
mcToast.success(t('plugins.configSaved'))
closeConfigDialog()
await refresh()
} catch (e: any) {
mcToast.error(e?.message || t('plugins.configFailed'))
}
}
function handleEscapeKey(e: KeyboardEvent) {
if (e.key === 'Escape' && configDialogPlugin.value) closeConfigDialog()
}
onMounted(() => {
loadPlugins()
window.addEventListener('keydown', handleEscapeKey)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleEscapeKey)
})
</script>
@ -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; }

View File

@ -73,12 +73,27 @@
</div>
<div class="setting-control">
<select v-model="settings.searchProvider" class="form-input" :disabled="!settings.searchEnabled">
<option value="serper">Serper (Google)</option>
<option value="tavily">Tavily</option>
<option v-for="opt in providerOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</div>
<div v-if="catalogError" class="setting-item">
<div class="setting-info">
<div class="setting-hint catalog-error-hint"> {{ t('settings.searchCatalogError') }}</div>
</div>
</div>
<div v-if="providerCatalog.resolvedId" class="setting-item">
<div class="setting-info">
<div class="setting-hint">
{{ t('settings.searchResolvedLabel') }}:
{{ providerCatalog.providers.find(p => p.id === providerCatalog.resolvedId)?.label }}
{{ t('settings.searchResolvedSource.' + (resolveSourceLabelKey(providerCatalog.resolvedSource))) }}
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.searchFallbackEnabled') }}</div>
@ -92,101 +107,124 @@
</div>
</div>
<!-- Serper 配置 -->
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.serperApiKey') }}</div>
<div class="setting-hint">{{ t('settings.hints.serperApiKey') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="serperApiKeyInput"
type="password"
class="form-input"
:placeholder="settings.serperApiKeyMasked || t('settings.model.apiKeyInput')"
:disabled="!settings.searchEnabled"
autocomplete="off"
/>
</div>
</div>
<div v-for="entry in providerCatalog.providers" :key="entry.id" class="provider-card">
<button type="button" class="provider-card-header" @click="toggleExpanded(entry.id)">
<span class="provider-card-name">{{ entry.label }}</span>
<!-- In catalog-fallback mode availability is unknown hide badges rather than guess -->
<span v-if="!catalogError" class="provider-card-badges">
<span v-if="entry.id === providerCatalog.resolvedId" class="badge badge-active">{{ t('settings.searchStatusActive') }}</span>
<span v-else-if="!entry.requiresCredential" class="badge">{{ t('settings.searchStatusNoCredential') }}</span>
<span v-else-if="entry.available" class="badge badge-ok">{{ t('settings.searchStatusConfigured') }}</span>
<span v-else class="badge badge-warn">{{ t('settings.searchStatusNotConfigured') }}</span>
</span>
<span class="provider-card-chevron">{{ isExpanded(entry.id) ? '▾' : '▸' }}</span>
</button>
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.serperBaseUrl') }}</div>
<div class="setting-hint">{{ t('settings.hints.serperBaseUrl') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="settings.serperBaseUrl"
type="text"
class="form-input"
placeholder="https://google.serper.dev/search"
:disabled="!settings.searchEnabled"
/>
</div>
</div>
<div v-if="isExpanded(entry.id)" class="provider-card-body">
<!-- Plugin-provided entry: no form here, point to the plugin page instead -->
<p v-if="!entry.builtin" class="setting-hint">
{{ t('settings.searchPluginManaged', { plugin: entry.pluginName }) }}
<router-link to="/plugins">{{ t('settings.searchGoToPlugins') }}</router-link>
</p>
<!-- Tavily 配置 -->
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.tavilyApiKey') }}</div>
<div class="setting-hint">{{ t('settings.hints.tavilyApiKey') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="tavilyApiKeyInput"
type="password"
class="form-input"
:placeholder="settings.tavilyApiKeyMasked || t('settings.model.apiKeyInput')"
:disabled="!settings.searchEnabled"
autocomplete="off"
/>
</div>
</div>
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.tavilyBaseUrl') }}</div>
<div class="setting-hint">{{ t('settings.hints.tavilyBaseUrl') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="settings.tavilyBaseUrl"
type="text"
class="form-input"
placeholder="https://api.tavily.com/search"
:disabled="!settings.searchEnabled"
/>
</div>
</div>
<!-- Keyless Provider 配置 -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.duckduckgoEnabled') }}</div>
<div class="setting-hint">{{ t('settings.hints.duckduckgoEnabled') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.duckduckgoEnabled" type="checkbox" :disabled="!settings.searchEnabled" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.searxngBaseUrl') }}</div>
<div class="setting-hint">{{ t('settings.hints.searxngBaseUrl') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="settings.searxngBaseUrl"
type="text"
class="form-input"
placeholder="http://searxng:8080"
:disabled="!settings.searchEnabled"
/>
<!-- Built-in providers: same fields/logic as before, keyed by id -->
<template v-if="entry.id === 'serper'">
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.serperApiKey') }}</div>
<div class="setting-hint">{{ t('settings.hints.serperApiKey') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="serperApiKeyInput"
type="password"
class="form-input"
:placeholder="settings.serperApiKeyMasked || t('settings.model.apiKeyInput')"
:disabled="!settings.searchEnabled"
autocomplete="off"
/>
</div>
</div>
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.serperBaseUrl') }}</div>
<div class="setting-hint">{{ t('settings.hints.serperBaseUrl') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="settings.serperBaseUrl"
type="text"
class="form-input"
placeholder="https://google.serper.dev/search"
:disabled="!settings.searchEnabled"
/>
</div>
</div>
</template>
<template v-else-if="entry.id === 'tavily'">
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.tavilyApiKey') }}</div>
<div class="setting-hint">{{ t('settings.hints.tavilyApiKey') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="tavilyApiKeyInput"
type="password"
class="form-input"
:placeholder="settings.tavilyApiKeyMasked || t('settings.model.apiKeyInput')"
:disabled="!settings.searchEnabled"
autocomplete="off"
/>
</div>
</div>
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.tavilyBaseUrl') }}</div>
<div class="setting-hint">{{ t('settings.hints.tavilyBaseUrl') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="settings.tavilyBaseUrl"
type="text"
class="form-input"
placeholder="https://api.tavily.com/search"
:disabled="!settings.searchEnabled"
/>
</div>
</div>
</template>
<template v-else-if="entry.id === 'duckduckgo'">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.duckduckgoEnabled') }}</div>
<div class="setting-hint">{{ t('settings.hints.duckduckgoEnabled') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.duckduckgoEnabled" type="checkbox" :disabled="!settings.searchEnabled" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
</template>
<template v-else-if="entry.id === 'searxng'">
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.searxngBaseUrl') }}</div>
<div class="setting-hint">{{ t('settings.hints.searxngBaseUrl') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="settings.searxngBaseUrl"
type="text"
class="form-input"
placeholder="http://searxng:8080"
:disabled="!settings.searchEnabled"
/>
</div>
</div>
</template>
</div>
</div>
</div>
@ -201,12 +239,13 @@
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { onMounted, reactive, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { settingsApi } from '@/api'
import { applyLocale } from '@/i18n'
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
import type { SystemSettings } from '@/types'
import { buildProviderOptions, builtinFallbackCatalog, resolveDefaultExpandedId, resolveSourceLabelKey } from '@/composables/useSearchProviderCatalog'
import type { SystemSettings, SearchProviderCatalog } from '@/types'
const { t } = useI18n()
const systemSettingsStore = useSystemSettingsStore()
@ -216,6 +255,39 @@ const savedTip = ref('')
const serperApiKeyInput = ref('')
const tavilyApiKeyInput = ref('')
const providerCatalog = ref<SearchProviderCatalog>({ providers: [], resolvedId: null, resolvedSource: null })
const expandedProviderId = ref<string | null>(null)
const catalogError = ref(false)
// Pass the saved provider id so it stays selectable even when the catalog
// doesn't contain it (fetch failure / owning plugin disabled) otherwise the
// select renders blank and a save would silently rewrite the value to ''.
const providerOptions = computed(() =>
buildProviderOptions(providerCatalog.value, t('settings.fields.searchProviderAuto'), settings.searchProvider))
function isExpanded(id: string) {
return expandedProviderId.value === id
}
function toggleExpanded(id: string) {
expandedProviderId.value = isExpanded(id) ? null : id
}
async function loadProviderCatalog() {
try {
const res: any = await settingsApi.getSearchProviders()
providerCatalog.value = res.data || { providers: [], resolvedId: null, resolvedSource: null }
catalogError.value = false
expandedProviderId.value = resolveDefaultExpandedId(providerCatalog.value)
} catch {
// Catalog outage must not hide the search config: fall back to the four
// built-in providers (their forms bind to plain settings fields and never
// needed the catalog) and surface a visible warning instead.
catalogError.value = true
providerCatalog.value = builtinFallbackCatalog()
expandedProviderId.value = null
}
}
const settings = reactive<SystemSettings>({
language: 'zh-CN',
streamEnabled: true,
@ -230,7 +302,7 @@ const settings = reactive<SystemSettings>({
})
onMounted(async () => {
await loadSettings()
await Promise.all([loadSettings(), loadProviderCatalog()])
})
async function loadSettings() {
@ -302,6 +374,18 @@ function showSavedTip(message: string) {
.save-tip { position: fixed; right: 24px; bottom: 24px; background: var(--mc-text-primary); color: var(--mc-text-inverse); padding: 10px 14px; border-radius: 10px; box-shadow: 0 10px 30px rgba(124, 63, 30, 0.22); }
.provider-card { border: 1px solid var(--mc-border); border-radius: 12px; margin-bottom: 12px; overflow: hidden; }
.provider-card-header { display: flex; align-items: center; gap: 10px; width: 100%; padding: 12px 16px; border: none; background: var(--mc-bg-elevated); color: inherit; font: inherit; text-align: left; cursor: pointer; }
.provider-card-name { font-weight: 600; flex: 1; }
.provider-card-badges { display: flex; gap: 6px; }
.badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
.badge-active { background: var(--mc-primary); color: white; }
.badge-ok { background: rgba(34, 197, 94, 0.15); color: rgb(22, 163, 74); }
.badge-warn { background: rgba(234, 179, 8, 0.15); color: rgb(161, 98, 7); }
.provider-card-chevron { color: var(--mc-text-secondary); }
.provider-card-body { padding: 4px 16px 16px; }
.catalog-error-hint { color: rgb(161, 98, 7); }
@media (max-width: 900px) {
.setting-item { flex-direction: column; }
.setting-control { width: 100%; justify-content: flex-start; }