diff --git a/mateclaw-server/src/main/java/vip/mate/doc/DocController.java b/mateclaw-server/src/main/java/vip/mate/doc/DocController.java new file mode 100644 index 00000000..2acf2d79 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/doc/DocController.java @@ -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; + +/** + * 内置帮助文档的只读接口,供前端文档查看器消费。 + * + *

文档本体打包在 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( + @RequestParam(defaultValue = "zh") String lang) { + return R.ok(docService.list(normalizeLang(lang))); + } + + @Operation(summary = "读取单篇帮助文档正文(已剥离 frontmatter)") + @GetMapping("/content") + public R> 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 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"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocService.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocService.java new file mode 100644 index 00000000..50cb474a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocService.java @@ -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)的读取服务。 + * + *

同时服务两类消费方:给智能体运行时用的 {@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 list(String lang) { + if (lang == null || !VALID_LANG.matcher(lang).matches()) { + return List.of(); + } + List 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)/.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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocTool.java index 5dea619b..4619be43 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocTool.java @@ -2,19 +2,12 @@ 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.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.Pattern; /** * MateClaw 项目文档读取工具 @@ -22,10 +15,10 @@ import java.util.regex.Pattern; */ @Slf4j @Component +@RequiredArgsConstructor public class MateClawDocTool { - private static final Pattern VALID_PATH = Pattern.compile("^(zh|en)/[a-z0-9_-]+\\.md$"); - private static final String DOCS_BASE = "docs/"; + private final MateClawDocService docService; @Tool(description = """ Read MateClaw project documentation. @@ -50,100 +43,34 @@ public class MateClawDocTool { if ("list".equalsIgnoreCase(action)) { return listDocs(); } else if ("read".equalsIgnoreCase(action)) { - return readDoc(path); + return docService.readRawForTool(path); } else { return "Error: Unknown action '" + action + "'. Use 'list' or 'read'."; } } private String listDocs() { - try { - PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); - List zhDocs = new ArrayList<>(); - List enDocs = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + sb.append("MateClaw Documentation\n\n"); - // Scan zh/ docs - try { - 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()); - } + sb.append("## 中文文档 (zh/)\n"); + appendGroup(sb, "zh"); - // Scan en/ docs - try { - 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()); - } + sb.append("\n## English Docs (en/)\n"); + appendGroup(sb, "en"); - StringBuilder sb = new StringBuilder(); - sb.append("MateClaw Documentation\n\n"); - - 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(); - } + sb.append("\nUse readMateClawDoc(action=\"read\", path=\"zh/config.md\") to read a specific doc."); + return sb.toString(); } - private String readDoc(String path) { - if (path == null || path.isBlank()) { - return "Error: 'path' is required when action='read'. Example: 'zh/config.md'"; + private void appendGroup(StringBuilder sb, String lang) { + List docs = docService.list(lang); + if (docs.isEmpty()) { + sb.append(" (none)\n"); + return; } - - // Security: validate path format - if (!VALID_PATH.matcher(path).matches()) { - return "Error: Invalid path format. Expected pattern: (zh|en)/.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(); + for (MateClawDocService.DocMeta doc : docs) { + sb.append(" - ").append(lang).append('/').append(doc.slug()).append(".md\n"); } } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/MateClawDocServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/MateClawDocServiceTest.java new file mode 100644 index 00000000..e7089df7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/MateClawDocServiceTest.java @@ -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 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:"); + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index fe56f934..49a5c439 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -1426,3 +1426,26 @@ export const approvalApi = { limit?: number }) => http.get('/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('/docs', { params: { lang } }), + + /** 读取单篇文档正文(已剥离 frontmatter)。 */ + content: (lang: string, slug: string) => + http.get('/docs/content', { params: { lang, slug } }), +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index e180df62..bcc38be5 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -413,6 +413,9 @@ export default { timeMinutesAgo: '{n}m ago', timeHoursAgo: '{n}h ago', }, + docs: { + title: 'Docs', + }, nav: { dashboard: 'Dashboard', chat: 'Chat', @@ -439,6 +442,7 @@ export default { settingsGroup: 'Settings', agents: 'Employees', security: 'Security', + docs: 'Docs', tokenUsage: 'Token Usage', cronJobs: 'Cron Jobs', scheduler: 'Scheduler', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 9536ecce..ab12d221 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -413,6 +413,9 @@ export default { timeMinutesAgo: '{n} 分钟前', timeHoursAgo: '{n} 小时前', }, + docs: { + title: '帮助文档', + }, nav: { dashboard: '仪表盘', chat: '对话', @@ -439,6 +442,7 @@ export default { settingsGroup: '设置', agents: '员工', security: '安全', + docs: '帮助文档', tokenUsage: 'Token 统计', cronJobs: '定时任务', scheduler: '调度中心', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index 0be63caa..5b6e92c8 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -66,6 +66,14 @@ const router = createRouter({ component: () => import('@/views/Memory/index.vue'), meta: { title: 'Memory', requiredCapability: 'view:memory' }, }, + { + // 内置帮助文档查看器。:slug? 让刷新 / 收藏能恢复当前文档。 + // 不加 requiredCapability —— 所有登录用户可见。 + path: 'docs/:slug?', + name: 'Docs', + component: () => import('@/views/Docs/index.vue'), + meta: { title: 'Docs' }, + }, // ==================== Connect ==================== { path: 'channels', diff --git a/mateclaw-ui/src/views/Docs/index.vue b/mateclaw-ui/src/views/Docs/index.vue new file mode 100644 index 00000000..16c602a0 --- /dev/null +++ b/mateclaw-ui/src/views/Docs/index.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/mateclaw-ui/src/views/layout/MainLayout.vue b/mateclaw-ui/src/views/layout/MainLayout.vue index aa0e38b0..81d7157a 100644 --- a/mateclaw-ui/src/views/layout/MainLayout.vue +++ b/mateclaw-ui/src/views/layout/MainLayout.vue @@ -534,6 +534,11 @@ const navGroups = computed(() => [ icon: ``, requiredCapability: 'manage:security', }, + { + path: '/docs', + label: t('nav.docs'), + icon: ``, + }, ] as NavItem[]), }, ].filter((group) => group.items.length > 0)) @@ -554,6 +559,9 @@ function isNavItemActive(item: { path: string; label: string }) { if (item.path === '/security') { return route.path.startsWith('/security') } + if (item.path === '/docs') { + return route.path.startsWith('/docs') + } return route.path === item.path }