feat(docs): expose bundled help docs via in-app viewer

Closes #330
This commit is contained in:
倪程伟 2026-06-15 07:48:49 +08:00 committed by GitHub
parent a0eba17688
commit 7c4380a116
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 576 additions and 92 deletions

View File

@ -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";
}
}

View File

@ -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;
}
}

View File

@ -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<String> zhDocs = new ArrayList<>();
List<String> 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<MateClawDocService.DocMeta> 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)/<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();
for (MateClawDocService.DocMeta doc : docs) {
sb.append(" - ").append(lang).append('/').append(doc.slug()).append(".md\n");
}
}
}

View File

@ -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 frontmattertitle/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:");
}
}

View File

@ -1426,3 +1426,26 @@ export const approvalApi = {
limit?: number
}) => 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 } }),
}

View File

@ -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',

View File

@ -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: '调度中心',

View File

@ -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',

View 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 } })
}
// ./securitymodels 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>

View File

@ -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>`,
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[]),
},
].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
}