SkillService.java 4.7 KB
package com.xly.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

/**
 * skill 存储 —— 业务流程知识做成文本技能,模型用 useSkill 载入后照步骤执行。
 *
 * <p>文件格式:{@code <名称>.md},首行 = 一句话用途(进 system prompt 索引),其余 = 技能正文。
 * 默认从 classpath {@code skills/*.md} 读取(随包发布、缓存一次);配置
 * {@code xly.skills.dir} 指向文件系统目录后改从该目录**每次现读**——改文件即热更,不用重启。
 */
@Service
public class SkillService {

    private static final Logger log = LoggerFactory.getLogger(SkillService.class);

    public record Skill(String name, String brief, String body) { }

    @Value("${xly.skills.dir:}")
    private String skillsDir;

    private volatile List<Skill> classpathCache;

    public List<Skill> all() {
        if (skillsDir != null && !skillsDir.isBlank() && Files.isDirectory(Path.of(skillsDir))) {
            return loadFromDir(Path.of(skillsDir));
        }
        List<Skill> c = classpathCache;
        if (c == null) {
            c = loadFromClasspath();
            classpathCache = c;
        }
        return c;
    }

    /** 按名精确匹配,其次互相包含(「新建报价单」→「新建报价」)。找不到返回 null。 */
    public Skill find(String name) {
        if (name == null || name.isBlank()) {
            return null;
        }
        String n = name.trim();
        List<Skill> skills = all();
        for (Skill s : skills) {
            if (s.name().equals(n)) {
                return s;
            }
        }
        for (Skill s : skills) {
            if (n.contains(s.name()) || s.name().contains(n)) {
                return s;
            }
        }
        return null;
    }

    /** system prompt 的技能索引(一行一个:名称:用途)。 */
    public String indexLines() {
        StringBuilder sb = new StringBuilder();
        for (Skill s : all()) {
            sb.append("- ").append(s.name()).append(":").append(s.brief).append('\n');
        }
        return sb.toString();
    }

    private List<Skill> loadFromDir(Path dir) {
        List<Skill> out = new ArrayList<>();
        try (Stream<Path> files = Files.list(dir)) {
            files.filter(p -> p.getFileName().toString().endsWith(".md")).sorted().forEach(p -> {
                try {
                    Skill s = parse(p.getFileName().toString(), Files.readString(p, StandardCharsets.UTF_8));
                    if (s != null) {
                        out.add(s);
                    }
                } catch (IOException e) {
                    log.warn("skill 读取失败 {}: {}", p, e.getMessage());
                }
            });
        } catch (IOException e) {
            log.warn("skill 目录读取失败 {}: {}", dir, e.getMessage());
        }
        return out;
    }

    private List<Skill> loadFromClasspath() {
        List<Skill> out = new ArrayList<>();
        try {
            Resource[] resources = new PathMatchingResourcePatternResolver()
                    .getResources("classpath:skills/*.md");
            for (Resource r : resources) {
                try {
                    Skill s = parse(r.getFilename(), new String(
                            r.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
                    if (s != null) {
                        out.add(s);
                    }
                } catch (IOException e) {
                    log.warn("skill 读取失败 {}: {}", r.getFilename(), e.getMessage());
                }
            }
        } catch (IOException e) {
            log.warn("classpath skills 加载失败: {}", e.getMessage());
        }
        out.sort((a, b) -> a.name().compareTo(b.name()));
        return out;
    }

    private static Skill parse(String filename, String content) {
        if (filename == null || content == null || content.isBlank()) {
            return null;
        }
        String name = filename.endsWith(".md") ? filename.substring(0, filename.length() - 3) : filename;
        int nl = content.indexOf('\n');
        String brief = (nl < 0 ? content : content.substring(0, nl)).trim();
        String body = nl < 0 ? "" : content.substring(nl + 1).trim();
        return new Skill(name, brief, body);
    }
}