@xingwangzhe/stalux 1.10.0 → 1.12.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 +32 -0
- package/package.json +2 -1
- package/src/index.ts +4 -0
- package/src/layouts/Stalux.astro +20 -1
- package/src/pages/api/posts.json.ts +33 -0
- package/src/scripts/webmcp.ts +469 -0
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,37 @@ 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 (with meta) | `/api/posts.json` |
|
|
177
|
+
| `stalux_get_post` | Fetch one post's metadata by abbrlink or title keyword | `/api/posts.json` |
|
|
178
|
+
| `stalux_current_post` | Metadata of the post currently being viewed | `/api/posts.json` |
|
|
179
|
+
| `stalux_random_post` | Pick a random post's metadata | `/api/posts.json` |
|
|
180
|
+
| `stalux_search_posts` | Full-text search across posts | Pagefind `/pagefind/` |
|
|
181
|
+
| `stalux_read_post` | Fetch a post's raw Markdown | `/posts/{abbrlink}.md` |
|
|
182
|
+
| `stalux_site_info` | Site title, URL, description + pointers to `llms.txt` / `llms-full.txt` | `site.yml` (build-time) |
|
|
183
|
+
|
|
184
|
+
All tools are `readOnlyHint: true` — they never modify any state.
|
|
185
|
+
|
|
186
|
+
**Enabling / disabling:** the tools follow the `conformance` setting in
|
|
187
|
+
`stalux/config/ai-discovery.yml`. Set it to `disabled` to stop registering
|
|
188
|
+
tools; `essential` / `recommended` / `complete` all enable them.
|
|
189
|
+
|
|
190
|
+
**Browser support:** WebMCP is a W3C community-group draft (Chrome 149 Origin
|
|
191
|
+
Trial). On browsers without a native `modelContext`, Stalux loads the
|
|
192
|
+
[`@mcp-b/webmcp-polyfill`](https://www.npmjs.com/package/@mcp-b/webmcp-polyfill)
|
|
193
|
+
so agents still work; the polyfill becomes a no-op once native support lands.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
165
197
|
## 📄 License
|
|
166
198
|
|
|
167
199
|
MIT License — see [LICENSE](./LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingwangzhe/stalux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.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",
|
package/src/index.ts
CHANGED
|
@@ -68,6 +68,10 @@ function getRoutes(baseDir: URL): RouteEntry[] {
|
|
|
68
68
|
pattern: "/api/post.abbrlink.json",
|
|
69
69
|
entrypoint: resolvePage("./pages/api/post.abbrlink.json.ts"),
|
|
70
70
|
},
|
|
71
|
+
{
|
|
72
|
+
pattern: "/api/posts.json",
|
|
73
|
+
entrypoint: resolvePage("./pages/api/posts.json.ts"),
|
|
74
|
+
},
|
|
71
75
|
|
|
72
76
|
// ---- Markdown 导出端点 ----
|
|
73
77
|
{ pattern: "/index.md", entrypoint: resolvePage("./pages/index.md.ts") },
|
package/src/layouts/Stalux.astro
CHANGED
|
@@ -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,33 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { getCollection } from "astro:content";
|
|
3
|
+
|
|
4
|
+
export const prerender = true;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 文章元信息索引(供 WebMCP 工具与外部消费)
|
|
8
|
+
*
|
|
9
|
+
* 相比 /api/post.abbrlink.json,包含完整元信息(日期/分类/标签/摘要/字数),
|
|
10
|
+
* 是 stalux_get_post / stalux_random_post 等 WebMCP 工具的廉价数据源。
|
|
11
|
+
*/
|
|
12
|
+
export const GET: APIRoute = async () => {
|
|
13
|
+
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
14
|
+
|
|
15
|
+
const payload = posts.map((post) => ({
|
|
16
|
+
title: post.data.title,
|
|
17
|
+
abbrlink: String(post.data.abbrlink),
|
|
18
|
+
date: post.data.date ?? undefined,
|
|
19
|
+
updated: post.data.updated ?? undefined,
|
|
20
|
+
tags: post.data.tags ?? [],
|
|
21
|
+
categories: post.data.categories ?? [],
|
|
22
|
+
desc: post.data.desc ?? "",
|
|
23
|
+
wordCount: post.data.wordCount ?? undefined,
|
|
24
|
+
url: `/posts/${post.data.abbrlink}/`,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
return new Response(JSON.stringify(payload), {
|
|
28
|
+
status: 200,
|
|
29
|
+
headers: {
|
|
30
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
};
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stalux WebMCP 工具注册(纯前端,零后端)
|
|
3
|
+
*
|
|
4
|
+
* 遵循 W3C webmachinelearning/webmcp 草案:网页通过 document.modelContext
|
|
5
|
+
* 向浏览器/代理注册工具。所有数据源均为构建期静态产物——
|
|
6
|
+
* /api/posts.json(文章元信息索引)、/api/post.abbrlink.json(兼容列表)、
|
|
7
|
+
* /posts/{id}.md(源码导出)、/llms.txt / /llms-full.txt(站点信息镜像)、
|
|
8
|
+
* /pagefind/(全文索引)。
|
|
9
|
+
*
|
|
10
|
+
* 浏览器无原生 modelContext 时,由布局注入的 @mcp-b/webmcp-polyfill
|
|
11
|
+
* 提供兜底实现;两者都没有则静默跳过注册,不影响普通访问者。
|
|
12
|
+
*
|
|
13
|
+
* Spec: https://github.com/webmachinelearning/webmcp
|
|
14
|
+
*/
|
|
15
|
+
// 浏览器无原生 document.modelContext 时由 polyfill 兜底安装;
|
|
16
|
+
// 有原生实现或已存在 WebMCP-aware 扩展时,polyfill 检测到后自动 no-op。
|
|
17
|
+
// 放在模块顶部 import,保证其副作用在下方注册逻辑执行前完成。
|
|
18
|
+
import "@mcp-b/webmcp-polyfill";
|
|
19
|
+
|
|
20
|
+
declare global {
|
|
21
|
+
interface Window {
|
|
22
|
+
__STALUX_SITE_INFO__?: {
|
|
23
|
+
title?: string;
|
|
24
|
+
url?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 规范(index.bs)中 ModelContextTool 字典的最小类型子集 */
|
|
31
|
+
interface WebMCPTool {
|
|
32
|
+
name: string;
|
|
33
|
+
title?: string;
|
|
34
|
+
description: string;
|
|
35
|
+
inputSchema?: object;
|
|
36
|
+
annotations?: {
|
|
37
|
+
readOnlyHint?: boolean;
|
|
38
|
+
untrustedContentHint?: boolean;
|
|
39
|
+
};
|
|
40
|
+
execute: (input: Record<string, unknown>) => Promise<unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ModelContext {
|
|
44
|
+
registerTool: (tool: WebMCPTool, options?: { signal?: AbortSignal }) => Promise<undefined>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface DocumentWithModelContext extends Document {
|
|
48
|
+
modelContext?: ModelContext;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// 工具执行辅助
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** 统一错误包装:返回结构化错误而非抛异常,便于 Agent 读取 */
|
|
56
|
+
function err(
|
|
57
|
+
code: string,
|
|
58
|
+
message: string,
|
|
59
|
+
extra?: Record<string, unknown>,
|
|
60
|
+
): { ok: false; code: string; message: string } & Record<string, unknown> {
|
|
61
|
+
return { ok: false, code, message, ...extra };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function fetchJSON(url: string): Promise<unknown> {
|
|
65
|
+
const r = await fetch(url, { headers: { Accept: "application/json" } });
|
|
66
|
+
if (!r.ok) return null;
|
|
67
|
+
return r.json();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 动态加载 Pagefind 全文索引(仅首次调用,结果跨调用缓存) */
|
|
71
|
+
let pagefindLoadPromise: Promise<{ search: (q: string) => Promise<unknown> } | null> | null = null;
|
|
72
|
+
async function loadPagefind(): Promise<{ search: (q: string) => Promise<unknown> } | null> {
|
|
73
|
+
if (!pagefindLoadPromise) {
|
|
74
|
+
pagefindLoadPromise = (async () => {
|
|
75
|
+
try {
|
|
76
|
+
// @vite-ignore: pagefind 索引是构建期产物,不参与依赖打包
|
|
77
|
+
const mod = await import(/* @vite-ignore */ "/pagefind/pagefind.js");
|
|
78
|
+
return mod.default ?? mod;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
}
|
|
84
|
+
return pagefindLoadPromise;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const MAX_LIST_PAGE_SIZE = 50;
|
|
88
|
+
const MAX_SEARCH_RESULTS = 10;
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// 文章元信息数据层(/api/posts.json)
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
interface PostMeta {
|
|
95
|
+
title: string;
|
|
96
|
+
abbrlink: string;
|
|
97
|
+
date?: string;
|
|
98
|
+
updated?: string;
|
|
99
|
+
tags?: string[];
|
|
100
|
+
categories?: string[];
|
|
101
|
+
desc?: string;
|
|
102
|
+
wordCount?: number;
|
|
103
|
+
url: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let postsMetaPromise: Promise<PostMeta[] | null> | null = null;
|
|
107
|
+
|
|
108
|
+
/** 加载文章元信息索引(惰性 + 缓存) */
|
|
109
|
+
function loadPostsMeta(): Promise<PostMeta[] | null> {
|
|
110
|
+
if (!postsMetaPromise) {
|
|
111
|
+
postsMetaPromise = fetchJSON("/api/posts.json").then((data) =>
|
|
112
|
+
Array.isArray(data) ? (data as PostMeta[]) : null,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return postsMetaPromise;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 按 abbrlink 精确查找 */
|
|
119
|
+
function findById(list: PostMeta[], id: string): PostMeta | undefined {
|
|
120
|
+
return list.find((p) => p.abbrlink === id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 按标题/标签关键词模糊查找(取第一条) */
|
|
124
|
+
function findByKeyword(list: PostMeta[], keyword: string): PostMeta | undefined {
|
|
125
|
+
const kw = keyword.toLowerCase();
|
|
126
|
+
return (
|
|
127
|
+
list.find((p) => p.title.toLowerCase().includes(kw)) ??
|
|
128
|
+
list.find((p) => (p.tags ?? []).some((t) => t.toLowerCase().includes(kw)))
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 简化为输出用的元信息视图 */
|
|
133
|
+
function briefMeta(p: PostMeta): Record<string, unknown> {
|
|
134
|
+
return {
|
|
135
|
+
title: p.title,
|
|
136
|
+
abbrlink: p.abbrlink,
|
|
137
|
+
date: p.date ?? undefined,
|
|
138
|
+
tags: p.tags ?? [],
|
|
139
|
+
categories: p.categories ?? [],
|
|
140
|
+
desc: p.desc ?? undefined,
|
|
141
|
+
wordCount: p.wordCount ?? undefined,
|
|
142
|
+
url: p.url,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 从当前 URL 解析 abbrlink(/posts/{abbrlink}/ 或 /posts/{abbrlink}.md) */
|
|
147
|
+
function currentAbbrlinkFromPath(): string | null {
|
|
148
|
+
const m = location.pathname.match(/^\/posts\/([^/]+?)(?:\.md)?\/?$/);
|
|
149
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// 工具定义
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
function listPostsTool(): WebMCPTool {
|
|
157
|
+
return {
|
|
158
|
+
name: "stalux_list_posts",
|
|
159
|
+
title: "列出博客文章",
|
|
160
|
+
description:
|
|
161
|
+
"分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink)、日期、分类、标签、摘要与文章页 URL。" +
|
|
162
|
+
"需要浏览全站文章、确认某篇文章的 abbrlink 时使用;不返回正文," +
|
|
163
|
+
"只要某篇元信息用 stalux_get_post,读正文请用 stalux_read_post。",
|
|
164
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
165
|
+
inputSchema: {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: {
|
|
168
|
+
page: { type: "integer", minimum: 1, default: 1, description: "页码,从 1 开始" },
|
|
169
|
+
pageSize: {
|
|
170
|
+
type: "integer",
|
|
171
|
+
minimum: 1,
|
|
172
|
+
maximum: MAX_LIST_PAGE_SIZE,
|
|
173
|
+
default: 10,
|
|
174
|
+
description: "每页条数,最大 " + MAX_LIST_PAGE_SIZE,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
additionalProperties: false,
|
|
178
|
+
},
|
|
179
|
+
execute: async (input) => {
|
|
180
|
+
const page = Math.max(1, Number(input.page) || 1);
|
|
181
|
+
const pageSize = Math.min(
|
|
182
|
+
MAX_LIST_PAGE_SIZE,
|
|
183
|
+
Math.max(1, Number(input.pageSize) || 10),
|
|
184
|
+
);
|
|
185
|
+
const list = await loadPostsMeta();
|
|
186
|
+
if (!list) return err("INDEX_UNAVAILABLE", "无法获取文章列表");
|
|
187
|
+
const total = list.length;
|
|
188
|
+
const totalPages = Math.ceil(total / pageSize) || 1;
|
|
189
|
+
const start = (page - 1) * pageSize;
|
|
190
|
+
if (start >= total) {
|
|
191
|
+
return err(
|
|
192
|
+
"OUT_OF_RANGE",
|
|
193
|
+
"第 " + page + " 页没有文章(共 " + totalPages + " 页)",
|
|
194
|
+
{
|
|
195
|
+
page,
|
|
196
|
+
totalPages,
|
|
197
|
+
total,
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const posts = list.slice(start, start + pageSize).map(briefMeta);
|
|
202
|
+
return { ok: true, page, pageSize, total, totalPages, posts };
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function getPostTool(): WebMCPTool {
|
|
208
|
+
return {
|
|
209
|
+
name: "stalux_get_post",
|
|
210
|
+
title: "查看文章信息",
|
|
211
|
+
description:
|
|
212
|
+
"按 abbrlink 或标题关键词定位一篇文章,返回其元信息(标题、日期、分类、标签、摘要、字数、文章页 URL)。" +
|
|
213
|
+
"只要元信息时用它,比读取正文便宜得多;读正文请用 stalux_read_post。" +
|
|
214
|
+
"获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
|
|
215
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
id: {
|
|
220
|
+
type: "string",
|
|
221
|
+
description:
|
|
222
|
+
"文章 abbrlink(如 f4442947)或标题关键词(模糊匹配,取第一条命中)",
|
|
223
|
+
},
|
|
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 list = await loadPostsMeta();
|
|
232
|
+
if (!list) return err("INDEX_UNAVAILABLE", "无法获取文章索引");
|
|
233
|
+
const post = findById(list, id) ?? findByKeyword(list, id);
|
|
234
|
+
if (!post) return err("NOT_FOUND", "未找到文章: " + id);
|
|
235
|
+
return { ok: true, post: briefMeta(post) };
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function currentPostTool(): WebMCPTool {
|
|
241
|
+
return {
|
|
242
|
+
name: "stalux_current_post",
|
|
243
|
+
title: "当前文章信息",
|
|
244
|
+
description:
|
|
245
|
+
"返回用户当前正在浏览的那篇文章的元信息。当用户说「这篇文章」「当前页面」时先调用它确定上下文," +
|
|
246
|
+
"比读正文便宜得多。不在文章页时返回错误。",
|
|
247
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
248
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
249
|
+
execute: async () => {
|
|
250
|
+
const id = currentAbbrlinkFromPath();
|
|
251
|
+
if (!id) {
|
|
252
|
+
return err("NOT_ON_POST_PAGE", "当前不在文章页(路径: " + location.pathname + ")");
|
|
253
|
+
}
|
|
254
|
+
const list = await loadPostsMeta();
|
|
255
|
+
if (!list) return err("INDEX_UNAVAILABLE", "无法获取文章索引");
|
|
256
|
+
const post = findById(list, id);
|
|
257
|
+
if (!post) return err("NOT_FOUND", "未找到当前文章: " + id);
|
|
258
|
+
return { ok: true, post: briefMeta(post) };
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function randomPostTool(): WebMCPTool {
|
|
264
|
+
return {
|
|
265
|
+
name: "stalux_random_post",
|
|
266
|
+
title: "随机一篇文章",
|
|
267
|
+
description:
|
|
268
|
+
"随机挑选一篇文章并返回其元信息(标题、abbrlink、日期、摘要、URL)。" +
|
|
269
|
+
"适合推荐、探索、或不确定从哪篇开始时使用;不返回正文,不导航。",
|
|
270
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
271
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
272
|
+
execute: async () => {
|
|
273
|
+
const list = await loadPostsMeta();
|
|
274
|
+
if (!list || list.length === 0) {
|
|
275
|
+
return err("INDEX_UNAVAILABLE", "无法获取文章索引");
|
|
276
|
+
}
|
|
277
|
+
const post = list[Math.floor(Math.random() * list.length)];
|
|
278
|
+
return { ok: true, post: briefMeta(post) };
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function searchPostsTool(): WebMCPTool {
|
|
284
|
+
return {
|
|
285
|
+
name: "stalux_search_posts",
|
|
286
|
+
title: "搜索博客文章",
|
|
287
|
+
description:
|
|
288
|
+
"用关键词搜索博客文章的标题、标签、分类与正文全文,返回命中文章的标题、链接与摘要片段。" +
|
|
289
|
+
"适合「博客里写过 X 吗」「帮我找一下关于 Y 的文章」这类问题;" +
|
|
290
|
+
"定位到具体文章后再用 stalux_read_post 读正文、stalux_get_post 取元信息。",
|
|
291
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
292
|
+
inputSchema: {
|
|
293
|
+
type: "object",
|
|
294
|
+
properties: {
|
|
295
|
+
keyword: { type: "string", description: "搜索关键词" },
|
|
296
|
+
limit: {
|
|
297
|
+
type: "integer",
|
|
298
|
+
minimum: 1,
|
|
299
|
+
maximum: MAX_SEARCH_RESULTS,
|
|
300
|
+
default: 10,
|
|
301
|
+
description: "最多返回条数",
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
required: ["keyword"],
|
|
305
|
+
additionalProperties: false,
|
|
306
|
+
},
|
|
307
|
+
execute: async (input) => {
|
|
308
|
+
const keyword = String(input.keyword ?? "").trim();
|
|
309
|
+
if (!keyword) return err("BAD_INPUT", "请提供搜索关键词");
|
|
310
|
+
const limit = Math.min(MAX_SEARCH_RESULTS, Math.max(1, Number(input.limit) || 10));
|
|
311
|
+
const pagefind = await loadPagefind();
|
|
312
|
+
if (!pagefind) return err("INDEX_UNAVAILABLE", "全文搜索索引不可用");
|
|
313
|
+
const res = (await pagefind.search(keyword)) as {
|
|
314
|
+
results?: Array<{ data: () => Promise<unknown> }>;
|
|
315
|
+
};
|
|
316
|
+
if (!res || !Array.isArray(res.results)) {
|
|
317
|
+
return err("SEARCH_FAILED", "搜索失败,请重试");
|
|
318
|
+
}
|
|
319
|
+
const items = await Promise.all(
|
|
320
|
+
res.results.slice(0, limit).map(async (r) => {
|
|
321
|
+
try {
|
|
322
|
+
const data = (await r.data()) as {
|
|
323
|
+
url?: string;
|
|
324
|
+
title?: string;
|
|
325
|
+
excerpt?: string;
|
|
326
|
+
};
|
|
327
|
+
return {
|
|
328
|
+
url: data?.url ?? "",
|
|
329
|
+
title: data?.title ?? "",
|
|
330
|
+
excerpt: (data?.excerpt ?? "").slice(0, 240),
|
|
331
|
+
};
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
const posts = items.filter((x): x is NonNullable<typeof x> => x !== null);
|
|
338
|
+
return { ok: true, keyword, count: posts.length, posts };
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function readPostTool(): WebMCPTool {
|
|
344
|
+
return {
|
|
345
|
+
name: "stalux_read_post",
|
|
346
|
+
title: "读取文章 Markdown",
|
|
347
|
+
description:
|
|
348
|
+
"按 abbrlink(文章永久链接 ID)读取一篇博客文章的原始 Markdown 全文,包含 frontmatter 与版权脚注。" +
|
|
349
|
+
"需要文章完整内容、引用或摘要时使用;只要元信息用 stalux_get_post(更便宜)。" +
|
|
350
|
+
"获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
|
|
351
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
352
|
+
inputSchema: {
|
|
353
|
+
type: "object",
|
|
354
|
+
properties: {
|
|
355
|
+
id: { type: "string", description: "文章的 abbrlink,如 f4442947" },
|
|
356
|
+
},
|
|
357
|
+
required: ["id"],
|
|
358
|
+
additionalProperties: false,
|
|
359
|
+
},
|
|
360
|
+
execute: async (input) => {
|
|
361
|
+
const id = String(input.id ?? "").trim();
|
|
362
|
+
if (!id) return err("BAD_INPUT", "请提供文章 abbrlink");
|
|
363
|
+
const url = "/posts/" + encodeURIComponent(id) + ".md";
|
|
364
|
+
const r = await fetch(url, { headers: { Accept: "text/markdown" } });
|
|
365
|
+
if (r.status === 404) return err("NOT_FOUND", "未找到文章: " + id);
|
|
366
|
+
if (!r.ok) return err("FETCH_FAILED", "无法获取文章 Markdown:" + url);
|
|
367
|
+
const markdown = await r.text();
|
|
368
|
+
return { ok: true, url, markdown: markdown.slice(0, 50000) };
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function siteInfoTool(): WebMCPTool {
|
|
374
|
+
return {
|
|
375
|
+
name: "stalux_site_info",
|
|
376
|
+
title: "站点信息",
|
|
377
|
+
description:
|
|
378
|
+
"返回博客的站点信息(标题、简介、站点 URL),并给出更完整的机器可读内容入口:" +
|
|
379
|
+
"/llms.txt(站点导航与文章链接列表)与 /llms-full.txt(全站 Markdown 镜像,含全部文章正文)。" +
|
|
380
|
+
"适合需要了解博客主题、定位内容、或批量获取全站数据时使用。",
|
|
381
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
382
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
383
|
+
execute: async () => {
|
|
384
|
+
const info = window.__STALUX_SITE_INFO__ ?? {};
|
|
385
|
+
return {
|
|
386
|
+
ok: true,
|
|
387
|
+
title: info.title ?? "",
|
|
388
|
+
url: info.url ?? "",
|
|
389
|
+
description: info.description ?? "",
|
|
390
|
+
llms: (info.url ?? "") + "/llms.txt",
|
|
391
|
+
llmsFull: (info.url ?? "") + "/llms-full.txt",
|
|
392
|
+
};
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildTools(): WebMCPTool[] {
|
|
398
|
+
return [
|
|
399
|
+
listPostsTool(),
|
|
400
|
+
getPostTool(),
|
|
401
|
+
currentPostTool(),
|
|
402
|
+
randomPostTool(),
|
|
403
|
+
searchPostsTool(),
|
|
404
|
+
readPostTool(),
|
|
405
|
+
siteInfoTool(),
|
|
406
|
+
];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// 注册逻辑
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
/** 当前注册批次使用的 AbortController,软导航时先 abort 注销再重注册 */
|
|
414
|
+
let activeController: AbortController | null = null;
|
|
415
|
+
|
|
416
|
+
async function registerTools(): Promise<{ registered: string[]; reason?: string }> {
|
|
417
|
+
const doc = document as DocumentWithModelContext;
|
|
418
|
+
const ctx = doc.modelContext;
|
|
419
|
+
if (!ctx || typeof ctx.registerTool !== "function") {
|
|
420
|
+
return { registered: [], reason: "unsupported" };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 注销上一轮注册(View Transitions 软导航后 modelContext 可能已重置)
|
|
424
|
+
if (activeController) {
|
|
425
|
+
try {
|
|
426
|
+
activeController.abort();
|
|
427
|
+
} catch {
|
|
428
|
+
/* 忽略注销异常 */
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const controller = new AbortController();
|
|
432
|
+
activeController = controller;
|
|
433
|
+
|
|
434
|
+
const tools = buildTools();
|
|
435
|
+
const registered: string[] = [];
|
|
436
|
+
// 逐个注册:单个失败不中断其余工具
|
|
437
|
+
for (const tool of tools) {
|
|
438
|
+
try {
|
|
439
|
+
await ctx.registerTool(tool, { signal: controller.signal });
|
|
440
|
+
registered.push(tool.name);
|
|
441
|
+
} catch (e) {
|
|
442
|
+
console.warn("[stalux/webmcp] 注册失败: " + tool.name, e);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return { registered };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// 挂载与自动注册
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
if (typeof document !== "undefined") {
|
|
453
|
+
// 首屏加载 + View Transitions 软导航都执行(soft navigation 时页面状态会重建)
|
|
454
|
+
document.addEventListener("astro:page-load", () => {
|
|
455
|
+
void registerTools().then((r) => {
|
|
456
|
+
if (r.reason === "unsupported") return; // 无 WebMCP 环境,静默跳过
|
|
457
|
+
if (r.registered.length) {
|
|
458
|
+
console.info(
|
|
459
|
+
"[stalux/webmcp] 已注册 " +
|
|
460
|
+
r.registered.length +
|
|
461
|
+
" 个工具: " +
|
|
462
|
+
r.registered.join(", "),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export {};
|