mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
feat(plugin): Plugin SDK + UI layout improvements
This commit is contained in:
parent
97e101ab52
commit
0adb3dddf1
BIN
data/mateclaw.mv.db
Normal file
BIN
data/mateclaw.mv.db
Normal file
Binary file not shown.
67
mateclaw-plugin-api/pom.xml
Normal file
67
mateclaw-plugin-api/pom.xml
Normal 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>
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <= 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
67
mateclaw-plugin-sample/pom.xml
Normal file
67
mateclaw-plugin-sample/pom.xml
Normal 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>
|
||||||
@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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": {}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 内部方法 ====================
|
// ==================== 内部方法 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
562
mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java
Normal file
562
mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有已启用的工具 Bean(Spring AI @Tool 注解方式)
|
* 获取所有已启用的工具 Bean(Spring 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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);
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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: '数据源管理',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
410
mateclaw-ui/src/views/Plugins.vue
Normal file
410
mateclaw-ui/src/views/Plugins.vue
Normal 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>
|
||||||
@ -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>`,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user