mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 12:27:53 +08:00
feat(skill): per-skill LESSONS.md + self-evolution v1
This commit is contained in:
parent
d927521d51
commit
359600c77f
@ -0,0 +1,138 @@
|
|||||||
|
package vip.mate.memory.tool;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONObject;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.memory.event.MemoryWriteEvent;
|
||||||
|
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
||||||
|
import vip.mate.workspace.document.WorkspaceFileService;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 §11.3.1 — universal {@code remember()} tool.
|
||||||
|
*
|
||||||
|
* <p>{@link StructuredMemoryTool} forces a typed schema (user/feedback/
|
||||||
|
* project/reference). That's the right primitive for stable knowledge,
|
||||||
|
* but skills also produce free-form lessons that don't fit those
|
||||||
|
* buckets. This tool gives the agent a single, parameter-light way to
|
||||||
|
* leave a note for the next conversation.
|
||||||
|
*
|
||||||
|
* <p>Storage path: appends to {@code MEMORY.md} under a
|
||||||
|
* {@code ## Recent Lessons} section so the next dream pass can
|
||||||
|
* consolidate it. {@link MemoryWriteEvent} is published on success so
|
||||||
|
* the existing SOUL summarizer K-counter advances.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UniversalMemoryTool {
|
||||||
|
|
||||||
|
private static final String MEMORY_FILENAME = "MEMORY.md";
|
||||||
|
private static final String LESSONS_HEADER = "## Recent Lessons";
|
||||||
|
private static final DateTimeFormatter ENTRY_TS = DateTimeFormatter
|
||||||
|
.ofPattern("yyyy-MM-dd HH:mm");
|
||||||
|
|
||||||
|
private final WorkspaceFileService workspaceFileService;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
|
@Tool(description = """
|
||||||
|
将一条自由形式的经验或洞察追加到 Agent 的长期记忆 (MEMORY.md)。
|
||||||
|
适用于不属于结构化 4 类 (user/feedback/project/reference) 的自由笔记。
|
||||||
|
内容会被下一次 Dream consolidation 整合到 SOUL.md 或事实区。
|
||||||
|
如果你需要记录的是结构化条目,优先用 remember_structured。
|
||||||
|
""")
|
||||||
|
public String remember(
|
||||||
|
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||||
|
@ToolParam(description = "要记住的内容(自由形式)") String content,
|
||||||
|
@ToolParam(description = "可选:来源上下文(skill 名 / conversation id)", required = false) String source) {
|
||||||
|
|
||||||
|
if (agentId == null) return error("agentId 不能为空");
|
||||||
|
if (content == null || content.isBlank()) return error("content 不能为空");
|
||||||
|
|
||||||
|
try {
|
||||||
|
WorkspaceFileEntity existing = workspaceFileService.getFile(agentId, MEMORY_FILENAME);
|
||||||
|
String existingContent = existing != null && existing.getContent() != null
|
||||||
|
? existing.getContent() : "";
|
||||||
|
String updated = appendLesson(existingContent, content, source);
|
||||||
|
workspaceFileService.saveFile(agentId, MEMORY_FILENAME, updated);
|
||||||
|
|
||||||
|
// RFC-090 §14.3 — universal remember() targets MEMORY.md (the
|
||||||
|
// canonical file), so this IS a MemoryWriteEvent. Skill-local
|
||||||
|
// lessons go through SkillLessonWrittenEvent instead and do
|
||||||
|
// NOT touch this path.
|
||||||
|
eventPublisher.publishEvent(new MemoryWriteEvent(agentId, MEMORY_FILENAME,
|
||||||
|
"remember", content));
|
||||||
|
|
||||||
|
JSONObject result = new JSONObject();
|
||||||
|
result.set("success", true);
|
||||||
|
result.set("file", MEMORY_FILENAME);
|
||||||
|
result.set("message", "已记入 MEMORY.md,将在下次 Dream consolidation 时整合");
|
||||||
|
return JSONUtil.toJsonPrettyStr(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[UniversalMemoryTool] remember failed: {}", e.getMessage());
|
||||||
|
return error("记忆写入失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the lesson under {@value LESSONS_HEADER}, preserving any
|
||||||
|
* existing MEMORY.md content. Creates the section header on first
|
||||||
|
* write.
|
||||||
|
*/
|
||||||
|
static String appendLesson(String existing, String content, String source) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String ts = LocalDateTime.now().format(ENTRY_TS);
|
||||||
|
String entry = "- " + ts
|
||||||
|
+ (source != null && !source.isBlank() ? " (" + source.trim() + ")" : "")
|
||||||
|
+ ": " + content.trim();
|
||||||
|
|
||||||
|
if (existing == null || existing.isBlank()) {
|
||||||
|
sb.append(LESSONS_HEADER).append("\n").append(entry).append("\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
int headerIdx = existing.indexOf(LESSONS_HEADER);
|
||||||
|
if (headerIdx < 0) {
|
||||||
|
sb.append(existing);
|
||||||
|
if (!existing.endsWith("\n")) sb.append("\n");
|
||||||
|
sb.append("\n").append(LESSONS_HEADER).append("\n").append(entry).append("\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert at the end of the Recent Lessons section (before the
|
||||||
|
// next ##-level heading or EOF).
|
||||||
|
int sectionStart = headerIdx + LESSONS_HEADER.length();
|
||||||
|
int nextHeading = findNextHeading(existing, sectionStart);
|
||||||
|
if (nextHeading < 0) {
|
||||||
|
sb.append(existing);
|
||||||
|
if (!existing.endsWith("\n")) sb.append("\n");
|
||||||
|
sb.append(entry).append("\n");
|
||||||
|
} else {
|
||||||
|
sb.append(existing, 0, nextHeading);
|
||||||
|
if (!existing.substring(0, nextHeading).endsWith("\n")) sb.append("\n");
|
||||||
|
sb.append(entry).append("\n\n");
|
||||||
|
sb.append(existing, nextHeading, existing.length());
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int findNextHeading(String content, int from) {
|
||||||
|
// Match a line starting with "## " (any heading level >= 2).
|
||||||
|
int idx = content.indexOf("\n## ", from);
|
||||||
|
return idx < 0 ? -1 : idx + 1; // position of '#' itself
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String error(String msg) {
|
||||||
|
JSONObject e = new JSONObject();
|
||||||
|
e.set("success", false);
|
||||||
|
e.set("error", msg);
|
||||||
|
return JSONUtil.toJsonPrettyStr(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.skill.lessons.SkillLessonsService;
|
||||||
import vip.mate.skill.manifest.SkillManifest;
|
import vip.mate.skill.manifest.SkillManifest;
|
||||||
import vip.mate.skill.model.SkillEntity;
|
import vip.mate.skill.model.SkillEntity;
|
||||||
import vip.mate.skill.runtime.SkillDependencyChecker;
|
import vip.mate.skill.runtime.SkillDependencyChecker;
|
||||||
@ -39,6 +40,7 @@ public class SkillController {
|
|||||||
private final SkillWorkspaceManager workspaceManager;
|
private final SkillWorkspaceManager workspaceManager;
|
||||||
private final SkillSynthesisService synthesisService;
|
private final SkillSynthesisService synthesisService;
|
||||||
private final SkillDependencyChecker dependencyChecker;
|
private final SkillDependencyChecker dependencyChecker;
|
||||||
|
private final SkillLessonsService lessonsService;
|
||||||
|
|
||||||
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@ -209,6 +211,40 @@ public class SkillController {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Lessons API (RFC-090 §7 + §11.4) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the per-skill {@code LESSONS.md} body for the detail drawer.
|
||||||
|
* Returns {@code entries: []} when the file is missing.
|
||||||
|
*/
|
||||||
|
@Operation(summary = "Read per-skill LESSONS.md (RFC-090 §11.4)")
|
||||||
|
@GetMapping("/{id}/lessons")
|
||||||
|
public R<Map<String, Object>> getLessons(@PathVariable Long id) {
|
||||||
|
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
|
||||||
|
.filter(r -> r != null && id.equals(r.getId()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (resolved == null) return R.fail("Skill not found: " + id);
|
||||||
|
String body = lessonsService.readLessons(resolved);
|
||||||
|
return R.ok(Map.of(
|
||||||
|
"skillId", id,
|
||||||
|
"skillName", resolved.getName(),
|
||||||
|
"raw", body == null ? "" : body
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Clear all lessons for a skill (RFC-090 §11.4)")
|
||||||
|
@PostMapping("/{id}/lessons/clear")
|
||||||
|
public R<Map<String, Object>> clearLessons(@PathVariable Long id) {
|
||||||
|
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
|
||||||
|
.filter(r -> r != null && id.equals(r.getId()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (resolved == null) return R.fail("Skill not found: " + id);
|
||||||
|
boolean cleared = lessonsService.clearLessons(resolved);
|
||||||
|
return R.ok(Map.of("cleared", cleared));
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Synthesis API (RFC-023) ====================
|
// ==================== Synthesis API (RFC-023) ====================
|
||||||
|
|
||||||
@Operation(summary = "从对话历史合成 Skill(RFC-023)")
|
@Operation(summary = "从对话历史合成 Skill(RFC-023)")
|
||||||
|
|||||||
@ -0,0 +1,271 @@
|
|||||||
|
package vip.mate.skill.lessons;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.skill.lessons.event.SkillLessonWrittenEvent;
|
||||||
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
|
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 §11.4 / §14.3 — per-skill {@code LESSONS.md} read & write.
|
||||||
|
*
|
||||||
|
* <p>SKILL.md is a deployment artifact and must remain read-only at
|
||||||
|
* runtime. Lessons captured by the skill itself land in a sibling file
|
||||||
|
* {@code LESSONS.md} so the system prompt enhancement layer can append
|
||||||
|
* recent experience without rewriting the manifest.
|
||||||
|
*
|
||||||
|
* <p>Why a dedicated file rather than {@code MEMORY.md}:
|
||||||
|
* <ul>
|
||||||
|
* <li>Lessons are scoped to <i>this</i> skill — the agent must not
|
||||||
|
* leak skill-specific tactics into another skill's context.</li>
|
||||||
|
* <li>Agents share canonical memory, but a single skill may belong
|
||||||
|
* to multiple agents; each agent's MEMORY.md is global.</li>
|
||||||
|
* <li>Per §14.3, mixing lessons into {@code MemoryWriteEvent} would
|
||||||
|
* inflate the SOUL summarizer's recompute counter.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Storage format (markdown, append-only, FIFO truncated):
|
||||||
|
* <pre>
|
||||||
|
* # Lessons learned for {skill-id}
|
||||||
|
*
|
||||||
|
* ## 2026-04-30 14:23 (conversation: 7a3b...)
|
||||||
|
* Lesson body
|
||||||
|
* Source: {skill-id} turn #N
|
||||||
|
*
|
||||||
|
* ## 2026-04-29 09:12 (conversation: 9c4d...)
|
||||||
|
* ...
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SkillLessonsService {
|
||||||
|
|
||||||
|
/** Cap entries to keep LESSONS.md from ballooning (§10.1 risk 3). */
|
||||||
|
private static final int DEFAULT_MAX_ENTRIES = 50;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter HEADING_TS = DateTimeFormatter
|
||||||
|
.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
|
.withLocale(Locale.ROOT);
|
||||||
|
|
||||||
|
private final SkillWorkspaceManager workspaceManager;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a lesson to {@code LESSONS.md}.
|
||||||
|
*
|
||||||
|
* <p>Side effects:
|
||||||
|
* <ol>
|
||||||
|
* <li>Creates the file if it doesn't exist (with the canonical
|
||||||
|
* header).</li>
|
||||||
|
* <li>Appends a new {@code ## yyyy-MM-dd HH:mm (conversation: id)}
|
||||||
|
* section with the lesson body.</li>
|
||||||
|
* <li>If total entries exceed {@code maxEntries}, drops the oldest
|
||||||
|
* sections (FIFO truncation).</li>
|
||||||
|
* <li>Publishes {@link SkillLessonWrittenEvent} on success.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param resolved the resolved skill (must have a workspace
|
||||||
|
* directory for the write to succeed; database-
|
||||||
|
* only skills are returned as a no-op)
|
||||||
|
* @param agentId agent that produced the lesson, may be null
|
||||||
|
* @param conversationId conversation context, may be null
|
||||||
|
* @param content lesson text — caller's responsibility to
|
||||||
|
* sanitize / truncate
|
||||||
|
* @param maxEntries upper bound on entries; non-positive falls
|
||||||
|
* back to {@link #DEFAULT_MAX_ENTRIES}
|
||||||
|
* @return new lesson id (UUID) when persisted, null on no-op
|
||||||
|
*/
|
||||||
|
public String recordLesson(ResolvedSkill resolved, Long agentId,
|
||||||
|
String conversationId, String content,
|
||||||
|
int maxEntries) {
|
||||||
|
if (resolved == null || resolved.getName() == null) {
|
||||||
|
log.debug("recordLesson: missing skill identity, skipping");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (content == null || content.isBlank()) {
|
||||||
|
log.debug("recordLesson: empty content for skill '{}', skipping", resolved.getName());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Path workspace = resolveWorkspace(resolved);
|
||||||
|
if (workspace == null) {
|
||||||
|
log.debug("recordLesson: no workspace directory for skill '{}', skipping",
|
||||||
|
resolved.getName());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Path lessonsFile = workspace.resolve("LESSONS.md");
|
||||||
|
int cap = maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES;
|
||||||
|
String lessonId = UUID.randomUUID().toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureFileWithHeader(lessonsFile, resolved.getName());
|
||||||
|
String section = buildSection(conversationId, content);
|
||||||
|
Files.writeString(lessonsFile, section,
|
||||||
|
StandardCharsets.UTF_8, StandardOpenOption.APPEND);
|
||||||
|
enforceCap(lessonsFile, resolved.getName(), cap);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Failed to write LESSONS.md for skill '{}': {}",
|
||||||
|
resolved.getName(), e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventPublisher.publishEvent(new SkillLessonWrittenEvent(
|
||||||
|
agentId,
|
||||||
|
resolved.getId(),
|
||||||
|
resolved.getName(),
|
||||||
|
conversationId,
|
||||||
|
content));
|
||||||
|
return lessonId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read raw {@code LESSONS.md} contents. Returns null when the file
|
||||||
|
* is missing — callers should treat as "no lessons yet" without
|
||||||
|
* branching on exception.
|
||||||
|
*/
|
||||||
|
public String readLessons(ResolvedSkill resolved) {
|
||||||
|
Path workspace = resolveWorkspace(resolved);
|
||||||
|
if (workspace == null) return null;
|
||||||
|
Path file = workspace.resolve("LESSONS.md");
|
||||||
|
if (!Files.exists(file)) return null;
|
||||||
|
try {
|
||||||
|
return Files.readString(file, StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Failed to read LESSONS.md for skill '{}': {}",
|
||||||
|
resolved.getName(), e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience for the prompt enhancement layer: returns the body
|
||||||
|
* (everything after the canonical title) so it can be appended to
|
||||||
|
* a skill's SKILL.md body without duplicating the title.
|
||||||
|
*/
|
||||||
|
public String readLessonsBody(ResolvedSkill resolved) {
|
||||||
|
String full = readLessons(resolved);
|
||||||
|
if (full == null || full.isBlank()) return null;
|
||||||
|
// Strip the leading "# Lessons learned for {name}\n\n" header.
|
||||||
|
int firstSection = full.indexOf("\n## ");
|
||||||
|
if (firstSection < 0) return full.trim();
|
||||||
|
return full.substring(firstSection + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single lesson by id by rewriting the file without the
|
||||||
|
* matching section. Returns {@code true} when an entry was actually
|
||||||
|
* removed.
|
||||||
|
*/
|
||||||
|
public boolean deleteLesson(ResolvedSkill resolved, String lessonId) {
|
||||||
|
// Lessons today have no per-section id — id is generated on
|
||||||
|
// write but not persisted. This is a placeholder for the API
|
||||||
|
// wiring; callers that need precise revert should use
|
||||||
|
// clearLessons or rebuild via re-recording. Returning false
|
||||||
|
// signals "no-op".
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe the LESSONS.md file entirely (called by the
|
||||||
|
* {@code DELETE /skills/{id}/lessons/clear} admin endpoint).
|
||||||
|
*/
|
||||||
|
public boolean clearLessons(ResolvedSkill resolved) {
|
||||||
|
Path workspace = resolveWorkspace(resolved);
|
||||||
|
if (workspace == null) return false;
|
||||||
|
Path file = workspace.resolve("LESSONS.md");
|
||||||
|
if (!Files.exists(file)) return false;
|
||||||
|
try {
|
||||||
|
Files.delete(file);
|
||||||
|
return true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Failed to clear LESSONS.md for skill '{}': {}",
|
||||||
|
resolved.getName(), e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== internals ====================
|
||||||
|
|
||||||
|
private Path resolveWorkspace(ResolvedSkill resolved) {
|
||||||
|
if (resolved == null || resolved.getName() == null) return null;
|
||||||
|
if (resolved.getSkillDir() != null) return resolved.getSkillDir();
|
||||||
|
Path convention = workspaceManager.resolveConventionPath(resolved.getName());
|
||||||
|
return Files.exists(convention) && Files.isDirectory(convention) ? convention : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureFileWithHeader(Path file, String skillName) throws IOException {
|
||||||
|
if (Files.exists(file)) return;
|
||||||
|
String header = "# Lessons learned for " + skillName + "\n\n";
|
||||||
|
Files.writeString(file, header, StandardCharsets.UTF_8,
|
||||||
|
StandardOpenOption.CREATE_NEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSection(String conversationId, String content) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n## ").append(LocalDateTime.now().format(HEADING_TS));
|
||||||
|
if (conversationId != null && !conversationId.isBlank()) {
|
||||||
|
sb.append(" (conversation: ").append(conversationId).append(")");
|
||||||
|
}
|
||||||
|
sb.append("\n").append(content.trim()).append("\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIFO truncation. Reads the file once, splits on {@code "\n## "},
|
||||||
|
* keeps the title + last {@code maxEntries} sections, and rewrites.
|
||||||
|
* Cheap enough for the typical N=50 cap.
|
||||||
|
*/
|
||||||
|
private void enforceCap(Path file, String skillName, int maxEntries) throws IOException {
|
||||||
|
if (maxEntries <= 0) return;
|
||||||
|
String full = Files.readString(file, StandardCharsets.UTF_8);
|
||||||
|
if (full.isBlank()) return;
|
||||||
|
|
||||||
|
String header = "# Lessons learned for " + skillName + "\n";
|
||||||
|
int firstSectionIdx = full.indexOf("\n## ");
|
||||||
|
if (firstSectionIdx < 0) return; // no sections yet
|
||||||
|
|
||||||
|
String preface = full.substring(0, firstSectionIdx);
|
||||||
|
String sectionsBlob = full.substring(firstSectionIdx + 1); // strip the leading newline
|
||||||
|
|
||||||
|
String[] sections = sectionsBlob.split("\n## ");
|
||||||
|
if (sections.length <= maxEntries) return;
|
||||||
|
|
||||||
|
List<String> kept = new ArrayList<>(maxEntries);
|
||||||
|
for (int i = sections.length - maxEntries; i < sections.length; i++) {
|
||||||
|
kept.add(sections[i]);
|
||||||
|
}
|
||||||
|
StringBuilder rebuilt = new StringBuilder();
|
||||||
|
if (!preface.isBlank()) rebuilt.append(preface).append("\n");
|
||||||
|
else rebuilt.append(header).append("\n");
|
||||||
|
for (int i = 0; i < kept.size(); i++) {
|
||||||
|
rebuilt.append("## ").append(kept.get(i));
|
||||||
|
if (i < kept.size() - 1 && !kept.get(i).endsWith("\n")) rebuilt.append("\n");
|
||||||
|
}
|
||||||
|
Files.writeString(file, rebuilt.toString(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook (package-private) — exposes the parsing rule used by readLessonsBody. */
|
||||||
|
static List<String> splitSections(String full) {
|
||||||
|
if (full == null || full.isBlank()) return Collections.emptyList();
|
||||||
|
int firstSectionIdx = full.indexOf("\n## ");
|
||||||
|
if (firstSectionIdx < 0) return Collections.emptyList();
|
||||||
|
String body = full.substring(firstSectionIdx + 1);
|
||||||
|
return java.util.Arrays.asList(body.split("\n## "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package vip.mate.skill.lessons;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONObject;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.skill.manifest.SkillManifest;
|
||||||
|
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||||
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 §11.4.2 — agent-callable {@code record_lesson} tool.
|
||||||
|
*
|
||||||
|
* <p>The tool is always exposed by the registry; the per-skill
|
||||||
|
* {@code self-evolution.lessons_enabled} switch governs whether the
|
||||||
|
* skill's manifest opts in. We don't filter at registry time so
|
||||||
|
* skills can dynamically toggle the flag without a rebuild. When the
|
||||||
|
* skill says no, the call returns a friendly explanation rather than
|
||||||
|
* silently writing.
|
||||||
|
*
|
||||||
|
* <p>Storage and event publishing live in
|
||||||
|
* {@link SkillLessonsService#recordLesson}. This class is the LLM-
|
||||||
|
* facing wrapper.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SkillLessonsTool {
|
||||||
|
|
||||||
|
private final SkillRuntimeService skillRuntimeService;
|
||||||
|
private final SkillLessonsService lessonsService;
|
||||||
|
|
||||||
|
@Tool(description = """
|
||||||
|
为指定 skill 记录一条 lesson(经验/洞察),追加到该 skill 的 LESSONS.md。
|
||||||
|
下次加载该 skill 时,LESSONS.md 内容会自动注入到 system prompt。
|
||||||
|
仅当 skill manifest 里 self-evolution.lessons_enabled=true 时生效(默认开启)。
|
||||||
|
如果想记录的内容跨 skill 通用,请改用 remember 或 remember_structured。
|
||||||
|
""")
|
||||||
|
public String record_lesson(
|
||||||
|
@ToolParam(description = "skill 的 slug(即 SKILL.md frontmatter 里的 name)") String skillName,
|
||||||
|
@ToolParam(description = "要记录的经验内容") String lesson,
|
||||||
|
@ToolParam(description = "可选:当前 Agent 的 ID", required = false) Long agentId,
|
||||||
|
@ToolParam(description = "可选:当前对话 ID", required = false) String conversationId) {
|
||||||
|
|
||||||
|
if (skillName == null || skillName.isBlank()) return error("skillName 不能为空");
|
||||||
|
if (lesson == null || lesson.isBlank()) return error("lesson 不能为空");
|
||||||
|
|
||||||
|
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
|
||||||
|
.filter(s -> s != null && skillName.equals(s.getName()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (resolved == null) {
|
||||||
|
return error("找不到 skill: " + skillName);
|
||||||
|
}
|
||||||
|
|
||||||
|
SkillManifest manifest = resolved.getManifest();
|
||||||
|
// §10.2 Q7 — default ON; only skip when the manifest explicitly opts out.
|
||||||
|
boolean lessonsEnabled = manifest == null
|
||||||
|
|| manifest.getSelfEvolution() == null
|
||||||
|
|| manifest.getSelfEvolution().isLessonsEnabled();
|
||||||
|
if (!lessonsEnabled) {
|
||||||
|
return error("该 skill 已在 manifest 中关闭 self-evolution.lessons_enabled,"
|
||||||
|
+ "无法记录 lesson。请改用 remember 工具或修改 manifest。");
|
||||||
|
}
|
||||||
|
|
||||||
|
int max = manifest != null && manifest.getSelfEvolution() != null
|
||||||
|
? manifest.getSelfEvolution().getLessonsMaxEntries() : 0;
|
||||||
|
|
||||||
|
String lessonId = lessonsService.recordLesson(resolved, agentId, conversationId,
|
||||||
|
lesson, max);
|
||||||
|
if (lessonId == null) {
|
||||||
|
return error("Lesson 记录失败:skill 可能仅存在于数据库(无 workspace 目录)。");
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject result = new JSONObject();
|
||||||
|
result.set("success", true);
|
||||||
|
result.set("skill", skillName);
|
||||||
|
result.set("lessonId", lessonId);
|
||||||
|
result.set("message", "Lesson 已记录,下次加载该 skill 时会自动注入到 system prompt。");
|
||||||
|
return JSONUtil.toJsonPrettyStr(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String error(String msg) {
|
||||||
|
JSONObject e = new JSONObject();
|
||||||
|
e.set("success", false);
|
||||||
|
e.set("error", msg);
|
||||||
|
return JSONUtil.toJsonPrettyStr(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package vip.mate.skill.lessons.event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 §14.3 — fires when a skill records a new lesson into its
|
||||||
|
* per-skill {@code LESSONS.md} file.
|
||||||
|
*
|
||||||
|
* <p>Distinct from {@code MemoryWriteEvent} on purpose: lessons are
|
||||||
|
* skill-local, agent-scoped notes rather than canonical memory writes.
|
||||||
|
* Mixing them into the SOUL summarizer would inflate its recompute
|
||||||
|
* count without improving the canonical memory's quality.
|
||||||
|
*
|
||||||
|
* <p>Default subscriber set is empty. A future "lesson → memory"
|
||||||
|
* aggregator can subscribe and decide when (if ever) to promote
|
||||||
|
* accumulated lessons up to MEMORY.md / SOUL.md.
|
||||||
|
*
|
||||||
|
* @param agentId agent that triggered the skill call (may be null
|
||||||
|
* for global / non-agent contexts)
|
||||||
|
* @param skillId DB id of the skill the lesson belongs to (may
|
||||||
|
* be null when the runtime resolved by name only)
|
||||||
|
* @param skillName slug identifier of the skill (always present)
|
||||||
|
* @param conversationId conversation in which the lesson was learned;
|
||||||
|
* may be null for offline / cron-driven flows
|
||||||
|
* @param content lesson text exactly as recorded (caller is
|
||||||
|
* responsible for any redaction / truncation)
|
||||||
|
*/
|
||||||
|
public record SkillLessonWrittenEvent(
|
||||||
|
Long agentId,
|
||||||
|
Long skillId,
|
||||||
|
String skillName,
|
||||||
|
String conversationId,
|
||||||
|
String content
|
||||||
|
) {}
|
||||||
@ -2,9 +2,12 @@ package vip.mate.skill.runtime;
|
|||||||
|
|
||||||
import com.github.benmanes.caffeine.cache.Cache;
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.skill.lessons.SkillLessonsService;
|
||||||
|
import vip.mate.skill.manifest.SkillManifest;
|
||||||
import vip.mate.skill.model.SkillEntity;
|
import vip.mate.skill.model.SkillEntity;
|
||||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
import vip.mate.skill.service.SkillService;
|
import vip.mate.skill.service.SkillService;
|
||||||
@ -25,11 +28,27 @@ import java.util.stream.Collectors;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SkillRuntimeService {
|
public class SkillRuntimeService {
|
||||||
|
|
||||||
private final SkillService skillService;
|
private final SkillService skillService;
|
||||||
private final SkillPackageResolver packageResolver;
|
private final SkillPackageResolver packageResolver;
|
||||||
|
/**
|
||||||
|
* {@code @Lazy} — SkillLessonsService depends on SkillWorkspaceManager,
|
||||||
|
* which is constructed early; the lazy proxy avoids a chicken-and-egg
|
||||||
|
* cycle when the runtime service initializes alongside the skill
|
||||||
|
* service stack. Using setter-style injection through the
|
||||||
|
* constructor below.
|
||||||
|
*/
|
||||||
|
private final SkillLessonsService lessonsService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public SkillRuntimeService(SkillService skillService,
|
||||||
|
SkillPackageResolver packageResolver,
|
||||||
|
@Lazy SkillLessonsService lessonsService) {
|
||||||
|
this.skillService = skillService;
|
||||||
|
this.packageResolver = packageResolver;
|
||||||
|
this.lessonsService = lessonsService;
|
||||||
|
}
|
||||||
|
|
||||||
// 缓存已解析的 active skills(5分钟过期)
|
// 缓存已解析的 active skills(5分钟过期)
|
||||||
private final Cache<String, List<ResolvedSkill>> activeSkillsCache = Caffeine.newBuilder()
|
private final Cache<String, List<ResolvedSkill>> activeSkillsCache = Caffeine.newBuilder()
|
||||||
@ -232,6 +251,41 @@ public class SkillRuntimeService {
|
|||||||
sb.append(" |\n");
|
sb.append(" |\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC-090 §11.4.3 + §10.2 Q6 — append per-skill LESSONS.md after
|
||||||
|
// the catalog so the LLM sees "Available Skills" first, then any
|
||||||
|
// accumulated lessons attached to each skill that has opted in.
|
||||||
|
appendLessonsBlock(sb, activeSkills);
|
||||||
|
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a "## Lessons learned" block to the prompt enhancement
|
||||||
|
* with one subsection per active skill that has lessons recorded.
|
||||||
|
*
|
||||||
|
* <p>Skills opt in via {@code self-evolution.lessons_enabled} (default
|
||||||
|
* true). Skills with no LESSONS.md content contribute nothing — we
|
||||||
|
* never emit an empty subsection.
|
||||||
|
*/
|
||||||
|
private void appendLessonsBlock(StringBuilder sb, List<ResolvedSkill> activeSkills) {
|
||||||
|
if (lessonsService == null || activeSkills == null || activeSkills.isEmpty()) return;
|
||||||
|
StringBuilder lessons = new StringBuilder();
|
||||||
|
for (ResolvedSkill skill : activeSkills) {
|
||||||
|
SkillManifest manifest = skill.getManifest();
|
||||||
|
boolean enabled = manifest == null
|
||||||
|
|| manifest.getSelfEvolution() == null
|
||||||
|
|| manifest.getSelfEvolution().isLessonsEnabled();
|
||||||
|
if (!enabled) continue;
|
||||||
|
String body = lessonsService.readLessonsBody(skill);
|
||||||
|
if (body == null || body.isBlank()) continue;
|
||||||
|
lessons.append("\n### ").append(skill.getName()).append("\n");
|
||||||
|
lessons.append(body).append("\n");
|
||||||
|
}
|
||||||
|
if (lessons.length() > 0) {
|
||||||
|
sb.append("\n\n## Lessons learned\n");
|
||||||
|
sb.append("Past observations the agent recorded for these skills. ");
|
||||||
|
sb.append("Treat them as advisory hints — the canonical SKILL.md still wins on conflict.\n");
|
||||||
|
sb.append(lessons);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -170,6 +170,10 @@ export const skillApi = {
|
|||||||
refreshRuntime: () => http.post('/skills/runtime/refresh'),
|
refreshRuntime: () => http.post('/skills/runtime/refresh'),
|
||||||
exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`),
|
exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`),
|
||||||
getWorkspaceInfo: (id: string | number) => http.get(`/skills/${id}/workspace`),
|
getWorkspaceInfo: (id: string | number) => http.get(`/skills/${id}/workspace`),
|
||||||
|
// RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md
|
||||||
|
requirements: (id: string | number) => http.get(`/skills/${id}/requirements`),
|
||||||
|
getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`),
|
||||||
|
clearLessons: (id: string | number) => http.post(`/skills/${id}/lessons/clear`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Skill Install ====================
|
// ==================== Skill Install ====================
|
||||||
|
|||||||
@ -1943,10 +1943,15 @@ export default {
|
|||||||
manifest: 'Manifest',
|
manifest: 'Manifest',
|
||||||
tools: 'Tools',
|
tools: 'Tools',
|
||||||
features: 'Features',
|
features: 'Features',
|
||||||
|
lessons: 'Lessons',
|
||||||
noManifest: 'This skill does not declare a v3 manifest. Legacy fields apply.',
|
noManifest: 'This skill does not declare a v3 manifest. Legacy fields apply.',
|
||||||
noTools: 'No tools advertised by active features.',
|
noTools: 'No tools advertised by active features.',
|
||||||
noFeatures: 'No features[] matrix declared. The skill is treated as a single default feature.',
|
noFeatures: 'No features[] matrix declared. The skill is treated as a single default feature.',
|
||||||
|
noLessons: 'No lessons recorded yet. The skill writes here after each call when self-evolution is enabled.',
|
||||||
toolsHint: 'These tool names are merged into the LLM allowed-tool list when this skill is bound to an agent. Tools owned by SETUP_NEEDED features stay hidden.',
|
toolsHint: 'These tool names are merged into the LLM allowed-tool list when this skill is bound to an agent. Tools owned by SETUP_NEEDED features stay hidden.',
|
||||||
|
lessonsHint: 'Per-skill LESSONS.md. Auto-injected after SKILL.md body when this skill is loaded.',
|
||||||
|
clearLessons: 'Clear all',
|
||||||
|
clearLessonsConfirm: 'Delete all recorded lessons for this skill? This cannot be undone.',
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
disabled: 'Disabled',
|
disabled: 'Disabled',
|
||||||
|
|||||||
@ -1945,10 +1945,15 @@ export default {
|
|||||||
manifest: 'Manifest',
|
manifest: 'Manifest',
|
||||||
tools: '工具',
|
tools: '工具',
|
||||||
features: '特性',
|
features: '特性',
|
||||||
|
lessons: 'Lessons',
|
||||||
noManifest: '该技能未声明 v3 manifest,使用旧字段。',
|
noManifest: '该技能未声明 v3 manifest,使用旧字段。',
|
||||||
noTools: '当前激活特性未暴露任何工具。',
|
noTools: '当前激活特性未暴露任何工具。',
|
||||||
noFeatures: '未声明 features[] 矩阵,技能被视为单一默认特性。',
|
noFeatures: '未声明 features[] 矩阵,技能被视为单一默认特性。',
|
||||||
|
noLessons: '尚未记录任何 lesson。开启 self-evolution 后,每次调用结束 skill 可向此处写入经验。',
|
||||||
toolsHint: 'Skill 绑定到 agent 时,这些工具名将合并进 LLM 的 allowed-tools。SETUP_NEEDED 特性下的工具保持隐藏。',
|
toolsHint: 'Skill 绑定到 agent 时,这些工具名将合并进 LLM 的 allowed-tools。SETUP_NEEDED 特性下的工具保持隐藏。',
|
||||||
|
lessonsHint: '该 skill 的 LESSONS.md 内容。下次加载时自动注入到 SKILL.md 正文之后。',
|
||||||
|
clearLessons: '全部清空',
|
||||||
|
clearLessonsConfirm: '删除该 skill 的全部 lessons?操作不可撤销。',
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
disabled: '已停用',
|
disabled: '已停用',
|
||||||
|
|||||||
@ -255,6 +255,9 @@
|
|||||||
{{ t('skills.detail.features') }}
|
{{ t('skills.detail.features') }}
|
||||||
<span v-if="detailFeaturesCount > 0" class="tab-count">{{ detailFeaturesCount }}</span>
|
<span v-if="detailFeaturesCount > 0" class="tab-count">{{ detailFeaturesCount }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="detail-tab" :class="{ active: detailTab === 'lessons' }" @click="detailTab = 'lessons'">
|
||||||
|
{{ t('skills.detail.lessons') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Manifest tab -->
|
<!-- Manifest tab -->
|
||||||
<div v-if="detailTab === 'manifest'" class="detail-section">
|
<div v-if="detailTab === 'manifest'" class="detail-section">
|
||||||
@ -297,6 +300,18 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- RFC-090 §11.4 — Lessons tab -->
|
||||||
|
<div v-if="detailTab === 'lessons'" class="detail-section">
|
||||||
|
<div class="lessons-header">
|
||||||
|
<p class="detail-hint">{{ t('skills.detail.lessonsHint') }}</p>
|
||||||
|
<button class="lessons-clear-btn" :disabled="!detailLessonsRaw" @click="clearLessons">
|
||||||
|
{{ t('skills.detail.clearLessons') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="detailLessonsLoading" class="detail-empty">{{ t('common.loading') }}</p>
|
||||||
|
<p v-else-if="!detailLessonsRaw" class="detail-empty">{{ t('skills.detail.noLessons') }}</p>
|
||||||
|
<pre v-else class="detail-pre">{{ detailLessonsRaw }}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
@ -429,7 +444,9 @@ const rescanning = ref<Record<string, boolean>>({})
|
|||||||
/** RFC-090 Phase 3 — detail drawer state. */
|
/** RFC-090 Phase 3 — detail drawer state. */
|
||||||
const detailDrawerVisible = ref(false)
|
const detailDrawerVisible = ref(false)
|
||||||
const detailSkill = ref<Skill | null>(null)
|
const detailSkill = ref<Skill | null>(null)
|
||||||
const detailTab = ref<'manifest' | 'tools' | 'features'>('manifest')
|
const detailTab = ref<'manifest' | 'tools' | 'features' | 'lessons'>('manifest')
|
||||||
|
const detailLessonsRaw = ref<string>('')
|
||||||
|
const detailLessonsLoading = ref(false)
|
||||||
|
|
||||||
const detailRuntime = computed(() =>
|
const detailRuntime = computed(() =>
|
||||||
detailSkill.value ? runtimeStatusMap.value[detailSkill.value.name] || null : null,
|
detailSkill.value ? runtimeStatusMap.value[detailSkill.value.name] || null : null,
|
||||||
@ -451,9 +468,45 @@ const detailFeaturesCount = computed(() => detailFeatures.value.length)
|
|||||||
function openDetailDrawer(skill: Skill) {
|
function openDetailDrawer(skill: Skill) {
|
||||||
detailSkill.value = skill
|
detailSkill.value = skill
|
||||||
detailTab.value = 'manifest'
|
detailTab.value = 'manifest'
|
||||||
|
detailLessonsRaw.value = ''
|
||||||
detailDrawerVisible.value = true
|
detailDrawerVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLessons() {
|
||||||
|
if (!detailSkill.value) return
|
||||||
|
detailLessonsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await skillApi.getLessons(detailSkill.value.id)
|
||||||
|
detailLessonsRaw.value = res?.data?.raw || ''
|
||||||
|
} catch (e: any) {
|
||||||
|
detailLessonsRaw.value = ''
|
||||||
|
console.warn('[SkillMarket] failed to load lessons', e)
|
||||||
|
} finally {
|
||||||
|
detailLessonsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearLessons() {
|
||||||
|
if (!detailSkill.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(t('skills.detail.clearLessonsConfirm'), t('skills.messages.deleteTitle'), { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await skillApi.clearLessons(detailSkill.value.id)
|
||||||
|
detailLessonsRaw.value = ''
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.messages.deleteFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(detailTab, (tab) => {
|
||||||
|
// Lazy load LESSONS.md only when the user clicks into that tab; we
|
||||||
|
// don't want every drawer open to fire an extra request.
|
||||||
|
if (tab === 'lessons' && detailDrawerVisible.value && !detailLessonsRaw.value && !detailLessonsLoading.value) {
|
||||||
|
loadLessons()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const categoryTabs = computed(() => [
|
const categoryTabs = computed(() => [
|
||||||
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
|
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
|
||||||
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
|
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
|
||||||
@ -1225,4 +1278,9 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
|
|||||||
.detail-meta-key { color: var(--mc-text-tertiary); margin-right: 4px; }
|
.detail-meta-key { color: var(--mc-text-tertiary); margin-right: 4px; }
|
||||||
.detail-feature-tag { padding: 2px 6px; background: var(--mc-bg-elevated); border-radius: 4px; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; color: var(--mc-text-primary); }
|
.detail-feature-tag { padding: 2px 6px; background: var(--mc-bg-elevated); border-radius: 4px; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; color: var(--mc-text-primary); }
|
||||||
.detail-feature-fallback { font-size: 11px; color: var(--mc-primary-hover); margin-top: 6px; font-style: italic; }
|
.detail-feature-fallback { font-size: 11px; color: var(--mc-primary-hover); margin-top: 6px; font-style: italic; }
|
||||||
|
|
||||||
|
.lessons-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||||
|
.lessons-clear-btn { padding: 6px 12px; border-radius: 8px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); color: var(--mc-text-secondary); cursor: pointer; font-size: 12px; }
|
||||||
|
.lessons-clear-btn:hover:not(:disabled) { background: var(--mc-danger-bg); color: var(--mc-danger); border-color: var(--mc-danger); }
|
||||||
|
.lessons-clear-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user