feat(plugin): Plugin SDK + UI layout improvements

This commit is contained in:
matevip 2026-04-13 18:38:03 +08:00
parent 97e101ab52
commit 0adb3dddf1
39 changed files with 2456 additions and 8 deletions

BIN
data/mateclaw.mv.db Normal file

Binary file not shown.

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>MateClaw Plugin API</name>
<description>Plugin SDK contract for MateClaw — external plugins depend only on this module</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.1.4</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring AI core — for ToolCallback, ChatModel -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
<scope>provided</scope>
</dependency>
<!-- Logging API -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
<scope>provided</scope>
</dependency>
<!-- Jackson for manifest parsing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.3</version>
<scope>provided</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
</project>

View File

@ -0,0 +1,34 @@
package vip.mate.plugin.api;
/**
* Plugin SPI contract all MateClaw plugins implement this interface.
*
* <p>Lifecycle:
* <ol>
* <li>{@link #onLoad(PluginContext)} called when the plugin is loaded, platform context injected</li>
* <li>{@link #onEnable()} called when the plugin is enabled, register features to the platform</li>
* <li>{@link #onDisable()} called when the plugin is disabled, clean up resources</li>
* </ol>
*
* @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();
}

View File

@ -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.
* <p>
* 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<Boolean> 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.
* <p>
* 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 <T> the type
* @return the config value, or null if not set
*/
<T> T getConfig(String key, Class<T> type);
/**
* Get a logger instance for this plugin.
*
* @return the logger
*/
Logger getLogger();
}

View File

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

View File

@ -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<String, ConfigField> 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<String, ConfigField> getConfig() { return config; }
public void setConfig(Map<String, ConfigField> 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 &lt;= 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 &lt; v2, 0 if equal, positive if v1 &gt; 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; }
}
}

View File

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

View File

@ -0,0 +1,78 @@
package vip.mate.plugin.api.channel;
/**
* Simplified channel adapter interface for plugins.
* <p>
* 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.
* <p>
* 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");
}
}

View File

@ -0,0 +1,77 @@
package vip.mate.plugin.api.memory;
import java.util.Collections;
import java.util.List;
/**
* Memory provider interface for plugins.
* <p>
* 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<Object> getToolBeans() {
return Collections.emptyList();
}
/**
* Session end hook.
*/
default void onSessionEnd(Long agentId, String conversationId) {
}
}

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-sample</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>MateClaw Sample Plugin</name>
<description>A sample plugin demonstrating the MateClaw Plugin SDK</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.1.4</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- MateClaw Plugin API -->
<dependency>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<!-- Spring AI (provided by the platform) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
<scope>provided</scope>
</dependency>
<!-- SLF4J (provided by the platform) -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
<scope>provided</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
</project>

View File

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

View File

@ -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": {}
}

View File

@ -54,6 +54,13 @@
</dependencyManagement> </dependencyManagement>
<dependencies> <dependencies>
<!-- ===== MateClaw Plugin API ===== -->
<dependency>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
</dependency>
<!-- ===== Web MVC不引入 WebFlux避免自动切换为响应式模式 ===== --> <!-- ===== Web MVC不引入 WebFlux避免自动切换为响应式模式 ===== -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@ -48,6 +48,9 @@ public class ChannelManager {
/** 运行中的渠道适配器channelId -> adapter */ /** 运行中的渠道适配器channelId -> adapter */
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>(); private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
/** 插件注册的渠道适配器pluginName -> adapter */
private final Map<String, ChannelAdapter> pluginChannels = new ConcurrentHashMap<>();
/** 读写锁读操作getAdapter 等用读锁写操作start/stop/replace用写锁 */ /** 读写锁读操作getAdapter 等用读锁写操作start/stop/replace用写锁 */
private final ReadWriteLock adapterLock = new ReentrantReadWriteLock(); private final ReadWriteLock adapterLock = new ReentrantReadWriteLock();
@ -227,12 +230,17 @@ public class ChannelManager {
} }
/** /**
* 按渠道类型获取适配器返回第一个匹配的 * 按渠道类型获取适配器返回第一个匹配的先查内置再查插件
*/ */
public Optional<ChannelAdapter> getAdapterByType(String channelType) { public Optional<ChannelAdapter> getAdapterByType(String channelType) {
adapterLock.readLock().lock(); adapterLock.readLock().lock();
try { try {
return activeAdapters.values().stream() Optional<ChannelAdapter> 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)) .filter(a -> a.getChannelType().equals(channelType))
.findFirst(); .findFirst();
} finally { } finally {
@ -241,12 +249,14 @@ public class ChannelManager {
} }
/** /**
* 获取所有运行中的渠道适配器 * 获取所有运行中的渠道适配器含插件渠道
*/ */
public Collection<ChannelAdapter> getActiveAdapters() { public Collection<ChannelAdapter> getActiveAdapters() {
adapterLock.readLock().lock(); adapterLock.readLock().lock();
try { try {
return List.copyOf(activeAdapters.values()); List<ChannelAdapter> all = new ArrayList<>(activeAdapters.values());
all.addAll(pluginChannels.values());
return List.copyOf(all);
} finally { } finally {
adapterLock.readLock().unlock(); adapterLock.readLock().unlock();
} }
@ -298,10 +308,12 @@ public class ChannelManager {
} }
/** /**
* 判断是否支持该渠道类型 * 判断是否支持该渠道类型含插件渠道
*/ */
public boolean isSupported(String channelType) { 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); 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);
}
}
// ==================== 内部方法 ==================== // ==================== 内部方法 ====================
/** /**

View File

@ -12,7 +12,10 @@ import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.llm.model.*; import vip.mate.llm.model.*;
import vip.mate.llm.repository.ModelProviderMapper; import vip.mate.llm.repository.ModelProviderMapper;
import org.springframework.ai.chat.model.ChatModel;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@ -24,6 +27,32 @@ public class ModelProviderService {
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
/** Plugin-registered ChatModel instances: providerId -> ChatModel */
private final Map<String, ChatModel> 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<ProviderInfoDTO> listProviders() { public List<ProviderInfoDTO> listProviders() {
List<ModelProviderEntity> providers = modelProviderMapper.selectList(new LambdaQueryWrapper<ModelProviderEntity>() List<ModelProviderEntity> providers = modelProviderMapper.selectList(new LambdaQueryWrapper<ModelProviderEntity>()
.orderByDesc(ModelProviderEntity::getIsLocal) .orderByDesc(ModelProviderEntity::getIsLocal)

View File

@ -29,6 +29,9 @@ public class MemoryManager {
private final List<MemoryProvider> providers; private final List<MemoryProvider> providers;
/** External plugin memory provider (single-select constraint) */
private volatile MemoryProvider externalPluginProvider = null;
public MemoryManager(List<MemoryProvider> allProviders, MemoryProperties properties) { public MemoryManager(List<MemoryProvider> allProviders, MemoryProperties properties) {
Set<String> disabled = properties.getDisabledProviders(); Set<String> disabled = properties.getDisabledProviders();
this.providers = allProviders.stream() this.providers = allProviders.stream()
@ -183,6 +186,55 @@ public class MemoryManager {
+ "</memory-context>"; + "</memory-context>";
} }
// ==================== 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 ==================== // ==================== Accessors ====================
public List<MemoryProvider> getProviders() { public List<MemoryProvider> getProviders() {

View File

@ -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<String> registeredTools = new ArrayList<>();
/** Channel types registered by this plugin */
private final List<String> 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;
}
}

View File

@ -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<String, Object> 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<String, Object> 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<Boolean> 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> T getConfig(String key, Class<T> 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;
}
}

View File

@ -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.
* <p>
* 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> workspaceService;
private final Map<String, LoadedPlugin> 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<Path> 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<Path> discoverPluginJars() {
List<Path> 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<Path> target, String source) {
try (Stream<Path> stream = Files.list(dir)) {
List<Path> 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<PluginInfo> listPlugins() {
Map<String, PluginInfo> result = new LinkedHashMap<>();
// In-memory loaded plugins
for (Map.Entry<String, LoadedPlugin> 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<PluginEntity> 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<String, Object> 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<String, PluginManifest.ConfigField> 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<String, PluginManifest.ConfigField> 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<String, Object> buildConfigSchema(PluginManifest manifest) {
if (manifest.getConfig() == null || manifest.getConfig().isEmpty()) {
return null;
}
Map<String, Object> schema = new LinkedHashMap<>();
manifest.getConfig().forEach((key, field) -> {
Map<String, Object> 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<String, Object> buildRedactedConfig(LoadedPlugin loaded) {
PluginEntity entity = findByName(loaded.getManifest().getName());
if (entity == null || entity.getConfigJson() == null || entity.getConfigJson().isBlank()) {
return null;
}
try {
Map<String, Object> config = objectMapper.readValue(entity.getConfigJson(), Map.class);
// Redact secret fields
Map<String, PluginManifest.ConfigField> schema = loaded.getManifest().getConfig();
if (schema != null) {
for (Map.Entry<String, PluginManifest.ConfigField> 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<PluginEntity>()
.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());
}
}
}

View File

@ -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";
}

View File

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

View File

@ -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<Object> getToolBeans() {
List<Object> beans = delegate.getToolBeans();
return beans != null ? beans : Collections.emptyList();
}
@Override
public void onSessionEnd(Long agentId, String conversationId) {
delegate.onSessionEnd(agentId, conversationId);
}
}

View File

@ -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<PluginInfo>> list() {
return R.ok(pluginManager.listPlugins());
}
@Operation(summary = "Get plugin detail")
@GetMapping("/{name}")
public R<PluginInfo> get(@PathVariable String name) {
return R.ok(pluginManager.getPlugin(name));
}
@Operation(summary = "Disable a plugin")
@PostMapping("/{name}/disable")
public R<Void> disable(@PathVariable String name) {
pluginManager.disablePlugin(name);
return R.ok();
}
@Operation(summary = "Enable a plugin")
@PostMapping("/{name}/enable")
public R<Void> enable(@PathVariable String name) {
pluginManager.enablePlugin(name);
return R.ok();
}
@Operation(summary = "Update plugin configuration")
@PutMapping("/{name}/config")
public R<Void> updateConfig(@PathVariable String name,
@RequestBody Map<String, Object> config) {
pluginManager.updateConfig(name, config);
return R.ok();
}
}

View File

@ -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;
}

View File

@ -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<String> registeredTools;
/** Channel types registered by this plugin */
private List<String> 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<String, Object> configSchema;
/** Current config values */
private Map<String, Object> currentConfig;
}

View File

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

View File

@ -21,6 +21,8 @@ import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Supplier;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -39,6 +41,31 @@ public class ToolRegistry {
private final ToolMapper toolMapper; private final ToolMapper toolMapper;
private final I18nService i18nService; private final I18nService i18nService;
// ==================== Plugin Tools ====================
/** Plugin-registered tool entries with lazy availability checks */
private final CopyOnWriteArrayList<PluginToolEntry> pluginTools = new CopyOnWriteArrayList<>();
/** A tool entry registered by a plugin */
public record PluginToolEntry(ToolCallback callback, Supplier<Boolean> 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<Boolean> 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);
}
/** /**
* 获取所有已启用的工具 BeanSpring AI @Tool 注解方式 * 获取所有已启用的工具 BeanSpring AI @Tool 注解方式
* 通过数据库 enabled 标志过滤确保 UI 开关真正生效 * 通过数据库 enabled 标志过滤确保 UI 开关真正生效
@ -118,8 +145,25 @@ public class ToolRegistry {
} }
} }
log.info("Building AgentToolSet: toolBeans={}, providers={}, totalCallbacks={}", // Plugin tool callbacks evaluate availability checks lazily
toolBeans.size(), providers.size(), localizedCallbacks.size()); 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); return AgentToolSet.fromCallbacks(toolBeans, localizedCallbacks);
} }

View File

@ -108,6 +108,9 @@ mateclaw:
search-path: /api/v1/search search-path: /api/v1/search
http-timeout: 15 http-timeout: 15
http-retries: 3 http-retries: 3
plugin:
enabled: true
user-dir: ${user.home}/.mateclaw/plugins
# MateClaw Agent 配置 # MateClaw Agent 配置
mate: mate:

View File

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

View File

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

View File

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

View File

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

View File

@ -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_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_user ON mate_audit_event(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_resource ON mate_audit_event(resource_type, resource_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);

View File

@ -433,6 +433,15 @@ export const dashboardApi = {
recentRuns: (limit = 20) => http.get('/dashboard/cron-runs', { params: { limit } }), 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<string, any>) => http.put(`/plugins/${name}/config`, config),
}
// ==================== Audit Events ==================== // ==================== Audit Events ====================
export const auditApi = { export const auditApi = {
listEvents: (params: { listEvents: (params: {

View File

@ -207,6 +207,7 @@ export default {
skills: 'Skills', skills: 'Skills',
wiki: 'Wiki KB', wiki: 'Wiki KB',
tools: 'Tools', tools: 'Tools',
plugins: 'Plugins',
core: 'Core', core: 'Core',
connect: 'Connect', connect: 'Connect',
system: 'System', system: 'System',
@ -1064,6 +1065,26 @@ export default {
toggleFailed: 'Failed to toggle tool status', 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: { datasources: {
kicker: 'Data Fabric', kicker: 'Data Fabric',
title: 'Datasources', title: 'Datasources',

View File

@ -210,6 +210,7 @@ export default {
skills: '技能', skills: '技能',
wiki: 'Wiki 知识库', wiki: 'Wiki 知识库',
tools: '工具', tools: '工具',
plugins: '插件',
datasources: '数据源', datasources: '数据源',
mcpServers: 'MCP 服务', mcpServers: 'MCP 服务',
settingsGroup: '设置', settingsGroup: '设置',
@ -1064,6 +1065,26 @@ export default {
toggleFailed: '切换工具状态失败', 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: { datasources: {
kicker: '数据接入', kicker: '数据接入',
title: '数据源管理', title: '数据源管理',

View File

@ -52,6 +52,12 @@ const router = createRouter({
component: () => import('@/views/Tools.vue'), component: () => import('@/views/Tools.vue'),
meta: { title: 'Tools' }, meta: { title: 'Tools' },
}, },
{
path: 'plugins',
name: 'Plugins',
component: () => import('@/views/Plugins.vue'),
meta: { title: 'Plugins' },
},
// ==================== Settings (absorbs advanced pages) ==================== // ==================== Settings (absorbs advanced pages) ====================
{ {
path: 'settings', path: 'settings',

View File

@ -0,0 +1,410 @@
<template>
<div class="mc-page-shell">
<div class="mc-page-frame">
<div class="mc-page-inner plugins-page">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">Extension</div>
<h1 class="mc-page-title">{{ t('plugins.title') }}</h1>
<p class="mc-page-desc">{{ t('plugins.desc') }}</p>
</div>
<button class="btn-secondary" @click="refresh">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
{{ t('plugins.refresh') }}
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="loading-state mc-surface-card">
<div class="loading-spinner"></div>
<p>{{ t('plugins.loading') }}</p>
</div>
<!-- Plugin Cards -->
<div v-else class="plugins-grid">
<div v-for="plugin in plugins" :key="plugin.name" class="plugin-card mc-surface-card">
<div class="plugin-header">
<div class="plugin-icon-wrap">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/>
<path d="M16 3h-8v4h8V3z"/>
</svg>
</div>
<div class="plugin-meta">
<div class="plugin-name">{{ plugin.displayName || plugin.name }}</div>
<div class="plugin-version">v{{ plugin.version }}</div>
</div>
<label class="toggle-switch" :class="{ disabled: toggling === plugin.name }">
<input type="checkbox" :checked="plugin.enabled"
:disabled="toggling === plugin.name"
@change="togglePlugin(plugin)" />
<span class="toggle-slider"></span>
</label>
</div>
<p class="plugin-desc">{{ plugin.description || t('plugins.noDescription') }}</p>
<div class="plugin-details">
<div class="plugin-detail-row">
<span class="detail-label">{{ t('plugins.type') }}</span>
<span class="type-badge" :class="'type-' + plugin.type">{{ plugin.type }}</span>
</div>
<div class="plugin-detail-row" v-if="plugin.author">
<span class="detail-label">{{ t('plugins.author') }}</span>
<span class="detail-value">{{ plugin.author }}</span>
</div>
<div class="plugin-detail-row">
<span class="detail-label">{{ t('plugins.status') }}</span>
<span class="status-badge" :class="'status-' + (plugin.status || '').toLowerCase()">
{{ plugin.status }}
</span>
</div>
</div>
<!-- Registered capabilities -->
<div class="plugin-capabilities" v-if="hasCapabilities(plugin)">
<div class="capability-section" v-if="plugin.registeredTools?.length">
<span class="capability-label">{{ t('plugins.tools') }}:</span>
<span class="capability-tag" v-for="tool in plugin.registeredTools" :key="tool">{{ tool }}</span>
</div>
<div class="capability-section" v-if="plugin.registeredChannels?.length">
<span class="capability-label">{{ t('plugins.channels') }}:</span>
<span class="capability-tag" v-for="ch in plugin.registeredChannels" :key="ch">{{ ch }}</span>
</div>
<div class="capability-section" v-if="plugin.registeredProvider">
<span class="capability-label">{{ t('plugins.provider') }}:</span>
<span class="capability-tag">{{ plugin.registeredProvider }}</span>
</div>
<div class="capability-section" v-if="plugin.registeredMemoryProvider">
<span class="capability-label">{{ t('plugins.memoryProvider') }}:</span>
<span class="capability-tag">{{ plugin.registeredMemoryProvider }}</span>
</div>
</div>
<!-- Error message -->
<div class="plugin-error" v-if="plugin.errorMessage">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{{ plugin.errorMessage }}
</div>
</div>
</div>
<!-- Empty state -->
<div v-if="plugins.length === 0 && !loading" class="empty-state mc-surface-card">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/>
<path d="M16 3h-8v4h8V3z"/>
</svg>
<p class="empty-title">{{ t('plugins.emptyTitle') }}</p>
<p class="empty-hint">{{ t('plugins.emptyHint') }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { pluginApi } from '@/api'
const { t } = useI18n()
interface PluginInfo {
name: string
version: string
type: string
displayName: string
description: string
author: string
enabled: boolean
status: string
errorMessage?: string
jarPath?: string
registeredTools?: string[]
registeredChannels?: string[]
registeredProvider?: string
registeredMemoryProvider?: string
}
const plugins = ref<PluginInfo[]>([])
const loading = ref(false)
const toggling = ref<string | null>(null)
async function loadPlugins() {
loading.value = true
try {
const res = await pluginApi.list()
plugins.value = res.data || []
} catch (e: any) {
ElMessage.error(t('plugins.loadFailed'))
} finally {
loading.value = false
}
}
async function togglePlugin(plugin: PluginInfo) {
toggling.value = plugin.name
try {
if (plugin.enabled) {
await pluginApi.disable(plugin.name)
ElMessage.success(t('plugins.disabled', { name: plugin.displayName || plugin.name }))
} else {
await pluginApi.enable(plugin.name)
ElMessage.success(t('plugins.enabled', { name: plugin.displayName || plugin.name }))
}
await loadPlugins()
} catch (e: any) {
ElMessage.error(e.message || t('plugins.toggleFailed'))
await loadPlugins()
} finally {
toggling.value = null
}
}
function refresh() {
loadPlugins()
}
function hasCapabilities(plugin: PluginInfo): boolean {
return !!(
plugin.registeredTools?.length ||
plugin.registeredChannels?.length ||
plugin.registeredProvider ||
plugin.registeredMemoryProvider
)
}
onMounted(() => {
loadPlugins()
})
</script>
<style scoped>
.plugins-page { gap: 18px; }
.plugins-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
margin-top: 8px;
}
.plugin-card {
padding: 20px;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.plugin-header {
display: flex;
align-items: center;
gap: 12px;
}
.plugin-icon-wrap {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: var(--mc-accent-bg, #f0f0ff);
color: var(--mc-accent, #6366f1);
flex-shrink: 0;
}
.plugin-meta {
flex: 1;
min-width: 0;
}
.plugin-name {
font-weight: 600;
font-size: 15px;
color: var(--mc-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.plugin-version {
font-size: 12px;
color: var(--mc-text-tertiary);
margin-top: 1px;
}
.plugin-desc {
font-size: 13px;
color: var(--mc-text-secondary);
line-height: 1.5;
margin: 0;
}
.plugin-details {
display: flex;
flex-direction: column;
gap: 6px;
}
.plugin-detail-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.detail-label {
color: var(--mc-text-tertiary);
min-width: 60px;
}
.detail-value {
color: var(--mc-text-secondary);
}
.type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
text-transform: uppercase;
}
.type-tool { background: #dbeafe; color: #1d4ed8; }
.type-provider { background: #fef3c7; color: #92400e; }
.type-channel { background: #d1fae5; color: #065f46; }
.type-memory { background: #ede9fe; color: #5b21b6; }
.status-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.status-enabled { background: #d1fae5; color: #065f46; }
.status-disabled { background: #f3f4f6; color: #6b7280; }
.status-error { background: #fee2e2; color: #991b1b; }
.status-loaded { background: #dbeafe; color: #1d4ed8; }
.plugin-capabilities {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 4px;
border-top: 1px solid var(--mc-border);
}
.capability-section {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: 12px;
}
.capability-label {
color: var(--mc-text-tertiary);
font-weight: 500;
}
.capability-tag {
background: var(--mc-surface-hover, #f5f5f5);
color: var(--mc-text-secondary);
padding: 1px 6px;
border-radius: 3px;
font-family: var(--mc-font-mono, monospace);
font-size: 11px;
}
.plugin-error {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 8px 10px;
background: #fef2f2;
border-radius: 6px;
font-size: 12px;
color: #991b1b;
line-height: 1.4;
}
.plugin-error svg { flex-shrink: 0; margin-top: 1px; }
/* Toggle switch (reuse pattern from Tools.vue) */
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background: var(--mc-border, #d1d5db); border-radius: 20px; transition: 0.2s;
}
.toggle-slider:before {
position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px;
background: white; border-radius: 50%; transition: 0.2s;
}
.toggle-switch input:checked + .toggle-slider { background: var(--mc-accent, #6366f1); }
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(16px); }
.toggle-switch.disabled { opacity: 0.5; pointer-events: none; }
/* Loading state */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 24px;
text-align: center;
border-radius: 12px;
color: var(--mc-text-tertiary);
}
.loading-spinner {
width: 28px; height: 28px;
border: 3px solid var(--mc-border, #e5e7eb);
border-top-color: var(--mc-accent, #6366f1);
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 24px;
text-align: center;
border-radius: 12px;
}
.empty-icon { color: var(--mc-text-tertiary); margin-bottom: 12px; }
.empty-title { font-weight: 600; color: var(--mc-text-primary); margin: 0 0 4px; }
.empty-hint { font-size: 13px; color: var(--mc-text-tertiary); margin: 0; }
/* btn-secondary reuse */
.btn-secondary {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
background: var(--mc-surface-hover, #f5f5f5); color: var(--mc-text-primary);
border: 1px solid var(--mc-border, #e5e7eb); cursor: pointer; transition: 0.15s;
}
.btn-secondary:hover { background: var(--mc-surface-active, #ebebeb); }
/* Dark mode overrides */
:root.dark .type-tool { background: #1e3a5f; color: #93c5fd; }
:root.dark .type-provider { background: #451a03; color: #fcd34d; }
:root.dark .type-channel { background: #064e3b; color: #6ee7b7; }
:root.dark .type-memory { background: #2e1065; color: #c4b5fd; }
:root.dark .status-enabled { background: #064e3b; color: #6ee7b7; }
:root.dark .status-disabled { background: #374151; color: #9ca3af; }
:root.dark .status-error { background: #450a0a; color: #fca5a5; }
:root.dark .status-loaded { background: #1e3a5f; color: #93c5fd; }
:root.dark .plugin-error { background: #450a0a; color: #fca5a5; }
:root.dark .plugin-icon-wrap { background: #2e1065; }
</style>

View File

@ -321,6 +321,11 @@ const navGroups = computed(() => [
label: t('nav.tools'), label: t('nav.tools'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`, icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
}, },
{
path: '/plugins',
label: t('nav.plugins'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 3h-8v4h8V3z"/></svg>`,
},
], ],
}, },
{ {