mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): Agent-autonomous skill synthesis — create/edit/patch via @Tool with security scanning
This commit is contained in:
parent
7d8d16e458
commit
a6e9a17208
@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.synthesis.SkillSynthesisService;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
@ -31,6 +32,7 @@ public class SkillController {
|
||||
private final SkillService skillService;
|
||||
private final SkillRuntimeService skillRuntimeService;
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final SkillSynthesisService synthesisService;
|
||||
|
||||
@Operation(summary = "获取技能列表")
|
||||
@GetMapping
|
||||
@ -129,6 +131,38 @@ public class SkillController {
|
||||
"resynced", resynced));
|
||||
}
|
||||
|
||||
// ==================== Synthesis API (RFC-023) ====================
|
||||
|
||||
@Operation(summary = "从对话历史合成 Skill(RFC-023)")
|
||||
@PostMapping("/synthesize-from-conversation")
|
||||
public R<Map<String, Object>> synthesizeFromConversation(@RequestBody Map<String, Object> body) {
|
||||
String conversationId = (String) body.get("conversationId");
|
||||
Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null;
|
||||
if (conversationId == null || conversationId.isBlank()) {
|
||||
return R.fail("conversationId is required");
|
||||
}
|
||||
|
||||
SkillSynthesisService.SynthesisResult result = synthesisService.synthesize(conversationId, agentId);
|
||||
|
||||
if (result.blocked()) {
|
||||
return R.ok(Map.of(
|
||||
"success", false,
|
||||
"blocked", true,
|
||||
"skillName", result.skillName() != null ? result.skillName() : "",
|
||||
"error", result.error(),
|
||||
"scanSummary", result.scanSummary() != null ? result.scanSummary() : ""
|
||||
));
|
||||
}
|
||||
if (!result.success()) {
|
||||
return R.ok(Map.of("success", false, "error", result.error()));
|
||||
}
|
||||
return R.ok(Map.of(
|
||||
"success", true,
|
||||
"skillId", result.skillId(),
|
||||
"skillName", result.skillName()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== Workspace API ====================
|
||||
|
||||
@Operation(summary = "将 skill 导出到工作区目录")
|
||||
|
||||
@ -74,6 +74,16 @@ public class SkillEntity {
|
||||
/** 标签(逗号分隔) */
|
||||
private String tags;
|
||||
|
||||
/** RFC-023:来源对话 ID(Agent 自治合成时记录) */
|
||||
private String sourceConversationId;
|
||||
|
||||
/**
|
||||
* RFC-023:安全扫描状态。
|
||||
* NULL = 旧数据或手动创建(不受扫描约束),PASSED = 扫描通过,FAILED = 扫描拦截。
|
||||
* listEnabledSkills 过滤条件:NULL 或 PASSED 才加载。
|
||||
*/
|
||||
private String securityScanStatus;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -60,13 +60,27 @@ public class SkillService {
|
||||
|
||||
/**
|
||||
* 获取已启用的技能列表(Agent 运行时使用)
|
||||
* <p>
|
||||
* RFC-023:追加 security_scan_status 过滤——FAILED 的 skill 不加载。
|
||||
* NULL(旧数据/手动创建)和 PASSED(扫描通过)都允许。
|
||||
*/
|
||||
public List<SkillEntity> listEnabledSkills() {
|
||||
return skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getEnabled, true)
|
||||
.and(w -> w.isNull(SkillEntity::getSecurityScanStatus)
|
||||
.or().eq(SkillEntity::getSecurityScanStatus, "PASSED"))
|
||||
.orderByAsc(SkillEntity::getName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称查找技能(RFC-023:SkillManageTool 重名检查用)
|
||||
*/
|
||||
public SkillEntity findByName(String name) {
|
||||
return skillMapper.selectOne(new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getName, name)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型获取技能列表
|
||||
*/
|
||||
@ -256,9 +270,18 @@ public class SkillService {
|
||||
return "";
|
||||
}
|
||||
|
||||
// --- 第一层:技能目录(始终注入,消耗很少的 token) ---
|
||||
// --- 第零层:Skill 自治引导(RFC-023,对标 hermes-agent prompt_builder.py:164-171) ---
|
||||
StringBuilder catalog = new StringBuilder();
|
||||
catalog.append("\n\n## Available Skills\n");
|
||||
catalog.append("\n\n## Skill Management\n\n");
|
||||
catalog.append("After completing a complex task (5+ tool calls), fixing a tricky error, ");
|
||||
catalog.append("or discovering a non-trivial workflow, save the approach as a skill using ");
|
||||
catalog.append("`skill_manage(action='create')` so you can reuse it next time.\n\n");
|
||||
catalog.append("When using a skill and finding it outdated, incomplete, or wrong, ");
|
||||
catalog.append("patch it immediately with `skill_manage(action='patch')` — don't wait to be asked. ");
|
||||
catalog.append("Skills that aren't maintained become liabilities.\n\n");
|
||||
|
||||
// --- 第一层:技能目录(始终注入,消耗很少的 token) ---
|
||||
catalog.append("## Available Skills\n");
|
||||
catalog.append("以下技能已启用,你可以在对话中根据用户需求灵活运用:\n\n");
|
||||
|
||||
for (SkillEntity skill : enabledSkills) {
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
package vip.mate.skill.synthesis;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* RFC-023: 自动 Skill 合成的配置注册
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(SkillSynthesisProperties.class)
|
||||
public class SkillSynthesisAutoConfiguration {
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package vip.mate.skill.synthesis;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* RFC-023: Skill 自动合成配置
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "mate.skill.synthesis")
|
||||
public class SkillSynthesisProperties {
|
||||
|
||||
/** 是否启用后台合成建议(对话结束后推 toast) */
|
||||
private boolean suggestEnabled = true;
|
||||
|
||||
/** 触发建议的最小对话消息数 */
|
||||
private int minMessageCount = 10;
|
||||
|
||||
/** 触发建议的最小工具调用数(从 metadata 统计) */
|
||||
private int minToolCallCount = 5;
|
||||
|
||||
/** 合成用的模型 ID(null = 跟随系统默认模型) */
|
||||
private String modelId;
|
||||
}
|
||||
@ -0,0 +1,284 @@
|
||||
package vip.mate.skill.synthesis;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.agent.AgentGraphBuilder;
|
||||
import vip.mate.agent.prompt.PromptLoader;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.runtime.SkillSecurityService;
|
||||
import vip.mate.skill.runtime.SkillValidationResult;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
import vip.mate.workspace.conversation.repository.MessageMapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* RFC-023: 从对话历史蒸馏 SKILL.md 的服务
|
||||
* <p>
|
||||
* 供 {@code POST /api/v1/skills/synthesize-from-conversation} 和前端"建议保存 Skill"流程调用。
|
||||
* 与 {@code SkillManageTool}(Agent 自治路径)互补——本服务是"用户主动触发"路径。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SkillSynthesisService {
|
||||
|
||||
private final MessageMapper messageMapper;
|
||||
private final SkillService skillService;
|
||||
private final SkillSecurityService securityService;
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final SkillSynthesisProperties properties;
|
||||
|
||||
private static final RetryTemplate NO_RETRY = RetryTemplate.builder().maxAttempts(1).build();
|
||||
|
||||
/**
|
||||
* 从对话历史合成 Skill
|
||||
*
|
||||
* @param conversationId 源对话 ID
|
||||
* @param agentId Agent ID(用于记录来源)
|
||||
* @return 合成结果(包含 skillId、name、status)
|
||||
*/
|
||||
public SynthesisResult synthesize(String conversationId, Long agentId) {
|
||||
// 1. 读取对话历史
|
||||
List<MessageEntity> messages = messageMapper.selectList(
|
||||
new LambdaQueryWrapper<MessageEntity>()
|
||||
.eq(MessageEntity::getConversationId, conversationId)
|
||||
.orderByAsc(MessageEntity::getCreateTime));
|
||||
if (messages.isEmpty()) {
|
||||
return SynthesisResult.failed("No messages found for conversation " + conversationId);
|
||||
}
|
||||
|
||||
// 2. 压缩对话为 LLM 输入(纯规则,不用 LLM)
|
||||
String condensed = condenseConversation(messages);
|
||||
if (condensed.length() < 100) {
|
||||
return SynthesisResult.failed("Conversation too short to synthesize a meaningful skill");
|
||||
}
|
||||
|
||||
// 3. 调 LLM 生成 SKILL.md
|
||||
String skillMd;
|
||||
try {
|
||||
skillMd = callLlm(condensed);
|
||||
} catch (Exception e) {
|
||||
log.error("[SkillSynthesis] LLM call failed for conversation={}: {}", conversationId, e.getMessage(), e);
|
||||
return SynthesisResult.failed("LLM call failed: " + e.getMessage());
|
||||
}
|
||||
|
||||
if (skillMd == null || skillMd.isBlank()) {
|
||||
return SynthesisResult.failed("LLM returned empty content");
|
||||
}
|
||||
|
||||
// 4. 提取名称
|
||||
String name = extractFrontmatterValue(skillMd, "name");
|
||||
if (name == null || name.isBlank()) {
|
||||
name = "auto-skill-" + System.currentTimeMillis();
|
||||
}
|
||||
name = name.strip().toLowerCase().replaceAll("[^a-z0-9._-]", "-");
|
||||
|
||||
// 去重
|
||||
SkillEntity existing = skillService.findByName(name);
|
||||
if (existing != null) {
|
||||
name = name + "-" + (System.currentTimeMillis() % 10000);
|
||||
}
|
||||
|
||||
// 5. 安全扫描
|
||||
SkillValidationResult scanResult = securityService.scanContent(skillMd, name);
|
||||
String scanStatus = scanResult.isBlocked() ? "FAILED" : "PASSED";
|
||||
|
||||
if (scanResult.isBlocked()) {
|
||||
log.warn("[SkillSynthesis] Security scan BLOCKED synthesized skill '{}': {}", name, scanResult.getSummary());
|
||||
return SynthesisResult.blocked(name, scanResult.getSummary());
|
||||
}
|
||||
|
||||
// 6. 保存
|
||||
try {
|
||||
SkillEntity skill = new SkillEntity();
|
||||
skill.setName(name);
|
||||
skill.setDescription(extractFrontmatterValue(skillMd, "description"));
|
||||
skill.setSkillType("custom");
|
||||
skill.setSkillContent(skillMd);
|
||||
skill.setEnabled(true);
|
||||
skill.setBuiltin(false);
|
||||
skill.setVersion(extractFrontmatterValue(skillMd, "version"));
|
||||
skill.setSourceConversationId(conversationId);
|
||||
skill.setSecurityScanStatus(scanStatus);
|
||||
|
||||
skillService.createSkill(skill);
|
||||
|
||||
try {
|
||||
workspaceManager.exportToWorkspace(name, skillMd);
|
||||
} catch (Exception e) {
|
||||
log.warn("[SkillSynthesis] Workspace export failed for '{}': {}", name, e.getMessage());
|
||||
}
|
||||
|
||||
log.info("[SkillSynthesis] Synthesized skill '{}' from conversation={}, agentId={}", name, conversationId, agentId);
|
||||
return SynthesisResult.success(skill.getId(), name);
|
||||
} catch (Exception e) {
|
||||
log.error("[SkillSynthesis] Failed to save skill '{}': {}", name, e.getMessage(), e);
|
||||
return SynthesisResult.failed("Save failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计对话中的工具调用数(用于建议器的阈值判断)
|
||||
*/
|
||||
public int countToolCalls(String conversationId) {
|
||||
Long count = messageMapper.selectCount(
|
||||
new LambdaQueryWrapper<MessageEntity>()
|
||||
.eq(MessageEntity::getConversationId, conversationId)
|
||||
.eq(MessageEntity::getRole, "tool"));
|
||||
return count != null ? count.intValue() : 0;
|
||||
}
|
||||
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
/**
|
||||
* 把对话历史压缩为 LLM 可消费的摘要(纯规则,不调 LLM)
|
||||
*/
|
||||
private String condenseConversation(List<MessageEntity> messages) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int maxLen = 12000; // 控制在 ~3K tokens
|
||||
|
||||
for (MessageEntity msg : messages) {
|
||||
if (sb.length() > maxLen) {
|
||||
sb.append("\n... (truncated, ").append(messages.size() - messages.indexOf(msg)).append(" messages remaining)");
|
||||
break;
|
||||
}
|
||||
|
||||
String role = msg.getRole();
|
||||
String content = msg.getContent();
|
||||
if (content == null || content.isBlank()) continue;
|
||||
|
||||
switch (role) {
|
||||
case "user" -> {
|
||||
sb.append("\n### User:\n");
|
||||
sb.append(truncate(content, 500));
|
||||
}
|
||||
case "assistant" -> {
|
||||
sb.append("\n### Assistant:\n");
|
||||
sb.append(truncate(content, 800));
|
||||
}
|
||||
case "tool" -> {
|
||||
sb.append("\n### Tool [").append(msg.getToolName() != null ? msg.getToolName() : "unknown").append("]:\n");
|
||||
// 工具结果只保留前 300 字符(通常很长)
|
||||
sb.append(truncate(content, 300));
|
||||
}
|
||||
// system messages 跳过
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String callLlm(String condensed) {
|
||||
String systemPrompt = PromptLoader.loadPrompt("skill/synthesize-system");
|
||||
String userTemplate = PromptLoader.loadPrompt("skill/synthesize-user");
|
||||
String userPrompt = userTemplate.replace("{conversation}", condensed);
|
||||
|
||||
ChatModel chatModel = buildChatModel();
|
||||
Prompt prompt = new Prompt(List.of(
|
||||
new SystemMessage(systemPrompt),
|
||||
new UserMessage(userPrompt)));
|
||||
|
||||
ChatResponse response = chatModel.call(prompt);
|
||||
if (response == null || response.getResult() == null
|
||||
|| response.getResult().getOutput() == null) {
|
||||
return null;
|
||||
}
|
||||
String text = response.getResult().getOutput().getText();
|
||||
|
||||
// 剥离 markdown 代码块
|
||||
if (text != null) {
|
||||
text = text.strip();
|
||||
if (text.startsWith("```")) {
|
||||
int firstNewline = text.indexOf('\n');
|
||||
if (firstNewline > 0) text = text.substring(firstNewline + 1);
|
||||
}
|
||||
if (text.endsWith("```")) {
|
||||
text = text.substring(0, text.length() - 3).strip();
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private ChatModel buildChatModel() {
|
||||
ModelConfigEntity model = null;
|
||||
if (properties.getModelId() != null && !properties.getModelId().isBlank()) {
|
||||
try {
|
||||
model = modelConfigService.getById(Long.parseLong(properties.getModelId()));
|
||||
} catch (Exception e) {
|
||||
log.warn("[SkillSynthesis] Invalid modelId '{}', falling back to default", properties.getModelId());
|
||||
}
|
||||
}
|
||||
if (model == null) {
|
||||
model = modelConfigService.getDefaultModel();
|
||||
}
|
||||
return agentGraphBuilder.buildRuntimeChatModel(model, NO_RETRY);
|
||||
}
|
||||
|
||||
private String extractFrontmatterValue(String content, String key) {
|
||||
if (content == null || !content.startsWith("---")) return null;
|
||||
int endIdx = content.indexOf("---", 3);
|
||||
if (endIdx < 0) return null;
|
||||
String frontmatter = content.substring(3, endIdx);
|
||||
for (String line : frontmatter.split("\n")) {
|
||||
String trimmed = line.strip();
|
||||
if (trimmed.startsWith(key + ":")) {
|
||||
String value = trimmed.substring(key.length() + 1).strip();
|
||||
if ((value.startsWith("\"") && value.endsWith("\""))
|
||||
|| (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.substring(1, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String truncate(String s, int maxLen) {
|
||||
if (s == null) return "";
|
||||
return s.length() <= maxLen ? s : s.substring(0, maxLen) + "...";
|
||||
}
|
||||
|
||||
// ==================== 结果 DTO ====================
|
||||
|
||||
public record SynthesisResult(
|
||||
boolean success,
|
||||
boolean blocked,
|
||||
Long skillId,
|
||||
String skillName,
|
||||
String error,
|
||||
String scanSummary
|
||||
) {
|
||||
public static SynthesisResult success(Long id, String name) {
|
||||
return new SynthesisResult(true, false, id, name, null, null);
|
||||
}
|
||||
|
||||
public static SynthesisResult failed(String error) {
|
||||
return new SynthesisResult(false, false, null, null, error, null);
|
||||
}
|
||||
|
||||
public static SynthesisResult blocked(String name, String scanSummary) {
|
||||
return new SynthesisResult(false, true, null, name, "Security scan blocked", scanSummary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package vip.mate.skill.synthesis;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.memory.event.ConversationCompletedEvent;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-023: 后台 Skill 合成建议器
|
||||
* <p>
|
||||
* 监听 {@link ConversationCompletedEvent},当对话消息数和工具调用数达到阈值时,
|
||||
* 通过 hook 事件通知前端"建议保存为 Skill"。<b>不自动创建 skill</b>——用户确认后
|
||||
* 通过 {@code POST /api/v1/skills/synthesize-from-conversation} 触发合成。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SkillSynthesisSuggestor {
|
||||
|
||||
private final SkillSynthesisProperties properties;
|
||||
private final SkillSynthesisService synthesisService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Async
|
||||
@EventListener
|
||||
public void onConversationCompleted(ConversationCompletedEvent event) {
|
||||
if (!properties.isSuggestEnabled()) return;
|
||||
|
||||
// 阈值检查 1: 消息数
|
||||
if (event.messageCount() < properties.getMinMessageCount()) return;
|
||||
|
||||
// 阈值检查 2: 工具调用数(需查 DB)
|
||||
int toolCallCount;
|
||||
try {
|
||||
toolCallCount = synthesisService.countToolCalls(event.conversationId());
|
||||
} catch (Exception e) {
|
||||
log.debug("[SkillSuggest] Failed to count tool calls: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (toolCallCount < properties.getMinToolCallCount()) return;
|
||||
|
||||
log.info("[SkillSuggest] Conversation {} qualifies for skill synthesis suggestion " +
|
||||
"(messages={}, toolCalls={})",
|
||||
event.conversationId(), event.messageCount(), toolCallCount);
|
||||
|
||||
// 发布一个 SkillSynthesisSuggestionEvent,供前端 SSE 或 hook 消费
|
||||
try {
|
||||
eventPublisher.publishEvent(new SkillSynthesisSuggestionEvent(
|
||||
event.agentId(),
|
||||
event.conversationId(),
|
||||
event.messageCount(),
|
||||
toolCallCount
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.warn("[SkillSuggest] Failed to publish suggestion event: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建议事件——前端监听后弹出 toast 提示用户"是否保存为 Skill"
|
||||
*/
|
||||
public record SkillSynthesisSuggestionEvent(
|
||||
Long agentId,
|
||||
String conversationId,
|
||||
int messageCount,
|
||||
int toolCallCount
|
||||
) {}
|
||||
}
|
||||
@ -0,0 +1,409 @@
|
||||
package vip.mate.tool.builtin;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.runtime.SkillSecurityService;
|
||||
import vip.mate.skill.runtime.SkillValidationResult;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* RFC-023: Agent 自治 Skill 管理工具
|
||||
* <p>
|
||||
* 对标 hermes-agent 的 skill_manager_tool.py,让 Agent 在对话中自主创建、编辑、
|
||||
* 修补和删除 Skill。每次写入前强制安全扫描,失败则拒绝并返回原因。
|
||||
* <p>
|
||||
* 系统 prompt 引导 Agent 使用此工具:
|
||||
* <blockquote>
|
||||
* "After completing a complex task (5+ tool calls), fixing a tricky error,
|
||||
* or discovering a non-trivial workflow, save the approach as a skill using
|
||||
* skill_manage so you can reuse it next time."
|
||||
* </blockquote>
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SkillManageTool {
|
||||
|
||||
private final SkillService skillService;
|
||||
private final SkillSecurityService securityService;
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
|
||||
/** Skill 名称格式:小写字母/数字/连字符/下划线/点,首字符必须是字母或数字 */
|
||||
private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9._-]{0,63}$");
|
||||
/** Skill 内容最大长度(~25K tokens) */
|
||||
private static final int MAX_CONTENT_CHARS = 100_000;
|
||||
|
||||
@Tool(description = """
|
||||
Manage reusable skills: create, edit, patch, or delete skill procedures (SKILL.md format).
|
||||
|
||||
Use this tool to save successful approaches, workflows, and solutions as reusable skills.
|
||||
When to create a skill:
|
||||
- After completing a complex task (5+ tool calls)
|
||||
- After fixing a tricky error with a non-obvious solution
|
||||
- After discovering a workflow worth remembering
|
||||
|
||||
When to patch a skill:
|
||||
- When using a skill and finding it outdated, incomplete, or wrong
|
||||
- Don't wait to be asked — patch immediately
|
||||
|
||||
Actions:
|
||||
- create: Create a new skill with SKILL.md content (YAML frontmatter + markdown body)
|
||||
- edit: Replace entire skill content (for major rewrites)
|
||||
- patch: Find-and-replace a specific section (for small fixes)
|
||||
- delete: Remove a skill
|
||||
|
||||
SKILL.md format example:
|
||||
---
|
||||
name: skill-name
|
||||
description: One-line description of what this skill does
|
||||
version: "1.0"
|
||||
---
|
||||
# Skill Title
|
||||
|
||||
## When to Use
|
||||
Describe the scenario...
|
||||
|
||||
## Steps
|
||||
1. First step with actual commands...
|
||||
2. Second step...
|
||||
|
||||
## Gotchas
|
||||
- Known pitfalls...
|
||||
|
||||
Security: Content is scanned for dangerous patterns before saving. Malicious content will be rejected.
|
||||
""")
|
||||
public String skill_manage(
|
||||
@JsonProperty(required = true)
|
||||
@JsonPropertyDescription("Action: create | edit | patch | delete")
|
||||
String action,
|
||||
|
||||
@JsonProperty(required = true)
|
||||
@JsonPropertyDescription("Skill name (lowercase letters, digits, hyphens, e.g., 'spring-boot-scaffold')")
|
||||
String name,
|
||||
|
||||
@JsonProperty
|
||||
@JsonPropertyDescription("SKILL.md full content (required for create/edit). YAML frontmatter + markdown body.")
|
||||
String content,
|
||||
|
||||
@JsonProperty
|
||||
@JsonPropertyDescription("For patch action: the existing text to find and replace")
|
||||
String oldText,
|
||||
|
||||
@JsonProperty
|
||||
@JsonPropertyDescription("For patch action: the new text to replace with")
|
||||
String newText
|
||||
) {
|
||||
if (action == null || action.isBlank()) {
|
||||
return "Error: action is required (create | edit | patch | delete)";
|
||||
}
|
||||
if (name == null || name.isBlank()) {
|
||||
return "Error: name is required";
|
||||
}
|
||||
|
||||
String normalizedName = name.strip().toLowerCase();
|
||||
if (!NAME_PATTERN.matcher(normalizedName).matches()) {
|
||||
return "Error: invalid skill name '" + normalizedName
|
||||
+ "'. Must match: lowercase letters, digits, hyphens, dots (1-64 chars, start with letter/digit)";
|
||||
}
|
||||
|
||||
return switch (action.strip().toLowerCase()) {
|
||||
case "create" -> doCreate(normalizedName, content);
|
||||
case "edit" -> doEdit(normalizedName, content);
|
||||
case "patch" -> doPatch(normalizedName, oldText, newText);
|
||||
case "delete" -> doDelete(normalizedName);
|
||||
default -> "Error: unknown action '" + action + "'. Use: create | edit | patch | delete";
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Create ====================
|
||||
|
||||
private String doCreate(String name, String content) {
|
||||
if (content == null || content.isBlank()) {
|
||||
return "Error: content is required for create action. Provide full SKILL.md content.";
|
||||
}
|
||||
if (content.length() > MAX_CONTENT_CHARS) {
|
||||
return "Error: content too large (" + content.length() + " chars, max " + MAX_CONTENT_CHARS + ")";
|
||||
}
|
||||
|
||||
// 检查重名
|
||||
SkillEntity existing = skillService.findByName(name);
|
||||
if (existing != null) {
|
||||
return "Error: skill '" + name + "' already exists. Use action='edit' to update or action='patch' for small fixes.";
|
||||
}
|
||||
|
||||
// 安全扫描
|
||||
String scanError = runSecurityScan(content, name);
|
||||
if (scanError != null) return scanError;
|
||||
|
||||
// 创建 skill
|
||||
try {
|
||||
SkillEntity skill = new SkillEntity();
|
||||
skill.setName(name);
|
||||
skill.setDescription(extractDescription(content));
|
||||
skill.setSkillType("custom");
|
||||
skill.setSkillContent(content);
|
||||
skill.setEnabled(true);
|
||||
skill.setBuiltin(false);
|
||||
skill.setVersion(extractVersion(content));
|
||||
skill.setSecurityScanStatus("PASSED");
|
||||
|
||||
skillService.createSkill(skill);
|
||||
|
||||
// 同步到 workspace 文件系统
|
||||
try {
|
||||
workspaceManager.exportToWorkspace(name, content);
|
||||
} catch (Exception e) {
|
||||
log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage());
|
||||
}
|
||||
|
||||
log.info("[SkillManage] Agent created skill: name={}, contentLen={}", name, content.length());
|
||||
return "Skill '" + name + "' created successfully (security scan: PASSED). "
|
||||
+ "It is now available in your skill list for future conversations.";
|
||||
} catch (Exception e) {
|
||||
log.error("[SkillManage] Failed to create skill '{}': {}", name, e.getMessage(), e);
|
||||
return "Error creating skill: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Edit (full rewrite) ====================
|
||||
|
||||
private String doEdit(String name, String content) {
|
||||
if (content == null || content.isBlank()) {
|
||||
return "Error: content is required for edit action. Provide full replacement SKILL.md content.";
|
||||
}
|
||||
if (content.length() > MAX_CONTENT_CHARS) {
|
||||
return "Error: content too large (" + content.length() + " chars, max " + MAX_CONTENT_CHARS + ")";
|
||||
}
|
||||
|
||||
SkillEntity existing = skillService.findByName(name);
|
||||
if (existing == null) {
|
||||
return "Error: skill '" + name + "' not found. Use action='create' to create it.";
|
||||
}
|
||||
if (Boolean.TRUE.equals(existing.getBuiltin())) {
|
||||
return "Error: cannot edit builtin skill '" + name + "'.";
|
||||
}
|
||||
|
||||
// 安全扫描
|
||||
String scanError = runSecurityScan(content, name);
|
||||
if (scanError != null) return scanError;
|
||||
|
||||
try {
|
||||
existing.setSkillContent(content);
|
||||
existing.setDescription(extractDescription(content));
|
||||
existing.setVersion(extractVersion(content));
|
||||
existing.setSecurityScanStatus("PASSED");
|
||||
skillService.updateSkill(existing);
|
||||
|
||||
try {
|
||||
workspaceManager.exportToWorkspace(name, content);
|
||||
} catch (Exception e) {
|
||||
log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage());
|
||||
}
|
||||
|
||||
log.info("[SkillManage] Agent edited skill: name={}, contentLen={}", name, content.length());
|
||||
return "Skill '" + name + "' updated successfully (security scan: PASSED).";
|
||||
} catch (Exception e) {
|
||||
log.error("[SkillManage] Failed to edit skill '{}': {}", name, e.getMessage(), e);
|
||||
return "Error editing skill: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Patch (find-and-replace) ====================
|
||||
|
||||
private String doPatch(String name, String oldText, String newText) {
|
||||
if (oldText == null || oldText.isBlank()) {
|
||||
return "Error: oldText is required for patch action.";
|
||||
}
|
||||
if (newText == null) {
|
||||
return "Error: newText is required for patch action (use empty string to delete a section).";
|
||||
}
|
||||
|
||||
SkillEntity existing = skillService.findByName(name);
|
||||
if (existing == null) {
|
||||
return "Error: skill '" + name + "' not found.";
|
||||
}
|
||||
if (Boolean.TRUE.equals(existing.getBuiltin())) {
|
||||
return "Error: cannot patch builtin skill '" + name + "'.";
|
||||
}
|
||||
|
||||
String currentContent = existing.getSkillContent();
|
||||
if (currentContent == null || currentContent.isBlank()) {
|
||||
return "Error: skill '" + name + "' has no content to patch.";
|
||||
}
|
||||
|
||||
// 精确匹配
|
||||
String patchedContent;
|
||||
if (currentContent.contains(oldText)) {
|
||||
patchedContent = currentContent.replace(oldText, newText);
|
||||
} else {
|
||||
// 宽松匹配:归一化空白后重试
|
||||
String normalizedCurrent = normalizeWhitespace(currentContent);
|
||||
String normalizedOld = normalizeWhitespace(oldText);
|
||||
if (normalizedCurrent.contains(normalizedOld)) {
|
||||
// 找到原始位置(用归一化版本定位,然后在原文中做替换)
|
||||
int normIdx = normalizedCurrent.indexOf(normalizedOld);
|
||||
// 回映射到原始文本(近似:找最近的原始位置)
|
||||
int approxStart = findApproximatePosition(currentContent, oldText);
|
||||
if (approxStart >= 0) {
|
||||
int approxEnd = approxStart + oldText.length();
|
||||
patchedContent = currentContent.substring(0, approxStart) + newText
|
||||
+ currentContent.substring(Math.min(approxEnd, currentContent.length()));
|
||||
} else {
|
||||
return "Error: could not locate oldText in skill content (fuzzy match found but position mapping failed). "
|
||||
+ "Try using action='edit' with full content instead.";
|
||||
}
|
||||
} else {
|
||||
return "Error: oldText not found in skill '" + name + "'. Check for whitespace differences. "
|
||||
+ "Tip: use action='edit' to replace entire content if patch is too tricky.";
|
||||
}
|
||||
}
|
||||
|
||||
if (patchedContent.length() > MAX_CONTENT_CHARS) {
|
||||
return "Error: patched content too large (" + patchedContent.length() + " chars, max " + MAX_CONTENT_CHARS + ")";
|
||||
}
|
||||
|
||||
// 安全扫描
|
||||
String scanError = runSecurityScan(patchedContent, name);
|
||||
if (scanError != null) return scanError;
|
||||
|
||||
try {
|
||||
existing.setSkillContent(patchedContent);
|
||||
existing.setDescription(extractDescription(patchedContent));
|
||||
existing.setSecurityScanStatus("PASSED");
|
||||
skillService.updateSkill(existing);
|
||||
|
||||
try {
|
||||
workspaceManager.exportToWorkspace(name, patchedContent);
|
||||
} catch (Exception e) {
|
||||
log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage());
|
||||
}
|
||||
|
||||
log.info("[SkillManage] Agent patched skill: name={}", name);
|
||||
return "Skill '" + name + "' patched successfully (security scan: PASSED).";
|
||||
} catch (Exception e) {
|
||||
log.error("[SkillManage] Failed to patch skill '{}': {}", name, e.getMessage(), e);
|
||||
return "Error patching skill: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Delete ====================
|
||||
|
||||
private String doDelete(String name) {
|
||||
SkillEntity existing = skillService.findByName(name);
|
||||
if (existing == null) {
|
||||
return "Error: skill '" + name + "' not found.";
|
||||
}
|
||||
if (Boolean.TRUE.equals(existing.getBuiltin())) {
|
||||
return "Error: cannot delete builtin skill '" + name + "'.";
|
||||
}
|
||||
|
||||
try {
|
||||
skillService.deleteSkill(existing.getId());
|
||||
log.info("[SkillManage] Agent deleted skill: name={}", name);
|
||||
return "Skill '" + name + "' deleted.";
|
||||
} catch (Exception e) {
|
||||
log.error("[SkillManage] Failed to delete skill '{}': {}", name, e.getMessage(), e);
|
||||
return "Error deleting skill: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/**
|
||||
* 运行安全扫描。通过返回 null,拒绝返回错误信息字符串。
|
||||
*/
|
||||
private String runSecurityScan(String content, String name) {
|
||||
try {
|
||||
SkillValidationResult result = securityService.scanContent(content, name);
|
||||
if (result.isBlocked()) {
|
||||
log.warn("[SkillManage] Security scan BLOCKED skill '{}': {}", name, result.getSummary());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Security scan BLOCKED: skill content contains dangerous patterns.\n");
|
||||
for (SkillValidationResult.Finding f : result.getFindings()) {
|
||||
if (f.getSeverity().isBlockLevel()) {
|
||||
sb.append("- [").append(f.getSeverity()).append("] ").append(f.getTitle());
|
||||
if (f.getRemediation() != null) {
|
||||
sb.append(" → Fix: ").append(f.getRemediation());
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
}
|
||||
sb.append("Please fix the issues and try again.");
|
||||
return sb.toString();
|
||||
}
|
||||
// 有 warning 但不 block 的情况,记录日志但允许通过
|
||||
if (!result.getWarnings().isEmpty()) {
|
||||
log.info("[SkillManage] Security scan passed with {} warnings for skill '{}'",
|
||||
result.getWarnings().size(), name);
|
||||
}
|
||||
return null; // 通过
|
||||
} catch (Exception e) {
|
||||
log.error("[SkillManage] Security scan failed for '{}': {}", name, e.getMessage(), e);
|
||||
return "Error: security scan failed (" + e.getMessage() + "). Skill not saved.";
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 YAML frontmatter 提取 description */
|
||||
private String extractDescription(String content) {
|
||||
String fm = extractFrontmatterValue(content, "description");
|
||||
return fm != null ? fm : "";
|
||||
}
|
||||
|
||||
/** 从 YAML frontmatter 提取 version */
|
||||
private String extractVersion(String content) {
|
||||
String v = extractFrontmatterValue(content, "version");
|
||||
return v != null ? v : "1.0";
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单提取 YAML frontmatter 中的值(不引入 YAML 库依赖)。
|
||||
* 支持格式:{@code key: value} 和 {@code key: "value"}
|
||||
*/
|
||||
private String extractFrontmatterValue(String content, String key) {
|
||||
if (content == null || !content.startsWith("---")) return null;
|
||||
int endIdx = content.indexOf("---", 3);
|
||||
if (endIdx < 0) return null;
|
||||
String frontmatter = content.substring(3, endIdx);
|
||||
for (String line : frontmatter.split("\n")) {
|
||||
String trimmed = line.strip();
|
||||
if (trimmed.startsWith(key + ":")) {
|
||||
String value = trimmed.substring(key.length() + 1).strip();
|
||||
// 去引号
|
||||
if ((value.startsWith("\"") && value.endsWith("\""))
|
||||
|| (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.substring(1, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 空白归一化(连续空白 → 单空格,trim) */
|
||||
private String normalizeWhitespace(String text) {
|
||||
return text.replaceAll("\\s+", " ").strip();
|
||||
}
|
||||
|
||||
/** 近似定位 oldText 在 content 中的位置(容忍空白差异) */
|
||||
private int findApproximatePosition(String content, String oldText) {
|
||||
// 按行首几个非空白词匹配
|
||||
String[] lines = oldText.split("\n");
|
||||
if (lines.length == 0) return -1;
|
||||
String firstLine = lines[0].strip();
|
||||
if (firstLine.isBlank() && lines.length > 1) firstLine = lines[1].strip();
|
||||
if (firstLine.isBlank()) return -1;
|
||||
// 取前 30 字符作为锚点
|
||||
String anchor = firstLine.substring(0, Math.min(30, firstLine.length()));
|
||||
return content.indexOf(anchor);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
-- V11: Auto Skill Synthesis (RFC-023)
|
||||
-- Agent 自治创建 skill 后记录来源对话和安全扫描状态
|
||||
ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS source_conversation_id VARCHAR(64) DEFAULT NULL;
|
||||
ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS security_scan_status VARCHAR(16) DEFAULT NULL;
|
||||
-- security_scan_status: NULL(旧数据/手动创建) / PASSED / FAILED
|
||||
@ -0,0 +1,5 @@
|
||||
-- V11: Auto Skill Synthesis (RFC-023)
|
||||
-- Agent 自治创建 skill 后记录来源对话和安全扫描状态
|
||||
ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS source_conversation_id VARCHAR(64) DEFAULT NULL;
|
||||
ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS security_scan_status VARCHAR(16) DEFAULT NULL;
|
||||
-- security_scan_status: NULL(旧数据/手动创建) / PASSED / FAILED
|
||||
@ -0,0 +1,40 @@
|
||||
你是 MateClaw 的 Skill 工程师。你的任务是从一段 Agent 对话历史中提取出一个**可复用的操作指南**(SKILL.md 格式)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
严格输出 SKILL.md 格式(YAML frontmatter + Markdown 正文),不要包含 markdown 代码块标记:
|
||||
|
||||
---
|
||||
name: skill-name-lowercase
|
||||
description: 一句话描述这个 skill 解决什么问题
|
||||
version: "1.0"
|
||||
---
|
||||
# Skill 标题
|
||||
|
||||
## 场景
|
||||
什么情况下应该使用这个 skill(1-2 句话)
|
||||
|
||||
## 前置条件
|
||||
- 需要的环境/工具/权限(如果有的话)
|
||||
|
||||
## 步骤
|
||||
1. 第一步(包含实际命令,不要虚构)
|
||||
2. 第二步
|
||||
3. ...
|
||||
|
||||
## 注意事项
|
||||
- 已知的坑或边界条件
|
||||
- 常见错误和解决方法
|
||||
|
||||
## 相关技能
|
||||
- [[other-skill-name]](如果知道的话)
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **只基于对话中实际执行过的命令和步骤**——不要虚构未出现的命令
|
||||
2. **不要包含敏感信息**——API key、密码、绝对路径、内网地址等必须去除或用占位符替代
|
||||
3. **name 必须是小写字母 + 数字 + 连字符**,简洁明了(如 `spring-boot-scaffold`、`docker-debug`)
|
||||
4. **description 不超过 100 字**
|
||||
5. **步骤要具体可操作**——读者看完应该能直接执行,而不是"请根据情况调整"
|
||||
6. **如果对话中尝试了多种方法,只保留最终成功的路径**——失败的尝试放到"注意事项"里
|
||||
7. **如果对话太简单(< 3 个有意义的步骤),返回空内容**——不要强行产出低质量 skill
|
||||
@ -0,0 +1,9 @@
|
||||
## 对话历史
|
||||
|
||||
以下是一段 Agent 与用户的完整对话记录(已压缩),包含用户请求、Agent 推理、工具调用和结果:
|
||||
|
||||
{conversation}
|
||||
|
||||
---
|
||||
|
||||
请从上述对话中提取一个可复用的 SKILL.md。如果对话内容太简单或不值得保存为 skill,请输出空内容。
|
||||
@ -208,6 +208,10 @@ export interface Skill {
|
||||
builtin?: boolean
|
||||
tags?: string
|
||||
createTime: string
|
||||
/** RFC-023: 来源对话 ID(AI 合成时记录) */
|
||||
sourceConversationId?: string
|
||||
/** RFC-023: 安全扫描状态 (PASSED / FAILED / null) */
|
||||
securityScanStatus?: string
|
||||
}
|
||||
|
||||
/** 运行时解析状态(来自 /runtime/status) */
|
||||
|
||||
@ -70,7 +70,18 @@
|
||||
<span class="runtime-badge" :class="getRuntimeBadgeClass(skill)">
|
||||
{{ getRuntimeLabel(skill) }}
|
||||
</span>
|
||||
<!-- Security Badge -->
|
||||
<!-- RFC-023: AI Synthesized Badge -->
|
||||
<span v-if="skill.sourceConversationId" class="runtime-badge rt-synthesized" title="Auto-synthesized from conversation">
|
||||
🤖 AI
|
||||
</span>
|
||||
<!-- Security Scan Status (RFC-023) -->
|
||||
<span v-if="skill.securityScanStatus === 'FAILED'" class="runtime-badge rt-blocked">
|
||||
🛡️ Scan Failed
|
||||
</span>
|
||||
<span v-else-if="skill.securityScanStatus === 'PASSED'" class="runtime-badge rt-ready">
|
||||
✓ Scanned
|
||||
</span>
|
||||
<!-- Security Badge (runtime) -->
|
||||
<span v-if="getSecurityBadge(skill)" class="runtime-badge" :class="getSecurityBadge(skill)?.cls">
|
||||
{{ getSecurityBadge(skill)?.label }}
|
||||
</span>
|
||||
@ -566,6 +577,8 @@ function getSkillTypeLabel(type: string) {
|
||||
.rt-fallback { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||
.rt-error { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
||||
.rt-blocked { background: var(--mc-danger-bg); color: var(--mc-danger); font-weight: 600; }
|
||||
.rt-synthesized { background: #f0f0ff; color: #6366f1; }
|
||||
:root.dark .rt-synthesized { background: rgba(99, 102, 241, 0.15); color: #818cf8; }
|
||||
.rt-sec-warning { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||
.rt-deps-missing { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||
.rt-disabled { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user