From 0adb3dddf163c62f99c3dd727a0c49e35378ab7b Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 13 Apr 2026 18:38:03 +0800 Subject: [PATCH] feat(plugin): Plugin SDK + UI layout improvements --- data/mateclaw.mv.db | Bin 0 -> 180224 bytes mateclaw-plugin-api/pom.xml | 67 +++ .../vip/mate/plugin/api/MateClawPlugin.java | 34 ++ .../vip/mate/plugin/api/PluginContext.java | 79 +++ .../vip/mate/plugin/api/PluginException.java | 17 + .../vip/mate/plugin/api/PluginManifest.java | 156 +++++ .../java/vip/mate/plugin/api/PluginType.java | 21 + .../api/channel/PluginChannelAdapter.java | 78 +++ .../api/memory/PluginMemoryProvider.java | 77 +++ mateclaw-plugin-sample/pom.xml | 67 +++ .../vip/mate/plugin/sample/HelloPlugin.java | 51 ++ .../src/main/resources/mateclaw-plugin.json | 11 + mateclaw-server/pom.xml | 7 + .../java/vip/mate/channel/ChannelManager.java | 53 +- .../llm/service/ModelProviderService.java | 29 + .../vip/mate/memory/spi/MemoryManager.java | 52 ++ .../java/vip/mate/plugin/LoadedPlugin.java | 47 ++ .../vip/mate/plugin/PluginContextImpl.java | 121 ++++ .../java/vip/mate/plugin/PluginManager.java | 562 ++++++++++++++++++ .../vip/mate/plugin/PluginProperties.java | 22 + .../plugin/bridge/PluginChannelBridge.java | 71 +++ .../plugin/bridge/PluginMemoryBridge.java | 64 ++ .../plugin/controller/PluginController.java | 60 ++ .../vip/mate/plugin/model/PluginEntity.java | 66 ++ .../vip/mate/plugin/model/PluginInfo.java | 46 ++ .../mate/plugin/repository/PluginMapper.java | 14 + .../main/java/vip/mate/tool/ToolRegistry.java | 48 +- .../src/main/resources/application.yml | 3 + .../db/migration/h2/V6__plugin_table.sql | 20 + .../mysql/V4__agent_thinking_level.sql | 3 + .../mysql/V5__conversation_parent.sql | 3 + .../db/migration/mysql/V6__plugin_table.sql | 20 + .../src/main/resources/db/schema.sql | 23 + mateclaw-ui/src/api/index.ts | 9 + mateclaw-ui/src/i18n/locales/en-US.ts | 21 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 21 + mateclaw-ui/src/router/index.ts | 6 + mateclaw-ui/src/views/Plugins.vue | 410 +++++++++++++ mateclaw-ui/src/views/layout/MainLayout.vue | 5 + 39 files changed, 2456 insertions(+), 8 deletions(-) create mode 100644 data/mateclaw.mv.db create mode 100644 mateclaw-plugin-api/pom.xml create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/MateClawPlugin.java create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginException.java create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginManifest.java create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/channel/PluginChannelAdapter.java create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java create mode 100644 mateclaw-plugin-sample/pom.xml create mode 100644 mateclaw-plugin-sample/src/main/java/vip/mate/plugin/sample/HelloPlugin.java create mode 100644 mateclaw-plugin-sample/src/main/resources/mateclaw-plugin.json create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/PluginProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginChannelBridge.java create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/repository/PluginMapper.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V6__plugin_table.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V4__agent_thinking_level.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V5__conversation_parent.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V6__plugin_table.sql create mode 100644 mateclaw-ui/src/views/Plugins.vue diff --git a/data/mateclaw.mv.db b/data/mateclaw.mv.db new file mode 100644 index 0000000000000000000000000000000000000000..3dead6b6b05a1385482c8379c7c1975db66f3346 GIT binary patch literal 180224 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 MhyK6&n(6=lKcSGsNdN!< literal 0 HcmV?d00001 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: ``, + }, ], }, {