From 8278c6dc39f9c5ab664d9404f47f6f406ef03460 Mon Sep 17 00:00:00 2001 From: Alice39s Date: Sun, 9 Aug 2026 21:24:45 +0900 Subject: [PATCH 1/7] feat(ai): support llms.txt, per-page Markdown and ChatGPT/Claude open --- AGENTS.md | 23 ++++++--- README.md | 14 ++++++ assets/js/ai-tools.js | 85 +++++++++++++++++++++++++++++++++ assets/styles/ai-tools.css | 39 +++++++++++++++ mkdocs-template.yml | 5 +- overrides/partials/actions.html | 32 +++++++++++++ pyproject.toml | 2 +- src/nmteam_support/cli.py | 15 +++++- src/nmteam_support/generator.py | 21 +++++++- src/nmteam_support/llms.py | 74 ++++++++++++++++++++++++++++ src/nmteam_support/template.py | 7 +++ tests/test_generator.py | 30 +++++++++++- tests/test_llms.py | 42 ++++++++++++++++ 13 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 assets/js/ai-tools.js create mode 100644 assets/styles/ai-tools.css create mode 100644 overrides/partials/actions.html create mode 100644 src/nmteam_support/llms.py create mode 100644 tests/test_llms.py diff --git a/AGENTS.md b/AGENTS.md index 11e35cf..6b689a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,10 @@ mkdocs-template.yml ─┤ generate() → cache/ ─copytree→ generated/ (mkdo redirects.json ──────┘ (含生成的 assets/js/redirects.js) ``` +构建后还有一步:`stage_markdown_copies()` 把 `cache/` 下每个 `.md` 复制到 `site/` +同路径(如 `site/nmbot-telegram/mcp.md`),作为每页的 Markdown 版本,供 +`/llms.txt` 链接、View-as-Markdown 按钮与 ChatGPT/Claude 打开。 + `generate()`(`src/nmteam_support/generator.py`)编排的完整链路: 1. 清空重建 `cache/` @@ -26,7 +30,8 @@ redirects.json ──────┘ (含生成的 assets/js/redirects.js 5. `image_pipeline.py` 用 PIL 为 assets 中每张 PNG/JPEG 生成 `.webp` 兄弟文件(质量 80;PNG 另出 256 色有损 fallback) 6. `nav.py` 生成 nav YAML,`template.py` 替换 `mkdocs-template.yml` 的 `# NAV_ARIA_START`/`# NAV_ARIA_END` 标记块写入 `mkdocs.yml` 7. `redirects.py` 读 `redirects.json` 生成 `cache/assets/js/redirects.js` -8. copytree 到 `generated/`,交给 `mkdocs build --strict` +8. `llms.py` 用扫描树 + 模板 `site_url` 生成 `cache/llms.txt`(llmstxt.org 规范:H1 + blockquote + H2 分节链接,链接指向 `.md` 版本;非 md 文件会被 mkdocs 原样拷到 `site/llms.txt`) +9. copytree 到 `generated/`,交给 `mkdocs build --strict` **图片双层管线**(关键机制):生成期 `image_pipeline.py` 产出同名 `.webp` 兄弟文件;渲染期 `markdown_images.py`(mkdocs 扩展,注册在 mkdocs-template.yml 的 markdown_extensions 中)把本地栅格图 `` 改写为 WebP-first ``,靠同名 `.webp` 约定对接。外部 URL 不下载不镜像。Markdown 中仍写普通图片语法。 @@ -36,15 +41,15 @@ redirects.json ──────┘ (含生成的 assets/js/redirects.js | 路径 | 用途 | |---|---| -| `src/nmteam_support/` | 工具链包(15 个模块,见 Important Files) | +| `src/nmteam_support/` | 工具链包(16 个模块,见 Important Files) | | `docs/` | **真实文档源**(唯一需要手工编辑的内容位置) | | `docs/nmbot-telegram/` | 产品中枢:`panel/`、`plus/`、`legal/`、`group/`、`faq/`、`business/`、`tools/`、`nmbot-intelligence/`、`credit/`、`update-log/`(`YYYY-MM.md` 月度日志)、`mcp/` | | `docs/contact-us/`、`docs/nmteam-account/` | 其他产品线 | | `docs/superpowers/` | 本地设计与实现工件(plans/specs),**已 gitignore,勿提交** | -| `assets/images/` | 图片母版:`shared/`(站级共享)、`nmbot/`(含 `mcp/`、`update-pictures/` 子目录);`assets/icons/`(SVG)、`assets/styles/`(CSS) | -| `overrides/` | mkdocs `custom_dir`,`main.html` 覆写 site_meta 移除主题版本号 | +| `assets/images/` | 图片母版:`shared/`(站级共享)、`nmbot/`(含 `mcp/`、`update-pictures/` 子目录);`assets/icons/`(SVG)、`assets/styles/`(CSS)、`assets/js/`(AI 工具脚本 `ai-tools.js`,随构建 stage 到生成站) | +| `overrides/` | mkdocs `custom_dir`:`main.html` 覆写 site_meta 移除主题版本号;`partials/actions.html` 追加 AI 工具按钮组(Markdown / ChatGPT / Claude,毛玻璃样式在 `assets/styles/ai-tools.css`,交互在 `assets/js/ai-tools.js`) | | `scripts/` | 三平台薄启动器(`nmteam.sh` / `nmteam.ps1` / `nmteam.bat`) | -| `tests/` | pytest 测试(15 个文件 + conftest.py) | +| `tests/` | pytest 测试(16 个文件 + conftest.py) | | `cache/`、`generated/`、`site/`、`mkdocs.yml` | 生成产物,勿手改勿提交 | ## Development Commands @@ -54,7 +59,8 @@ uv sync # 安装依赖(按 .python-version 3.14 自 uv run nmteam install # 同上,CLI 封装 uv run nmteam dev # 生成文档结构 + mkdocs serve http://127.0.0.1:8000, # 监听 docs/、assets/、mkdocs-template.yml 变化自动重生成 + 热更新 -uv run nmteam build # generate() + mkdocs build --strict → site/ +uv run nmteam build # generate() + mkdocs build --strict → site/, + # 再 stage 每页 .md 副本到 site/ uv run nmteam generate # 仅重新生成文档结构 uv run nmteam clean # 清理 cache/ generated/ site/ uv run nmteam check # 全部质量检查(见下) @@ -106,6 +112,7 @@ Markdown 文档(`docs/`): | `src/nmteam_support/contributing.py` | 非 index.md 注入贡献提示 admonition | | `src/nmteam_support/redirects.py` | redirects.json 管理(损坏保护)+ redirects.js 生成 | | `src/nmteam_support/image_pipeline.py` + `markdown_images.py` | PIL 生成 .webp 兄弟文件;mkdocs 扩展包 WebP-first `` | +| `src/nmteam_support/llms.py` | `render_llms_txt()` 从扫描树生成 `/llms.txt`(llmstxt.org 规范;链接指向各页 `.md` 版本) | | `src/nmteam_support/models.py` | `PageMetadata`/`DocEntry` frozen dataclass | | `pyproject.toml` | 包元数据、依赖、入口、pytest/ruff/hatchling 配置 | | `uv.lock` | 锁定依赖(mkdocs 1.6.1、mkdocs-material 9.7.7、mkdocs-minify-plugin 0.8.0、pillow 12.3.0、typer 0.27.1、pytest 9.1.1、ruff 0.16.2、mdformat 1.0.0 等) | @@ -114,6 +121,9 @@ Markdown 文档(`docs/`): | `.github/workflows/ci.yml` | 三 OS 矩阵 CI(push main/dev + PR):uv sync --frozen → nmteam check → 验证三个启动器 | | `.mdformat.toml` | mdformat 配置(wrap=keep、LF) | | `overrides/main.html` | 移除 meta 中 mkdocs-material 版本号 | +| `overrides/partials/actions.html` | 覆盖 material actions partial:保留编辑/查看按钮,追加 AI 工具按钮组 | +| `assets/js/ai-tools.js` | 按钮交互:View-as-Markdown 链接(根相对 `.md` 路径)、ChatGPT/Claude 点击后 fetch 页面 `.md` 内容作为提示词打开(fetch 失败降级为仅 URL) | +| `assets/styles/ai-tools.css` | 毛玻璃按钮样式(半透明 + backdrop-filter blur + 柔和阴影,适配明暗主题) | ## Runtime/Tooling Preferences @@ -127,6 +137,7 @@ Markdown 文档(`docs/`): - **pytest**(dev 组 `pytest>=9.0`),配置在 pyproject.toml:`testpaths = ["tests"]`、`pythonpath = ["src"]`、`addopts = "-ra"`。 - 测试约定:`tests/` 单层目录,每个模块一个 `test_.py`(test_cli、test_generator、test_scanner、test_frontmatter、test_nav、test_index、test_docslist、test_contributing、test_redirects、test_image_pipeline、test_markdown_images、test_models、test_scripts、test_build_config 等)。 +- `test_llms.py` 覆盖 llms.txt 生成(标题/摘要/分节/.md 链接/base_url/空树);`test_generator.py` 覆盖 llms.txt 落盘与 `stage_markdown_copies` 镜像 cache。 - 输入构造分级:conftest 的 `docs_dir` fixture(tmp_path 构造最小 docs 树,含 index/hide_docs_list/hide frontmatter)→ `tmp_path` 手写单文件 → 全流程 generate 集成。**不依赖真实 docs/ 内容**。 - CLI 测试用 `typer.testing.CliRunner` + monkeypatch;启动器测试用 subprocess + 假 uv 脚本。 - **无覆盖率门槛**(无 pytest-cov、CI 无 coverage 步骤)——新增功能时给模块补 `test_.py` 行为测试即可。 diff --git a/README.md b/README.md index 9d3329f..a5c36b3 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,20 @@ uv run nmteam build Markdown 中仍使用普通图片语法,构建工具会自动输出 WebP 优先的 `` 元素。外部 URL 不会被下载或镜像。 +## AI 支持 + +站点面向语言模型提供以下能力: + +- `/llms.txt`:按 [llmstxt.org](https://llmstxt.org/) 规范生成的站点索引, + 每个链接指向页面的 Markdown 版本。 +- 每页 Markdown 版本:`nmteam build` 时在每个页面旁生成同路径的 `.md` 文件 + (如 `/nmbot-telegram/mcp.md`)。 +- 页面顶部的 AI 工具按钮:**Markdown**(查看本页 Markdown)、**ChatGPT** / + **Claude**(将本页内容作为上下文在 ChatGPT/Claude 中打开)。 + +注意:`llms.txt` 与 `.md` 版本由 `nmteam build` 输出到 `site/`;开发模式 +(`nmteam dev`)下 ChatGPT/Claude 按钮会退化为仅携带页面链接的提示词。 + ### 其他命令 ```bash diff --git a/assets/js/ai-tools.js b/assets/js/ai-tools.js new file mode 100644 index 0000000..e1ec4cc --- /dev/null +++ b/assets/js/ai-tools.js @@ -0,0 +1,85 @@ +// AI tools: view-as-Markdown, open in ChatGPT / Claude. +// The page's raw Markdown twin lives at the same docs-relative path with a +// ".md" suffix (e.g. /nmbot-telegram/mcp.md); it is staged into site/ by the +// build. When the twin is missing (e.g. `mkdocs serve`), ChatGPT/Claude fall +// back to a prompt that references the page URL instead of its content. +(function () { + "use strict"; + + var ENDPOINTS = { + chatgpt: "https://chatgpt.com/?q=", + claude: "https://claude.ai/new?q=", + }; + // Keep the prompt URL short enough for chat providers to accept. + var MAX_PROMPT = 60000; + + function mdUrl(raw) { + // Root-relative: a relative path would resolve against the page URL, + // e.g. /nmbot-telegram/mcp/ + "nmbot-telegram/mcp.md" -> wrong twin. + // The home page has an empty page.url and its twin is /index.md + // (already carries the ".md" suffix). + if (!raw) { + return "/index.md"; + } + return "/" + raw.replace(/\/+$/, "") + ".md"; + } + + function pageUrl(raw) { + return location.origin + "/" + (raw || ""); + } + + function buildPrompt(content, url) { + if (!content) { + return "请阅读此文档页面并回答我的问题:" + url; + } + var header = "以下是 support.nmteam.xyz 文档页面的内容,请基于此内容回答我的问题:\n\n"; + var body = content; + if (body.length > MAX_PROMPT) { + body = body.slice(0, MAX_PROMPT) + "\n\n…(内容过长已截断)"; + } + return header + body; + } + + function openAi(which, prompt) { + window.open(ENDPOINTS[which] + encodeURIComponent(prompt), "_blank", "noopener"); + } + + function wireFetch(button, which, md, url) { + button.addEventListener("click", function (event) { + event.preventDefault(); + fetch(md) + .then(function (response) { + return response.ok ? response.text() : ""; + }) + .catch(function () { + return ""; + }) + .then(function (content) { + openAi(which, buildPrompt(content, url)); + }); + }); + } + + document.addEventListener("DOMContentLoaded", function () { + var box = document.querySelector(".ai-tools"); + if (!box) { + return; + } + var raw = box.getAttribute("data-md-url") || ""; + var md = mdUrl(raw); + var url = pageUrl(raw); + + var markdownLink = box.querySelector('[data-ai="markdown"]'); + if (markdownLink) { + markdownLink.setAttribute("href", md); + } + var chatgpt = box.querySelector('[data-ai="chatgpt"]'); + if (chatgpt) { + wireFetch(chatgpt, "chatgpt", md, url); + } + var claude = box.querySelector('[data-ai="claude"]'); + if (claude) { + wireFetch(claude, "claude", md, url); + } + }); +})(); diff --git a/assets/styles/ai-tools.css b/assets/styles/ai-tools.css new file mode 100644 index 0000000..6c868fa --- /dev/null +++ b/assets/styles/ai-tools.css @@ -0,0 +1,39 @@ +/* AI tools: view-as-Markdown, open in ChatGPT / Claude. + Glassmorphism style (frosted translucent pill buttons), adapting to the + Material light/dark palettes via theme CSS variables. */ +.ai-tools { + display: inline-flex; + gap: 6px; + margin-inline-start: 8px; + vertical-align: middle; +} + +.ai-tools__link { + display: inline-flex; + align-items: center; + padding: 4px 12px; + font-size: 12px; + line-height: 1.6; + color: var(--md-typeset-color); + background: color-mix(in srgb, var(--md-default-bg-color) 62%, transparent); + -webkit-backdrop-filter: blur(12px) saturate(160%); + backdrop-filter: blur(12px) saturate(160%); + border: 1px solid color-mix(in srgb, var(--md-default-fg-color) 22%, transparent); + border-radius: 999px; + box-shadow: 0 1px 4px color-mix(in srgb, var(--md-default-fg-color) 12%, transparent); + cursor: pointer; + text-decoration: none; + transition: background .2s, color .2s, border-color .2s, box-shadow .2s; +} + +.ai-tools__link:hover { + color: var(--md-primary-fg-color); + background: color-mix(in srgb, var(--md-default-bg-color) 88%, var(--md-primary-fg-color)); + border-color: color-mix(in srgb, var(--md-primary-fg-color) 45%, transparent); + box-shadow: 0 2px 10px color-mix(in srgb, var(--md-primary-fg-color) 25%, transparent); +} + +.ai-tools__link:focus-visible { + outline: 2px solid var(--md-primary-fg-color); + outline-offset: 2px; +} diff --git a/mkdocs-template.yml b/mkdocs-template.yml index a7fff67..6ea2ea3 100644 --- a/mkdocs-template.yml +++ b/mkdocs-template.yml @@ -1,4 +1,5 @@ site_name: nmTeam Support +site_url: https://support.nmteam.xyz plugins: - search - minify: @@ -33,8 +34,8 @@ theme: - navigation.sections - toc.integrate - navigation.top -extra_css: [assets/styles/docsList.css, assets/styles/plus.css] -extra_javascript: [assets/js/redirects.js] +extra_css: [assets/styles/docsList.css, assets/styles/plus.css, assets/styles/ai-tools.css] +extra_javascript: [assets/js/redirects.js, assets/js/ai-tools.js] docs_dir: generated extra: diff --git a/overrides/partials/actions.html b/overrides/partials/actions.html new file mode 100644 index 0000000..0cba32f --- /dev/null +++ b/overrides/partials/actions.html @@ -0,0 +1,32 @@ +{#- + Overrides Material's actions partial: keeps the edit/view source buttons + (unused today, no repo_url is configured) and adds the AI tools button + group (view as Markdown, open in ChatGPT / Claude). +-#} +{% if page.edit_url %} + {% if "content.action.edit" in features %} + + {% set icon = config.theme.icon.edit or "material/file-edit-outline" %} + {% include ".icons/" ~ icon ~ ".svg" %} + + {% endif %} + {% if "content.action.view" in features %} + {% if "/blob/" in page.edit_url %} + {% set part = "blob" %} + {% else %} + {% set part = "edit" %} + {% endif %} + + {% set icon = config.theme.icon.view or "material/file-eye-outline" %} + {% include ".icons/" ~ icon ~ ".svg" %} + + {% endif %} +{% endif %} + +{% if page %} +
+ Markdown + + +
+{% endif %} diff --git a/pyproject.toml b/pyproject.toml index 06c54e1..bad984b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ exclude = ["docs"] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM", "W", "C4", "RUF"] ignore = ["E501"] -allowed-confusables = [",", "。", "?", ":", "!"] +allowed-confusables = [",", "。", "?", ":", ";", "!"] [tool.ruff.lint.isort] known-first-party = ["nmteam_support"] diff --git a/src/nmteam_support/cli.py b/src/nmteam_support/cli.py index cd798a0..37d0e1f 100644 --- a/src/nmteam_support/cli.py +++ b/src/nmteam_support/cli.py @@ -10,7 +10,12 @@ import typer -from nmteam_support.generator import GeneratorOptions, default_options, generate +from nmteam_support.generator import ( + GeneratorOptions, + default_options, + generate, + stage_markdown_copies, +) from nmteam_support.redirects import ( RedirectConfigError, read_redirects, @@ -55,7 +60,13 @@ def cmd_clean(options: GeneratorOptions) -> int: def cmd_build(options: GeneratorOptions) -> int: """Regenerate and build the static site into site/.""" generate(options) - return subprocess.call([sys.executable, "-m", "mkdocs", "build", "--strict", "--clean"]) + code = subprocess.call([sys.executable, "-m", "mkdocs", "build", "--strict", "--clean"]) + if code: + return code + site_dir = options.mkdocs_yml_path.parent / "site" + stage_markdown_copies(options.cache_dir, site_dir) + print("Markdown copies staged into site/.") + return 0 def cmd_dev(options: GeneratorOptions) -> int: diff --git a/src/nmteam_support/generator.py b/src/nmteam_support/generator.py index 2b97525..3635f57 100644 --- a/src/nmteam_support/generator.py +++ b/src/nmteam_support/generator.py @@ -10,10 +10,11 @@ from nmteam_support.frontmatter import split_frontmatter from nmteam_support.image_pipeline import stage_assets from nmteam_support.index import render_index_page +from nmteam_support.llms import render_llms_txt from nmteam_support.nav import build_nav_yaml from nmteam_support.redirects import load_redirects, render_redirects_js from nmteam_support.scanner import ScannedDir, scan_docs -from nmteam_support.template import render_mkdocs_yml +from nmteam_support.template import extract_site_url, render_mkdocs_yml @dataclass(frozen=True) @@ -61,6 +62,10 @@ def generate(options: GeneratorOptions) -> None: template = options.template_path.read_text(encoding="UTF-8", errors="ignore") options.mkdocs_yml_path.write_text(render_mkdocs_yml(template, nav_yaml), encoding="UTF-8") + base_url = extract_site_url(template) + (cache_dir / "llms.txt").write_text(render_llms_txt(root, base_url), encoding="UTF-8") + print("llms.txt generated.") + redirects = load_redirects(options.redirects_path) if redirects is None: print("Warning: redirects.json not found. Skipping redirects generation.") @@ -76,6 +81,20 @@ def generate(options: GeneratorOptions) -> None: print("Documentation generated.") +def stage_markdown_copies(cache_dir: Path, site_dir: Path) -> None: + """Copy every staged ``.md`` into ``site/`` so each page has a raw Markdown twin. + + The copies land at the same docs-relative path (e.g. ``nmbot-telegram/mcp.md``), + which is the URL served for ``/nmbot-telegram/mcp.md``. MkDocs copies ``llms.txt`` + verbatim but would re-render any ``.md`` placed inside the docs dir, so the twins + are staged only after the build, directly into the site output. + """ + for source in cache_dir.rglob("*.md"): + target = site_dir / source.relative_to(cache_dir) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + + def _write_tree(docs_dir: Path, cache_dir: Path, scan: ScannedDir) -> None: for doc in scan.docs: source = docs_dir / doc.path diff --git a/src/nmteam_support/llms.py b/src/nmteam_support/llms.py new file mode 100644 index 0000000..759deb7 --- /dev/null +++ b/src/nmteam_support/llms.py @@ -0,0 +1,74 @@ +"""llms.txt generation from the scanned docs tree.""" + +from __future__ import annotations + +from nmteam_support.models import DocEntry +from nmteam_support.scanner import ScannedDir + +SITE_TITLE = "nmTeam Support" + +# Optional section skipped when a shorter context is needed (llmstxt.org spec). +OPTIONAL_SECTION = "Optional" + + +def render_llms_txt(root: ScannedDir, base_url: str) -> str: + """Render the ``/llms.txt`` file following the llmstxt.org proposal. + + Every link points to the raw Markdown copy staged next to the rendered + page (same docs-relative path), so LLMs can fetch page bodies directly. + """ + lines = [f"# {SITE_TITLE}", ""] + summary = root.index_meta.description or "nmTeam 官方支持文档站。" + lines.append(f"> {summary}") + lines.append("") + lines.append("本文件面向语言模型,提供 nmTeam 支持文档的索引;") + lines.append("每个链接指向对应页面的 Markdown 版本。") + lines.append("") + + sections = _sections(root) + if sections: + for title, entries in sections: + lines.append(f"## {title}") + lines.append("") + for entry in entries: + lines.append(_render_entry(entry, base_url)) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def _render_entry(entry: DocEntry, base_url: str) -> str: + line = f"- [{entry.title}]({base_url}/{entry.path})" + if entry.description: + line += f": {entry.description}" + return line + + +def _sections(root: ScannedDir) -> list[tuple[str, list[DocEntry]]]: + """Collect ``(section title, entries)`` pairs, one per directory with content.""" + sections: list[tuple[str, list[DocEntry]]] = [] + entries = _index_entry(root) + list(root.docs) + if entries: + title = root.index_meta.title or "站点" + sections.append((title, _sorted(entries))) + for sub in root.subdirs: + sections.extend(_sections(sub)) + return sections + + +def _index_entry(scan: ScannedDir) -> list[DocEntry]: + """The directory's own index.md as a DocEntry, when present.""" + if not scan.has_index: + return [] + path = f"{scan.rel_path}/index.md" if scan.rel_path else "index.md" + return [ + DocEntry( + title=scan.index_meta.title, + description=scan.index_meta.description or "目录索引", + path=path, + name="index.md", + ) + ] + + +def _sorted(entries: list[DocEntry]) -> list[DocEntry]: + return sorted(entries, key=lambda entry: (entry.index, entry.path)) diff --git a/src/nmteam_support/template.py b/src/nmteam_support/template.py index 95ad7a7..5f927da 100644 --- a/src/nmteam_support/template.py +++ b/src/nmteam_support/template.py @@ -8,6 +8,7 @@ NAV_END = "# NAV_ARIA_END" _NAV_PATTERN = re.compile(r"# NAV_ARIA_START.*# NAV_ARIA_END", re.S) +_SITE_URL_PATTERN = re.compile(r"^\s*site_url:\s*(\S+)\s*$", re.M) def render_mkdocs_yml(template: str, nav_yaml: str) -> str: @@ -16,3 +17,9 @@ def render_mkdocs_yml(template: str, nav_yaml: str) -> str: # Callable replacement: re.sub otherwise interprets backslash escapes in the # replacement string (e.g. "\1" -> group reference), mangling literal nav text. return _NAV_PATTERN.sub(lambda _m: block, template) + + +def extract_site_url(template: str, fallback: str = "https://support.nmteam.xyz") -> str: + """Read ``site_url:`` from the template, falling back when absent.""" + match = _SITE_URL_PATTERN.search(template) + return match.group(1).strip().rstrip("/") if match else fallback diff --git a/tests/test_generator.py b/tests/test_generator.py index 8c64b7c..8cffe30 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -5,7 +5,12 @@ import pytest from PIL import Image -from nmteam_support.generator import GeneratorOptions, generate, render_doc_file +from nmteam_support.generator import ( + GeneratorOptions, + generate, + render_doc_file, + stage_markdown_copies, +) def _full_options(tmp_path: Path, docs_dir: Path, redirects: str | None = None) -> GeneratorOptions: @@ -92,6 +97,29 @@ def test_generate_writes_redirects_before_copy(tmp_path, docs_dir): assert "/a/" in js.read_text(encoding="utf-8") +def test_generate_writes_llms_txt(tmp_path, docs_dir): + options = _full_options(tmp_path, docs_dir) + generate(options) + llms = options.generated_dir / "llms.txt" + assert llms.exists() + content = llms.read_text(encoding="utf-8") + assert content.startswith("# nmTeam Support\n") + assert "nmbot-telegram/mcp.md" in content + assert "https://support.nmteam.xyz/nmbot-telegram/mcp.md" in content + + +def test_stage_markdown_copies_mirrors_cache(tmp_path, docs_dir): + options = _full_options(tmp_path, docs_dir) + generate(options) + site_dir = tmp_path / "site" + stage_markdown_copies(options.cache_dir, site_dir) + twin = site_dir / "nmbot-telegram" / "mcp.md" + assert twin.exists() + cached = options.cache_dir / "nmbot-telegram" / "mcp.md" + assert twin.read_text(encoding="utf-8") == cached.read_text(encoding="utf-8") + assert (site_dir / "index.md").exists() + + def test_generate_skips_superpowers_in_output(tmp_path, docs_dir): internal = docs_dir / "superpowers" internal.mkdir() diff --git a/tests/test_llms.py b/tests/test_llms.py new file mode 100644 index 0000000..9f5fecf --- /dev/null +++ b/tests/test_llms.py @@ -0,0 +1,42 @@ +"""llms.txt generation tests.""" + +from nmteam_support.llms import render_llms_txt +from nmteam_support.scanner import scan_docs + +BASE_URL = "https://support.nmteam.xyz" + + +def test_llms_txt_has_header_and_summary(docs_dir): + content = render_llms_txt(scan_docs(docs_dir), BASE_URL) + assert content.startswith("# nmTeam Support\n") + assert "> 支持中心。" in content + assert content.endswith("\n") + + +def test_llms_txt_sections_and_links(docs_dir): + content = render_llms_txt(scan_docs(docs_dir), BASE_URL) + assert "## nmTeam 支持" in content # root section + assert "## nmBot Telegram" in content + assert "## 联系我们" in content + assert "- [MCP 配置](https://support.nmteam.xyz/nmbot-telegram/mcp.md): 配置 MCP。" in content + assert "- [关于](https://support.nmteam.xyz/about.md): 了解此文档。" in content + assert "- [nmTeam 支持](https://support.nmteam.xyz/index.md): 支持中心。" in content + + +def test_llms_txt_index_entries_use_index_paths(docs_dir): + content = render_llms_txt(scan_docs(docs_dir), BASE_URL) + assert "- [nmBot Telegram](https://support.nmteam.xyz/nmbot-telegram/index.md)" in content + assert "- [联系我们](https://support.nmteam.xyz/contact-us/index.md)" in content + + +def test_llms_txt_links_use_base_url(docs_dir): + content = render_llms_txt(scan_docs(docs_dir), "https://docs.example.org/sub") + assert "https://docs.example.org/sub/nmbot-telegram/mcp.md" in content + assert "support.nmteam.xyz" not in content + + +def test_llms_txt_empty_tree(tmp_path): + (tmp_path / "docs").mkdir() + content = render_llms_txt(scan_docs(tmp_path / "docs"), BASE_URL) + assert content.startswith("# nmTeam Support\n") + assert "## " not in content From 351fc8565f6761978ee51ccd26791358f7bb4ac8 Mon Sep 17 00:00:00 2001 From: Alice39s Date: Sun, 9 Aug 2026 21:41:22 +0900 Subject: [PATCH 2/7] fix(ai): align AI tool buttons with Material conventions, graceful dev fallback --- assets/js/ai-tools.js | 19 +++++++++++++++++++ assets/styles/ai-tools.css | 7 +++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/assets/js/ai-tools.js b/assets/js/ai-tools.js index e1ec4cc..a67fc89 100644 --- a/assets/js/ai-tools.js +++ b/assets/js/ai-tools.js @@ -72,6 +72,25 @@ var markdownLink = box.querySelector('[data-ai="markdown"]'); if (markdownLink) { markdownLink.setAttribute("href", md); + // The Markdown twins are only staged into site/ by `nmteam build`; + // under `mkdocs serve` they 404, so explain instead of dead-linking. + markdownLink.addEventListener("click", function (event) { + event.preventDefault(); + fetch(md, { method: "HEAD" }).then(function (response) { + var contentType = response.headers.get("Content-Type") || ""; + // `mkdocs serve` renders .md URLs as HTML pages; only the + // static twins staged by `nmteam build` are served as + // Markdown. Navigate only for the real thing. + if (response.ok && contentType.indexOf("html") === -1) { + location.href = md; + } else { + window.alert( + "Markdown 版本在构建产物中提供。请先运行 `uv run nmteam build`," + + "或改用 ChatGPT / Claude 按钮。" + ); + } + }); + }); } var chatgpt = box.querySelector('[data-ai="chatgpt"]'); if (chatgpt) { diff --git a/assets/styles/ai-tools.css b/assets/styles/ai-tools.css index 6c868fa..8230158 100644 --- a/assets/styles/ai-tools.css +++ b/assets/styles/ai-tools.css @@ -4,8 +4,11 @@ .ai-tools { display: inline-flex; gap: 6px; - margin-inline-start: 8px; - vertical-align: middle; + /* Align with Material's action buttons (edit/view) in the top-right + corner of the content area; without the float the group renders + stacked against the hero banner on the home page. */ + float: right; + margin: 8px 8px 0 0; } .ai-tools__link { From 8f2a26e8b776533227b2cdfe2a4dc80ff4d8d24c Mon Sep 17 00:00:00 2001 From: Alice39s Date: Mon, 10 Aug 2026 07:34:59 +0900 Subject: [PATCH 3/7] fix(ui): restore homepage rendering and add Open menu --- AGENTS.md | 87 ++++++++-------- README.md | 7 +- assets/js/ai-tools.js | 178 +++++++++++++++++++------------- assets/styles/ai-tools.css | 143 ++++++++++++++++++++----- docs/index.md | 11 +- overrides/partials/actions.html | 48 +++++++-- tests/test_homepage.py | 18 ++++ 7 files changed, 335 insertions(+), 157 deletions(-) create mode 100644 tests/test_homepage.py diff --git a/AGENTS.md b/AGENTS.md index 6b689a1..045a5c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,14 +24,14 @@ redirects.json ──────┘ (含生成的 assets/js/redirects.js `generate()`(`src/nmteam_support/generator.py`)编排的完整链路: 1. 清空重建 `cache/` -2. `scanner.scan_docs()` 递归扫描 `docs/` 得到文档树(`SKIP_DIRS`/`INTERNAL_DIRS` 过滤) -3. 写入处理后的页面(非 index.md 注入贡献提示,`contributing.py`) -4. `index.py` 为每目录生成 `index.md`(自动 generated 标记 + docsList 卡片,`docslist.py` 渲染 HTML) -5. `image_pipeline.py` 用 PIL 为 assets 中每张 PNG/JPEG 生成 `.webp` 兄弟文件(质量 80;PNG 另出 256 色有损 fallback) -6. `nav.py` 生成 nav YAML,`template.py` 替换 `mkdocs-template.yml` 的 `# NAV_ARIA_START`/`# NAV_ARIA_END` 标记块写入 `mkdocs.yml` -7. `redirects.py` 读 `redirects.json` 生成 `cache/assets/js/redirects.js` -8. `llms.py` 用扫描树 + 模板 `site_url` 生成 `cache/llms.txt`(llmstxt.org 规范:H1 + blockquote + H2 分节链接,链接指向 `.md` 版本;非 md 文件会被 mkdocs 原样拷到 `site/llms.txt`) -9. copytree 到 `generated/`,交给 `mkdocs build --strict` +1. `scanner.scan_docs()` 递归扫描 `docs/` 得到文档树(`SKIP_DIRS`/`INTERNAL_DIRS` 过滤) +1. 写入处理后的页面(非 index.md 注入贡献提示,`contributing.py`) +1. `index.py` 为每目录生成 `index.md`(自动 generated 标记 + docsList 卡片,`docslist.py` 渲染 HTML) +1. `image_pipeline.py` 用 PIL 为 assets 中每张 PNG/JPEG 生成 `.webp` 兄弟文件(质量 80;PNG 另出 256 色有损 fallback) +1. `nav.py` 生成 nav YAML,`template.py` 替换 `mkdocs-template.yml` 的 `# NAV_ARIA_START`/`# NAV_ARIA_END` 标记块写入 `mkdocs.yml` +1. `redirects.py` 读 `redirects.json` 生成 `cache/assets/js/redirects.js` +1. `llms.py` 用扫描树 + 模板 `site_url` 生成 `cache/llms.txt`(llmstxt.org 规范:H1 + blockquote + H2 分节链接,链接指向 `.md` 版本;非 md 文件会被 mkdocs 原样拷到 `site/llms.txt`) +1. copytree 到 `generated/`,交给 `mkdocs build --strict` **图片双层管线**(关键机制):生成期 `image_pipeline.py` 产出同名 `.webp` 兄弟文件;渲染期 `markdown_images.py`(mkdocs 扩展,注册在 mkdocs-template.yml 的 markdown_extensions 中)把本地栅格图 `` 改写为 WebP-first ``,靠同名 `.webp` 约定对接。外部 URL 不下载不镜像。Markdown 中仍写普通图片语法。 @@ -39,18 +39,18 @@ redirects.json ──────┘ (含生成的 assets/js/redirects.js ## Key Directories -| 路径 | 用途 | -|---|---| -| `src/nmteam_support/` | 工具链包(16 个模块,见 Important Files) | -| `docs/` | **真实文档源**(唯一需要手工编辑的内容位置) | -| `docs/nmbot-telegram/` | 产品中枢:`panel/`、`plus/`、`legal/`、`group/`、`faq/`、`business/`、`tools/`、`nmbot-intelligence/`、`credit/`、`update-log/`(`YYYY-MM.md` 月度日志)、`mcp/` | -| `docs/contact-us/`、`docs/nmteam-account/` | 其他产品线 | -| `docs/superpowers/` | 本地设计与实现工件(plans/specs),**已 gitignore,勿提交** | -| `assets/images/` | 图片母版:`shared/`(站级共享)、`nmbot/`(含 `mcp/`、`update-pictures/` 子目录);`assets/icons/`(SVG)、`assets/styles/`(CSS)、`assets/js/`(AI 工具脚本 `ai-tools.js`,随构建 stage 到生成站) | -| `overrides/` | mkdocs `custom_dir`:`main.html` 覆写 site_meta 移除主题版本号;`partials/actions.html` 追加 AI 工具按钮组(Markdown / ChatGPT / Claude,毛玻璃样式在 `assets/styles/ai-tools.css`,交互在 `assets/js/ai-tools.js`) | -| `scripts/` | 三平台薄启动器(`nmteam.sh` / `nmteam.ps1` / `nmteam.bat`) | -| `tests/` | pytest 测试(16 个文件 + conftest.py) | -| `cache/`、`generated/`、`site/`、`mkdocs.yml` | 生成产物,勿手改勿提交 | +| 路径 | 用途 | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/nmteam_support/` | 工具链包(16 个模块,见 Important Files) | +| `docs/` | **真实文档源**(唯一需要手工编辑的内容位置) | +| `docs/nmbot-telegram/` | 产品中枢:`panel/`、`plus/`、`legal/`、`group/`、`faq/`、`business/`、`tools/`、`nmbot-intelligence/`、`credit/`、`update-log/`(`YYYY-MM.md` 月度日志)、`mcp/` | +| `docs/contact-us/`、`docs/nmteam-account/` | 其他产品线 | +| `docs/superpowers/` | 本地设计与实现工件(plans/specs),**已 gitignore,勿提交** | +| `assets/images/` | 图片母版:`shared/`(站级共享)、`nmbot/`(含 `mcp/`、`update-pictures/` 子目录);`assets/icons/`(SVG)、`assets/styles/`(CSS)、`assets/js/`(AI 工具脚本 `ai-tools.js`,随构建 stage 到生成站) | +| `overrides/` | mkdocs `custom_dir`:`main.html` 覆写 site_meta 移除主题版本号;`partials/actions.html` 追加 Fumadocs 风格 Open 菜单(GitHub / Markdown / Scira AI / ChatGPT / Claude / Cursor,样式在 `assets/styles/ai-tools.css`,交互在 `assets/js/ai-tools.js`) | +| `scripts/` | 三平台薄启动器(`nmteam.sh` / `nmteam.ps1` / `nmteam.bat`) | +| `tests/` | pytest 测试(17 个文件 + conftest.py) | +| `cache/`、`generated/`、`site/`、`mkdocs.yml` | 生成产物,勿手改勿提交 | ## Development Commands @@ -71,6 +71,7 @@ uv run nmteam --help 平台启动器(定位仓库根后原样透传参数,无业务逻辑):`scripts/nmteam.sh dev`、`.\scripts\nmteam.ps1 dev`、`scripts\nmteam.bat dev`。 **质量检查**(`nmteam check`,CI 同样执行): + ```bash uv run ruff check . uv run ruff format --check . @@ -101,29 +102,29 @@ Markdown 文档(`docs/`): ## Important Files -| 文件 | 职责 | -|---|---| -| `src/nmteam_support/cli.py` | Typer 入口 `main`;install/dev/generate/build/clean/check + redirects 子命令 | -| `src/nmteam_support/generator.py` | `generate()` 端到端编排 + `GeneratorOptions`/`default_options` | -| `src/nmteam_support/scanner.py` | 递归扫描 docs/ 树,SKIP_DIRS/INTERNAL_DIRS 过滤 | -| `src/nmteam_support/frontmatter.py` | frontmatter 解析(title/description/index/flag) | -| `src/nmteam_support/index.py` + `docslist.py` | 每目录 index.md 生成 + docsList 卡片 HTML | -| `src/nmteam_support/nav.py` + `template.py` | nav YAML 生成;NAV_ARIA 标记块替换写 mkdocs.yml | -| `src/nmteam_support/contributing.py` | 非 index.md 注入贡献提示 admonition | -| `src/nmteam_support/redirects.py` | redirects.json 管理(损坏保护)+ redirects.js 生成 | -| `src/nmteam_support/image_pipeline.py` + `markdown_images.py` | PIL 生成 .webp 兄弟文件;mkdocs 扩展包 WebP-first `` | -| `src/nmteam_support/llms.py` | `render_llms_txt()` 从扫描树生成 `/llms.txt`(llmstxt.org 规范;链接指向各页 `.md` 版本) | -| `src/nmteam_support/models.py` | `PageMetadata`/`DocEntry` frozen dataclass | -| `pyproject.toml` | 包元数据、依赖、入口、pytest/ruff/hatchling 配置 | -| `uv.lock` | 锁定依赖(mkdocs 1.6.1、mkdocs-material 9.7.7、mkdocs-minify-plugin 0.8.0、pillow 12.3.0、typer 0.27.1、pytest 9.1.1、ruff 0.16.2、mdformat 1.0.0 等) | -| `mkdocs-template.yml` | mkdocs 配置模板(material zh 黄色双 palette、minify 插件、custom_dir overrides、`nmteam_support.markdown_images` 扩展、NAV_ARIA 标记) | -| `redirects.json` | 顶层 `redirects` 对象:`{旧路径带斜杠: 新路径}` | -| `.github/workflows/ci.yml` | 三 OS 矩阵 CI(push main/dev + PR):uv sync --frozen → nmteam check → 验证三个启动器 | -| `.mdformat.toml` | mdformat 配置(wrap=keep、LF) | -| `overrides/main.html` | 移除 meta 中 mkdocs-material 版本号 | -| `overrides/partials/actions.html` | 覆盖 material actions partial:保留编辑/查看按钮,追加 AI 工具按钮组 | -| `assets/js/ai-tools.js` | 按钮交互:View-as-Markdown 链接(根相对 `.md` 路径)、ChatGPT/Claude 点击后 fetch 页面 `.md` 内容作为提示词打开(fetch 失败降级为仅 URL) | -| `assets/styles/ai-tools.css` | 毛玻璃按钮样式(半透明 + backdrop-filter blur + 柔和阴影,适配明暗主题) | +| 文件 | 职责 | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/nmteam_support/cli.py` | Typer 入口 `main`;install/dev/generate/build/clean/check + redirects 子命令 | +| `src/nmteam_support/generator.py` | `generate()` 端到端编排 + `GeneratorOptions`/`default_options` | +| `src/nmteam_support/scanner.py` | 递归扫描 docs/ 树,SKIP_DIRS/INTERNAL_DIRS 过滤 | +| `src/nmteam_support/frontmatter.py` | frontmatter 解析(title/description/index/flag) | +| `src/nmteam_support/index.py` + `docslist.py` | 每目录 index.md 生成 + docsList 卡片 HTML | +| `src/nmteam_support/nav.py` + `template.py` | nav YAML 生成;NAV_ARIA 标记块替换写 mkdocs.yml | +| `src/nmteam_support/contributing.py` | 非 index.md 注入贡献提示 admonition | +| `src/nmteam_support/redirects.py` | redirects.json 管理(损坏保护)+ redirects.js 生成 | +| `src/nmteam_support/image_pipeline.py` + `markdown_images.py` | PIL 生成 .webp 兄弟文件;mkdocs 扩展包 WebP-first `` | +| `src/nmteam_support/llms.py` | `render_llms_txt()` 从扫描树生成 `/llms.txt`(llmstxt.org 规范;链接指向各页 `.md` 版本) | +| `src/nmteam_support/models.py` | `PageMetadata`/`DocEntry` frozen dataclass | +| `pyproject.toml` | 包元数据、依赖、入口、pytest/ruff/hatchling 配置 | +| `uv.lock` | 锁定依赖(mkdocs 1.6.1、mkdocs-material 9.7.7、mkdocs-minify-plugin 0.8.0、pillow 12.3.0、typer 0.27.1、pytest 9.1.1、ruff 0.16.2、mdformat 1.0.0 等) | +| `mkdocs-template.yml` | mkdocs 配置模板(material zh 黄色双 palette、minify 插件、custom_dir overrides、`nmteam_support.markdown_images` 扩展、NAV_ARIA 标记) | +| `redirects.json` | 顶层 `redirects` 对象:`{旧路径带斜杠: 新路径}` | +| `.github/workflows/ci.yml` | 三 OS 矩阵 CI(push main/dev + PR):uv sync --frozen → nmteam check → 验证三个启动器 | +| `.mdformat.toml` | mdformat 配置(wrap=keep、LF) | +| `overrides/main.html` | 移除 meta 中 mkdocs-material 版本号 | +| `overrides/partials/actions.html` | 覆盖 material actions partial:保留编辑/查看按钮,追加六项 Open 操作菜单 | +| `assets/js/ai-tools.js` | 菜单交互:开关状态、外部点击/Escape 关闭、View-as-Markdown 开发模式提示,以及 Scira AI / ChatGPT / Claude / Cursor 页面 URL 深链 | +| `assets/styles/ai-tools.css` | Open 触发器与半透明弹层样式,适配明暗主题和窄屏 | ## Runtime/Tooling Preferences diff --git a/README.md b/README.md index a5c36b3..8013520 100644 --- a/README.md +++ b/README.md @@ -69,11 +69,12 @@ Markdown 中仍使用普通图片语法,构建工具会自动输出 WebP 每个链接指向页面的 Markdown 版本。 - 每页 Markdown 版本:`nmteam build` 时在每个页面旁生成同路径的 `.md` 文件 (如 `/nmbot-telegram/mcp.md`)。 -- 页面顶部的 AI 工具按钮:**Markdown**(查看本页 Markdown)、**ChatGPT** / - **Claude**(将本页内容作为上下文在 ChatGPT/Claude 中打开)。 +- 页面顶部的 **Open** 菜单:提供 GitHub 源文件、Markdown 版本,以及 + Scira AI、ChatGPT、Claude、Cursor 六种打开方式。 注意:`llms.txt` 与 `.md` 版本由 `nmteam build` 输出到 `site/`;开发模式 -(`nmteam dev`)下 ChatGPT/Claude 按钮会退化为仅携带页面链接的提示词。 +(`nmteam dev`)下 View as Markdown 会提示先构建,AI 操作仍可通过当前页面 +URL 打开。 ### 其他命令 diff --git a/assets/js/ai-tools.js b/assets/js/ai-tools.js index a67fc89..c16fc84 100644 --- a/assets/js/ai-tools.js +++ b/assets/js/ai-tools.js @@ -1,23 +1,15 @@ -// AI tools: view-as-Markdown, open in ChatGPT / Claude. -// The page's raw Markdown twin lives at the same docs-relative path with a -// ".md" suffix (e.g. /nmbot-telegram/mcp.md); it is staged into site/ by the -// build. When the twin is missing (e.g. `mkdocs serve`), ChatGPT/Claude fall -// back to a prompt that references the page URL instead of its content. +// Fumadocs-style page actions menu. (function () { "use strict"; var ENDPOINTS = { - chatgpt: "https://chatgpt.com/?q=", + scira: "https://scira.ai/?q=", + chatgpt: "https://chatgpt.com/?prompt=", claude: "https://claude.ai/new?q=", + cursor: "https://cursor.com/link/prompt?text=", }; - // Keep the prompt URL short enough for chat providers to accept. - var MAX_PROMPT = 60000; function mdUrl(raw) { - // Root-relative: a relative path would resolve against the page URL, - // e.g. /nmbot-telegram/mcp/ + "nmbot-telegram/mcp.md" -> wrong twin. - // The home page has an empty page.url and its twin is /index.md - // (already carries the ".md" suffix). if (!raw) { return "/index.md"; } @@ -28,77 +20,119 @@ return location.origin + "/" + (raw || ""); } - function buildPrompt(content, url) { - if (!content) { - return "请阅读此文档页面并回答我的问题:" + url; + function providerPrompt(url) { + return "Read " + url + ", I want to ask questions about it."; + } + + function providerUrl(provider, prompt) { + var url = ENDPOINTS[provider] + encodeURIComponent(prompt); + if (provider === "chatgpt") { + return url + "&hints=search"; } - var header = "以下是 support.nmteam.xyz 文档页面的内容,请基于此内容回答我的问题:\n\n"; - var body = content; - if (body.length > MAX_PROMPT) { - body = body.slice(0, MAX_PROMPT) + "\n\n…(内容过长已截断)"; + return url; + } + + function setOpen(box, open, restoreFocus) { + var trigger = box.querySelector(".ai-tools__trigger"); + var menu = box.querySelector(".ai-tools__menu"); + box.classList.toggle("is-open", open); + trigger.setAttribute("aria-expanded", String(open)); + menu.hidden = !open; + if (!open && restoreFocus) { + trigger.focus(); } - return header + body; } - function openAi(which, prompt) { - window.open(ENDPOINTS[which] + encodeURIComponent(prompt), "_blank", "noopener"); + function showMarkdownUnavailable() { + window.alert( + "Markdown 版本在构建产物中提供。请先运行 `uv run nmteam build`," + + "或使用其他打开方式。" + ); } - function wireFetch(button, which, md, url) { - button.addEventListener("click", function (event) { + function wireMarkdown(link, md) { + var availability = fetch(md, { method: "HEAD" }) + .then(function (response) { + var contentType = response.headers.get("Content-Type") || ""; + return response.ok && contentType.indexOf("html") === -1; + }) + .catch(function () { + return false; + }) + .then(function (available) { + link.dataset.rawAvailable = String(available); + return available; + }); + + link.href = md; + link.addEventListener("click", function (event) { + if (link.dataset.rawAvailable === "true") { + return; + } + event.preventDefault(); - fetch(md) - .then(function (response) { - return response.ok ? response.text() : ""; - }) - .catch(function () { - return ""; - }) - .then(function (content) { - openAi(which, buildPrompt(content, url)); - }); + if (link.dataset.rawAvailable === "false") { + showMarkdownUnavailable(); + return; + } + + var target = window.open("about:blank", "_blank"); + if (target) { + target.opener = null; + } + availability.then(function (available) { + if (available && target) { + target.location.replace(md); + return; + } + if (target) { + target.close(); + } + showMarkdownUnavailable(); + }); }); } - document.addEventListener("DOMContentLoaded", function () { - var box = document.querySelector(".ai-tools"); - if (!box) { - return; - } + function wireMenu(box) { + var trigger = box.querySelector(".ai-tools__trigger"); + var menu = box.querySelector(".ai-tools__menu"); var raw = box.getAttribute("data-md-url") || ""; - var md = mdUrl(raw); - var url = pageUrl(raw); - - var markdownLink = box.querySelector('[data-ai="markdown"]'); - if (markdownLink) { - markdownLink.setAttribute("href", md); - // The Markdown twins are only staged into site/ by `nmteam build`; - // under `mkdocs serve` they 404, so explain instead of dead-linking. - markdownLink.addEventListener("click", function (event) { + var markdown = menu.querySelector('[data-ai="markdown"]'); + var prompt = providerPrompt(pageUrl(raw)); + + wireMarkdown(markdown, mdUrl(raw)); + ["scira", "chatgpt", "claude", "cursor"].forEach(function (provider) { + menu.querySelector('[data-ai="' + provider + '"]').href = providerUrl( + provider, + prompt + ); + }); + + trigger.addEventListener("click", function () { + setOpen(box, menu.hidden, false); + }); + + menu.addEventListener("click", function (event) { + if (event.target.closest(".ai-tools__item")) { + setOpen(box, false, false); + } + }); + + document.addEventListener("click", function (event) { + if (!menu.hidden && !box.contains(event.target)) { + setOpen(box, false, false); + } + }); + + document.addEventListener("keydown", function (event) { + if (event.key === "Escape" && !menu.hidden) { event.preventDefault(); - fetch(md, { method: "HEAD" }).then(function (response) { - var contentType = response.headers.get("Content-Type") || ""; - // `mkdocs serve` renders .md URLs as HTML pages; only the - // static twins staged by `nmteam build` are served as - // Markdown. Navigate only for the real thing. - if (response.ok && contentType.indexOf("html") === -1) { - location.href = md; - } else { - window.alert( - "Markdown 版本在构建产物中提供。请先运行 `uv run nmteam build`," + - "或改用 ChatGPT / Claude 按钮。" - ); - } - }); - }); - } - var chatgpt = box.querySelector('[data-ai="chatgpt"]'); - if (chatgpt) { - wireFetch(chatgpt, "chatgpt", md, url); - } - var claude = box.querySelector('[data-ai="claude"]'); - if (claude) { - wireFetch(claude, "claude", md, url); - } + setOpen(box, false, true); + } + }); + } + + document.addEventListener("DOMContentLoaded", function () { + document.querySelectorAll(".ai-tools").forEach(wireMenu); }); })(); diff --git a/assets/styles/ai-tools.css b/assets/styles/ai-tools.css index 8230158..4a90b24 100644 --- a/assets/styles/ai-tools.css +++ b/assets/styles/ai-tools.css @@ -1,42 +1,129 @@ -/* AI tools: view-as-Markdown, open in ChatGPT / Claude. - Glassmorphism style (frosted translucent pill buttons), adapting to the - Material light/dark palettes via theme CSS variables. */ +/* Fumadocs-style page actions menu. */ .ai-tools { - display: inline-flex; - gap: 6px; - /* Align with Material's action buttons (edit/view) in the top-right - corner of the content area; without the float the group renders - stacked against the hero banner on the home page. */ + position: relative; + z-index: 4; float: right; - margin: 8px 8px 0 0; + margin: 0 0 12px 12px; } -.ai-tools__link { +.ai-tools__trigger { display: inline-flex; align-items: center; - padding: 4px 12px; + justify-content: center; + min-height: 30px; + gap: 8px; + padding: 5px 9px; + font: inherit; font-size: 12px; - line-height: 1.6; - color: var(--md-typeset-color); - background: color-mix(in srgb, var(--md-default-bg-color) 62%, transparent); - -webkit-backdrop-filter: blur(12px) saturate(160%); - backdrop-filter: blur(12px) saturate(160%); - border: 1px solid color-mix(in srgb, var(--md-default-fg-color) 22%, transparent); - border-radius: 999px; - box-shadow: 0 1px 4px color-mix(in srgb, var(--md-default-fg-color) 12%, transparent); + font-weight: 600; + line-height: 18px; + color: var(--md-default-fg-color); + background: var(--md-default-bg-color); + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 6px; cursor: pointer; - text-decoration: none; - transition: background .2s, color .2s, border-color .2s, box-shadow .2s; + transition: background-color 120ms ease, border-color 120ms ease; } -.ai-tools__link:hover { - color: var(--md-primary-fg-color); - background: color-mix(in srgb, var(--md-default-bg-color) 88%, var(--md-primary-fg-color)); - border-color: color-mix(in srgb, var(--md-primary-fg-color) 45%, transparent); - box-shadow: 0 2px 10px color-mix(in srgb, var(--md-primary-fg-color) 25%, transparent); +.ai-tools__trigger:hover, +.ai-tools.is-open .ai-tools__trigger { + background: var(--md-default-fg-color--lightest); + border-color: var(--md-default-fg-color--lighter); } -.ai-tools__link:focus-visible { - outline: 2px solid var(--md-primary-fg-color); +.ai-tools__trigger:focus-visible, +.ai-tools__item:focus-visible { + outline: 2px solid var(--md-accent-fg-color); outline-offset: 2px; } + +.ai-tools__chevron, +.ai-tools__item-icon, +.ai-tools__external { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; +} + +.ai-tools__chevron { + width: 14px; + height: 14px; + color: var(--md-default-fg-color--light); + transition: transform 140ms ease; +} + +.ai-tools.is-open .ai-tools__chevron { + transform: rotate(180deg); +} + +.ai-tools__menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + display: grid; + width: 216px; + max-width: calc(100vw - 32px); + padding: 8px; + color: var(--md-default-fg-color); + background: color-mix(in srgb, var(--md-default-bg-color) 88%, transparent); + -webkit-backdrop-filter: blur(16px) saturate(135%); + backdrop-filter: blur(16px) saturate(135%); + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 12px; + box-shadow: 0 16px 40px color-mix(in srgb, #000 22%, transparent); +} + +.ai-tools__menu[hidden] { + display: none; +} + +.md-typeset .ai-tools__item { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 16px; + align-items: center; + gap: 10px; + min-height: 32px; + padding: 6px 8px; + font-size: 14px; + line-height: 20px; + color: var(--md-default-fg-color); + border-radius: 8px; + text-decoration: none; + transition: color 100ms ease, background-color 100ms ease; +} + +.md-typeset .ai-tools__item:hover { + color: var(--md-default-fg-color); + background: var(--md-default-fg-color--lightest); +} + +.ai-tools__item-icon { + width: 18px; + height: 18px; +} + +.ai-tools__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-tools__external { + width: 16px; + height: 16px; + color: var(--md-default-fg-color--light); +} + +.ai-tools svg { + width: 100%; + height: 100%; + fill: currentcolor; +} + +@media (max-width: 44.984375em) { + .ai-tools { + margin-right: 4px; + } +} diff --git a/docs/index.md b/docs/index.md index d9b990d..7bc6ea2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,11 +12,11 @@ hide:

在此获取 nmTeam 旗下产品的支持。

-
+
## 产品和服务 -
+ -
+
## 关于帮助文档 @@ -48,9 +48,12 @@ nmTeam 帮助文档由 nmTeam 成员和社区志愿者共同编辑。您可以 访问 [nmTeam 官网](https://nmteam.xyz)了解 nmTeam 的最新动态和产品信息。 -
+