@xingwangzhe/stalux 1.35.11 → 1.35.13
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 +24 -11
- package/package.json +5 -2
- package/src/components/stalux/common/Typewriter.astro +32 -1
- package/src/components/stalux/posts/postContent.astro +7 -1
- package/src/index.ts +51 -104
- package/src/internal/bounded-tasks.ts +28 -0
- package/src/internal/compact-styles.ts +55 -13
- package/src/internal/font-slices.ts +150 -47
- package/src/internal/html-images.ts +2 -0
- package/src/internal/html-output.ts +98 -0
- package/src/internal/image-dimensions.ts +99 -74
- package/src/internal/page-output-cache.ts +26 -9
- package/src/internal/public-assets.ts +59 -0
- package/src/internal/runtime-cache-key.ts +14 -3
- package/src/internal/style-names.generated.ts +27 -26
- package/src/layouts/PostLayout.astro +1 -0
- package/src/layouts/Stalux.astro +3 -1
- package/src/pages/api/post.abbrlink.json.ts +0 -3
- package/src/pages/api/posts.json.ts +16 -19
- package/src/pages/openapi.json.ts +3 -44
- package/src/pages/posts/[post].md.ts +4 -40
- package/src/plugins/feature-flags.ts +8 -1
- package/src/scripts/navigation-font.ts +28 -0
- package/src/scripts/navigation.ts +4 -2
- package/src/scripts/page-runtime.ts +20 -6
- package/src/scripts/pagefind-api.ts +63 -0
- package/src/scripts/photoswipe.ts +13 -3
- package/src/scripts/random-posts.ts +12 -10
- package/src/scripts/search-dialog.ts +2 -1
- package/src/scripts/view-transitions.ts +12 -1
- package/src/scripts/webmcp.ts +397 -141
- package/src/scripts/word-list.ts +13 -3
- package/src/styles/base.css +7 -0
- package/src/styles/generated.css +1 -1
- package/src/utils/collections-stats.ts +9 -22
- package/src/utils/content-index.ts +13 -4
- package/src/utils/dayjs.ts +26 -11
- package/src/utils/feed.ts +30 -29
- package/src/utils/post-lookup.ts +51 -0
- package/src/utils/post-markdown.ts +31 -0
- package/src/utils/site-path.ts +4 -0
- package/src/utils/toc-scrollspy.ts +20 -6
- package/src/utils/word-count-utils.ts +45 -41
package/src/scripts/webmcp.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createClientLogger } from "./logger";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Stalux WebMCP 工具注册(纯前端,零后端)
|
|
5
3
|
*
|
|
@@ -19,8 +17,13 @@ import { createClientLogger } from "./logger";
|
|
|
19
17
|
*/
|
|
20
18
|
// 浏览器无原生 document.modelContext 时由 polyfill 兜底安装;
|
|
21
19
|
// 有原生实现或已存在 WebMCP-aware 扩展时,polyfill 检测到后自动 no-op。
|
|
22
|
-
//
|
|
23
|
-
import "@mcp-b/webmcp-polyfill";
|
|
20
|
+
// ESM entry exports an initializer; importing it alone does not install the API.
|
|
21
|
+
import { initializeWebMCPPolyfill } from "@mcp-b/webmcp-polyfill";
|
|
22
|
+
import { createPostLookup } from "../utils/post-lookup";
|
|
23
|
+
import { sitePath } from "../utils/site-path";
|
|
24
|
+
import { createClientLogger } from "./logger";
|
|
25
|
+
import { registerPageLifecycle } from "./page-runtime";
|
|
26
|
+
import { loadPagefind } from "./pagefind-api";
|
|
24
27
|
|
|
25
28
|
const logger = createClientLogger("webmcp");
|
|
26
29
|
|
|
@@ -45,7 +48,10 @@ interface WebMCPTool {
|
|
|
45
48
|
readOnlyHint?: boolean;
|
|
46
49
|
untrustedContentHint?: boolean;
|
|
47
50
|
};
|
|
48
|
-
execute: (
|
|
51
|
+
execute: (
|
|
52
|
+
input: Record<string, unknown>,
|
|
53
|
+
options?: { signal?: AbortSignal },
|
|
54
|
+
) => Promise<unknown>;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
interface ModelContext {
|
|
@@ -87,7 +93,10 @@ function err(
|
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
async function fetchJSON(url: string): Promise<unknown> {
|
|
90
|
-
const r = await fetch(url, {
|
|
96
|
+
const r = await fetch(url, {
|
|
97
|
+
headers: { Accept: "application/json" },
|
|
98
|
+
signal: AbortSignal.timeout(15000),
|
|
99
|
+
});
|
|
91
100
|
if (!r.ok) {
|
|
92
101
|
logger.debug(`JSON request failed; status=${r.status}`);
|
|
93
102
|
return null;
|
|
@@ -95,27 +104,6 @@ async function fetchJSON(url: string): Promise<unknown> {
|
|
|
95
104
|
return r.json();
|
|
96
105
|
}
|
|
97
106
|
|
|
98
|
-
/** 动态加载 Pagefind 全文索引(仅首次调用,结果跨调用缓存) */
|
|
99
|
-
let pagefindLoadPromise: Promise<{ search: (q: string) => Promise<unknown> } | null> | null = null;
|
|
100
|
-
async function loadPagefind(): Promise<{ search: (q: string) => Promise<unknown> } | null> {
|
|
101
|
-
logger.debug(pagefindLoadPromise ? "Pagefind module cache hit" : "loading Pagefind module");
|
|
102
|
-
if (!pagefindLoadPromise) {
|
|
103
|
-
pagefindLoadPromise = (async () => {
|
|
104
|
-
try {
|
|
105
|
-
// 用完整 URL 构造,让 Vite 在 dev(vite:import-analysis)与 build 都不解析该路径;
|
|
106
|
-
// pagefind 索引是构建期产物,运行时从站点根加载。
|
|
107
|
-
const url = new URL("/pagefind/pagefind.js", location.origin).href;
|
|
108
|
-
const mod = await import(/* @vite-ignore */ url);
|
|
109
|
-
return mod.default ?? mod;
|
|
110
|
-
} catch {
|
|
111
|
-
logger.warn("Pagefind index unavailable; search requires a production index");
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
})();
|
|
115
|
-
}
|
|
116
|
-
return pagefindLoadPromise;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
107
|
const MAX_LIST_PAGE_SIZE = 50;
|
|
120
108
|
const MAX_SEARCH_RESULTS = 10;
|
|
121
109
|
|
|
@@ -136,29 +124,61 @@ interface PostMeta {
|
|
|
136
124
|
}
|
|
137
125
|
|
|
138
126
|
let postsMetaPromise: Promise<PostMeta[] | null> | null = null;
|
|
127
|
+
const postLookups = new WeakMap<PostMeta[], ReturnType<typeof createPostLookup<PostMeta>>>();
|
|
128
|
+
|
|
129
|
+
function lookupFor(list: PostMeta[]) {
|
|
130
|
+
let lookup = postLookups.get(list);
|
|
131
|
+
if (!lookup) {
|
|
132
|
+
lookup = createPostLookup(list);
|
|
133
|
+
postLookups.set(list, lookup);
|
|
134
|
+
}
|
|
135
|
+
return lookup;
|
|
136
|
+
}
|
|
139
137
|
|
|
140
138
|
/** 加载文章元信息索引(惰性 + 缓存) */
|
|
141
139
|
function loadPostsMeta(): Promise<PostMeta[] | null> {
|
|
142
140
|
if (!postsMetaPromise) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
const attempt = fetchJSON(sitePath("api/posts.json"))
|
|
142
|
+
.then((data) => {
|
|
143
|
+
if (
|
|
144
|
+
!Array.isArray(data) ||
|
|
145
|
+
!data.every(
|
|
146
|
+
(post) =>
|
|
147
|
+
post &&
|
|
148
|
+
typeof post.title === "string" &&
|
|
149
|
+
typeof post.abbrlink === "string" &&
|
|
150
|
+
typeof post.url === "string" &&
|
|
151
|
+
(post.desc === undefined || typeof post.desc === "string") &&
|
|
152
|
+
(!post.categories ||
|
|
153
|
+
(Array.isArray(post.categories) &&
|
|
154
|
+
post.categories.every(
|
|
155
|
+
(category: unknown) => typeof category === "string",
|
|
156
|
+
))) &&
|
|
157
|
+
(!post.tags ||
|
|
158
|
+
(Array.isArray(post.tags) &&
|
|
159
|
+
post.tags.every((tag: unknown) => typeof tag === "string"))),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
return null;
|
|
163
|
+
return data as PostMeta[];
|
|
164
|
+
})
|
|
165
|
+
.catch(() => null);
|
|
166
|
+
postsMetaPromise = attempt;
|
|
167
|
+
void attempt.then((result) => {
|
|
168
|
+
if (!result && postsMetaPromise === attempt) postsMetaPromise = null;
|
|
169
|
+
});
|
|
146
170
|
}
|
|
147
171
|
return postsMetaPromise;
|
|
148
172
|
}
|
|
149
173
|
|
|
150
174
|
/** 按 abbrlink 精确查找 */
|
|
151
175
|
function findById(list: PostMeta[], id: string): PostMeta | undefined {
|
|
152
|
-
return list.
|
|
176
|
+
return lookupFor(list).findById(id);
|
|
153
177
|
}
|
|
154
178
|
|
|
155
179
|
/** 按标题/标签关键词模糊查找(取第一条) */
|
|
156
180
|
function findByKeyword(list: PostMeta[], keyword: string): PostMeta | undefined {
|
|
157
|
-
|
|
158
|
-
return (
|
|
159
|
-
list.find((p) => p.title.toLowerCase().includes(kw)) ??
|
|
160
|
-
list.find((p) => (p.tags ?? []).some((t) => t.toLowerCase().includes(kw)))
|
|
161
|
-
);
|
|
181
|
+
return lookupFor(list).findByKeyword(keyword);
|
|
162
182
|
}
|
|
163
183
|
|
|
164
184
|
/** 简化为输出用的元信息视图 */
|
|
@@ -171,15 +191,43 @@ function briefMeta(p: PostMeta): Record<string, unknown> {
|
|
|
171
191
|
categories: p.categories ?? [],
|
|
172
192
|
desc: p.desc ?? undefined,
|
|
173
193
|
wordCount: p.wordCount ?? undefined,
|
|
174
|
-
url: p.url,
|
|
194
|
+
url: new URL(p.url, location.origin).href,
|
|
175
195
|
};
|
|
176
196
|
}
|
|
177
197
|
|
|
178
198
|
/** 从当前 URL 解析 abbrlink(/posts/{abbrlink}/ 或 /posts/{abbrlink}.md) */
|
|
179
199
|
function currentAbbrlinkFromPath(): string | null {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
200
|
+
return postIdFromUrl(location.href);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function postIdFromUrl(value: string): string | null {
|
|
204
|
+
try {
|
|
205
|
+
const url = new URL(value, location.origin);
|
|
206
|
+
if (url.origin !== location.origin) return null;
|
|
207
|
+
const prefix = sitePath("posts/");
|
|
208
|
+
if (!url.pathname.startsWith(prefix)) return null;
|
|
209
|
+
const suffix = url.pathname.slice(prefix.length);
|
|
210
|
+
const path = suffix.endsWith("/") ? suffix.slice(0, -1) : suffix.replace(/\.md$/, "");
|
|
211
|
+
if (!path || path.includes("/")) return null;
|
|
212
|
+
return decodeURIComponent(path);
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function integerInput(
|
|
219
|
+
value: unknown,
|
|
220
|
+
fallback: number,
|
|
221
|
+
minimum: number,
|
|
222
|
+
maximum = Number.MAX_SAFE_INTEGER,
|
|
223
|
+
): number | undefined {
|
|
224
|
+
if (value === undefined) return fallback;
|
|
225
|
+
return typeof value === "number" &&
|
|
226
|
+
Number.isSafeInteger(value) &&
|
|
227
|
+
value >= minimum &&
|
|
228
|
+
value <= maximum
|
|
229
|
+
? value
|
|
230
|
+
: undefined;
|
|
183
231
|
}
|
|
184
232
|
|
|
185
233
|
// ---------------------------------------------------------------------------
|
|
@@ -194,11 +242,11 @@ function listPostsTool(): WebMCPTool {
|
|
|
194
242
|
"分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink)、日期、分类、标签、摘要与文章页 URL。" +
|
|
195
243
|
"需要浏览全站文章、确认某篇文章的 abbrlink 时使用;不返回正文," +
|
|
196
244
|
"只要某篇元信息用 stalux_get_post,读正文请用 stalux_read_post。" +
|
|
197
|
-
"pageSize
|
|
245
|
+
"pageSize 可设 50 减少翻页,按 totalPages 继续分页;页码越界时会自动返回最后一页(clamped 标记)。",
|
|
198
246
|
"Paginated list of all published posts, returning title, permanent link (abbrlink), date, categories, tags, description and post page URL. " +
|
|
199
247
|
"Use it to browse the whole blog or find a post's abbrlink; it does not return body content — " +
|
|
200
248
|
"use stalux_get_post for one post's metadata, or stalux_read_post for the full body. " +
|
|
201
|
-
"
|
|
249
|
+
"Use pageSize 50 to reduce calls and follow totalPages to continue; out-of-range pages clamp to the last page (clamped flag).",
|
|
202
250
|
),
|
|
203
251
|
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
204
252
|
inputSchema: {
|
|
@@ -224,11 +272,16 @@ function listPostsTool(): WebMCPTool {
|
|
|
224
272
|
additionalProperties: false,
|
|
225
273
|
},
|
|
226
274
|
execute: async (input) => {
|
|
227
|
-
const page =
|
|
228
|
-
const pageSize =
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
275
|
+
const page = integerInput(input.page, 1, 1);
|
|
276
|
+
const pageSize = integerInput(input.pageSize, 10, 1, MAX_LIST_PAGE_SIZE);
|
|
277
|
+
if (page === undefined || pageSize === undefined)
|
|
278
|
+
return err(
|
|
279
|
+
"BAD_INPUT",
|
|
280
|
+
pick(
|
|
281
|
+
"页码和每页条数必须是有效整数",
|
|
282
|
+
"Page and pageSize must be valid integers",
|
|
283
|
+
),
|
|
284
|
+
);
|
|
232
285
|
const list = await loadPostsMeta();
|
|
233
286
|
if (!list)
|
|
234
287
|
return err(
|
|
@@ -279,8 +332,8 @@ function getPostTool(): WebMCPTool {
|
|
|
279
332
|
additionalProperties: false,
|
|
280
333
|
},
|
|
281
334
|
execute: async (input) => {
|
|
282
|
-
const id =
|
|
283
|
-
if (!id) {
|
|
335
|
+
const id = typeof input.id === "string" ? input.id.trim() : "";
|
|
336
|
+
if (!id || id.length > 512) {
|
|
284
337
|
return err(
|
|
285
338
|
"BAD_INPUT",
|
|
286
339
|
pick(
|
|
@@ -372,9 +425,11 @@ function searchPostsTool(): WebMCPTool {
|
|
|
372
425
|
title: pick("搜索博客文章", "Search blog posts"),
|
|
373
426
|
description: pick(
|
|
374
427
|
"用关键词搜索博客文章的标题、标签、分类与正文全文,返回命中文章的标题、链接与摘要片段。" +
|
|
428
|
+
"全文索引不可用时仅返回元信息匹配,并标记 fullText: false;不把索引故障当成全文无匹配。" +
|
|
375
429
|
"适合「博客里写过 X 吗」「帮我找一下关于 Y 的文章」这类问题;" +
|
|
376
430
|
"定位到具体文章后再用 stalux_read_post 读正文、stalux_get_post 取元信息。",
|
|
377
431
|
"Search the full text of all posts (title, tags, categories, body) by keyword, returning matching posts with title, URL and excerpt snippets. " +
|
|
432
|
+
"If Pagefind is unavailable, metadata-only matches are marked fullText: false; index failures are not treated as an empty full-text result. " +
|
|
378
433
|
"Ideal for questions like 'has the blog covered X?' or 'find posts about Y'; " +
|
|
379
434
|
"once a post is located, use stalux_read_post for the body or stalux_get_post for metadata.",
|
|
380
435
|
),
|
|
@@ -397,48 +452,98 @@ function searchPostsTool(): WebMCPTool {
|
|
|
397
452
|
required: ["keyword"],
|
|
398
453
|
additionalProperties: false,
|
|
399
454
|
},
|
|
400
|
-
execute: async (input) => {
|
|
401
|
-
const keyword =
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
455
|
+
execute: async (input, options) => {
|
|
456
|
+
const keyword = typeof input.keyword === "string" ? input.keyword.trim() : "";
|
|
457
|
+
const limit = integerInput(input.limit, 10, 1, MAX_SEARCH_RESULTS);
|
|
458
|
+
if (!keyword || keyword.length > 256 || limit === undefined)
|
|
459
|
+
return err("BAD_INPUT", pick("搜索参数无效", "Invalid search parameters"));
|
|
460
|
+
const list = await loadPostsMeta();
|
|
461
|
+
if (!list)
|
|
462
|
+
return err("INDEX_UNAVAILABLE", pick("文章索引不可用", "Post index unavailable"));
|
|
463
|
+
const metadataMatches = lookupFor(list)
|
|
464
|
+
.findMatches(keyword, limit)
|
|
465
|
+
.map((post) => ({
|
|
466
|
+
abbrlink: post.abbrlink,
|
|
467
|
+
title: post.title,
|
|
468
|
+
url: new URL(post.url, location.origin).href,
|
|
469
|
+
excerpt: (post.desc ?? "").slice(0, 240),
|
|
470
|
+
}));
|
|
471
|
+
const fallback = (code: string) =>
|
|
472
|
+
metadataMatches.length
|
|
473
|
+
? {
|
|
474
|
+
ok: true,
|
|
475
|
+
keyword,
|
|
476
|
+
count: metadataMatches.length,
|
|
477
|
+
posts: metadataMatches,
|
|
478
|
+
source: "metadata",
|
|
479
|
+
fullText: false,
|
|
480
|
+
notice: pick(
|
|
481
|
+
"全文索引不可用,仅返回标题、标签、分类和摘要匹配",
|
|
482
|
+
"Full-text index unavailable; returning title, tag, category and description matches only",
|
|
483
|
+
),
|
|
484
|
+
}
|
|
485
|
+
: err(
|
|
486
|
+
code,
|
|
487
|
+
pick(
|
|
488
|
+
"全文索引不可用,且元信息没有匹配项",
|
|
489
|
+
"Full-text index unavailable and no metadata matches",
|
|
490
|
+
),
|
|
491
|
+
);
|
|
405
492
|
const pagefind = await loadPagefind();
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
);
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
493
|
+
options?.signal?.throwIfAborted();
|
|
494
|
+
if (!pagefind) return fallback("INDEX_UNAVAILABLE");
|
|
495
|
+
try {
|
|
496
|
+
const result = await pagefind.search(keyword);
|
|
497
|
+
if (!result || !Array.isArray(result.results)) return fallback("SEARCH_FAILED");
|
|
498
|
+
const posts: Record<string, unknown>[] = [...metadataMatches];
|
|
499
|
+
const seen = new Set(metadataMatches.map((post) => post.abbrlink));
|
|
500
|
+
for (
|
|
501
|
+
let offset = 0;
|
|
502
|
+
offset < result.results.length && posts.length < limit;
|
|
503
|
+
offset += MAX_SEARCH_RESULTS
|
|
504
|
+
) {
|
|
505
|
+
options?.signal?.throwIfAborted();
|
|
506
|
+
const batch = await Promise.all(
|
|
507
|
+
result.results
|
|
508
|
+
.slice(offset, offset + MAX_SEARCH_RESULTS)
|
|
509
|
+
.map(async (entry) => {
|
|
510
|
+
try {
|
|
511
|
+
return await entry.data();
|
|
512
|
+
} catch {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
517
|
+
for (const data of batch) {
|
|
518
|
+
if (!data) continue;
|
|
519
|
+
const id = postIdFromUrl(data.url);
|
|
520
|
+
const post = id ? findById(list, id) : undefined;
|
|
521
|
+
if (!post || seen.has(post.abbrlink)) continue;
|
|
522
|
+
seen.add(post.abbrlink);
|
|
523
|
+
posts.push({
|
|
524
|
+
abbrlink: post.abbrlink,
|
|
525
|
+
url: new URL(data.url, location.origin).href,
|
|
526
|
+
title:
|
|
527
|
+
typeof data.meta?.title === "string" && data.meta.title
|
|
528
|
+
? data.meta.title
|
|
529
|
+
: post.title,
|
|
530
|
+
excerpt: (data.plain_excerpt ?? post.desc ?? "").slice(0, 240),
|
|
531
|
+
});
|
|
532
|
+
if (posts.length >= limit) break;
|
|
437
533
|
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
ok: true,
|
|
537
|
+
keyword,
|
|
538
|
+
count: posts.length,
|
|
539
|
+
posts,
|
|
540
|
+
source: "pagefind+metadata",
|
|
541
|
+
fullText: true,
|
|
542
|
+
};
|
|
543
|
+
} catch {
|
|
544
|
+
if (options?.signal?.aborted) throw options.signal.reason;
|
|
545
|
+
return fallback("SEARCH_FAILED");
|
|
546
|
+
}
|
|
442
547
|
},
|
|
443
548
|
};
|
|
444
549
|
}
|
|
@@ -446,19 +551,32 @@ function searchPostsTool(): WebMCPTool {
|
|
|
446
551
|
function readPostTool(): WebMCPTool {
|
|
447
552
|
return {
|
|
448
553
|
name: "stalux_read_post",
|
|
449
|
-
title: pick("
|
|
554
|
+
title: pick("读取文章内容", "Read post content"),
|
|
450
555
|
description: pick(
|
|
451
|
-
"按 abbrlink
|
|
452
|
-
"
|
|
556
|
+
"按 abbrlink 读取文章。开启 Markdown 导出时返回规范化 Markdown,否则从 Pagefind 返回已索引纯文本(不含 Markdown 语法)。" +
|
|
557
|
+
"format 标明格式;truncated 为 true 时用 nextOffset 续读,直到 nextOffset 为 null。只要元信息用 stalux_get_post(更便宜)。" +
|
|
453
558
|
"获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
|
|
454
|
-
"Read
|
|
455
|
-
"
|
|
559
|
+
"Read a post by abbrlink: normalized Markdown when exports are enabled, otherwise indexed plain text from Pagefind without Markdown syntax. " +
|
|
560
|
+
"Check format; when truncated, continue with nextOffset until null. For metadata only, stalux_get_post is cheaper. " +
|
|
456
561
|
"To get an abbrlink, first use stalux_list_posts or stalux_search_posts.",
|
|
457
562
|
),
|
|
458
563
|
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
459
564
|
inputSchema: {
|
|
460
565
|
type: "object",
|
|
461
566
|
properties: {
|
|
567
|
+
offset: {
|
|
568
|
+
type: "integer",
|
|
569
|
+
minimum: 0,
|
|
570
|
+
default: 0,
|
|
571
|
+
description: "UTF-16 content offset; continue with nextOffset",
|
|
572
|
+
},
|
|
573
|
+
maxChars: {
|
|
574
|
+
type: "integer",
|
|
575
|
+
minimum: 1,
|
|
576
|
+
maximum: 50000,
|
|
577
|
+
default: 50000,
|
|
578
|
+
description: "Maximum UTF-16 units returned; a surrogate pair is never split",
|
|
579
|
+
},
|
|
462
580
|
id: {
|
|
463
581
|
type: "string",
|
|
464
582
|
description: pick(
|
|
@@ -470,22 +588,122 @@ function readPostTool(): WebMCPTool {
|
|
|
470
588
|
required: ["id"],
|
|
471
589
|
additionalProperties: false,
|
|
472
590
|
},
|
|
473
|
-
execute: async (input) => {
|
|
474
|
-
const id =
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const r = await fetch(url, { headers: { Accept: "text/markdown" } });
|
|
479
|
-
if (r.status === 404)
|
|
480
|
-
return err("NOT_FOUND", pick("未找到文章: ", "Post not found: ") + id);
|
|
481
|
-
if (!r.ok) {
|
|
591
|
+
execute: async (input, options) => {
|
|
592
|
+
const id = typeof input.id === "string" ? input.id.trim() : "";
|
|
593
|
+
const offset = integerInput(input.offset, 0, 0);
|
|
594
|
+
const maxChars = integerInput(input.maxChars, 50000, 1, 50000);
|
|
595
|
+
if (!id || id.length > 512 || offset === undefined || maxChars === undefined)
|
|
482
596
|
return err(
|
|
483
|
-
"
|
|
484
|
-
pick(
|
|
597
|
+
"BAD_INPUT",
|
|
598
|
+
pick(
|
|
599
|
+
"请提供有效的文章 ID 和读取范围",
|
|
600
|
+
"Provide a valid post ID and content range",
|
|
601
|
+
),
|
|
485
602
|
);
|
|
603
|
+
const list = await loadPostsMeta();
|
|
604
|
+
if (!list)
|
|
605
|
+
return err("INDEX_UNAVAILABLE", pick("文章索引不可用", "Post index unavailable"));
|
|
606
|
+
const post = findById(list, id);
|
|
607
|
+
if (!post) return err("NOT_FOUND", pick("未找到文章: ", "Post not found: ") + id);
|
|
608
|
+
const url = new URL(sitePath(`posts/${encodeURIComponent(id)}.md`), location.origin)
|
|
609
|
+
.href;
|
|
610
|
+
let content: string | undefined;
|
|
611
|
+
let format = "text/markdown";
|
|
612
|
+
let source = url;
|
|
613
|
+
if (document.body?.dataset.staluxMarkdownExport !== "false") {
|
|
614
|
+
try {
|
|
615
|
+
const signal = options?.signal
|
|
616
|
+
? AbortSignal.any([options.signal, AbortSignal.timeout(15000)])
|
|
617
|
+
: AbortSignal.timeout(15000);
|
|
618
|
+
const response = await fetch(url, {
|
|
619
|
+
headers: { Accept: "text/markdown" },
|
|
620
|
+
signal,
|
|
621
|
+
});
|
|
622
|
+
if (response.ok && !response.headers.get("Content-Type")?.includes("text/html"))
|
|
623
|
+
content = await response.text();
|
|
624
|
+
} catch {
|
|
625
|
+
if (options?.signal?.aborted) throw options.signal.reason;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (content === undefined) {
|
|
629
|
+
const pagefind = await loadPagefind();
|
|
630
|
+
if (!pagefind)
|
|
631
|
+
return err(
|
|
632
|
+
"CONTENT_UNAVAILABLE",
|
|
633
|
+
pick(
|
|
634
|
+
"Markdown 和静态搜索正文均不可用",
|
|
635
|
+
"Neither Markdown nor indexed content is available",
|
|
636
|
+
),
|
|
637
|
+
);
|
|
638
|
+
try {
|
|
639
|
+
const result = await pagefind.search(null, { filters: { post: id } });
|
|
640
|
+
for (const entry of result.results) {
|
|
641
|
+
const data = await entry.data();
|
|
642
|
+
if (postIdFromUrl(data.url) === id && typeof data.content === "string") {
|
|
643
|
+
content = data.content;
|
|
644
|
+
format = "text/plain";
|
|
645
|
+
source = new URL(data.url, location.origin).href;
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
} catch {
|
|
650
|
+
return err(
|
|
651
|
+
"CONTENT_UNAVAILABLE",
|
|
652
|
+
pick(
|
|
653
|
+
"读取静态搜索正文失败,请重试",
|
|
654
|
+
"Unable to read indexed content; retry",
|
|
655
|
+
),
|
|
656
|
+
);
|
|
657
|
+
}
|
|
486
658
|
}
|
|
487
|
-
|
|
488
|
-
|
|
659
|
+
if (content === undefined)
|
|
660
|
+
return err(
|
|
661
|
+
"CONTENT_UNAVAILABLE",
|
|
662
|
+
pick(
|
|
663
|
+
"该文章没有可读取的静态正文",
|
|
664
|
+
"No static content is available for this post",
|
|
665
|
+
),
|
|
666
|
+
);
|
|
667
|
+
if (
|
|
668
|
+
offset > content.length ||
|
|
669
|
+
(offset > 0 &&
|
|
670
|
+
/[\uDC00-\uDFFF]/u.test(content.charAt(offset)) &&
|
|
671
|
+
/[\uD800-\uDBFF]/u.test(content.charAt(offset - 1)))
|
|
672
|
+
)
|
|
673
|
+
return err(
|
|
674
|
+
"BAD_INPUT",
|
|
675
|
+
pick(
|
|
676
|
+
"读取偏移超出正文范围或切开 Unicode 字符",
|
|
677
|
+
"Offset is outside the content or splits a Unicode character",
|
|
678
|
+
),
|
|
679
|
+
);
|
|
680
|
+
let end = Math.min(content.length, offset + maxChars);
|
|
681
|
+
if (
|
|
682
|
+
end < content.length &&
|
|
683
|
+
/[\uD800-\uDBFF]/u.test(content.charAt(end - 1)) &&
|
|
684
|
+
/[\uDC00-\uDFFF]/u.test(content.charAt(end))
|
|
685
|
+
)
|
|
686
|
+
end--;
|
|
687
|
+
if (end === offset && end < content.length)
|
|
688
|
+
return err(
|
|
689
|
+
"BAD_INPUT",
|
|
690
|
+
pick(
|
|
691
|
+
"maxChars 太小,无法容纳当前 Unicode 字符",
|
|
692
|
+
"maxChars is too small for this Unicode character",
|
|
693
|
+
),
|
|
694
|
+
);
|
|
695
|
+
const chunk = content.slice(offset, end);
|
|
696
|
+
return {
|
|
697
|
+
ok: true,
|
|
698
|
+
abbrlink: id,
|
|
699
|
+
url: source,
|
|
700
|
+
format,
|
|
701
|
+
...(format === "text/markdown" ? { markdown: chunk } : { content: chunk }),
|
|
702
|
+
offset,
|
|
703
|
+
totalChars: content.length,
|
|
704
|
+
truncated: end < content.length,
|
|
705
|
+
nextOffset: end < content.length ? end : null,
|
|
706
|
+
};
|
|
489
707
|
},
|
|
490
708
|
};
|
|
491
709
|
}
|
|
@@ -518,15 +736,17 @@ function siteInfoTool(): WebMCPTool {
|
|
|
518
736
|
url: info.url ?? "",
|
|
519
737
|
description: info.description ?? "",
|
|
520
738
|
lang: info.lang ?? "",
|
|
521
|
-
llms:
|
|
522
|
-
llmsFull:
|
|
739
|
+
llms: new URL(sitePath("llms.txt"), location.origin).href,
|
|
740
|
+
llmsFull: new URL(sitePath("llms-full.txt"), location.origin).href,
|
|
741
|
+
markdownExport: document.body?.dataset.staluxMarkdownExport === "true",
|
|
742
|
+
transport: "WebMCP",
|
|
523
743
|
};
|
|
524
744
|
},
|
|
525
745
|
};
|
|
526
746
|
}
|
|
527
747
|
|
|
528
748
|
function buildTools(): WebMCPTool[] {
|
|
529
|
-
|
|
749
|
+
const tools: WebMCPTool[] = [
|
|
530
750
|
listPostsTool(),
|
|
531
751
|
getPostTool(),
|
|
532
752
|
currentPostTool(),
|
|
@@ -535,16 +755,46 @@ function buildTools(): WebMCPTool[] {
|
|
|
535
755
|
readPostTool(),
|
|
536
756
|
siteInfoTool(),
|
|
537
757
|
];
|
|
758
|
+
return tools.map((tool) => {
|
|
759
|
+
const execute = tool.execute;
|
|
760
|
+
return {
|
|
761
|
+
...tool,
|
|
762
|
+
execute: async (input: Record<string, unknown>, options?: { signal?: AbortSignal }) => {
|
|
763
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
764
|
+
return err("BAD_INPUT", pick("参数必须是对象", "Input must be an object"));
|
|
765
|
+
const properties =
|
|
766
|
+
(tool.inputSchema as { properties?: Record<string, unknown> })?.properties ??
|
|
767
|
+
{};
|
|
768
|
+
if (Object.keys(input).some((key) => !Object.hasOwn(properties, key)))
|
|
769
|
+
return err("BAD_INPUT", pick("包含未知参数", "Unknown input property"));
|
|
770
|
+
if (options?.signal?.aborted) throw options.signal.reason;
|
|
771
|
+
try {
|
|
772
|
+
const result = await execute(input, options);
|
|
773
|
+
options?.signal?.throwIfAborted();
|
|
774
|
+
return result;
|
|
775
|
+
} catch (error) {
|
|
776
|
+
if (options?.signal?.aborted) throw options.signal.reason;
|
|
777
|
+
logger.error("tool execution failed", error);
|
|
778
|
+
return err(
|
|
779
|
+
"FETCH_FAILED",
|
|
780
|
+
pick(
|
|
781
|
+
"读取静态博客资源失败,请重试",
|
|
782
|
+
"Unable to read static blog resources; retry",
|
|
783
|
+
),
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
});
|
|
538
789
|
}
|
|
539
790
|
|
|
540
791
|
// ---------------------------------------------------------------------------
|
|
541
792
|
// 注册逻辑
|
|
542
793
|
// ---------------------------------------------------------------------------
|
|
543
794
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
async function registerTools(): Promise<{ registered: string[]; reason?: string }> {
|
|
795
|
+
async function registerTools(
|
|
796
|
+
signal: AbortSignal,
|
|
797
|
+
): Promise<{ registered: string[]; reason?: string }> {
|
|
548
798
|
const doc = document as DocumentWithModelContext;
|
|
549
799
|
const ctx = doc.modelContext;
|
|
550
800
|
if (!ctx || typeof ctx.registerTool !== "function") {
|
|
@@ -552,25 +802,28 @@ async function registerTools(): Promise<{ registered: string[]; reason?: string
|
|
|
552
802
|
return { registered: [], reason: "unsupported" };
|
|
553
803
|
}
|
|
554
804
|
|
|
555
|
-
// 注销上一轮注册(View Transitions 软导航后 modelContext 可能已重置)
|
|
556
|
-
if (activeController) {
|
|
557
|
-
try {
|
|
558
|
-
activeController.abort();
|
|
559
|
-
} catch {
|
|
560
|
-
logger.debug("previous registration cleanup unavailable");
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
const controller = new AbortController();
|
|
564
|
-
activeController = controller;
|
|
565
|
-
|
|
566
805
|
const tools = buildTools();
|
|
567
806
|
const registered: string[] = [];
|
|
568
807
|
// 逐个注册:单个失败不中断其余工具
|
|
569
808
|
for (const tool of tools) {
|
|
809
|
+
if (signal.aborted) break;
|
|
570
810
|
try {
|
|
571
|
-
await ctx.registerTool(
|
|
811
|
+
await ctx.registerTool(
|
|
812
|
+
{
|
|
813
|
+
...tool,
|
|
814
|
+
execute: (input, executionOptions) =>
|
|
815
|
+
tool.execute(input, {
|
|
816
|
+
signal: executionOptions?.signal
|
|
817
|
+
? AbortSignal.any([signal, executionOptions.signal])
|
|
818
|
+
: signal,
|
|
819
|
+
}),
|
|
820
|
+
},
|
|
821
|
+
{ signal },
|
|
822
|
+
);
|
|
823
|
+
if (signal.aborted) break;
|
|
572
824
|
registered.push(tool.name);
|
|
573
825
|
} catch (e) {
|
|
826
|
+
if (signal.aborted) break;
|
|
574
827
|
logger.error(`tool registration failed: ${tool.name}`, e);
|
|
575
828
|
}
|
|
576
829
|
}
|
|
@@ -581,18 +834,21 @@ async function registerTools(): Promise<{ registered: string[]; reason?: string
|
|
|
581
834
|
// 挂载与自动注册
|
|
582
835
|
// ---------------------------------------------------------------------------
|
|
583
836
|
|
|
584
|
-
|
|
585
|
-
//
|
|
586
|
-
document.
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
837
|
+
registerPageLifecycle("webmcp", () => {
|
|
838
|
+
// Astro can hoist processed scripts from conditional template branches.
|
|
839
|
+
if (document.body?.dataset.staluxWebmcpEnabled === "false") return;
|
|
840
|
+
initializeWebMCPPolyfill();
|
|
841
|
+
const controller = new AbortController();
|
|
842
|
+
void registerTools(controller.signal)
|
|
843
|
+
.then((r) => {
|
|
844
|
+
if (controller.signal.aborted || r.reason === "unsupported") return;
|
|
845
|
+
if (r.registered.length) {
|
|
846
|
+
logger.debug(`已注册 ${r.registered.length} 个工具: ${r.registered.join(", ")}`);
|
|
847
|
+
}
|
|
848
|
+
})
|
|
849
|
+
.catch((error: unknown) => {
|
|
850
|
+
if (!controller.signal.aborted) logger.error("registration batch failed", error);
|
|
851
|
+
});
|
|
852
|
+
// Abort also unregisters tools already installed by this page's batch.
|
|
853
|
+
return () => controller.abort();
|
|
854
|
+
});
|