@xingwangzhe/stalux 1.35.12 → 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 +2 -1
- package/src/components/stalux/common/Typewriter.astro +32 -1
- package/src/components/stalux/posts/postContent.astro +7 -1
- package/src/index.ts +49 -101
- package/src/internal/font-slices.ts +125 -31
- 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 +10 -2
- 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 +4 -6
- package/src/pages/openapi.json.ts +3 -44
- package/src/pages/posts/[post].md.ts +4 -40
- package/src/scripts/navigation-font.ts +28 -0
- package/src/scripts/page-runtime.ts +20 -6
- package/src/scripts/pagefind-api.ts +63 -0
- package/src/scripts/view-transitions.ts +12 -1
- package/src/scripts/webmcp.ts +385 -136
- package/src/styles/base.css +7 -0
- package/src/styles/generated.css +1 -1
- package/src/utils/post-lookup.ts +23 -0
- package/src/utils/post-markdown.ts +31 -0
- package/src/utils/site-path.ts +4 -0
package/src/scripts/webmcp.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { createPostLookup } from "../utils/post-lookup";
|
|
2
|
-
import { createClientLogger } from "./logger";
|
|
3
|
-
|
|
4
1
|
/**
|
|
5
2
|
* Stalux WebMCP 工具注册(纯前端,零后端)
|
|
6
3
|
*
|
|
@@ -20,8 +17,13 @@ import { createClientLogger } from "./logger";
|
|
|
20
17
|
*/
|
|
21
18
|
// 浏览器无原生 document.modelContext 时由 polyfill 兜底安装;
|
|
22
19
|
// 有原生实现或已存在 WebMCP-aware 扩展时,polyfill 检测到后自动 no-op。
|
|
23
|
-
//
|
|
24
|
-
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";
|
|
25
27
|
|
|
26
28
|
const logger = createClientLogger("webmcp");
|
|
27
29
|
|
|
@@ -46,7 +48,10 @@ interface WebMCPTool {
|
|
|
46
48
|
readOnlyHint?: boolean;
|
|
47
49
|
untrustedContentHint?: boolean;
|
|
48
50
|
};
|
|
49
|
-
execute: (
|
|
51
|
+
execute: (
|
|
52
|
+
input: Record<string, unknown>,
|
|
53
|
+
options?: { signal?: AbortSignal },
|
|
54
|
+
) => Promise<unknown>;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
interface ModelContext {
|
|
@@ -88,7 +93,10 @@ function err(
|
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
async function fetchJSON(url: string): Promise<unknown> {
|
|
91
|
-
const r = await fetch(url, {
|
|
96
|
+
const r = await fetch(url, {
|
|
97
|
+
headers: { Accept: "application/json" },
|
|
98
|
+
signal: AbortSignal.timeout(15000),
|
|
99
|
+
});
|
|
92
100
|
if (!r.ok) {
|
|
93
101
|
logger.debug(`JSON request failed; status=${r.status}`);
|
|
94
102
|
return null;
|
|
@@ -96,27 +104,6 @@ async function fetchJSON(url: string): Promise<unknown> {
|
|
|
96
104
|
return r.json();
|
|
97
105
|
}
|
|
98
106
|
|
|
99
|
-
/** 动态加载 Pagefind 全文索引(仅首次调用,结果跨调用缓存) */
|
|
100
|
-
let pagefindLoadPromise: Promise<{ search: (q: string) => Promise<unknown> } | null> | null = null;
|
|
101
|
-
async function loadPagefind(): Promise<{ search: (q: string) => Promise<unknown> } | null> {
|
|
102
|
-
logger.debug(pagefindLoadPromise ? "Pagefind module cache hit" : "loading Pagefind module");
|
|
103
|
-
if (!pagefindLoadPromise) {
|
|
104
|
-
pagefindLoadPromise = (async () => {
|
|
105
|
-
try {
|
|
106
|
-
// 用完整 URL 构造,让 Vite 在 dev(vite:import-analysis)与 build 都不解析该路径;
|
|
107
|
-
// pagefind 索引是构建期产物,运行时从站点根加载。
|
|
108
|
-
const url = new URL("/pagefind/pagefind.js", location.origin).href;
|
|
109
|
-
const mod = await import(/* @vite-ignore */ url);
|
|
110
|
-
return mod.default ?? mod;
|
|
111
|
-
} catch {
|
|
112
|
-
logger.warn("Pagefind index unavailable; search requires a production index");
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
|
-
})();
|
|
116
|
-
}
|
|
117
|
-
return pagefindLoadPromise;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
107
|
const MAX_LIST_PAGE_SIZE = 50;
|
|
121
108
|
const MAX_SEARCH_RESULTS = 10;
|
|
122
109
|
|
|
@@ -151,9 +138,35 @@ function lookupFor(list: PostMeta[]) {
|
|
|
151
138
|
/** 加载文章元信息索引(惰性 + 缓存) */
|
|
152
139
|
function loadPostsMeta(): Promise<PostMeta[] | null> {
|
|
153
140
|
if (!postsMetaPromise) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
});
|
|
157
170
|
}
|
|
158
171
|
return postsMetaPromise;
|
|
159
172
|
}
|
|
@@ -178,15 +191,43 @@ function briefMeta(p: PostMeta): Record<string, unknown> {
|
|
|
178
191
|
categories: p.categories ?? [],
|
|
179
192
|
desc: p.desc ?? undefined,
|
|
180
193
|
wordCount: p.wordCount ?? undefined,
|
|
181
|
-
url: p.url,
|
|
194
|
+
url: new URL(p.url, location.origin).href,
|
|
182
195
|
};
|
|
183
196
|
}
|
|
184
197
|
|
|
185
198
|
/** 从当前 URL 解析 abbrlink(/posts/{abbrlink}/ 或 /posts/{abbrlink}.md) */
|
|
186
199
|
function currentAbbrlinkFromPath(): string | null {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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;
|
|
190
231
|
}
|
|
191
232
|
|
|
192
233
|
// ---------------------------------------------------------------------------
|
|
@@ -201,11 +242,11 @@ function listPostsTool(): WebMCPTool {
|
|
|
201
242
|
"分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink)、日期、分类、标签、摘要与文章页 URL。" +
|
|
202
243
|
"需要浏览全站文章、确认某篇文章的 abbrlink 时使用;不返回正文," +
|
|
203
244
|
"只要某篇元信息用 stalux_get_post,读正文请用 stalux_read_post。" +
|
|
204
|
-
"pageSize
|
|
245
|
+
"pageSize 可设 50 减少翻页,按 totalPages 继续分页;页码越界时会自动返回最后一页(clamped 标记)。",
|
|
205
246
|
"Paginated list of all published posts, returning title, permanent link (abbrlink), date, categories, tags, description and post page URL. " +
|
|
206
247
|
"Use it to browse the whole blog or find a post's abbrlink; it does not return body content — " +
|
|
207
248
|
"use stalux_get_post for one post's metadata, or stalux_read_post for the full body. " +
|
|
208
|
-
"
|
|
249
|
+
"Use pageSize 50 to reduce calls and follow totalPages to continue; out-of-range pages clamp to the last page (clamped flag).",
|
|
209
250
|
),
|
|
210
251
|
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
211
252
|
inputSchema: {
|
|
@@ -231,11 +272,16 @@ function listPostsTool(): WebMCPTool {
|
|
|
231
272
|
additionalProperties: false,
|
|
232
273
|
},
|
|
233
274
|
execute: async (input) => {
|
|
234
|
-
const page =
|
|
235
|
-
const pageSize =
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
);
|
|
239
285
|
const list = await loadPostsMeta();
|
|
240
286
|
if (!list)
|
|
241
287
|
return err(
|
|
@@ -286,8 +332,8 @@ function getPostTool(): WebMCPTool {
|
|
|
286
332
|
additionalProperties: false,
|
|
287
333
|
},
|
|
288
334
|
execute: async (input) => {
|
|
289
|
-
const id =
|
|
290
|
-
if (!id) {
|
|
335
|
+
const id = typeof input.id === "string" ? input.id.trim() : "";
|
|
336
|
+
if (!id || id.length > 512) {
|
|
291
337
|
return err(
|
|
292
338
|
"BAD_INPUT",
|
|
293
339
|
pick(
|
|
@@ -379,9 +425,11 @@ function searchPostsTool(): WebMCPTool {
|
|
|
379
425
|
title: pick("搜索博客文章", "Search blog posts"),
|
|
380
426
|
description: pick(
|
|
381
427
|
"用关键词搜索博客文章的标题、标签、分类与正文全文,返回命中文章的标题、链接与摘要片段。" +
|
|
428
|
+
"全文索引不可用时仅返回元信息匹配,并标记 fullText: false;不把索引故障当成全文无匹配。" +
|
|
382
429
|
"适合「博客里写过 X 吗」「帮我找一下关于 Y 的文章」这类问题;" +
|
|
383
430
|
"定位到具体文章后再用 stalux_read_post 读正文、stalux_get_post 取元信息。",
|
|
384
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. " +
|
|
385
433
|
"Ideal for questions like 'has the blog covered X?' or 'find posts about Y'; " +
|
|
386
434
|
"once a post is located, use stalux_read_post for the body or stalux_get_post for metadata.",
|
|
387
435
|
),
|
|
@@ -404,48 +452,98 @@ function searchPostsTool(): WebMCPTool {
|
|
|
404
452
|
required: ["keyword"],
|
|
405
453
|
additionalProperties: false,
|
|
406
454
|
},
|
|
407
|
-
execute: async (input) => {
|
|
408
|
-
const keyword =
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
+
);
|
|
412
492
|
const pagefind = await loadPagefind();
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
);
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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;
|
|
444
533
|
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
+
}
|
|
449
547
|
},
|
|
450
548
|
};
|
|
451
549
|
}
|
|
@@ -453,19 +551,32 @@ function searchPostsTool(): WebMCPTool {
|
|
|
453
551
|
function readPostTool(): WebMCPTool {
|
|
454
552
|
return {
|
|
455
553
|
name: "stalux_read_post",
|
|
456
|
-
title: pick("
|
|
554
|
+
title: pick("读取文章内容", "Read post content"),
|
|
457
555
|
description: pick(
|
|
458
|
-
"按 abbrlink
|
|
459
|
-
"
|
|
556
|
+
"按 abbrlink 读取文章。开启 Markdown 导出时返回规范化 Markdown,否则从 Pagefind 返回已索引纯文本(不含 Markdown 语法)。" +
|
|
557
|
+
"format 标明格式;truncated 为 true 时用 nextOffset 续读,直到 nextOffset 为 null。只要元信息用 stalux_get_post(更便宜)。" +
|
|
460
558
|
"获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
|
|
461
|
-
"Read
|
|
462
|
-
"
|
|
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. " +
|
|
463
561
|
"To get an abbrlink, first use stalux_list_posts or stalux_search_posts.",
|
|
464
562
|
),
|
|
465
563
|
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
466
564
|
inputSchema: {
|
|
467
565
|
type: "object",
|
|
468
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
|
+
},
|
|
469
580
|
id: {
|
|
470
581
|
type: "string",
|
|
471
582
|
description: pick(
|
|
@@ -477,22 +588,122 @@ function readPostTool(): WebMCPTool {
|
|
|
477
588
|
required: ["id"],
|
|
478
589
|
additionalProperties: false,
|
|
479
590
|
},
|
|
480
|
-
execute: async (input) => {
|
|
481
|
-
const id =
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const r = await fetch(url, { headers: { Accept: "text/markdown" } });
|
|
486
|
-
if (r.status === 404)
|
|
487
|
-
return err("NOT_FOUND", pick("未找到文章: ", "Post not found: ") + id);
|
|
488
|
-
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)
|
|
489
596
|
return err(
|
|
490
|
-
"
|
|
491
|
-
pick(
|
|
597
|
+
"BAD_INPUT",
|
|
598
|
+
pick(
|
|
599
|
+
"请提供有效的文章 ID 和读取范围",
|
|
600
|
+
"Provide a valid post ID and content range",
|
|
601
|
+
),
|
|
492
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
|
+
}
|
|
493
627
|
}
|
|
494
|
-
|
|
495
|
-
|
|
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
|
+
}
|
|
658
|
+
}
|
|
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
|
+
};
|
|
496
707
|
},
|
|
497
708
|
};
|
|
498
709
|
}
|
|
@@ -525,15 +736,17 @@ function siteInfoTool(): WebMCPTool {
|
|
|
525
736
|
url: info.url ?? "",
|
|
526
737
|
description: info.description ?? "",
|
|
527
738
|
lang: info.lang ?? "",
|
|
528
|
-
llms:
|
|
529
|
-
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",
|
|
530
743
|
};
|
|
531
744
|
},
|
|
532
745
|
};
|
|
533
746
|
}
|
|
534
747
|
|
|
535
748
|
function buildTools(): WebMCPTool[] {
|
|
536
|
-
|
|
749
|
+
const tools: WebMCPTool[] = [
|
|
537
750
|
listPostsTool(),
|
|
538
751
|
getPostTool(),
|
|
539
752
|
currentPostTool(),
|
|
@@ -542,16 +755,46 @@ function buildTools(): WebMCPTool[] {
|
|
|
542
755
|
readPostTool(),
|
|
543
756
|
siteInfoTool(),
|
|
544
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
|
+
});
|
|
545
789
|
}
|
|
546
790
|
|
|
547
791
|
// ---------------------------------------------------------------------------
|
|
548
792
|
// 注册逻辑
|
|
549
793
|
// ---------------------------------------------------------------------------
|
|
550
794
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
async function registerTools(): Promise<{ registered: string[]; reason?: string }> {
|
|
795
|
+
async function registerTools(
|
|
796
|
+
signal: AbortSignal,
|
|
797
|
+
): Promise<{ registered: string[]; reason?: string }> {
|
|
555
798
|
const doc = document as DocumentWithModelContext;
|
|
556
799
|
const ctx = doc.modelContext;
|
|
557
800
|
if (!ctx || typeof ctx.registerTool !== "function") {
|
|
@@ -559,25 +802,28 @@ async function registerTools(): Promise<{ registered: string[]; reason?: string
|
|
|
559
802
|
return { registered: [], reason: "unsupported" };
|
|
560
803
|
}
|
|
561
804
|
|
|
562
|
-
// 注销上一轮注册(View Transitions 软导航后 modelContext 可能已重置)
|
|
563
|
-
if (activeController) {
|
|
564
|
-
try {
|
|
565
|
-
activeController.abort();
|
|
566
|
-
} catch {
|
|
567
|
-
logger.debug("previous registration cleanup unavailable");
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
const controller = new AbortController();
|
|
571
|
-
activeController = controller;
|
|
572
|
-
|
|
573
805
|
const tools = buildTools();
|
|
574
806
|
const registered: string[] = [];
|
|
575
807
|
// 逐个注册:单个失败不中断其余工具
|
|
576
808
|
for (const tool of tools) {
|
|
809
|
+
if (signal.aborted) break;
|
|
577
810
|
try {
|
|
578
|
-
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;
|
|
579
824
|
registered.push(tool.name);
|
|
580
825
|
} catch (e) {
|
|
826
|
+
if (signal.aborted) break;
|
|
581
827
|
logger.error(`tool registration failed: ${tool.name}`, e);
|
|
582
828
|
}
|
|
583
829
|
}
|
|
@@ -588,18 +834,21 @@ async function registerTools(): Promise<{ registered: string[]; reason?: string
|
|
|
588
834
|
// 挂载与自动注册
|
|
589
835
|
// ---------------------------------------------------------------------------
|
|
590
836
|
|
|
591
|
-
|
|
592
|
-
//
|
|
593
|
-
document.
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
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
|
+
});
|