diff --git a/data/mateclaw.mv.db b/data/mateclaw.mv.db new file mode 100644 index 00000000..3dead6b6 Binary files /dev/null and b/data/mateclaw.mv.db differ diff --git a/mateclaw-plugin-api/pom.xml b/mateclaw-plugin-api/pom.xml new file mode 100644 index 00000000..0221ea40 --- /dev/null +++ b/mateclaw-plugin-api/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + vip.mate + mateclaw-plugin-api + 1.1.0-SNAPSHOT + jar + + MateClaw Plugin API + Plugin SDK contract for MateClaw — external plugins depend only on this module + + + 21 + 21 + 21 + UTF-8 + 1.1.4 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + org.springframework.ai + spring-ai-model + provided + + + + + org.slf4j + slf4j-api + 2.0.16 + provided + + + + + com.fasterxml.jackson.core + jackson-databind + 2.18.3 + provided + + + + + + spring-milestones + https://repo.spring.io/milestone + false + + + diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/MateClawPlugin.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/MateClawPlugin.java new file mode 100644 index 00000000..1801dfb2 --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/MateClawPlugin.java @@ -0,0 +1,34 @@ +package vip.mate.plugin.api; + +/** + * Plugin SPI contract — all MateClaw plugins implement this interface. + * + *

Lifecycle: + *

    + *
  1. {@link #onLoad(PluginContext)} — called when the plugin is loaded, platform context injected
  2. + *
  3. {@link #onEnable()} — called when the plugin is enabled, register features to the platform
  4. + *
  5. {@link #onDisable()} — called when the plugin is disabled, clean up resources
  6. + *
+ * + * @author MateClaw Team + */ +public interface MateClawPlugin { + + /** + * Called when the plugin is loaded. Use the provided context to register + * tools, providers, channels, or memory providers. + * + * @param context platform context providing registration APIs + */ + void onLoad(PluginContext context); + + /** + * Called when the plugin is enabled. Perform any startup logic here. + */ + void onEnable(); + + /** + * Called when the plugin is disabled. Clean up resources, close connections, etc. + */ + void onDisable(); +} diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java new file mode 100644 index 00000000..5eb50c5e --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java @@ -0,0 +1,79 @@ +package vip.mate.plugin.api; + +import org.slf4j.Logger; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.tool.ToolCallback; +import vip.mate.plugin.api.channel.PluginChannelAdapter; +import vip.mate.plugin.api.memory.PluginMemoryProvider; + +import java.util.function.Supplier; + +/** + * Platform API provided to plugins for registering capabilities. + * + * @author MateClaw Team + */ +public interface PluginContext { + + /** + * Register a tool that will be available to agents. + * + * @param tool the tool callback + */ + void registerTool(ToolCallback tool); + + /** + * Register a tool with an availability check. + *

+ * The check function is evaluated lazily each time the agent tool set is built. + * When it returns {@code false}, the tool is silently excluded from the agent's + * available tools — useful for tools that require an external API key or dependency. + * + * @param tool the tool callback + * @param availabilityCheck returns true if the tool should be available + */ + void registerTool(ToolCallback tool, Supplier availabilityCheck); + + /** + * Register a custom LLM provider. + * + * @param providerId unique provider identifier + * @param chatModel the chat model implementation + */ + void registerProvider(String providerId, ChatModel chatModel); + + /** + * Register a messaging channel adapter. + * + * @param channel the channel adapter + */ + void registerChannel(PluginChannelAdapter channel); + + /** + * Register a memory provider. + *

+ * Only one external memory provider is allowed at a time. + * If another plugin has already registered one, a {@link PluginException} is thrown. + * + * @param provider the memory provider + * @throws PluginException if an external memory provider is already registered + */ + void registerMemoryProvider(PluginMemoryProvider provider); + + /** + * Read a configuration value from the plugin's config. + * + * @param key the config key + * @param type the expected type + * @param the type + * @return the config value, or null if not set + */ + T getConfig(String key, Class type); + + /** + * Get a logger instance for this plugin. + * + * @return the logger + */ + Logger getLogger(); +} diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginException.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginException.java new file mode 100644 index 00000000..69f27200 --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginException.java @@ -0,0 +1,17 @@ +package vip.mate.plugin.api; + +/** + * Runtime exception for plugin-related errors. + * + * @author MateClaw Team + */ +public class PluginException extends RuntimeException { + + public PluginException(String message) { + super(message); + } + + public PluginException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginManifest.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginManifest.java new file mode 100644 index 00000000..794ee3e2 --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginManifest.java @@ -0,0 +1,156 @@ +package vip.mate.plugin.api; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +/** + * Plugin manifest model — deserialized from {@code mateclaw-plugin.json} in the plugin JAR root. + * + * @author MateClaw Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class PluginManifest { + + private String name; + private String version; + private String type; + private String displayName; + private String description; + private String entrypoint; + + @JsonProperty("minPlatformVersion") + private String minPlatformVersion; + + private String author; + + private Map config; + + // ==================== Getters & Setters ==================== + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getVersion() { return version; } + public void setVersion(String version) { this.version = version; } + + public String getType() { return type; } + public void setType(String type) { this.type = type; } + + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getEntrypoint() { return entrypoint; } + public void setEntrypoint(String entrypoint) { this.entrypoint = entrypoint; } + + public String getMinPlatformVersion() { return minPlatformVersion; } + public void setMinPlatformVersion(String minPlatformVersion) { this.minPlatformVersion = minPlatformVersion; } + + public String getAuthor() { return author; } + public void setAuthor(String author) { this.author = author; } + + public Map getConfig() { return config; } + public void setConfig(Map config) { this.config = config; } + + /** + * Validate that required fields are present. + * + * @throws PluginException if validation fails + */ + public void validate() { + if (name == null || name.isBlank()) { + throw new PluginException("Plugin manifest missing required field: name"); + } + if (version == null || version.isBlank()) { + throw new PluginException("Plugin manifest missing required field: version"); + } + if (entrypoint == null || entrypoint.isBlank()) { + throw new PluginException("Plugin manifest missing required field: entrypoint"); + } + if (type == null || type.isBlank()) { + throw new PluginException("Plugin manifest missing required field: type"); + } + // Validate type is a known enum value + try { + PluginType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new PluginException("Unknown plugin type '" + type + + "'. Allowed: " + java.util.Arrays.toString(PluginType.values())); + } + } + + /** + * Check if this plugin is compatible with the given platform version. + * + * @param platformVersion current platform version (e.g. "1.1.0") + * @return true if compatible (minPlatformVersion <= platformVersion) + */ + public boolean isCompatibleWith(String platformVersion) { + if (minPlatformVersion == null || minPlatformVersion.isBlank()) { + return true; // No constraint + } + return compareVersions(platformVersion, minPlatformVersion) >= 0; + } + + /** + * Simple semver comparison: returns negative if v1 < v2, 0 if equal, positive if v1 > v2. + */ + private static int compareVersions(String v1, String v2) { + String[] parts1 = v1.split("\\."); + String[] parts2 = v2.split("\\."); + int len = Math.max(parts1.length, parts2.length); + for (int i = 0; i < len; i++) { + int p1 = i < parts1.length ? parseIntSafe(parts1[i]) : 0; + int p2 = i < parts2.length ? parseIntSafe(parts2[i]) : 0; + if (p1 != p2) return Integer.compare(p1, p2); + } + return 0; + } + + private static int parseIntSafe(String s) { + try { + // Strip non-numeric suffixes like "-SNAPSHOT" + return Integer.parseInt(s.replaceAll("[^0-9].*", "")); + } catch (NumberFormatException e) { + return 0; + } + } + + /** + * Get the plugin type as enum. + */ + public PluginType getPluginType() { + if (type == null) return null; + try { + return PluginType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new PluginException("Unknown plugin type: " + type); + } + } + + // ==================== Config Field ==================== + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ConfigField { + private String type; + private boolean required; + private boolean secret; + private String description; + + public String getType() { return type; } + public void setType(String type) { this.type = type; } + + public boolean isRequired() { return required; } + public void setRequired(boolean required) { this.required = required; } + + public boolean isSecret() { return secret; } + public void setSecret(boolean secret) { this.secret = secret; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + } +} diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java new file mode 100644 index 00000000..e6287ec1 --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java @@ -0,0 +1,21 @@ +package vip.mate.plugin.api; + +/** + * Plugin types covering the main extension scenarios. + * + * @author MateClaw Team + */ +public enum PluginType { + + /** Register new agent tools */ + TOOL, + + /** Register new LLM providers */ + PROVIDER, + + /** Register new messaging channels */ + CHANNEL, + + /** Register new memory providers */ + MEMORY +} diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/channel/PluginChannelAdapter.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/channel/PluginChannelAdapter.java new file mode 100644 index 00000000..17f891cd --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/channel/PluginChannelAdapter.java @@ -0,0 +1,78 @@ +package vip.mate.plugin.api.channel; + +/** + * Simplified channel adapter interface for plugins. + *

+ * Plugins implement this interface to register new messaging channels. + * The platform wraps it in an internal ChannelAdapter via a bridge. + * + * @author MateClaw Team + */ +public interface PluginChannelAdapter { + + /** + * Start the channel (establish connections, register webhooks, etc.). + */ + void start(); + + /** + * Stop the channel (disconnect, clean up resources). + */ + void stop(); + + /** + * Whether the channel is currently running. + */ + boolean isRunning(); + + /** + * The channel type identifier, e.g. "line", "whatsapp". + */ + String getChannelType(); + + /** + * Human-readable display name. + */ + default String getDisplayName() { + return getChannelType(); + } + + /** + * Send a text message to a target. + * + * @param targetId target identifier (user/group/channel ID) + * @param content message content (Markdown format) + */ + void sendMessage(String targetId, String content); + + /** + * Handle an incoming message from the channel. + *

+ * The platform will call this when a webhook or push message arrives. + * Plugins should process the message and use their internal routing logic. + * + * @param senderId sender identifier + * @param content message text + * @param rawData raw message data (JSON string) for advanced processing + */ + default void onMessage(String senderId, String content, String rawData) { + // Default no-op; plugins override if they need to handle incoming messages + } + + /** + * Whether this channel supports proactive sending (without webhook context). + */ + default boolean supportsProactiveSend() { + return false; + } + + /** + * Proactively send a message (without webhook callback context). + * + * @param targetId target identifier + * @param content message content + */ + default void proactiveSend(String targetId, String content) { + throw new UnsupportedOperationException(getChannelType() + " does not support proactive send"); + } +} diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java new file mode 100644 index 00000000..2474d585 --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java @@ -0,0 +1,77 @@ +package vip.mate.plugin.api.memory; + +import java.util.Collections; +import java.util.List; + +/** + * Memory provider interface for plugins. + *

+ * Mirrors the platform's internal MemoryProvider SPI with no server-internal dependencies. + * The platform wraps it via a bridge. + * + * @author MateClaw Team + */ +public interface PluginMemoryProvider { + + /** + * Unique provider identifier, e.g. "vector_memory", "graph_memory". + */ + String id(); + + /** + * Ordering for system prompt assembly and lifecycle dispatch. + * Lower values run first. Builtin = 0. + */ + default int order() { + return 200; + } + + /** + * Runtime availability check. Should not make network calls. + */ + default boolean isAvailable() { + return true; + } + + /** + * System prompt contribution. Called once at agent build time. + * + * @param agentId the agent ID + * @return text to include in system prompt, or empty string to skip + */ + default String systemPromptBlock(Long agentId) { + return ""; + } + + /** + * Pre-turn context recall. Called before each LLM API call. + * + * @param agentId the agent ID + * @param userQuery the current user message + * @return context text to inject, or empty string + */ + default String prefetch(Long agentId, String userQuery) { + return ""; + } + + /** + * Post-turn sync. Called after LLM response is available. + * Should be non-blocking (async). + */ + default void syncTurn(Long agentId, String conversationId, + String userMessage, String assistantReply) { + } + + /** + * Tool beans this provider wants to expose to the agent. + */ + default List getToolBeans() { + return Collections.emptyList(); + } + + /** + * Session end hook. + */ + default void onSessionEnd(Long agentId, String conversationId) { + } +} diff --git a/mateclaw-plugin-sample/pom.xml b/mateclaw-plugin-sample/pom.xml new file mode 100644 index 00000000..a9149840 --- /dev/null +++ b/mateclaw-plugin-sample/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + vip.mate + mateclaw-plugin-sample + 1.0.0 + jar + + MateClaw Sample Plugin + A sample plugin demonstrating the MateClaw Plugin SDK + + + 21 + 21 + 21 + UTF-8 + 1.1.4 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + vip.mate + mateclaw-plugin-api + 1.1.0-SNAPSHOT + provided + + + + + org.springframework.ai + spring-ai-model + provided + + + + + org.slf4j + slf4j-api + 2.0.16 + provided + + + + + + spring-milestones + https://repo.spring.io/milestone + false + + + diff --git a/mateclaw-plugin-sample/src/main/java/vip/mate/plugin/sample/HelloPlugin.java b/mateclaw-plugin-sample/src/main/java/vip/mate/plugin/sample/HelloPlugin.java new file mode 100644 index 00000000..98e37fc6 --- /dev/null +++ b/mateclaw-plugin-sample/src/main/java/vip/mate/plugin/sample/HelloPlugin.java @@ -0,0 +1,51 @@ +package vip.mate.plugin.sample; + +import org.slf4j.Logger; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; +import vip.mate.plugin.api.MateClawPlugin; +import vip.mate.plugin.api.PluginContext; + +/** + * Sample plugin demonstrating the MateClaw Plugin SDK. + *

+ * Registers a simple "hello_world" tool that agents can call. + * + * @author MateClaw Team + */ +public class HelloPlugin implements MateClawPlugin { + + private Logger log; + + @Override + public void onLoad(PluginContext context) { + this.log = context.getLogger(); + log.info("HelloPlugin loading..."); + + // Register tool callbacks from this class's @Tool methods + ToolCallback[] callbacks = ToolCallbacks.from(this); + for (ToolCallback callback : callbacks) { + context.registerTool(callback); + } + + log.info("HelloPlugin loaded, registered {} tools", callbacks.length); + } + + @Override + public void onEnable() { + if (log != null) log.info("HelloPlugin enabled"); + } + + @Override + public void onDisable() { + if (log != null) log.info("HelloPlugin disabled"); + } + + @Tool(description = "A greeting tool from the Hello World plugin. Returns a friendly greeting message for the given name.") + public String hello_world( + @ToolParam(description = "The name to greet") String name) { + return "Hello, " + name + "! This message comes from the MateClaw Hello World plugin."; + } +} diff --git a/mateclaw-plugin-sample/src/main/resources/mateclaw-plugin.json b/mateclaw-plugin-sample/src/main/resources/mateclaw-plugin.json new file mode 100644 index 00000000..d61aacfe --- /dev/null +++ b/mateclaw-plugin-sample/src/main/resources/mateclaw-plugin.json @@ -0,0 +1,11 @@ +{ + "name": "mateclaw-plugin-hello", + "version": "1.0.0", + "type": "tool", + "displayName": "Hello World Plugin", + "description": "A sample plugin that demonstrates the MateClaw Plugin SDK by registering a simple greeting tool.", + "entrypoint": "vip.mate.plugin.sample.HelloPlugin", + "minPlatformVersion": "1.1.0", + "author": "MateClaw Team", + "config": {} +} diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml index f6584292..c2ff2390 100644 --- a/mateclaw-server/pom.xml +++ b/mateclaw-server/pom.xml @@ -54,6 +54,13 @@ + + + vip.mate + mateclaw-plugin-api + 1.1.0-SNAPSHOT + + org.springframework.boot diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index 2c4046d6..e8369eb5 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -48,6 +48,9 @@ public class ChannelManager { /** 运行中的渠道适配器:channelId -> adapter */ private final Map activeAdapters = new HashMap<>(); + /** 插件注册的渠道适配器:pluginName -> adapter */ + private final Map pluginChannels = new ConcurrentHashMap<>(); + /** 读写锁:读操作(getAdapter 等)用读锁,写操作(start/stop/replace)用写锁 */ private final ReadWriteLock adapterLock = new ReentrantReadWriteLock(); @@ -227,12 +230,17 @@ public class ChannelManager { } /** - * 按渠道类型获取适配器(返回第一个匹配的) + * 按渠道类型获取适配器(返回第一个匹配的,先查内置再查插件) */ public Optional getAdapterByType(String channelType) { adapterLock.readLock().lock(); try { - return activeAdapters.values().stream() + Optional builtin = activeAdapters.values().stream() + .filter(a -> a.getChannelType().equals(channelType)) + .findFirst(); + if (builtin.isPresent()) return builtin; + // Fallback to plugin channels + return pluginChannels.values().stream() .filter(a -> a.getChannelType().equals(channelType)) .findFirst(); } finally { @@ -241,12 +249,14 @@ public class ChannelManager { } /** - * 获取所有运行中的渠道适配器 + * 获取所有运行中的渠道适配器(含插件渠道) */ public Collection getActiveAdapters() { adapterLock.readLock().lock(); try { - return List.copyOf(activeAdapters.values()); + List all = new ArrayList<>(activeAdapters.values()); + all.addAll(pluginChannels.values()); + return List.copyOf(all); } finally { adapterLock.readLock().unlock(); } @@ -298,10 +308,12 @@ public class ChannelManager { } /** - * 判断是否支持该渠道类型 + * 判断是否支持该渠道类型(含插件渠道) */ public boolean isSupported(String channelType) { - return SUPPORTED_TYPES.contains(channelType); + if (SUPPORTED_TYPES.contains(channelType)) return true; + return pluginChannels.values().stream() + .anyMatch(a -> a.getChannelType().equals(channelType)); } // ==================== 主动推送 ==================== @@ -345,6 +357,35 @@ public class ChannelManager { sendToChannel(session.getChannelId(), session.getTargetId(), content); } + // ==================== 插件渠道管理 ==================== + + /** + * Register a channel adapter from a plugin. + * + * @param pluginName the plugin name (used as key for unregistration) + * @param adapter the channel adapter + */ + public void registerPluginChannel(String pluginName, ChannelAdapter adapter) { + try { + adapter.start(); + pluginChannels.put(pluginName, adapter); + log.info("Plugin channel registered: {} (type={})", pluginName, adapter.getChannelType()); + } catch (Exception e) { + log.error("Failed to start plugin channel {}: {}", pluginName, e.getMessage(), e); + } + } + + /** + * Unregister a plugin channel. + */ + public void unregisterPluginChannel(String pluginName) { + ChannelAdapter adapter = pluginChannels.remove(pluginName); + if (adapter != null) { + stopAdapterSafely(adapter, "unregisterPluginChannel"); + log.info("Plugin channel unregistered: {}", pluginName); + } + } + // ==================== 内部方法 ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index 7519ae47..951eb3ec 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -12,7 +12,10 @@ import vip.mate.llm.event.ModelConfigChangedEvent; import vip.mate.llm.model.*; import vip.mate.llm.repository.ModelProviderMapper; +import org.springframework.ai.chat.model.ChatModel; + import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @Service @@ -24,6 +27,32 @@ public class ModelProviderService { private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper = new ObjectMapper(); + /** Plugin-registered ChatModel instances: providerId -> ChatModel */ + private final Map pluginChatModels = new ConcurrentHashMap<>(); + + /** + * Register a ChatModel from a plugin. + */ + public void registerPluginChatModel(String providerId, ChatModel chatModel) { + pluginChatModels.put(providerId, chatModel); + } + + /** + * Unregister a plugin ChatModel. + */ + public void unregisterPluginChatModel(String providerId) { + pluginChatModels.remove(providerId); + } + + /** + * Get a plugin-registered ChatModel. + * + * @return the ChatModel, or null if not registered by a plugin + */ + public ChatModel getPluginChatModel(String providerId) { + return pluginChatModels.get(providerId); + } + public List listProviders() { List providers = modelProviderMapper.selectList(new LambdaQueryWrapper() .orderByDesc(ModelProviderEntity::getIsLocal) diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java index ec4d2967..bd5b5423 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java @@ -29,6 +29,9 @@ public class MemoryManager { private final List providers; + /** External plugin memory provider (single-select constraint) */ + private volatile MemoryProvider externalPluginProvider = null; + public MemoryManager(List allProviders, MemoryProperties properties) { Set disabled = properties.getDisabledProviders(); this.providers = allProviders.stream() @@ -183,6 +186,55 @@ public class MemoryManager { + ""; } + // ==================== Plugin Provider Registration ==================== + + /** + * Register an external plugin memory provider. + * Only one external provider is allowed at a time (single-select constraint). + * + * @param provider the memory provider to register + * @throws vip.mate.plugin.api.PluginException if an external provider is already registered + */ + public synchronized void registerPluginProvider(MemoryProvider provider) { + if (externalPluginProvider != null) { + throw new vip.mate.plugin.api.PluginException( + "Only one external memory provider allowed. Current: " + externalPluginProvider.id()); + } + if (!provider.isAvailable()) { + log.warn("[MemoryManager] Plugin provider '{}' is not available, skipping", provider.id()); + return; + } + externalPluginProvider = provider; + providers.add(provider); + providers.sort(Comparator.comparingInt(MemoryProvider::order)); + log.info("[MemoryManager] Plugin provider registered: {}", provider.id()); + } + + /** + * Unregister the external plugin memory provider. + */ + public synchronized void unregisterPluginProvider(String providerId) { + if (externalPluginProvider != null && externalPluginProvider.id().equals(providerId)) { + providers.removeIf(p -> p.id().equals(providerId)); + externalPluginProvider = null; + log.info("[MemoryManager] Plugin provider unregistered: {}", providerId); + } + } + + /** + * Whether an external plugin memory provider is registered. + */ + public boolean hasExternalProvider() { + return externalPluginProvider != null; + } + + /** + * Get the external plugin memory provider's ID. + */ + public String getExternalProviderName() { + return externalPluginProvider != null ? externalPluginProvider.id() : null; + } + // ==================== Accessors ==================== public List getProviders() { diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java b/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java new file mode 100644 index 00000000..2c455883 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java @@ -0,0 +1,47 @@ +package vip.mate.plugin; + +import lombok.Data; +import vip.mate.plugin.api.MateClawPlugin; +import vip.mate.plugin.api.PluginManifest; + +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.List; + +/** + * Internal state holder for a loaded plugin. + * + * @author MateClaw Team + */ +@Data +public class LoadedPlugin { + + private final PluginManifest manifest; + private final MateClawPlugin plugin; + private final URLClassLoader classLoader; + + /** Set after construction (circular reference with PluginContextImpl) */ + private PluginContextImpl context; + + /** Names of tools registered by this plugin */ + private final List registeredTools = new ArrayList<>(); + + /** Channel types registered by this plugin */ + private final List registeredChannels = new ArrayList<>(); + + /** Provider ID registered by this plugin (null if none) */ + private String registeredProvider; + + /** Memory provider ID registered by this plugin (null if none) */ + private String registeredMemoryProvider; + + /** Whether the plugin is currently enabled */ + private boolean enabled = true; + + public LoadedPlugin(PluginManifest manifest, MateClawPlugin plugin, + URLClassLoader classLoader) { + this.manifest = manifest; + this.plugin = plugin; + this.classLoader = classLoader; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java b/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java new file mode 100644 index 00000000..554f4db5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java @@ -0,0 +1,121 @@ +package vip.mate.plugin; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.tool.ToolCallback; +import vip.mate.channel.ChannelManager; +import vip.mate.llm.service.ModelProviderService; +import vip.mate.memory.spi.MemoryManager; +import vip.mate.plugin.api.PluginContext; +import vip.mate.plugin.api.PluginException; +import vip.mate.plugin.api.PluginManifest; +import vip.mate.plugin.api.channel.PluginChannelAdapter; +import vip.mate.plugin.api.memory.PluginMemoryProvider; +import vip.mate.plugin.bridge.PluginChannelBridge; +import vip.mate.plugin.bridge.PluginMemoryBridge; +import vip.mate.tool.ToolRegistry; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.Map; +import java.util.function.Supplier; + +/** + * Platform-side implementation of {@link PluginContext}. + * Bridges plugin registrations to the corresponding platform services. + * + * @author MateClaw Team + */ +public class PluginContextImpl implements PluginContext { + + private final LoadedPlugin loadedPlugin; + private final PluginManifest manifest; + private final ToolRegistry toolRegistry; + private final ChannelManager channelManager; + private final MemoryManager memoryManager; + private final ModelProviderService modelProviderService; + private final Map configMap; + private final Logger logger; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public PluginContextImpl(LoadedPlugin loadedPlugin, + PluginManifest manifest, + ToolRegistry toolRegistry, + ChannelManager channelManager, + MemoryManager memoryManager, + ModelProviderService modelProviderService, + String configJson) { + this.loadedPlugin = loadedPlugin; + this.manifest = manifest; + this.toolRegistry = toolRegistry; + this.channelManager = channelManager; + this.memoryManager = memoryManager; + this.modelProviderService = modelProviderService; + this.logger = LoggerFactory.getLogger("plugin." + manifest.getName()); + this.configMap = parseConfig(configJson); + } + + @SuppressWarnings("unchecked") + private Map parseConfig(String configJson) { + if (configJson == null || configJson.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(configJson, Map.class); + } catch (Exception e) { + logger.warn("Failed to parse plugin config JSON: {}", e.getMessage()); + return Map.of(); + } + } + + @Override + public void registerTool(ToolCallback tool) { + registerTool(tool, () -> true); + } + + @Override + public void registerTool(ToolCallback tool, Supplier availabilityCheck) { + toolRegistry.registerPluginTool(tool, availabilityCheck); + loadedPlugin.getRegisteredTools().add(tool.getToolDefinition().name()); + } + + @Override + public void registerProvider(String providerId, ChatModel chatModel) { + modelProviderService.registerPluginChatModel(providerId, chatModel); + loadedPlugin.setRegisteredProvider(providerId); + } + + @Override + public void registerChannel(PluginChannelAdapter channel) { + PluginChannelBridge bridge = new PluginChannelBridge(channel); + channelManager.registerPluginChannel(manifest.getName(), bridge); + loadedPlugin.getRegisteredChannels().add(channel.getChannelType()); + } + + @Override + public void registerMemoryProvider(PluginMemoryProvider provider) { + if (memoryManager.hasExternalProvider()) { + throw new PluginException( + "Only one external memory provider allowed. Current: " + + memoryManager.getExternalProviderName()); + } + PluginMemoryBridge bridge = new PluginMemoryBridge(provider); + memoryManager.registerPluginProvider(bridge); + loadedPlugin.setRegisteredMemoryProvider(provider.id()); + } + + @Override + @SuppressWarnings("unchecked") + public T getConfig(String key, Class type) { + Object value = configMap.get(key); + if (value == null) return null; + if (type.isInstance(value)) return (T) value; + return objectMapper.convertValue(value, type); + } + + @Override + public Logger getLogger() { + return logger; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java b/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java new file mode 100644 index 00000000..5d3322eb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java @@ -0,0 +1,562 @@ +package vip.mate.plugin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +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.PluginException; +import vip.mate.plugin.api.PluginManifest; +import vip.mate.plugin.model.PluginEntity; +import vip.mate.plugin.model.PluginInfo; +import vip.mate.plugin.repository.PluginMapper; +import vip.mate.tool.ToolRegistry; +import vip.mate.workspace.core.model.WorkspaceEntity; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.*; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.jar.JarFile; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; + +/** + * Plugin lifecycle manager. + *

+ * Discovers, validates, loads, and manages plugins from JAR files. + * Supports three discovery paths with priority: workspace > user-global > classpath. + * Plugins are loaded on application startup and can be enabled/disabled at runtime. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PluginManager { + + /** Current platform version for minPlatformVersion compatibility check */ + private static final String PLATFORM_VERSION = "1.1.0"; + + private final PluginProperties pluginProperties; + private final PluginMapper pluginMapper; + private final ToolRegistry toolRegistry; + private final ChannelManager channelManager; + private final MemoryManager memoryManager; + private final ModelProviderService modelProviderService; + private final Optional workspaceService; + + private final Map plugins = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Load all plugins on application startup. + * Scans three paths in priority order: workspace > user-global. + * Higher priority plugins shadow lower priority ones with the same name. + */ + @EventListener(ApplicationReadyEvent.class) + @Order(250) + public void loadAllPlugins() { + if (!pluginProperties.isEnabled()) { + log.info("Plugin system is disabled"); + return; + } + + // Collect plugin JARs from all discovery paths in priority order + List discoveredJars = discoverPluginJars(); + + int loaded = 0; + int skipped = 0; + int failed = 0; + + for (Path jar : discoveredJars) { + try { + // Read manifest to check name before loading + PluginManifest manifest = readManifest(jar); + if (plugins.containsKey(manifest.getName())) { + log.info("Plugin {} already loaded from higher-priority path, skipping: {}", + manifest.getName(), jar); + skipped++; + continue; + } + loadPlugin(jar); + loaded++; + } catch (Exception e) { + failed++; + log.error("Failed to load plugin from {}: {}", jar.getFileName(), e.getMessage(), e); + recordError(jar, e); + } + } + + log.info("Plugin loading complete: {} loaded, {} skipped (duplicate), {} failed", loaded, skipped, failed); + } + + /** + * Discover plugin JARs from all paths in priority order. + * Workspace plugins (highest priority) come first, then user-global. + */ + private List discoverPluginJars() { + List jars = new ArrayList<>(); + + // 1. Workspace-level plugins (highest priority) + workspaceService.ifPresent(ws -> { + try { + WorkspaceEntity defaultWs = ws.getBySlug(WorkspaceService.DEFAULT_SLUG); + if (defaultWs != null && defaultWs.getBasePath() != null) { + Path workspacePluginDir = Paths.get(defaultWs.getBasePath(), "plugins"); + if (Files.isDirectory(workspacePluginDir)) { + scanJars(workspacePluginDir, jars, "workspace"); + } + } + } catch (Exception e) { + log.debug("Workspace plugin scan skipped: {}", e.getMessage()); + } + }); + + // 2. User-global plugins + Path userDir = Paths.get(pluginProperties.getUserDir()); + try { + Files.createDirectories(userDir); + scanJars(userDir, jars, "user-global"); + } catch (IOException e) { + log.warn("Failed to scan user plugin directory {}: {}", userDir, e.getMessage()); + } + + log.info("Discovered {} plugin JAR(s) across all paths", jars.size()); + return jars; + } + + private void scanJars(Path dir, List target, String source) { + try (Stream stream = Files.list(dir)) { + List found = stream + .filter(p -> p.toString().endsWith(".jar")) + .toList(); + if (!found.isEmpty()) { + log.info("Found {} plugin JAR(s) in {} ({})", found.size(), dir, source); + target.addAll(found); + } + } catch (IOException e) { + log.warn("Failed to list {} plugin directory {}: {}", source, dir, e.getMessage()); + } + } + + /** + * Load a single plugin from a JAR file. + * Ensures ClassLoader cleanup on any failure, and rolls back registrations + * if onLoad/onEnable throws. + */ + public void loadPlugin(Path jarPath) throws Exception { + log.info("Loading plugin from: {}", jarPath.getFileName()); + + // 1. Read and validate manifest + PluginManifest manifest = readManifest(jarPath); + manifest.validate(); + + // Check platform version compatibility + if (!manifest.isCompatibleWith(PLATFORM_VERSION)) { + throw new PluginException("Plugin " + manifest.getName() + " requires platform version " + + manifest.getMinPlatformVersion() + " but current is " + PLATFORM_VERSION); + } + + String pluginName = manifest.getName(); + + // 2. Check if already loaded + if (plugins.containsKey(pluginName)) { + log.warn("Plugin {} already loaded, skipping: {}", pluginName, jarPath); + return; + } + + // 3. Check DB for disabled state + PluginEntity existing = findByName(pluginName); + if (existing != null && !Boolean.TRUE.equals(existing.getEnabled())) { + log.info("Plugin {} is disabled in database, skipping", pluginName); + upsertEntity(manifest, jarPath.toString(), "DISABLED", null); + return; + } + + // 4. Create ClassLoader — all subsequent failures must close it + URLClassLoader classLoader = new URLClassLoader( + new URL[]{jarPath.toUri().toURL()}, + getClass().getClassLoader() + ); + + try { + // 5. Instantiate entrypoint + Class entryClass = classLoader.loadClass(manifest.getEntrypoint()); + if (!MateClawPlugin.class.isAssignableFrom(entryClass)) { + throw new PluginException("Entrypoint class " + manifest.getEntrypoint() + + " does not implement MateClawPlugin"); + } + MateClawPlugin plugin = (MateClawPlugin) entryClass.getDeclaredConstructor().newInstance(); + + // 6. Create context and loaded plugin + String configJson = existing != null ? existing.getConfigJson() : null; + LoadedPlugin loadedPlugin = new LoadedPlugin(manifest, plugin, classLoader); + + PluginContextImpl context = new PluginContextImpl( + loadedPlugin, manifest, + toolRegistry, channelManager, memoryManager, modelProviderService, + configJson + ); + loadedPlugin.setContext(context); + + // 7. Call lifecycle methods — rollback registrations on failure + try { + plugin.onLoad(context); + plugin.onEnable(); + } catch (Exception e) { + log.error("Plugin {} lifecycle failed, rolling back registrations: {}", pluginName, e.getMessage()); + rollbackRegistrations(loadedPlugin); + throw e; + } + + // 8. Register successfully + plugins.put(pluginName, loadedPlugin); + upsertEntity(manifest, jarPath.toString(), "ENABLED", null); + + log.info("Plugin loaded: {} v{} (type={}, tools={}, channels={})", + pluginName, manifest.getVersion(), manifest.getType(), + loadedPlugin.getRegisteredTools().size(), + loadedPlugin.getRegisteredChannels().size()); + + } catch (Exception e) { + // ClassLoader cleanup on any failure + try { + classLoader.close(); + } catch (IOException closeEx) { + log.warn("Failed to close ClassLoader for {}: {}", jarPath.getFileName(), closeEx.getMessage()); + } + throw e; + } + } + + /** + * Rollback all registrations made by a plugin during failed onLoad/onEnable. + */ + private void rollbackRegistrations(LoadedPlugin loaded) { + for (String toolName : loaded.getRegisteredTools()) { + try { toolRegistry.unregisterPluginTool(toolName); } catch (Exception e) { /* best effort */ } + } + if (!loaded.getRegisteredChannels().isEmpty()) { + try { channelManager.unregisterPluginChannel(loaded.getManifest().getName()); } catch (Exception e) { /* best effort */ } + } + if (loaded.getRegisteredMemoryProvider() != null) { + try { memoryManager.unregisterPluginProvider(loaded.getRegisteredMemoryProvider()); } catch (Exception e) { /* best effort */ } + } + if (loaded.getRegisteredProvider() != null) { + try { modelProviderService.unregisterPluginChatModel(loaded.getRegisteredProvider()); } catch (Exception e) { /* best effort */ } + } + } + + /** + * Disable a plugin by name. + */ + public void disablePlugin(String name) { + LoadedPlugin loaded = plugins.get(name); + if (loaded == null) { + throw new PluginException("Plugin not found or not running: " + name); + } + + // Call onDisable + try { + loaded.getPlugin().onDisable(); + } catch (Exception e) { + log.warn("Plugin {} onDisable() threw exception: {}", name, e.getMessage()); + } + + // Unregister all capabilities + int toolsRemoved = 0; + for (String toolName : loaded.getRegisteredTools()) { + toolRegistry.unregisterPluginTool(toolName); + toolsRemoved++; + } + channelManager.unregisterPluginChannel(name); + int channelsRemoved = loaded.getRegisteredChannels().size(); + + String memoryRemoved = null; + if (loaded.getRegisteredMemoryProvider() != null) { + memoryManager.unregisterPluginProvider(loaded.getRegisteredMemoryProvider()); + memoryRemoved = loaded.getRegisteredMemoryProvider(); + } + + String providerRemoved = null; + if (loaded.getRegisteredProvider() != null) { + modelProviderService.unregisterPluginChatModel(loaded.getRegisteredProvider()); + providerRemoved = loaded.getRegisteredProvider(); + } + + loaded.setEnabled(false); + plugins.remove(name); + updateStatus(name, false, "DISABLED", null); + + log.info("Plugin disabled: {} (tools={}, channels={}, provider={}, memory={})", + name, toolsRemoved, channelsRemoved, + providerRemoved != null ? providerRemoved : "none", + memoryRemoved != null ? memoryRemoved : "none"); + } + + /** + * Enable a previously disabled plugin. + * Validates JAR still exists before re-loading. + */ + public void enablePlugin(String name) { + PluginEntity entity = findByName(name); + if (entity == null) { + throw new PluginException("Plugin not found in database: " + name); + } + if (entity.getJarPath() == null) { + throw new PluginException("Plugin JAR path unknown for: " + name); + } + + // Validate JAR still exists on disk + Path jarPath = Paths.get(entity.getJarPath()); + if (!Files.exists(jarPath)) { + updateStatus(name, false, "ERROR", "JAR file not found: " + entity.getJarPath()); + throw new PluginException("Plugin JAR file no longer exists: " + entity.getJarPath()); + } + + updateStatus(name, true, "LOADING", null); + + try { + loadPlugin(jarPath); + } catch (Exception e) { + updateStatus(name, false, "ERROR", e.getMessage()); + throw new PluginException("Failed to re-enable plugin " + name + ": " + e.getMessage(), e); + } + } + + /** + * List all known plugins (loaded + DB-only). + * Secret config values are redacted in the response. + */ + @SuppressWarnings("unchecked") + public List listPlugins() { + Map result = new LinkedHashMap<>(); + + // In-memory loaded plugins + for (Map.Entry entry : plugins.entrySet()) { + LoadedPlugin loaded = entry.getValue(); + PluginManifest m = loaded.getManifest(); + result.put(entry.getKey(), PluginInfo.builder() + .name(m.getName()) + .version(m.getVersion()) + .type(m.getType()) + .displayName(m.getDisplayName()) + .description(m.getDescription()) + .author(m.getAuthor()) + .enabled(loaded.isEnabled()) + .status("ENABLED") + .registeredTools(List.copyOf(loaded.getRegisteredTools())) + .registeredChannels(List.copyOf(loaded.getRegisteredChannels())) + .registeredProvider(loaded.getRegisteredProvider()) + .registeredMemoryProvider(loaded.getRegisteredMemoryProvider()) + .configSchema(buildConfigSchema(m)) + .currentConfig(buildRedactedConfig(loaded)) + .build()); + } + + // DB-only entries (disabled plugins not in memory) + List dbPlugins = pluginMapper.selectList(new LambdaQueryWrapper<>()); + for (PluginEntity entity : dbPlugins) { + if (!result.containsKey(entity.getName())) { + result.put(entity.getName(), PluginInfo.builder() + .name(entity.getName()) + .version(entity.getVersion()) + .type(entity.getPluginType()) + .displayName(entity.getDisplayName()) + .description(entity.getDescription()) + .author(entity.getAuthor()) + .enabled(Boolean.TRUE.equals(entity.getEnabled())) + .status(entity.getStatus()) + .errorMessage(entity.getErrorMessage()) + .jarPath(entity.getJarPath()) + .registeredTools(List.of()) + .registeredChannels(List.of()) + .build()); + } + } + + return new ArrayList<>(result.values()); + } + + /** + * Get a single plugin's info by name. + */ + public PluginInfo getPlugin(String name) { + return listPlugins().stream() + .filter(p -> p.getName().equals(name)) + .findFirst() + .orElseThrow(() -> new PluginException("Plugin not found: " + name)); + } + + /** + * Update a plugin's configuration. + */ + public void updateConfig(String name, Map config) { + PluginEntity entity = findByName(name); + if (entity == null) { + throw new PluginException("Plugin not found: " + name); + } + + // Validate config keys against manifest if plugin is loaded + LoadedPlugin loaded = plugins.get(name); + if (loaded != null && loaded.getManifest().getConfig() != null) { + Map schema = loaded.getManifest().getConfig(); + for (String key : config.keySet()) { + if (!schema.containsKey(key)) { + log.warn("Plugin {} config: unknown key '{}' (not in manifest schema)", name, key); + } + } + // Check required fields + for (Map.Entry schemaEntry : schema.entrySet()) { + if (schemaEntry.getValue().isRequired() && !config.containsKey(schemaEntry.getKey())) { + throw new PluginException("Missing required config field: " + schemaEntry.getKey()); + } + } + } + + try { + entity.setConfigJson(objectMapper.writeValueAsString(config)); + pluginMapper.updateById(entity); + log.info("Plugin config updated: {}", name); + } catch (PluginException e) { + throw e; + } catch (Exception e) { + throw new PluginException("Failed to update config for " + name + ": " + e.getMessage(), e); + } + } + + // ==================== Config Schema & Redaction ==================== + + /** + * Build config schema map from manifest for frontend display. + */ + private Map buildConfigSchema(PluginManifest manifest) { + if (manifest.getConfig() == null || manifest.getConfig().isEmpty()) { + return null; + } + Map schema = new LinkedHashMap<>(); + manifest.getConfig().forEach((key, field) -> { + Map fieldInfo = new LinkedHashMap<>(); + fieldInfo.put("type", field.getType()); + fieldInfo.put("required", field.isRequired()); + fieldInfo.put("secret", field.isSecret()); + fieldInfo.put("description", field.getDescription()); + schema.put(key, fieldInfo); + }); + return schema; + } + + /** + * Build config map with secret values redacted. + */ + @SuppressWarnings("unchecked") + private Map buildRedactedConfig(LoadedPlugin loaded) { + PluginEntity entity = findByName(loaded.getManifest().getName()); + if (entity == null || entity.getConfigJson() == null || entity.getConfigJson().isBlank()) { + return null; + } + try { + Map config = objectMapper.readValue(entity.getConfigJson(), Map.class); + // Redact secret fields + Map schema = loaded.getManifest().getConfig(); + if (schema != null) { + for (Map.Entry schemaEntry : schema.entrySet()) { + if (schemaEntry.getValue().isSecret() && config.containsKey(schemaEntry.getKey())) { + Object val = config.get(schemaEntry.getKey()); + if (val != null && !val.toString().isBlank()) { + config.put(schemaEntry.getKey(), "****"); + } + } + } + } + return config; + } catch (Exception e) { + return null; + } + } + + // ==================== Internal Methods ==================== + + private PluginManifest readManifest(Path jarPath) throws IOException { + try (JarFile jarFile = new JarFile(jarPath.toFile())) { + ZipEntry entry = jarFile.getEntry("mateclaw-plugin.json"); + if (entry == null) { + throw new PluginException("No mateclaw-plugin.json found in " + jarPath); + } + try (InputStream is = jarFile.getInputStream(entry)) { + return objectMapper.readValue(is, PluginManifest.class); + } + } + } + + private PluginEntity findByName(String name) { + return pluginMapper.selectOne(new LambdaQueryWrapper() + .eq(PluginEntity::getName, name)); + } + + private void upsertEntity(PluginManifest manifest, String jarPath, String status, String error) { + PluginEntity existing = findByName(manifest.getName()); + if (existing != null) { + existing.setVersion(manifest.getVersion()); + existing.setPluginType(manifest.getType()); + existing.setDisplayName(manifest.getDisplayName()); + existing.setDescription(manifest.getDescription()); + existing.setAuthor(manifest.getAuthor()); + existing.setEntrypoint(manifest.getEntrypoint()); + existing.setJarPath(jarPath); + existing.setStatus(status); + existing.setErrorMessage(error); + existing.setEnabled(!"DISABLED".equals(status) && !"ERROR".equals(status)); + pluginMapper.updateById(existing); + } else { + PluginEntity entity = new PluginEntity(); + entity.setName(manifest.getName()); + entity.setVersion(manifest.getVersion()); + entity.setPluginType(manifest.getType()); + entity.setDisplayName(manifest.getDisplayName()); + entity.setDescription(manifest.getDescription()); + entity.setAuthor(manifest.getAuthor()); + entity.setEntrypoint(manifest.getEntrypoint()); + entity.setJarPath(jarPath); + entity.setStatus(status); + entity.setErrorMessage(error); + entity.setEnabled(!"DISABLED".equals(status) && !"ERROR".equals(status)); + entity.setCreateTime(LocalDateTime.now()); + entity.setUpdateTime(LocalDateTime.now()); + entity.setConfigJson("{}"); + pluginMapper.insert(entity); + } + } + + private void updateStatus(String name, boolean enabled, String status, String error) { + PluginEntity entity = findByName(name); + if (entity != null) { + entity.setEnabled(enabled); + entity.setStatus(status); + entity.setErrorMessage(error); + pluginMapper.updateById(entity); + } + } + + private void recordError(Path jarPath, Exception e) { + try { + PluginManifest manifest = readManifest(jarPath); + upsertEntity(manifest, jarPath.toString(), "ERROR", e.getMessage()); + } catch (Exception ex) { + log.warn("Cannot record plugin error (manifest unreadable): {} — {}", jarPath.getFileName(), ex.getMessage()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/PluginProperties.java b/mateclaw-server/src/main/java/vip/mate/plugin/PluginProperties.java new file mode 100644 index 00000000..24f7f3f7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginProperties.java @@ -0,0 +1,22 @@ +package vip.mate.plugin; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Plugin SDK configuration properties. + * + * @author MateClaw Team + */ +@Data +@Component +@ConfigurationProperties(prefix = "mateclaw.plugin") +public class PluginProperties { + + /** Whether the plugin system is enabled */ + private boolean enabled = true; + + /** User-global plugin directory */ + private String userDir = System.getProperty("user.home") + "/.mateclaw/plugins"; +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginChannelBridge.java b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginChannelBridge.java new file mode 100644 index 00000000..76528b4a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginChannelBridge.java @@ -0,0 +1,71 @@ +package vip.mate.plugin.bridge; + +import vip.mate.channel.ChannelAdapter; +import vip.mate.channel.ChannelMessage; +import vip.mate.plugin.api.channel.PluginChannelAdapter; + +/** + * Bridge that wraps a plugin's {@link PluginChannelAdapter} into the platform's + * internal {@link ChannelAdapter} interface. + * + * @author MateClaw Team + */ +public class PluginChannelBridge implements ChannelAdapter { + + private final PluginChannelAdapter delegate; + + public PluginChannelBridge(PluginChannelAdapter delegate) { + this.delegate = delegate; + } + + @Override + public void start() { + delegate.start(); + } + + @Override + public void stop() { + delegate.stop(); + } + + @Override + public boolean isRunning() { + return delegate.isRunning(); + } + + @Override + public void onMessage(ChannelMessage message) { + // Bridge: convert internal ChannelMessage to simplified plugin format + String rawData = message.getRawPayload() != null ? message.getRawPayload().toString() : null; + delegate.onMessage( + message.getSenderId(), + message.getContent(), + rawData + ); + } + + @Override + public void sendMessage(String targetId, String content) { + delegate.sendMessage(targetId, content); + } + + @Override + public boolean supportsProactiveSend() { + return delegate.supportsProactiveSend(); + } + + @Override + public void proactiveSend(String targetId, String content) { + delegate.proactiveSend(targetId, content); + } + + @Override + public String getChannelType() { + return delegate.getChannelType(); + } + + @Override + public String getDisplayName() { + return delegate.getDisplayName(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java new file mode 100644 index 00000000..23ce8c45 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java @@ -0,0 +1,64 @@ +package vip.mate.plugin.bridge; + +import vip.mate.memory.spi.MemoryProvider; +import vip.mate.plugin.api.memory.PluginMemoryProvider; + +import java.util.Collections; +import java.util.List; + +/** + * Bridge that wraps a plugin's {@link PluginMemoryProvider} into the platform's + * internal {@link MemoryProvider} interface. + * + * @author MateClaw Team + */ +public class PluginMemoryBridge implements MemoryProvider { + + private final PluginMemoryProvider delegate; + + public PluginMemoryBridge(PluginMemoryProvider delegate) { + this.delegate = delegate; + } + + @Override + public String id() { + return delegate.id(); + } + + @Override + public int order() { + return delegate.order(); + } + + @Override + public boolean isAvailable() { + return delegate.isAvailable(); + } + + @Override + public String systemPromptBlock(Long agentId) { + return delegate.systemPromptBlock(agentId); + } + + @Override + public String prefetch(Long agentId, String userQuery) { + return delegate.prefetch(agentId, userQuery); + } + + @Override + public void syncTurn(Long agentId, String conversationId, + String userMessage, String assistantReply) { + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); + } + + @Override + public List getToolBeans() { + List beans = delegate.getToolBeans(); + return beans != null ? beans : Collections.emptyList(); + } + + @Override + public void onSessionEnd(Long agentId, String conversationId) { + delegate.onSessionEnd(agentId, conversationId); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java b/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java new file mode 100644 index 00000000..dd24ca49 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java @@ -0,0 +1,60 @@ +package vip.mate.plugin.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import vip.mate.common.result.R; +import vip.mate.plugin.PluginManager; +import vip.mate.plugin.model.PluginInfo; + +import java.util.List; +import java.util.Map; + +/** + * Plugin management REST API. + * + * @author MateClaw Team + */ +@Tag(name = "Plugin Management") +@RestController +@RequestMapping("/api/v1/plugins") +@RequiredArgsConstructor +public class PluginController { + + private final PluginManager pluginManager; + + @Operation(summary = "List all plugins") + @GetMapping + public R> list() { + return R.ok(pluginManager.listPlugins()); + } + + @Operation(summary = "Get plugin detail") + @GetMapping("/{name}") + public R get(@PathVariable String name) { + return R.ok(pluginManager.getPlugin(name)); + } + + @Operation(summary = "Disable a plugin") + @PostMapping("/{name}/disable") + public R disable(@PathVariable String name) { + pluginManager.disablePlugin(name); + return R.ok(); + } + + @Operation(summary = "Enable a plugin") + @PostMapping("/{name}/enable") + public R enable(@PathVariable String name) { + pluginManager.enablePlugin(name); + return R.ok(); + } + + @Operation(summary = "Update plugin configuration") + @PutMapping("/{name}/config") + public R updateConfig(@PathVariable String name, + @RequestBody Map config) { + pluginManager.updateConfig(name, config); + return R.ok(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java new file mode 100644 index 00000000..cf2c77fd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java @@ -0,0 +1,66 @@ +package vip.mate.plugin.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Plugin entity — persists plugin state in mate_plugin table. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_plugin") +public class PluginEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** Plugin name (unique identifier from manifest) */ + private String name; + + /** Plugin version */ + private String version; + + /** Plugin type: tool / provider / channel / memory */ + private String pluginType; + + /** Display name */ + private String displayName; + + /** Description */ + private String description; + + /** Author */ + private String author; + + /** Fully qualified entrypoint class name */ + private String entrypoint; + + /** JAR file path on disk */ + private String jarPath; + + /** Plugin configuration as JSON */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String configJson; + + /** Whether the plugin is enabled */ + private Boolean enabled; + + /** Runtime status: LOADED / ENABLED / DISABLED / ERROR */ + private String status; + + /** Error message if loading failed */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String errorMessage; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java new file mode 100644 index 00000000..817d5592 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java @@ -0,0 +1,46 @@ +package vip.mate.plugin.model; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * Plugin info DTO for REST API responses. + * + * @author MateClaw Team + */ +@Data +@Builder +public class PluginInfo { + + private String name; + private String version; + private String type; + private String displayName; + private String description; + private String author; + private boolean enabled; + private String status; + private String errorMessage; + private String jarPath; + + /** Names of tools registered by this plugin */ + private List registeredTools; + + /** Channel types registered by this plugin */ + private List registeredChannels; + + /** Provider ID registered by this plugin (null if none) */ + private String registeredProvider; + + /** Memory provider ID registered by this plugin (null if none) */ + private String registeredMemoryProvider; + + /** Plugin config schema (from manifest) */ + private Map configSchema; + + /** Current config values */ + private Map currentConfig; +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/repository/PluginMapper.java b/mateclaw-server/src/main/java/vip/mate/plugin/repository/PluginMapper.java new file mode 100644 index 00000000..0384e3a0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/repository/PluginMapper.java @@ -0,0 +1,14 @@ +package vip.mate.plugin.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.plugin.model.PluginEntity; + +/** + * MyBatis Plus mapper for mate_plugin table. + * + * @author MateClaw Team + */ +@Mapper +public interface PluginMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java index 945e3279..24cdadd2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java @@ -21,6 +21,8 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Supplier; import java.util.stream.Collectors; /** @@ -39,6 +41,31 @@ public class ToolRegistry { private final ToolMapper toolMapper; private final I18nService i18nService; + // ==================== Plugin Tools ==================== + + /** Plugin-registered tool entries with lazy availability checks */ + private final CopyOnWriteArrayList pluginTools = new CopyOnWriteArrayList<>(); + + /** A tool entry registered by a plugin */ + public record PluginToolEntry(ToolCallback callback, Supplier availabilityCheck) {} + + /** + * Register a tool from a plugin with an availability check. + * The check is evaluated lazily each time the tool set is built. + */ + public void registerPluginTool(ToolCallback callback, Supplier availabilityCheck) { + pluginTools.add(new PluginToolEntry(callback, availabilityCheck != null ? availabilityCheck : () -> true)); + log.info("Plugin tool registered: {}", callback.getToolDefinition().name()); + } + + /** + * Unregister a plugin tool by name. + */ + public void unregisterPluginTool(String toolName) { + pluginTools.removeIf(entry -> entry.callback().getToolDefinition().name().equals(toolName)); + log.info("Plugin tool unregistered: {}", toolName); + } + /** * 获取所有已启用的工具 Bean(Spring AI @Tool 注解方式) * 通过数据库 enabled 标志过滤,确保 UI 开关真正生效 @@ -118,8 +145,25 @@ public class ToolRegistry { } } - log.info("Building AgentToolSet: toolBeans={}, providers={}, totalCallbacks={}", - toolBeans.size(), providers.size(), localizedCallbacks.size()); + // Plugin tool callbacks — evaluate availability checks lazily + int pluginToolCount = 0; + for (PluginToolEntry entry : pluginTools) { + try { + if (Boolean.TRUE.equals(entry.availabilityCheck().get())) { + localizedCallbacks.add(entry.callback()); + pluginToolCount++; + } else { + log.debug("Plugin tool excluded (availability check failed): {}", + entry.callback().getToolDefinition().name()); + } + } catch (Exception e) { + log.warn("Plugin tool availability check failed for {}: {}", + entry.callback().getToolDefinition().name(), e.getMessage()); + } + } + + log.info("Building AgentToolSet: toolBeans={}, providers={}, pluginTools={}, totalCallbacks={}", + toolBeans.size(), providers.size(), pluginToolCount, localizedCallbacks.size()); return AgentToolSet.fromCallbacks(toolBeans, localizedCallbacks); } diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 2ab8cc8a..3faaf4b1 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -108,6 +108,9 @@ mateclaw: search-path: /api/v1/search http-timeout: 15 http-retries: 3 + plugin: + enabled: true + user-dir: ${user.home}/.mateclaw/plugins # MateClaw Agent 配置 mate: diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V6__plugin_table.sql b/mateclaw-server/src/main/resources/db/migration/h2/V6__plugin_table.sql new file mode 100644 index 00000000..194b6f33 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V6__plugin_table.sql @@ -0,0 +1,20 @@ +-- Plugin SDK: mate_plugin table +CREATE TABLE IF NOT EXISTS mate_plugin ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + version VARCHAR(32) NOT NULL, + plugin_type VARCHAR(32) NOT NULL, + display_name VARCHAR(128), + description TEXT, + author VARCHAR(128), + entrypoint VARCHAR(256) NOT NULL, + jar_path VARCHAR(512), + config_json TEXT NOT NULL DEFAULT '{}', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + status VARCHAR(32) NOT NULL DEFAULT 'LOADED', + error_message TEXT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_plugin_name ON mate_plugin(name); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V4__agent_thinking_level.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V4__agent_thinking_level.sql new file mode 100644 index 00000000..4b98c14e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V4__agent_thinking_level.sql @@ -0,0 +1,3 @@ +-- V4: Add default_thinking_level to mate_agent +-- Supports: off / low / medium / high / max (null = follow model default) +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS default_thinking_level VARCHAR(32) DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V5__conversation_parent.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V5__conversation_parent.sql new file mode 100644 index 00000000..8a0d82a4 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V5__conversation_parent.sql @@ -0,0 +1,3 @@ +-- V5: Add parent_conversation_id to mate_conversation for multi-agent delegation tracking +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS parent_conversation_id VARCHAR(64) DEFAULT NULL; +CREATE INDEX idx_conversation_parent ON mate_conversation(parent_conversation_id); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V6__plugin_table.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V6__plugin_table.sql new file mode 100644 index 00000000..ec5100c6 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V6__plugin_table.sql @@ -0,0 +1,20 @@ +-- Plugin SDK: mate_plugin table +CREATE TABLE IF NOT EXISTS mate_plugin ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + version VARCHAR(32) NOT NULL, + plugin_type VARCHAR(32) NOT NULL, + display_name VARCHAR(128), + description TEXT, + author VARCHAR(128), + entrypoint VARCHAR(256) NOT NULL, + jar_path VARCHAR(512), + config_json TEXT NOT NULL DEFAULT ('{}'), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + status VARCHAR(32) NOT NULL DEFAULT 'LOADED', + error_message TEXT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_plugin_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/mateclaw-server/src/main/resources/db/schema.sql b/mateclaw-server/src/main/resources/db/schema.sql index 8e2db168..9f7a6316 100644 --- a/mateclaw-server/src/main/resources/db/schema.sql +++ b/mateclaw-server/src/main/resources/db/schema.sql @@ -642,3 +642,26 @@ CREATE TABLE IF NOT EXISTS mate_audit_event ( CREATE INDEX IF NOT EXISTS idx_audit_ws_time ON mate_audit_event(workspace_id, create_time); CREATE INDEX IF NOT EXISTS idx_audit_user ON mate_audit_event(user_id); CREATE INDEX IF NOT EXISTS idx_audit_resource ON mate_audit_event(resource_type, resource_id); + +-- ============================================= +-- 插件表(Plugin SDK) +-- ============================================= +CREATE TABLE IF NOT EXISTS mate_plugin ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + version VARCHAR(32) NOT NULL, + plugin_type VARCHAR(32) NOT NULL, + display_name VARCHAR(128), + description TEXT, + author VARCHAR(128), + entrypoint VARCHAR(256) NOT NULL, + jar_path VARCHAR(512), + config_json TEXT NOT NULL DEFAULT '{}', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + status VARCHAR(32) NOT NULL DEFAULT 'LOADED', + error_message TEXT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_plugin_name ON mate_plugin(name); diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 51ef5ec9..70d3875a 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -433,6 +433,15 @@ export const dashboardApi = { recentRuns: (limit = 20) => http.get('/dashboard/cron-runs', { params: { limit } }), } +// ==================== Plugins ==================== +export const pluginApi = { + list: () => http.get('/plugins'), + get: (name: string) => http.get(`/plugins/${name}`), + disable: (name: string) => http.post(`/plugins/${name}/disable`), + enable: (name: string) => http.post(`/plugins/${name}/enable`), + updateConfig: (name: string, config: Record) => http.put(`/plugins/${name}/config`, config), +} + // ==================== Audit Events ==================== export const auditApi = { listEvents: (params: { diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index ee86db89..12b84e6c 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -207,6 +207,7 @@ export default { skills: 'Skills', wiki: 'Wiki KB', tools: 'Tools', + plugins: 'Plugins', core: 'Core', connect: 'Connect', system: 'System', @@ -1064,6 +1065,26 @@ export default { toggleFailed: 'Failed to toggle tool status', }, }, + plugins: { + title: 'Plugins', + desc: 'Manage external plugins loaded from JAR files', + refresh: 'Refresh', + type: 'Type', + author: 'Author', + status: 'Status', + tools: 'Tools', + channels: 'Channels', + provider: 'Provider', + memoryProvider: 'Memory', + noDescription: 'No description', + emptyTitle: 'No plugins installed', + emptyHint: 'Place plugin JAR files in ~/.mateclaw/plugins/ and restart the server', + loading: 'Loading plugins...', + loadFailed: 'Failed to load plugins', + enabled: '{name} enabled', + disabled: '{name} disabled', + toggleFailed: 'Failed to toggle plugin status', + }, datasources: { kicker: 'Data Fabric', title: 'Datasources', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 151300ec..5c957f89 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -210,6 +210,7 @@ export default { skills: '技能', wiki: 'Wiki 知识库', tools: '工具', + plugins: '插件', datasources: '数据源', mcpServers: 'MCP 服务', settingsGroup: '设置', @@ -1064,6 +1065,26 @@ export default { toggleFailed: '切换工具状态失败', }, }, + plugins: { + title: '插件', + desc: '管理从 JAR 文件加载的外部插件', + refresh: '刷新', + type: '类型', + author: '作者', + status: '状态', + tools: '工具', + channels: '渠道', + provider: 'Provider', + memoryProvider: '记忆', + noDescription: '暂无描述', + emptyTitle: '暂无已安装插件', + emptyHint: '将插件 JAR 文件放入 ~/.mateclaw/plugins/ 目录后重启服务', + loading: '加载插件中...', + loadFailed: '加载插件失败', + enabled: '{name} 已启用', + disabled: '{name} 已禁用', + toggleFailed: '切换插件状态失败', + }, datasources: { kicker: '数据接入', title: '数据源管理', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index e5cade1a..f5b93e4d 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -52,6 +52,12 @@ const router = createRouter({ component: () => import('@/views/Tools.vue'), meta: { title: 'Tools' }, }, + { + path: 'plugins', + name: 'Plugins', + component: () => import('@/views/Plugins.vue'), + meta: { title: 'Plugins' }, + }, // ==================== Settings (absorbs advanced pages) ==================== { path: 'settings', diff --git a/mateclaw-ui/src/views/Plugins.vue b/mateclaw-ui/src/views/Plugins.vue new file mode 100644 index 00000000..b30ed3f3 --- /dev/null +++ b/mateclaw-ui/src/views/Plugins.vue @@ -0,0 +1,410 @@ + + + + + diff --git a/mateclaw-ui/src/views/layout/MainLayout.vue b/mateclaw-ui/src/views/layout/MainLayout.vue index e1a4e27e..72094f74 100644 --- a/mateclaw-ui/src/views/layout/MainLayout.vue +++ b/mateclaw-ui/src/views/layout/MainLayout.vue @@ -321,6 +321,11 @@ const navGroups = computed(() => [ label: t('nav.tools'), icon: ``, }, + { + path: '/plugins', + label: t('nav.plugins'), + icon: ``, + }, ], }, {