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 载入后照步骤执行。 * *

文件格式:{@code skills/*.md}(文件名用 ASCII——部署链路里 unzip war 会把中文文件名搞乱), * 首行 = 技能名,次行 = 一句话用途(进 system prompt 索引),其余 = 技能正文。 * 默认从 classpath 读取(随包发布、缓存一次);配置 {@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 classpathCache; public List all() { if (skillsDir != null && !skillsDir.isBlank() && Files.isDirectory(Path.of(skillsDir))) { return loadFromDir(Path.of(skillsDir)); } List 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 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 loadFromDir(Path dir) { List out = new ArrayList<>(); try (Stream files = Files.list(dir)) { files.filter(p -> p.getFileName().toString().endsWith(".md")).sorted().forEach(p -> { try { Skill s = parse(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 loadFromClasspath() { List out = new ArrayList<>(); try { Resource[] resources = new PathMatchingResourcePatternResolver() .getResources("classpath:skills/*.md"); for (Resource r : resources) { try { Skill s = parse(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 content) { if (content == null || content.isBlank()) { return null; } String[] parts = content.split("\n", 3); if (parts.length < 3) { return null; } String name = parts[0].trim(); String brief = parts[1].trim(); String body = parts[2].trim(); if (name.isEmpty() || body.isEmpty()) { return null; } return new Skill(name, brief, body); } }