mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
parent
a0eba17688
commit
7c4380a116
@ -0,0 +1,73 @@
|
|||||||
|
package vip.mate.doc;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.tool.builtin.MateClawDocService;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置帮助文档的只读接口,供前端文档查看器消费。
|
||||||
|
*
|
||||||
|
* <p>文档本体打包在 classpath:docs/{zh,en}/ 下,与给智能体用的
|
||||||
|
* {@link MateClawDocService} 共享同一套扫描/校验逻辑。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Tag(name = "Docs")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/docs")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DocController {
|
||||||
|
|
||||||
|
private final MateClawDocService docService;
|
||||||
|
|
||||||
|
@Operation(summary = "列出某语言下的全部帮助文档(slug + 标题)")
|
||||||
|
@GetMapping
|
||||||
|
public R<List<MateClawDocService.DocMeta>> list(
|
||||||
|
@RequestParam(defaultValue = "zh") String lang) {
|
||||||
|
return R.ok(docService.list(normalizeLang(lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "读取单篇帮助文档正文(已剥离 frontmatter)")
|
||||||
|
@GetMapping("/content")
|
||||||
|
public R<Map<String, Object>> content(
|
||||||
|
@RequestParam(defaultValue = "zh") String lang,
|
||||||
|
@RequestParam String slug) {
|
||||||
|
String normLang = normalizeLang(lang);
|
||||||
|
String body = docService.read(normLang, slug);
|
||||||
|
if (body == null) {
|
||||||
|
return R.fail(404, "Document not found");
|
||||||
|
}
|
||||||
|
String title = docService.list(normLang).stream()
|
||||||
|
.filter(d -> d.slug().equals(slug))
|
||||||
|
.map(MateClawDocService.DocMeta::title)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(slug);
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("slug", slug);
|
||||||
|
payload.put("title", title);
|
||||||
|
payload.put("content", body);
|
||||||
|
return R.ok(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLang(String lang) {
|
||||||
|
if (lang == null) {
|
||||||
|
return "zh";
|
||||||
|
}
|
||||||
|
String l = lang.toLowerCase();
|
||||||
|
// 前端 locale 形如 zh-CN / en-US,取主语言段。
|
||||||
|
if (l.startsWith("en")) {
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
return "zh";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置项目文档(classpath:docs/{zh,en}/*.md)的读取服务。
|
||||||
|
*
|
||||||
|
* <p>同时服务两类消费方:给智能体运行时用的 {@link MateClawDocTool},以及给前端
|
||||||
|
* 文档查看器用的 REST 接口。把 classpath 扫描、路径白名单校验、frontmatter 剥离
|
||||||
|
* 等逻辑收敛在这里,避免两处重复。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class MateClawDocService {
|
||||||
|
|
||||||
|
/** 合法语言目录。 */
|
||||||
|
private static final Pattern VALID_LANG = Pattern.compile("^(zh|en)$");
|
||||||
|
/** 合法 slug —— 仅小写字母、数字、连字符、下划线,禁止路径穿越。 */
|
||||||
|
private static final Pattern VALID_SLUG = Pattern.compile("^[a-z0-9_-]+$");
|
||||||
|
/** 兼容 MateClawDocTool 的旧式 "lang/slug.md" 路径。 */
|
||||||
|
private static final Pattern VALID_PATH = Pattern.compile("^(zh|en)/[a-z0-9_-]+\\.md$");
|
||||||
|
private static final String DOCS_BASE = "docs/";
|
||||||
|
/** VitePress 首页,无正文,从用户可见列表中排除。 */
|
||||||
|
private static final String INDEX_SLUG = "index";
|
||||||
|
|
||||||
|
/** 开头的 YAML frontmatter 块:`---\n ... \n---`。 */
|
||||||
|
private static final Pattern FRONTMATTER = Pattern.compile("^---\\s*\\n.*?\\n---\\s*\\n", Pattern.DOTALL);
|
||||||
|
/** frontmatter 里的 `title:` 字段。 */
|
||||||
|
private static final Pattern TITLE_FIELD = Pattern.compile("(?m)^title:\\s*(.+?)\\s*$");
|
||||||
|
/** 正文里的首个 ATX 一级标题 `# xxx`。 */
|
||||||
|
private static final Pattern H1 = Pattern.compile("(?m)^#\\s+(.+?)\\s*$");
|
||||||
|
|
||||||
|
public record DocMeta(String slug, String title) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出某语言下的全部文档(排除 index.md),按 slug 排序,
|
||||||
|
* 每篇带一个用于展示的标题。
|
||||||
|
*/
|
||||||
|
public List<DocMeta> list(String lang) {
|
||||||
|
if (lang == null || !VALID_LANG.matcher(lang).matches()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<DocMeta> docs = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||||
|
Resource[] resources = resolver.getResources("classpath:docs/" + lang + "/*.md");
|
||||||
|
for (Resource r : resources) {
|
||||||
|
String filename = r.getFilename();
|
||||||
|
if (filename == null || !filename.endsWith(".md")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String slug = filename.substring(0, filename.length() - ".md".length());
|
||||||
|
if (INDEX_SLUG.equals(slug)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
docs.add(new DocMeta(slug, resolveTitle(r, slug)));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("No {} docs found: {}", lang, e.getMessage());
|
||||||
|
}
|
||||||
|
docs.sort((a, b) -> a.slug().compareTo(b.slug()));
|
||||||
|
return docs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 (lang, slug) 对应文档的正文,剥离开头的 YAML frontmatter。
|
||||||
|
*
|
||||||
|
* @return 正文内容;找不到或参数非法时返回 {@code null}。
|
||||||
|
*/
|
||||||
|
public String read(String lang, String slug) {
|
||||||
|
if (lang == null || !VALID_LANG.matcher(lang).matches()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (slug == null || !VALID_SLUG.matcher(slug).matches()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String raw = readRaw(lang + "/" + slug + ".md");
|
||||||
|
return raw == null ? null : stripFrontmatter(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 "lang/slug.md" 形式读取原始文件内容(含 frontmatter),用于
|
||||||
|
* {@link MateClawDocTool} 的 read action。返回错误字符串以保持其旧契约。
|
||||||
|
*/
|
||||||
|
String readRawForTool(String path) {
|
||||||
|
if (path == null || path.isBlank()) {
|
||||||
|
return "Error: 'path' is required when action='read'. Example: 'zh/config.md'";
|
||||||
|
}
|
||||||
|
if (!VALID_PATH.matcher(path).matches()) {
|
||||||
|
return "Error: Invalid path format. Expected pattern: (zh|en)/<topic>.md, e.g. 'zh/config.md'";
|
||||||
|
}
|
||||||
|
String raw = readRaw(path);
|
||||||
|
return raw == null ? "Error: Document not found: " + path : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readRaw(String path) {
|
||||||
|
try {
|
||||||
|
ClassPathResource resource = new ClassPathResource(DOCS_BASE + path);
|
||||||
|
if (!resource.exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try (InputStream is = resource.getInputStream()) {
|
||||||
|
String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
log.info("Read doc {}: {} bytes", path, content.length());
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to read doc {}: {}", path, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveTitle(Resource resource, String slug) {
|
||||||
|
String raw = null;
|
||||||
|
try (InputStream is = resource.getInputStream()) {
|
||||||
|
raw = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("Failed to read doc for title {}: {}", slug, e.getMessage());
|
||||||
|
}
|
||||||
|
if (raw == null) {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
Matcher fm = FRONTMATTER.matcher(raw);
|
||||||
|
if (fm.find()) {
|
||||||
|
Matcher title = TITLE_FIELD.matcher(fm.group());
|
||||||
|
if (title.find()) {
|
||||||
|
return unquote(title.group(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Matcher h1 = H1.matcher(stripFrontmatter(raw));
|
||||||
|
if (h1.find()) {
|
||||||
|
return h1.group(1).trim();
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripFrontmatter(String raw) {
|
||||||
|
Matcher m = FRONTMATTER.matcher(raw);
|
||||||
|
return m.find() ? raw.substring(m.end()) : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String unquote(String s) {
|
||||||
|
String t = s.trim();
|
||||||
|
if (t.length() >= 2 && ((t.startsWith("\"") && t.endsWith("\"")) || (t.startsWith("'") && t.endsWith("'")))) {
|
||||||
|
return t.substring(1, t.length() - 1).trim();
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,19 +2,12 @@ package vip.mate.tool.builtin;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.core.io.Resource;
|
|
||||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MateClaw 项目文档读取工具
|
* MateClaw 项目文档读取工具
|
||||||
@ -22,10 +15,10 @@ import java.util.regex.Pattern;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class MateClawDocTool {
|
public class MateClawDocTool {
|
||||||
|
|
||||||
private static final Pattern VALID_PATH = Pattern.compile("^(zh|en)/[a-z0-9_-]+\\.md$");
|
private final MateClawDocService docService;
|
||||||
private static final String DOCS_BASE = "docs/";
|
|
||||||
|
|
||||||
@Tool(description = """
|
@Tool(description = """
|
||||||
Read MateClaw project documentation.
|
Read MateClaw project documentation.
|
||||||
@ -50,100 +43,34 @@ public class MateClawDocTool {
|
|||||||
if ("list".equalsIgnoreCase(action)) {
|
if ("list".equalsIgnoreCase(action)) {
|
||||||
return listDocs();
|
return listDocs();
|
||||||
} else if ("read".equalsIgnoreCase(action)) {
|
} else if ("read".equalsIgnoreCase(action)) {
|
||||||
return readDoc(path);
|
return docService.readRawForTool(path);
|
||||||
} else {
|
} else {
|
||||||
return "Error: Unknown action '" + action + "'. Use 'list' or 'read'.";
|
return "Error: Unknown action '" + action + "'. Use 'list' or 'read'.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String listDocs() {
|
private String listDocs() {
|
||||||
try {
|
StringBuilder sb = new StringBuilder();
|
||||||
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
sb.append("MateClaw Documentation\n\n");
|
||||||
List<String> zhDocs = new ArrayList<>();
|
|
||||||
List<String> enDocs = new ArrayList<>();
|
|
||||||
|
|
||||||
// Scan zh/ docs
|
sb.append("## 中文文档 (zh/)\n");
|
||||||
try {
|
appendGroup(sb, "zh");
|
||||||
Resource[] zhResources = resolver.getResources("classpath:docs/zh/*.md");
|
|
||||||
for (Resource r : zhResources) {
|
|
||||||
String filename = r.getFilename();
|
|
||||||
if (filename != null) {
|
|
||||||
zhDocs.add(filename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.debug("No zh docs found: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan en/ docs
|
sb.append("\n## English Docs (en/)\n");
|
||||||
try {
|
appendGroup(sb, "en");
|
||||||
Resource[] enResources = resolver.getResources("classpath:docs/en/*.md");
|
|
||||||
for (Resource r : enResources) {
|
|
||||||
String filename = r.getFilename();
|
|
||||||
if (filename != null) {
|
|
||||||
enDocs.add(filename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.debug("No en docs found: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
sb.append("\nUse readMateClawDoc(action=\"read\", path=\"zh/config.md\") to read a specific doc.");
|
||||||
sb.append("MateClaw Documentation\n\n");
|
return sb.toString();
|
||||||
|
|
||||||
sb.append("## 中文文档 (zh/)\n");
|
|
||||||
if (zhDocs.isEmpty()) {
|
|
||||||
sb.append(" (none)\n");
|
|
||||||
} else {
|
|
||||||
zhDocs.sort(String::compareTo);
|
|
||||||
for (String doc : zhDocs) {
|
|
||||||
sb.append(" - zh/").append(doc).append("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.append("\n## English Docs (en/)\n");
|
|
||||||
if (enDocs.isEmpty()) {
|
|
||||||
sb.append(" (none)\n");
|
|
||||||
} else {
|
|
||||||
enDocs.sort(String::compareTo);
|
|
||||||
for (String doc : enDocs) {
|
|
||||||
sb.append(" - en/").append(doc).append("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.append("\nUse readMateClawDoc(action=\"read\", path=\"zh/config.md\") to read a specific doc.");
|
|
||||||
return sb.toString();
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to list docs: {}", e.getMessage());
|
|
||||||
return "Error: Failed to list documentation files: " + e.getMessage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String readDoc(String path) {
|
private void appendGroup(StringBuilder sb, String lang) {
|
||||||
if (path == null || path.isBlank()) {
|
List<MateClawDocService.DocMeta> docs = docService.list(lang);
|
||||||
return "Error: 'path' is required when action='read'. Example: 'zh/config.md'";
|
if (docs.isEmpty()) {
|
||||||
|
sb.append(" (none)\n");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
for (MateClawDocService.DocMeta doc : docs) {
|
||||||
// Security: validate path format
|
sb.append(" - ").append(lang).append('/').append(doc.slug()).append(".md\n");
|
||||||
if (!VALID_PATH.matcher(path).matches()) {
|
|
||||||
return "Error: Invalid path format. Expected pattern: (zh|en)/<topic>.md, e.g. 'zh/config.md'";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
ClassPathResource resource = new ClassPathResource(DOCS_BASE + path);
|
|
||||||
if (!resource.exists()) {
|
|
||||||
return "Error: Document not found: " + path;
|
|
||||||
}
|
|
||||||
|
|
||||||
try (InputStream is = resource.getInputStream()) {
|
|
||||||
String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
|
||||||
log.info("Read doc {}: {} bytes", path, content.length());
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("Failed to read doc {}: {}", path, e.getMessage());
|
|
||||||
return "Error: Failed to read document: " + e.getMessage();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 针对内置文档服务的单元测试。直接读 classpath 上打包的真实文档
|
||||||
|
* (src/main/resources/docs/{zh,en}/),不需要额外测试资源。
|
||||||
|
*/
|
||||||
|
class MateClawDocServiceTest {
|
||||||
|
|
||||||
|
private final MateClawDocService service = new MateClawDocService();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("list(zh) 返回文档且排除 VitePress 首页 index.md")
|
||||||
|
void listExcludesIndex() {
|
||||||
|
List<MateClawDocService.DocMeta> docs = service.list("zh");
|
||||||
|
|
||||||
|
assertThat(docs).isNotEmpty();
|
||||||
|
assertThat(docs).noneMatch(d -> d.slug().equals("index"));
|
||||||
|
// config.md 一定存在,且标题取的是中文 H1 而非文件名。
|
||||||
|
assertThat(docs)
|
||||||
|
.filteredOn(d -> d.slug().equals("config"))
|
||||||
|
.singleElement()
|
||||||
|
.satisfies(d -> assertThat(d.title()).isNotBlank().isNotEqualTo("config"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("list 对非法语言返回空")
|
||||||
|
void listRejectsInvalidLang() {
|
||||||
|
assertThat(service.list("fr")).isEmpty();
|
||||||
|
assertThat(service.list("../zh")).isEmpty();
|
||||||
|
assertThat(service.list(null)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("read 剥离开头的 YAML frontmatter")
|
||||||
|
void readStripsFrontmatter() {
|
||||||
|
// wiki.md 带 frontmatter(title/description/head)。
|
||||||
|
String body = service.read("zh", "wiki");
|
||||||
|
|
||||||
|
assertThat(body).isNotNull();
|
||||||
|
assertThat(body.stripLeading()).doesNotStartWith("---");
|
||||||
|
// `name: keywords` 只出现在 frontmatter 的 head meta 里,剥离后不应残留。
|
||||||
|
assertThat(body).doesNotContain("name: keywords");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("read 拒绝非法 slug / 路径穿越")
|
||||||
|
void readRejectsInvalidSlug() {
|
||||||
|
assertThat(service.read("zh", "../application")).isNull();
|
||||||
|
assertThat(service.read("zh", "config.md")).isNull();
|
||||||
|
assertThat(service.read("zh", "a/b")).isNull();
|
||||||
|
assertThat(service.read("fr", "config")).isNull();
|
||||||
|
assertThat(service.read("zh", "does-not-exist-xyz")).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("readRawForTool 保留 frontmatter 并对非法路径返回错误串")
|
||||||
|
void readRawForToolContract() {
|
||||||
|
assertThat(service.readRawForTool("zh/config.md")).doesNotStartWith("Error:");
|
||||||
|
assertThat(service.readRawForTool("../etc/passwd")).startsWith("Error:");
|
||||||
|
assertThat(service.readRawForTool(null)).startsWith("Error:");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1426,3 +1426,26 @@ export const approvalApi = {
|
|||||||
limit?: number
|
limit?: number
|
||||||
}) => http.get<ResolutionLog[]>('/approval/resolutions', { params }),
|
}) => http.get<ResolutionLog[]>('/approval/resolutions', { params }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 内置帮助文档 ====================
|
||||||
|
|
||||||
|
export interface DocMeta {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocContent {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const docsApi = {
|
||||||
|
/** 列出某语言下的全部帮助文档(slug + 标题)。 */
|
||||||
|
list: (lang: string) =>
|
||||||
|
http.get<DocMeta[]>('/docs', { params: { lang } }),
|
||||||
|
|
||||||
|
/** 读取单篇文档正文(已剥离 frontmatter)。 */
|
||||||
|
content: (lang: string, slug: string) =>
|
||||||
|
http.get<DocContent>('/docs/content', { params: { lang, slug } }),
|
||||||
|
}
|
||||||
|
|||||||
@ -413,6 +413,9 @@ export default {
|
|||||||
timeMinutesAgo: '{n}m ago',
|
timeMinutesAgo: '{n}m ago',
|
||||||
timeHoursAgo: '{n}h ago',
|
timeHoursAgo: '{n}h ago',
|
||||||
},
|
},
|
||||||
|
docs: {
|
||||||
|
title: 'Docs',
|
||||||
|
},
|
||||||
nav: {
|
nav: {
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
@ -439,6 +442,7 @@ export default {
|
|||||||
settingsGroup: 'Settings',
|
settingsGroup: 'Settings',
|
||||||
agents: 'Employees',
|
agents: 'Employees',
|
||||||
security: 'Security',
|
security: 'Security',
|
||||||
|
docs: 'Docs',
|
||||||
tokenUsage: 'Token Usage',
|
tokenUsage: 'Token Usage',
|
||||||
cronJobs: 'Cron Jobs',
|
cronJobs: 'Cron Jobs',
|
||||||
scheduler: 'Scheduler',
|
scheduler: 'Scheduler',
|
||||||
|
|||||||
@ -413,6 +413,9 @@ export default {
|
|||||||
timeMinutesAgo: '{n} 分钟前',
|
timeMinutesAgo: '{n} 分钟前',
|
||||||
timeHoursAgo: '{n} 小时前',
|
timeHoursAgo: '{n} 小时前',
|
||||||
},
|
},
|
||||||
|
docs: {
|
||||||
|
title: '帮助文档',
|
||||||
|
},
|
||||||
nav: {
|
nav: {
|
||||||
dashboard: '仪表盘',
|
dashboard: '仪表盘',
|
||||||
chat: '对话',
|
chat: '对话',
|
||||||
@ -439,6 +442,7 @@ export default {
|
|||||||
settingsGroup: '设置',
|
settingsGroup: '设置',
|
||||||
agents: '员工',
|
agents: '员工',
|
||||||
security: '安全',
|
security: '安全',
|
||||||
|
docs: '帮助文档',
|
||||||
tokenUsage: 'Token 统计',
|
tokenUsage: 'Token 统计',
|
||||||
cronJobs: '定时任务',
|
cronJobs: '定时任务',
|
||||||
scheduler: '调度中心',
|
scheduler: '调度中心',
|
||||||
|
|||||||
@ -66,6 +66,14 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/Memory/index.vue'),
|
component: () => import('@/views/Memory/index.vue'),
|
||||||
meta: { title: 'Memory', requiredCapability: 'view:memory' },
|
meta: { title: 'Memory', requiredCapability: 'view:memory' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 内置帮助文档查看器。:slug? 让刷新 / 收藏能恢复当前文档。
|
||||||
|
// 不加 requiredCapability —— 所有登录用户可见。
|
||||||
|
path: 'docs/:slug?',
|
||||||
|
name: 'Docs',
|
||||||
|
component: () => import('@/views/Docs/index.vue'),
|
||||||
|
meta: { title: 'Docs' },
|
||||||
|
},
|
||||||
// ==================== Connect ====================
|
// ==================== Connect ====================
|
||||||
{
|
{
|
||||||
path: 'channels',
|
path: 'channels',
|
||||||
|
|||||||
207
mateclaw-ui/src/views/Docs/index.vue
Normal file
207
mateclaw-ui/src/views/Docs/index.vue
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
<template>
|
||||||
|
<div class="docs-page">
|
||||||
|
<aside class="docs-sidebar">
|
||||||
|
<div class="docs-sidebar__title">{{ t('docs.title') }}</div>
|
||||||
|
<nav class="docs-nav">
|
||||||
|
<button
|
||||||
|
v-for="doc in docs"
|
||||||
|
:key="doc.slug"
|
||||||
|
class="docs-nav__item"
|
||||||
|
:class="{ 'docs-nav__item--active': doc.slug === activeSlug }"
|
||||||
|
@click="selectDoc(doc.slug)"
|
||||||
|
>
|
||||||
|
{{ doc.title }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="docs-content">
|
||||||
|
<div v-if="loading" class="docs-state">{{ t('common.loading') }}</div>
|
||||||
|
<div v-else-if="error" class="docs-state docs-state--error">{{ error }}</div>
|
||||||
|
<article
|
||||||
|
v-else
|
||||||
|
ref="contentEl"
|
||||||
|
class="markdown-body docs-article"
|
||||||
|
v-html="rendered"
|
||||||
|
@click="onContentClick"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { docsApi, type DocMeta } from '@/api'
|
||||||
|
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { renderMarkdown } = useMarkdownRenderer()
|
||||||
|
|
||||||
|
const docs = ref<DocMeta[]>([])
|
||||||
|
const activeSlug = ref<string>('')
|
||||||
|
const content = ref<string>('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string>('')
|
||||||
|
const contentEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// 后端文档目录只分 zh / en,取 app locale 的主语言段。
|
||||||
|
const lang = computed(() => (locale.value.startsWith('en') ? 'en' : 'zh'))
|
||||||
|
|
||||||
|
// Wikilink 替换是 chat 专用语义,文档里不需要;关掉避免误伤 [[...]] 文本。
|
||||||
|
const rendered = computed(() => renderMarkdown(content.value, { wikilink: 'none' }))
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
try {
|
||||||
|
const res: any = await docsApi.list(lang.value)
|
||||||
|
docs.value = res.data || []
|
||||||
|
} catch (e) {
|
||||||
|
docs.value = []
|
||||||
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContent(slug: string) {
|
||||||
|
if (!slug) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res: any = await docsApi.content(lang.value, slug)
|
||||||
|
content.value = res.data?.content || ''
|
||||||
|
activeSlug.value = slug
|
||||||
|
contentEl.value?.scrollTo({ top: 0 })
|
||||||
|
} catch (e) {
|
||||||
|
content.value = ''
|
||||||
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDoc(slug: string) {
|
||||||
|
if (slug === activeSlug.value) return
|
||||||
|
router.push({ name: 'Docs', params: { slug } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拦截文档间的相对链接(如 ./security、models),转成 SPA 内导航,避免整页刷新。
|
||||||
|
// 外链(target=_blank,由 renderer 标注)保持默认行为。
|
||||||
|
function onContentClick(e: MouseEvent) {
|
||||||
|
const anchor = (e.target as HTMLElement)?.closest('a')
|
||||||
|
if (!anchor) return
|
||||||
|
if (anchor.target === '_blank') return
|
||||||
|
const href = anchor.getAttribute('href') || ''
|
||||||
|
if (!href || href.startsWith('#')) return
|
||||||
|
const m = /^(?:\.\/|\.\.\/)?([a-z0-9_-]+)(?:\.md)?(?:[#?].*)?$/i.exec(href)
|
||||||
|
if (!m) return
|
||||||
|
const slug = m[1].toLowerCase()
|
||||||
|
if (!docs.value.some((d) => d.slug === slug)) return
|
||||||
|
e.preventDefault()
|
||||||
|
selectDoc(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由 slug 变化 → 加载对应文档;无 slug 时回退到第一篇。
|
||||||
|
watch(
|
||||||
|
() => route.params.slug,
|
||||||
|
(slug) => {
|
||||||
|
const target = (slug as string) || docs.value[0]?.slug
|
||||||
|
if (target && target !== activeSlug.value) {
|
||||||
|
loadContent(target)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 切换 app 语言 → 重新拉目录并重载当前文档。
|
||||||
|
watch(lang, async () => {
|
||||||
|
await loadList()
|
||||||
|
const target = activeSlug.value || docs.value[0]?.slug
|
||||||
|
if (target) await loadContent(target)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadList()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
if (slug) {
|
||||||
|
await loadContent(slug)
|
||||||
|
} else if (docs.value[0]) {
|
||||||
|
// 落到第一篇:改写 URL,由 slug watcher 负责加载(避免重复请求)。
|
||||||
|
router.replace({ name: 'Docs', params: { slug: docs.value[0].slug } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.docs-page {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-sidebar {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-sidebar__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav__item {
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav__item:hover {
|
||||||
|
background: var(--hover-bg, #f3f4f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav__item--active {
|
||||||
|
background: var(--active-bg, #eef2ff);
|
||||||
|
color: var(--primary-color, #4f46e5);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 28px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-article {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-state {
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-state--error {
|
||||||
|
color: var(--danger-color, #dc2626);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -534,6 +534,11 @@ const navGroups = computed(() => [
|
|||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
|
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
|
||||||
requiredCapability: 'manage:security',
|
requiredCapability: 'manage:security',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/docs',
|
||||||
|
label: t('nav.docs'),
|
||||||
|
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>`,
|
||||||
|
},
|
||||||
] as NavItem[]),
|
] as NavItem[]),
|
||||||
},
|
},
|
||||||
].filter((group) => group.items.length > 0))
|
].filter((group) => group.items.length > 0))
|
||||||
@ -554,6 +559,9 @@ function isNavItemActive(item: { path: string; label: string }) {
|
|||||||
if (item.path === '/security') {
|
if (item.path === '/security') {
|
||||||
return route.path.startsWith('/security')
|
return route.path.startsWith('/security')
|
||||||
}
|
}
|
||||||
|
if (item.path === '/docs') {
|
||||||
|
return route.path.startsWith('/docs')
|
||||||
|
}
|
||||||
return route.path === item.path
|
return route.path === item.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user