mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
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:
parent
9fb3550e91
commit
53a12ee18d
@ -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()) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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())), "是否开启流式响应");
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
/** 反注册插件 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);
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
84
mateclaw-ui/src/composables/useSearchProviderCatalog.ts
Normal file
84
mateclaw-ui/src/composables/useSearchProviderCatalog.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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/ 目录后重启服务',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">×</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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user