@xingwangzhe/stalux 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,6 +84,7 @@ bun run dev
84
84
  - 📐 **Math formula rendering** (KaTeX / MathML)
85
85
  - 💬 **Waline** comment system
86
86
  - 🤖 **LLM discovery files** (llms.txt / llms-full.txt)
87
+ - 🤝 **WebMCP tools** for AI agents (W3C draft, pure front-end)
87
88
  - ⚡ **View transitions** for smooth navigation
88
89
  - 🌐 **i18n** (English / Chinese)
89
90
  - 🏷️ **Tags, categories, archives** pages
@@ -162,6 +163,34 @@ Full documentation and live demo: **[stalux.needhelp.icu](https://stalux.needhel
162
163
 
163
164
  ---
164
165
 
166
+ ## 🤝 WebMCP / AI Agents
167
+
168
+ Stalux ships built-in WebMCP tools that let AI agents (browsers with
169
+ `document.modelContext`, e.g. Chrome's built-in Gemini or the [Ask nekuda](https://chromewebstore.google.com/detail/ask-nekuda/amochnnbmnkjjlblolhpddkokhnalkjp)
170
+ extension) interact with your blog directly — **no backend required**.
171
+
172
+ When a WebMCP-aware browser opens your site, these read-only tools are registered:
173
+
174
+ | Tool | What it does | Backing data |
175
+ | --------------------- | ----------------------------------------------------------------------- | ------------------------- |
176
+ | `stalux_list_posts` | Paginated list of all posts | `/api/post.abbrlink.json` |
177
+ | `stalux_search_posts` | Full-text search across posts | Pagefind `/pagefind/` |
178
+ | `stalux_read_post` | Fetch a post's raw Markdown | `/posts/{abbrlink}.md` |
179
+ | `stalux_site_info` | Site title, URL, description + pointers to `llms.txt` / `llms-full.txt` | `site.yml` (build-time) |
180
+
181
+ All tools are `readOnlyHint: true` — they never modify any state.
182
+
183
+ **Enabling / disabling:** the tools follow the `conformance` setting in
184
+ `stalux/config/ai-discovery.yml`. Set it to `disabled` to stop registering
185
+ tools; `essential` / `recommended` / `complete` all enable them.
186
+
187
+ **Browser support:** WebMCP is a W3C community-group draft (Chrome 149 Origin
188
+ Trial). On browsers without a native `modelContext`, Stalux loads the
189
+ [`@mcp-b/webmcp-polyfill`](https://www.npmjs.com/package/@mcp-b/webmcp-polyfill)
190
+ so agents still work; the polyfill becomes a no-op once native support lands.
191
+
192
+ ---
193
+
165
194
  ## 📄 License
166
195
 
167
196
  MIT License — see [LICENSE](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -78,6 +78,7 @@
78
78
  "@astrojs/sitemap": "^3.7.3",
79
79
  "@expressive-code/plugin-line-numbers": "^0.44.1",
80
80
  "@lucide/astro": "^1.28.0",
81
+ "@mcp-b/webmcp-polyfill": "^4.0.0",
81
82
  "@pagefind/component-ui": "^1.5.2",
82
83
  "@waline/client": "^3.15.2",
83
84
  "@xingwangzhe/satteri-mermaid": "0.7.3",
@@ -4,7 +4,7 @@ import Footer from "@components/stalux/layout/footer.astro";
4
4
  import { getCollection } from "astro:content";
5
5
  import "@styles/base/init.css";
6
6
  import "@styles/shared/stagger.css";
7
- import { getAuthorData, getPromoteData, getSiteData } from "@utils/config-utils";
7
+ import { getAuthorData, getAiDiscoveryData, getPromoteData, getSiteData } from "@utils/config-utils";
8
8
  const props = Astro.props as Props;
9
9
 
10
10
  interface Props {
@@ -26,6 +26,7 @@ const configEntries = await getCollection("config");
26
26
  const siteConfig = getSiteData(configEntries);
27
27
  const authorConfig = getAuthorData(configEntries);
28
28
  const promoteConfig = getPromoteData(configEntries);
29
+ const aiConfig = getAiDiscoveryData(configEntries);
29
30
  const htmlLang = siteConfig.lang || "zh-CN";
30
31
 
31
32
  // Resolve font subset CSS URL
@@ -78,6 +79,24 @@ const llmPromote = llmPromoteRaw
78
79
  <script>
79
80
  import "../scripts/background.ts";
80
81
  </script>
82
+ {
83
+ aiConfig?.conformance !== "disabled" && (
84
+ <>
85
+ <script
86
+ define:vars={{
87
+ siteTitle: siteConfig.title,
88
+ siteUrl: siteConfig.url,
89
+ siteDesc: siteConfig.description,
90
+ }}
91
+ >
92
+ window.__STALUX_SITE_INFO__ = { title: siteTitle, url: siteUrl, description: siteDesc };
93
+ </script>
94
+ <script>
95
+ import "../scripts/webmcp.ts";
96
+ </script>
97
+ </>
98
+ )
99
+ }
81
100
  <body
82
101
  class="stalux-root dark"
83
102
  data-stalux-bg-layer="a"
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Stalux WebMCP 工具注册(纯前端,零后端)
3
+ *
4
+ * 遵循 W3C webmachinelearning/webmcp 草案:网页通过 document.modelContext
5
+ * 向浏览器/代理注册工具。所有数据源均为构建期静态产物——
6
+ * /api/post.abbrlink.json(文章索引)、/posts/{id}.md(源码导出)、
7
+ * /llms.txt / /llms-full.txt(站点信息镜像)、/pagefind/(全文索引)。
8
+ *
9
+ * 浏览器无原生 modelContext 时,由布局注入的 @mcp-b/webmcp-polyfill
10
+ * 提供兜底实现;两者都没有则静默跳过注册,不影响普通访问者。
11
+ *
12
+ * Spec: https://github.com/webmachinelearning/webmcp
13
+ */
14
+ // 浏览器无原生 document.modelContext 时由 polyfill 兜底安装;
15
+ // 有原生实现或已存在 WebMCP-aware 扩展时,polyfill 检测到后自动 no-op。
16
+ // 放在模块顶部 import,保证其副作用在下方注册逻辑执行前完成。
17
+ import "@mcp-b/webmcp-polyfill";
18
+
19
+ declare global {
20
+ interface Window {
21
+ __STALUX_SITE_INFO__?: {
22
+ title?: string;
23
+ url?: string;
24
+ description?: string;
25
+ };
26
+ }
27
+ }
28
+
29
+ /** 规范(index.bs)中 ModelContextTool 字典的最小类型子集 */
30
+ interface WebMCPTool {
31
+ name: string;
32
+ title?: string;
33
+ description: string;
34
+ inputSchema?: object;
35
+ annotations?: {
36
+ readOnlyHint?: boolean;
37
+ untrustedContentHint?: boolean;
38
+ };
39
+ execute: (input: Record<string, unknown>) => Promise<unknown>;
40
+ }
41
+
42
+ interface ModelContext {
43
+ registerTool: (tool: WebMCPTool, options?: { signal?: AbortSignal }) => Promise<undefined>;
44
+ }
45
+
46
+ interface DocumentWithModelContext extends Document {
47
+ modelContext?: ModelContext;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // 工具执行辅助
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** 统一错误包装:返回结构化错误而非抛异常,便于 Agent 读取 */
55
+ function err(
56
+ code: string,
57
+ message: string,
58
+ extra?: Record<string, unknown>,
59
+ ): { ok: false; code: string; message: string } & Record<string, unknown> {
60
+ return { ok: false, code, message, ...extra };
61
+ }
62
+
63
+ async function fetchJSON(url: string): Promise<unknown> {
64
+ const r = await fetch(url, { headers: { Accept: "application/json" } });
65
+ if (!r.ok) return null;
66
+ return r.json();
67
+ }
68
+
69
+ /** 动态加载 Pagefind 全文索引(仅首次调用,结果跨调用缓存) */
70
+ let pagefindLoadPromise: Promise<{ search: (q: string) => Promise<unknown> } | null> | null = null;
71
+ async function loadPagefind(): Promise<{ search: (q: string) => Promise<unknown> } | null> {
72
+ if (!pagefindLoadPromise) {
73
+ pagefindLoadPromise = (async () => {
74
+ try {
75
+ // @vite-ignore: pagefind 索引是构建期产物,不参与依赖打包
76
+ const mod = await import(/* @vite-ignore */ "/pagefind/pagefind.js");
77
+ return mod.default ?? mod;
78
+ } catch {
79
+ return null;
80
+ }
81
+ })();
82
+ }
83
+ return pagefindLoadPromise;
84
+ }
85
+
86
+ const MAX_LIST_PAGE_SIZE = 50;
87
+ const MAX_SEARCH_RESULTS = 10;
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // 工具定义
91
+ // ---------------------------------------------------------------------------
92
+
93
+ interface PostIndexEntry {
94
+ title: string;
95
+ abbrlink: string | number;
96
+ }
97
+
98
+ function listPostsTool(): WebMCPTool {
99
+ return {
100
+ name: "stalux_list_posts",
101
+ title: "列出博客文章",
102
+ description:
103
+ "分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink)、文章页 URL。" +
104
+ "需要浏览全站文章、确认某篇文章的 abbrlink 时使用;不返回正文," +
105
+ "正文请用 stalux_read_post。",
106
+ annotations: { readOnlyHint: true, untrustedContentHint: false },
107
+ inputSchema: {
108
+ type: "object",
109
+ properties: {
110
+ page: { type: "integer", minimum: 1, default: 1, description: "页码,从 1 开始" },
111
+ pageSize: {
112
+ type: "integer",
113
+ minimum: 1,
114
+ maximum: MAX_LIST_PAGE_SIZE,
115
+ default: 10,
116
+ description: "每页条数,最大 " + MAX_LIST_PAGE_SIZE,
117
+ },
118
+ },
119
+ additionalProperties: false,
120
+ },
121
+ execute: async (input) => {
122
+ const page = Math.max(1, Number(input.page) || 1);
123
+ const pageSize = Math.min(
124
+ MAX_LIST_PAGE_SIZE,
125
+ Math.max(1, Number(input.pageSize) || 10),
126
+ );
127
+ const index = (await fetchJSON("/api/post.abbrlink.json")) as PostIndexEntry[] | null;
128
+ if (!Array.isArray(index)) return err("INDEX_UNAVAILABLE", "无法获取文章列表");
129
+ const total = index.length;
130
+ const totalPages = Math.ceil(total / pageSize) || 1;
131
+ const start = (page - 1) * pageSize;
132
+ if (start >= total) {
133
+ return err(
134
+ "OUT_OF_RANGE",
135
+ "第 " + page + " 页没有文章(共 " + totalPages + " 页)",
136
+ {
137
+ page,
138
+ totalPages,
139
+ total,
140
+ },
141
+ );
142
+ }
143
+ const posts = index.slice(start, start + pageSize).map((p) => ({
144
+ title: p.title,
145
+ abbrlink: String(p.abbrlink),
146
+ url: "/posts/" + p.abbrlink + "/",
147
+ }));
148
+ return { ok: true, page, pageSize, total, totalPages, posts };
149
+ },
150
+ };
151
+ }
152
+
153
+ function searchPostsTool(): WebMCPTool {
154
+ return {
155
+ name: "stalux_search_posts",
156
+ title: "搜索博客文章",
157
+ description:
158
+ "用关键词搜索博客文章的标题、标签、分类与正文全文,返回命中文章的标题、链接与摘要片段。" +
159
+ "适合「博客里写过 X 吗」「帮我找一下关于 Y 的文章」这类问题。",
160
+ annotations: { readOnlyHint: true, untrustedContentHint: false },
161
+ inputSchema: {
162
+ type: "object",
163
+ properties: {
164
+ keyword: { type: "string", description: "搜索关键词" },
165
+ limit: {
166
+ type: "integer",
167
+ minimum: 1,
168
+ maximum: MAX_SEARCH_RESULTS,
169
+ default: 10,
170
+ description: "最多返回条数",
171
+ },
172
+ },
173
+ required: ["keyword"],
174
+ additionalProperties: false,
175
+ },
176
+ execute: async (input) => {
177
+ const keyword = String(input.keyword ?? "").trim();
178
+ if (!keyword) return err("BAD_INPUT", "请提供搜索关键词");
179
+ const limit = Math.min(MAX_SEARCH_RESULTS, Math.max(1, Number(input.limit) || 10));
180
+ const pagefind = await loadPagefind();
181
+ if (!pagefind) return err("INDEX_UNAVAILABLE", "全文搜索索引不可用");
182
+ const res = (await pagefind.search(keyword)) as {
183
+ results?: Array<{ data: () => Promise<unknown> }>;
184
+ };
185
+ if (!res || !Array.isArray(res.results)) {
186
+ return err("SEARCH_FAILED", "搜索失败,请重试");
187
+ }
188
+ const items = await Promise.all(
189
+ res.results.slice(0, limit).map(async (r) => {
190
+ try {
191
+ const data = (await r.data()) as {
192
+ url?: string;
193
+ title?: string;
194
+ excerpt?: string;
195
+ };
196
+ return {
197
+ url: data?.url ?? "",
198
+ title: data?.title ?? "",
199
+ excerpt: (data?.excerpt ?? "").slice(0, 240),
200
+ };
201
+ } catch {
202
+ return null;
203
+ }
204
+ }),
205
+ );
206
+ const posts = items.filter((x): x is NonNullable<typeof x> => x !== null);
207
+ return { ok: true, keyword, count: posts.length, posts };
208
+ },
209
+ };
210
+ }
211
+
212
+ function readPostTool(): WebMCPTool {
213
+ return {
214
+ name: "stalux_read_post",
215
+ title: "读取文章 Markdown",
216
+ description:
217
+ "按 abbrlink(文章永久链接 ID)读取一篇博客文章的原始 Markdown 全文,包含 frontmatter 与版权脚注。" +
218
+ "需要文章完整内容、引用或摘要时使用;获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
219
+ annotations: { readOnlyHint: true, untrustedContentHint: false },
220
+ inputSchema: {
221
+ type: "object",
222
+ properties: {
223
+ id: { type: "string", description: "文章的 abbrlink,如 f4442947" },
224
+ },
225
+ required: ["id"],
226
+ additionalProperties: false,
227
+ },
228
+ execute: async (input) => {
229
+ const id = String(input.id ?? "").trim();
230
+ if (!id) return err("BAD_INPUT", "请提供文章 abbrlink");
231
+ const url = "/posts/" + encodeURIComponent(id) + ".md";
232
+ const r = await fetch(url, { headers: { Accept: "text/markdown" } });
233
+ if (r.status === 404) return err("NOT_FOUND", "未找到文章: " + id);
234
+ if (!r.ok) return err("FETCH_FAILED", "无法获取文章 Markdown:" + url);
235
+ const markdown = await r.text();
236
+ return { ok: true, url, markdown: markdown.slice(0, 50000) };
237
+ },
238
+ };
239
+ }
240
+
241
+ function siteInfoTool(): WebMCPTool {
242
+ return {
243
+ name: "stalux_site_info",
244
+ title: "站点信息",
245
+ description:
246
+ "返回博客的站点信息(标题、简介、站点 URL),并给出更完整的机器可读内容入口:" +
247
+ "/llms.txt(站点导航与文章链接列表)与 /llms-full.txt(全站 Markdown 镜像,含全部文章正文)。" +
248
+ "适合需要了解博客主题、定位内容、或批量获取全站数据时使用。",
249
+ annotations: { readOnlyHint: true, untrustedContentHint: false },
250
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
251
+ execute: async () => {
252
+ const info = window.__STALUX_SITE_INFO__ ?? {};
253
+ return {
254
+ ok: true,
255
+ title: info.title ?? "",
256
+ url: info.url ?? "",
257
+ description: info.description ?? "",
258
+ llms: (info.url ?? "") + "/llms.txt",
259
+ llmsFull: (info.url ?? "") + "/llms-full.txt",
260
+ };
261
+ },
262
+ };
263
+ }
264
+
265
+ function buildTools(): WebMCPTool[] {
266
+ return [listPostsTool(), searchPostsTool(), readPostTool(), siteInfoTool()];
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // 注册逻辑
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /** 当前注册批次使用的 AbortController,软导航时先 abort 注销再重注册 */
274
+ let activeController: AbortController | null = null;
275
+
276
+ async function registerTools(): Promise<{ registered: string[]; reason?: string }> {
277
+ const doc = document as DocumentWithModelContext;
278
+ const ctx = doc.modelContext;
279
+ if (!ctx || typeof ctx.registerTool !== "function") {
280
+ return { registered: [], reason: "unsupported" };
281
+ }
282
+
283
+ // 注销上一轮注册(View Transitions 软导航后 modelContext 可能已重置)
284
+ if (activeController) {
285
+ try {
286
+ activeController.abort();
287
+ } catch {
288
+ /* 忽略注销异常 */
289
+ }
290
+ }
291
+ const controller = new AbortController();
292
+ activeController = controller;
293
+
294
+ const tools = buildTools();
295
+ const registered: string[] = [];
296
+ // 逐个注册:单个失败不中断其余工具
297
+ for (const tool of tools) {
298
+ try {
299
+ await ctx.registerTool(tool, { signal: controller.signal });
300
+ registered.push(tool.name);
301
+ } catch (e) {
302
+ console.warn("[stalux/webmcp] 注册失败: " + tool.name, e);
303
+ }
304
+ }
305
+ return { registered };
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // 挂载与自动注册
310
+ // ---------------------------------------------------------------------------
311
+
312
+ if (typeof document !== "undefined") {
313
+ // 首屏加载 + View Transitions 软导航都执行(soft navigation 时页面状态会重建)
314
+ document.addEventListener("astro:page-load", () => {
315
+ void registerTools().then((r) => {
316
+ if (r.reason === "unsupported") return; // 无 WebMCP 环境,静默跳过
317
+ if (r.registered.length) {
318
+ console.info(
319
+ "[stalux/webmcp] 已注册 " +
320
+ r.registered.length +
321
+ " 个工具: " +
322
+ r.registered.join(", "),
323
+ );
324
+ }
325
+ });
326
+ });
327
+ }
328
+
329
+ export {};