openclaw-server 0.1.0 → 0.2.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.
@@ -0,0 +1,235 @@
1
+ import type { BookmarkSearchMessageInspection } from "./types.js";
2
+
3
+ const SEARCH_COMMAND_PATTERNS = [
4
+ /^(?:请|麻烦|帮我|给我|我想|我要|想|帮忙)?\s*(?:在)?(?:书签篮|书签栏|书签蓝)?(?:里)?\s*(?:搜索|搜一下|搜下|搜个|搜搜|搜)\s*[::]?\s*(.+)$/iu,
5
+ /^(?:请|麻烦|帮我|给我|我想|我要|想|帮忙)?\s*(?:在)?(?:书签篮|书签栏|书签蓝)?(?:里)?\s*(?:查找|查询)\s*[::]?\s*(.+)$/iu,
6
+ /^(?:请|麻烦|帮我|给我|我想|我要|想|帮忙)?\s*(?:在)?(?:书签篮|书签栏|书签蓝)?(?:里)?\s*(?:找一下|找找|找篇|找一篇|找)\s*[::]?\s*(.+)$/iu,
7
+ /^(?:search|find|look up)\s+(.+)$/iu,
8
+ ];
9
+
10
+ const LATEST_COMMAND_PATTERNS = [
11
+ /^(?:请|麻烦|帮我|给我|我想|我要|想|帮忙)?\s*(?:在)?(?:书签篮|书签栏|书签蓝)?(?:里)?\s*(?:看看|查看|列出|展示|打开|来点)?\s*(?:最新更新|最近更新|最新文章|最近文章|全部文章|最新资源|最近资源)\s*$/iu,
12
+ ];
13
+
14
+ const CATEGORY_LIST_PATTERNS = [
15
+ /^(?:请|麻烦|帮我|给我|我想|我要|想|帮忙)?\s*(?:在)?(?:书签篮|书签栏|书签蓝)?(?:里)?\s*(?:看看|查看|列出|展示|打开)?\s*(?:分类|分类列表|分类目录|分类导航|一级分类|一级分类列表)\s*$/iu,
16
+ /^(?:在)?(?:书签篮|书签栏|书签蓝)?(?:里)?(?:有|有哪些)(?:什么)?(?:分类|一级分类)\s*$/iu,
17
+ ];
18
+
19
+ const CATEGORY_LINK_PATTERNS = [
20
+ /^(?:请|麻烦|帮我|给我|我想|我要|想|帮忙)?\s*(?:在)?(?:书签篮|书签栏|书签蓝)?(?:里)?\s*(?:看看|查看|列出|展示|打开)?\s*(?:分类|栏目|目录)\s*[::]?\s*(.+?)\s*(?:的)?(?:常用链接|常用网站|常用站点|常用网址)\s*$/iu,
21
+ /^(?:请|麻烦|帮我|给我|我想|我要|想|帮忙)?\s*(?:在)?(?:书签篮|书签栏|书签蓝)?(?:里)?\s*(?:看看|查看|列出|展示|打开)?\s*(.+?)\s*(?:分类|栏目|目录)?(?:的)?(?:常用链接|常用网站|常用站点|常用网址)\s*$/iu,
22
+ ];
23
+
24
+ const CATEGORY_ARTICLE_PATTERNS = [
25
+ /^(?:请|麻烦|帮我|给我|我想|我要|想|帮忙)?\s*(?:在)?(?:书签篮|书签栏|书签蓝)?(?:里)?\s*(?:看看|查看|列出|展示|打开|推荐)?\s*(?:分类|栏目|目录)\s*[::]?\s*(.+?)\s*(?:的)?(?:文章|资源|内容)?\s*$/iu,
26
+ /^(?:请|麻烦|帮我|给我|我想|我要|想|帮忙)?\s*(?:在)?(?:书签篮|书签栏|书签蓝)?(?:里)?\s*(?:看看|查看|列出|展示|打开|推荐)?\s*(.+?)\s*(?:分类|栏目|目录)\s*(?:的)?(?:文章|资源|内容)?\s*$/iu,
27
+ ];
28
+
29
+ const SEARCH_HINT_PATTERN =
30
+ /(?:书签篮|书签栏|书签蓝|搜索|搜一下|搜下|搜个|搜搜|查找|查询|search|find|look up)/iu;
31
+
32
+ const IMPLICIT_SEARCH_EXACT_BLOCKLIST = new Set([
33
+ "你好",
34
+ "您好",
35
+ "嗨",
36
+ "哈喽",
37
+ "hello",
38
+ "hi",
39
+ "hey",
40
+ "在吗",
41
+ "帮助",
42
+ "help",
43
+ "谢谢",
44
+ "thanks",
45
+ "thank you",
46
+ "再见",
47
+ "bye",
48
+ ]);
49
+
50
+ const IMPLICIT_SEARCH_PREFIX_BLOCK_PATTERN =
51
+ /^(?:你|我|请|帮我|麻烦|怎么|如何|为什么|什么|谁|哪|几|能不能|可以|会不会|有没有|要不要|是否|介绍一下|说一下|告诉我)/iu;
52
+
53
+ const IMPLICIT_SEARCH_QUESTION_PATTERN = /(?:如何|怎么样|怎样|咋样|吗|呢|么|嘛|\?|?)/iu;
54
+
55
+ const EMPTY_QUERY_TOKENS = new Set([
56
+ "书签",
57
+ "书签篮",
58
+ "书签栏",
59
+ "书签蓝",
60
+ "文章",
61
+ "分类",
62
+ "结果",
63
+ "内容",
64
+ "链接",
65
+ "网址",
66
+ "资料",
67
+ "一下",
68
+ ]);
69
+
70
+ function cleanupFreeText(query: string): string {
71
+ return query
72
+ .normalize("NFKC")
73
+ .replace(/^[\s"'`“”‘’《》【】()()]+|[\s"'`“”‘’《》【】()()]+$/gu, "")
74
+ .replace(/^(?:关于|有关|相关|相关文章|相关结果|相关内容|分类|栏目|目录)\s*/iu, "")
75
+ .replace(/\s*(?:相关的)?(?:书签|文章|结果|内容|链接|网址|资料)\s*$/iu, "")
76
+ .replace(/\s+/g, " ")
77
+ .trim();
78
+ }
79
+
80
+ function cleanupSearchQuery(query: string): string {
81
+ return cleanupFreeText(query);
82
+ }
83
+
84
+ function cleanupCategoryName(query: string): string {
85
+ return cleanupFreeText(query)
86
+ .replace(/\s*(?:分类|栏目|目录)\s*$/iu, "")
87
+ .trim();
88
+ }
89
+
90
+ function isMeaningfulSearchQuery(query: string): boolean {
91
+ const normalized = cleanupSearchQuery(query);
92
+ if (!normalized) {
93
+ return false;
94
+ }
95
+
96
+ return !EMPTY_QUERY_TOKENS.has(normalized.toLowerCase());
97
+ }
98
+
99
+ function extractImplicitBookmarkSearchQuery(text: string): string | undefined {
100
+ const query = cleanupSearchQuery(text);
101
+ if (!isMeaningfulSearchQuery(query)) {
102
+ return undefined;
103
+ }
104
+
105
+ const normalized = query.toLowerCase();
106
+ if (IMPLICIT_SEARCH_EXACT_BLOCKLIST.has(normalized)) {
107
+ return undefined;
108
+ }
109
+
110
+ if (IMPLICIT_SEARCH_PREFIX_BLOCK_PATTERN.test(query)) {
111
+ return undefined;
112
+ }
113
+
114
+ if (IMPLICIT_SEARCH_QUESTION_PATTERN.test(query)) {
115
+ return undefined;
116
+ }
117
+
118
+ if (query.includes("\n") || query.length > 40) {
119
+ return undefined;
120
+ }
121
+
122
+ const wordCount = query.split(/\s+/u).filter(Boolean).length;
123
+ if (wordCount > 6) {
124
+ return undefined;
125
+ }
126
+
127
+ return query;
128
+ }
129
+
130
+ export function extractBookmarkSearchQuery(text: string): string | undefined {
131
+ for (const pattern of SEARCH_COMMAND_PATTERNS) {
132
+ const match = text.match(pattern);
133
+ if (!match?.[1]) {
134
+ continue;
135
+ }
136
+
137
+ const query = cleanupSearchQuery(match[1]);
138
+ if (isMeaningfulSearchQuery(query)) {
139
+ return query;
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ return undefined;
145
+ }
146
+
147
+ function extractBookmarkCategoryName(
148
+ patterns: RegExp[],
149
+ text: string,
150
+ ): string | undefined {
151
+ for (const pattern of patterns) {
152
+ const match = text.match(pattern);
153
+ if (!match?.[1]) {
154
+ continue;
155
+ }
156
+
157
+ const category = cleanupCategoryName(match[1]);
158
+ if (category) {
159
+ return category;
160
+ }
161
+ }
162
+
163
+ return undefined;
164
+ }
165
+
166
+ export function inspectBookmarkSearchQuery(text: string): BookmarkSearchMessageInspection {
167
+ const explicitSearchQuery = extractBookmarkSearchQuery(text);
168
+ if (explicitSearchQuery) {
169
+ return {
170
+ shouldHandle: true,
171
+ reason: "query",
172
+ action: "search",
173
+ query: explicitSearchQuery,
174
+ };
175
+ }
176
+
177
+ if (LATEST_COMMAND_PATTERNS.some((pattern) => pattern.test(text))) {
178
+ return {
179
+ shouldHandle: true,
180
+ reason: "query",
181
+ action: "latest",
182
+ };
183
+ }
184
+
185
+ if (CATEGORY_LIST_PATTERNS.some((pattern) => pattern.test(text))) {
186
+ return {
187
+ shouldHandle: true,
188
+ reason: "query",
189
+ action: "categories",
190
+ };
191
+ }
192
+
193
+ const categoryForLinks = extractBookmarkCategoryName(CATEGORY_LINK_PATTERNS, text);
194
+ if (categoryForLinks) {
195
+ return {
196
+ shouldHandle: true,
197
+ reason: "query",
198
+ action: "category_links",
199
+ category: categoryForLinks,
200
+ };
201
+ }
202
+
203
+ const categoryForArticles = extractBookmarkCategoryName(CATEGORY_ARTICLE_PATTERNS, text);
204
+ if (categoryForArticles) {
205
+ return {
206
+ shouldHandle: true,
207
+ reason: "query",
208
+ action: "category_articles",
209
+ category: categoryForArticles,
210
+ };
211
+ }
212
+
213
+ const query = extractImplicitBookmarkSearchQuery(text);
214
+ if (query) {
215
+ return {
216
+ shouldHandle: true,
217
+ reason: "query",
218
+ action: "search",
219
+ query,
220
+ };
221
+ }
222
+
223
+ if (SEARCH_HINT_PATTERN.test(text)) {
224
+ return {
225
+ shouldHandle: true,
226
+ reason: "query",
227
+ missing: "query",
228
+ };
229
+ }
230
+
231
+ return {
232
+ shouldHandle: false,
233
+ reason: "no_match",
234
+ };
235
+ }
@@ -0,0 +1,330 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { BookmarkSearchService } from "./service.js";
3
+ import type { BookmarkCategory, BookmarkLink, BookmarkSearchProvider } from "./types.js";
4
+
5
+ type ProviderSearchRecord = {
6
+ title: string;
7
+ type?: string;
8
+ desc?: string;
9
+ url: string;
10
+ articleCount?: number;
11
+ firstCategory?: string;
12
+ secondCategory?: string;
13
+ updatedAt?: string;
14
+ };
15
+
16
+ function createProvider(
17
+ records: Record<string, ProviderSearchRecord[]>,
18
+ extras: {
19
+ latestArticles?: ProviderSearchRecord[];
20
+ latestBrowseUrl?: string;
21
+ categories?: BookmarkCategory[];
22
+ categoryArticles?: Record<string, ProviderSearchRecord[]>;
23
+ categoryLinks?: Record<string, BookmarkLink[]>;
24
+ } = {},
25
+ ): BookmarkSearchProvider {
26
+ const toItem = (item: ProviderSearchRecord) => ({
27
+ articleCount: item.articleCount,
28
+ desc: item.desc,
29
+ firstCategory: item.firstCategory,
30
+ secondCategory: item.secondCategory,
31
+ type: item.type ?? "文章",
32
+ title: item.title,
33
+ updatedAt: item.updatedAt,
34
+ url: item.url,
35
+ });
36
+
37
+ return {
38
+ searchKeyword: vi.fn(async ({ keyword }) =>
39
+ (records[keyword] ?? []).map((item) => toItem(item)),
40
+ ),
41
+ listLatestArticles: vi.fn(async () => ({
42
+ items: (extras.latestArticles ?? []).map((item) => toItem(item)),
43
+ total: extras.latestArticles?.length ?? 0,
44
+ browseUrl: extras.latestBrowseUrl,
45
+ })),
46
+ listTopCategories: vi.fn(async () => extras.categories ?? []),
47
+ listCategoryArticles: vi.fn(async ({ category }) => ({
48
+ items: (extras.categoryArticles?.[category.name] ?? []).map((item) => toItem(item)),
49
+ total: extras.categoryArticles?.[category.name]?.length ?? 0,
50
+ browseUrl: category.url,
51
+ })),
52
+ listCategoryLinks: vi.fn(async ({ category }) => extras.categoryLinks?.[category.name] ?? []),
53
+ };
54
+ }
55
+
56
+ describe("BookmarkSearchService", () => {
57
+ it("renders only article results with linked titles and plain descriptions", async () => {
58
+ const service = new BookmarkSearchService(
59
+ createProvider({
60
+ python: [
61
+ {
62
+ title: "Python 教程:循环语句",
63
+ desc: "整理了 Python 循环语句、break 和 continue 的常见用法。",
64
+ type: "文章",
65
+ url: "https://shuqianlan.com/static-article/detail-html/python-loop.html",
66
+ },
67
+ {
68
+ title: "Python 资源合集",
69
+ type: "二级分类",
70
+ articleCount: 8,
71
+ url: "https://shuqianlan.com/static-article/cate-page/second/python.html",
72
+ },
73
+ ],
74
+ }),
75
+ );
76
+
77
+ const reply = await service.processMessage({ userId: "u1", text: "搜 python" });
78
+ expect(reply.intent).toBe("search_results");
79
+ expect(reply.reply).toContain("找到 1 条“python”相关结果");
80
+ expect(reply.reply).toContain("[Python 教程:循环语句](https://shuqianlan.com/static-article/detail-html/python-loop.html)");
81
+ expect(reply.reply).toContain("简介:整理了 Python 循环语句、break 和 continue 的常见用法。");
82
+ expect(reply.reply).not.toContain("[整理了 Python 循环语句、break 和 continue 的常见用法。]");
83
+ expect(reply.reply).not.toContain("文章");
84
+ expect(reply.reply).not.toContain("Python 资源合集");
85
+ expect(reply.reply).not.toContain("cate-page/second/python.html");
86
+ });
87
+
88
+ it("supports bare-keyword bookmark searches", async () => {
89
+ const service = new BookmarkSearchService(
90
+ createProvider({
91
+ 百度: [
92
+ {
93
+ title: "百度一下,你就知道",
94
+ desc: "全球领先的中文搜索引擎。",
95
+ url: "https://shuqianlan.com/static-article/detail-html/baidu.html",
96
+ },
97
+ ],
98
+ }),
99
+ );
100
+
101
+ const reply = await service.processMessage({ userId: "u1", text: "百度" });
102
+ expect(reply.intent).toBe("search_results");
103
+ expect(reply.reply).toContain("百度一下,你就知道");
104
+ expect(reply.reply).toContain("全球领先的中文搜索引擎。");
105
+ });
106
+
107
+ it("falls back to token search and keeps the strongest intersection matches", async () => {
108
+ const service = new BookmarkSearchService(
109
+ createProvider({
110
+ notion: [
111
+ {
112
+ title: "Notion",
113
+ url: "https://shuqianlan.com/static-article/detail-html/notion.html",
114
+ },
115
+ {
116
+ title: "Notion AI",
117
+ url: "https://shuqianlan.com/static-article/detail-html/notion-ai.html",
118
+ },
119
+ ],
120
+ ai: [
121
+ {
122
+ title: "Notion AI",
123
+ url: "https://shuqianlan.com/static-article/detail-html/notion-ai.html",
124
+ },
125
+ {
126
+ title: "Midjourney",
127
+ url: "https://shuqianlan.com/static-article/detail-html/midjourney.html",
128
+ },
129
+ ],
130
+ }),
131
+ );
132
+
133
+ const reply = await service.processMessage({ userId: "u2", text: "搜索 notion ai" });
134
+ expect(reply.intent).toBe("search_results");
135
+ expect(reply.reply).toContain("Notion AI");
136
+ expect(reply.reply).not.toContain("Midjourney");
137
+ });
138
+
139
+ it("falls back to token search when exact matches only contain categories", async () => {
140
+ const service = new BookmarkSearchService(
141
+ createProvider({
142
+ "cesium tutorial": [
143
+ {
144
+ title: "Cesium 教程分类",
145
+ type: "二级分类",
146
+ articleCount: 5,
147
+ url: "https://shuqianlan.com/static-article/cate-page/second/cesium.html",
148
+ },
149
+ ],
150
+ cesium: [
151
+ {
152
+ title: "Cesium 获取地形高度 全面深度教程",
153
+ desc: "覆盖地形高度采样、坐标转换和实际项目中的精度处理。",
154
+ type: "文章",
155
+ url: "https://shuqianlan.com/static-article/detail-html/cesium-height.html",
156
+ },
157
+ ],
158
+ tutorial: [
159
+ {
160
+ title: "Cesium 获取地形高度 全面深度教程",
161
+ desc: "覆盖地形高度采样、坐标转换和实际项目中的精度处理。",
162
+ type: "文章",
163
+ url: "https://shuqianlan.com/static-article/detail-html/cesium-height.html",
164
+ },
165
+ ],
166
+ }),
167
+ );
168
+
169
+ const reply = await service.processMessage({ userId: "u2", text: "搜索 cesium tutorial" });
170
+ expect(reply.intent).toBe("search_results");
171
+ expect(reply.reply).toContain("Cesium 获取地形高度 全面深度教程");
172
+ expect(reply.reply).not.toContain("Cesium 教程分类");
173
+ });
174
+
175
+ it("numbers multiple article results while keeping only titles clickable", async () => {
176
+ const service = new BookmarkSearchService(
177
+ createProvider({
178
+ react: [
179
+ {
180
+ title: "React 状态管理清单",
181
+ desc: "梳理 useState、context 和状态拆分时的常见取舍与边界。",
182
+ type: "文章",
183
+ url: "https://shuqianlan.com/static-article/detail-html/react-state.html",
184
+ },
185
+ {
186
+ title: "React 性能排查指南",
187
+ desc: "整理定位重渲染、异步更新和列表卡顿时最常用的排查步骤。",
188
+ type: "文章",
189
+ url: "https://shuqianlan.com/static-article/detail-html/react-performance.html",
190
+ },
191
+ ],
192
+ }),
193
+ );
194
+
195
+ const reply = await service.processMessage({ userId: "u7", text: "搜 react" });
196
+ expect(reply.intent).toBe("search_results");
197
+ expect(reply.reply).toContain("1、[React 状态管理清单](https://shuqianlan.com/static-article/detail-html/react-state.html)");
198
+ expect(reply.reply).toContain("2、[React 性能排查指南](https://shuqianlan.com/static-article/detail-html/react-performance.html)");
199
+ expect(reply.reply).toContain(
200
+ "1、[React 状态管理清单](https://shuqianlan.com/static-article/detail-html/react-state.html) \n简介:梳理 useState、context 和状态拆分时的常见取舍与边...",
201
+ );
202
+ expect(reply.reply).toContain(
203
+ "简介:梳理 useState、context 和状态拆分时的常见取舍与边...\n\n2、[React 性能排查指南](https://shuqianlan.com/static-article/detail-html/react-performance.html)",
204
+ );
205
+ expect(reply.reply).toContain("简介:梳理 useState、context 和状态拆分时的常见取舍与边...");
206
+ expect(reply.reply).not.toContain("[梳理 useState、context 和状态拆分时的常见取舍与边...");
207
+ });
208
+
209
+ it("lists latest updated articles from the bookmark directory", async () => {
210
+ const service = new BookmarkSearchService(
211
+ createProvider(
212
+ {},
213
+ {
214
+ latestArticles: [
215
+ {
216
+ title: "Cesium 获取地形高度 全面深度教程",
217
+ desc: "覆盖地形高度采样、坐标转换和实际项目中的精度处理。",
218
+ firstCategory: "开发工具",
219
+ secondCategory: "地图",
220
+ updatedAt: "2026-03-13 18:20:00",
221
+ url: "https://shuqianlan.com/static-article/detail-html/cesium-height.html",
222
+ },
223
+ ],
224
+ latestBrowseUrl: "https://shuqianlan.com/static-article/page/page_1.html",
225
+ },
226
+ ),
227
+ );
228
+
229
+ const reply = await service.processMessage({ userId: "u3", text: "最新文章" });
230
+ expect(reply.intent).toBe("latest_results");
231
+ expect(reply.reply).toContain("最近更新");
232
+ expect(reply.reply).toContain(
233
+ "[Cesium 获取地形高度 全面深度教程](https://shuqianlan.com/static-article/detail-html/cesium-height.html)",
234
+ );
235
+ expect(reply.reply).not.toContain("开发工具 > 地图");
236
+ expect(reply.reply).not.toContain("更新:2026-03-13 18:20:00");
237
+ expect(reply.reply).toContain("[查看全部文章](https://shuqianlan.com/static-article/page/page_1.html)");
238
+ });
239
+
240
+ it("lists first-level categories from the bookmark directory", async () => {
241
+ const service = new BookmarkSearchService(
242
+ createProvider(
243
+ {},
244
+ {
245
+ categories: [
246
+ {
247
+ name: "开发工具",
248
+ articleCount: 12,
249
+ url: "https://shuqianlan.com/static-article/cate-page/first/dev-tools.html",
250
+ },
251
+ {
252
+ name: "设计灵感",
253
+ articleCount: 8,
254
+ url: "https://shuqianlan.com/static-article/cate-page/first/design.html",
255
+ },
256
+ ],
257
+ },
258
+ ),
259
+ );
260
+
261
+ const reply = await service.processMessage({ userId: "u4", text: "分类列表" });
262
+ expect(reply.intent).toBe("category_list");
263
+ expect(reply.reply).toContain("一级分类");
264
+ expect(reply.reply).toContain("1、[开发工具](https://shuqianlan.com/static-article/cate-page/first/dev-tools.html)(12 篇)");
265
+ expect(reply.reply).toContain("前端分类");
266
+ });
267
+
268
+ it("shows category articles and common links", async () => {
269
+ const service = new BookmarkSearchService(
270
+ createProvider(
271
+ {},
272
+ {
273
+ categories: [
274
+ {
275
+ name: "开发工具",
276
+ articleCount: 12,
277
+ url: "https://shuqianlan.com/static-article/cate-page/first/dev-tools.html",
278
+ },
279
+ ],
280
+ categoryArticles: {
281
+ 开发工具: [
282
+ {
283
+ title: "Vite 构建优化指南",
284
+ desc: "整理了依赖预构建、拆包和生产构建性能优化的常见做法。",
285
+ firstCategory: "开发工具",
286
+ secondCategory: "前端工程化",
287
+ updatedAt: "2026-03-12 09:30:00",
288
+ url: "https://shuqianlan.com/static-article/detail-html/vite-guide.html",
289
+ },
290
+ ],
291
+ },
292
+ categoryLinks: {
293
+ 开发工具: [
294
+ {
295
+ name: "Vite",
296
+ url: "https://vite.dev",
297
+ },
298
+ {
299
+ name: "TypeScript",
300
+ url: "https://www.typescriptlang.org",
301
+ },
302
+ ],
303
+ },
304
+ },
305
+ ),
306
+ );
307
+
308
+ const categoryReply = await service.processMessage({ userId: "u5", text: "开发工具分类" });
309
+ expect(categoryReply.intent).toBe("category_results");
310
+ expect(categoryReply.reply).toContain("“开发工具”文章");
311
+ expect(categoryReply.reply).toContain("开发工具 > 前端工程化 · 更新:2026-03-12 09:30:00");
312
+ expect(categoryReply.reply).toContain("[打开“开发工具”分类页](https://shuqianlan.com/static-article/cate-page/first/dev-tools.html)");
313
+
314
+ const linksReply = await service.processMessage({ userId: "u6", text: "开发工具常用链接" });
315
+ expect(linksReply.intent).toBe("category_links");
316
+ expect(linksReply.reply).toContain("1、[Vite](https://vite.dev)");
317
+ expect(linksReply.reply).toContain("2、[TypeScript](https://www.typescriptlang.org)");
318
+ });
319
+
320
+ it("asks for a query when the user only says to search", async () => {
321
+ const service = new BookmarkSearchService(createProvider({}));
322
+
323
+ const firstReply = await service.processMessage({ userId: "u3", text: "帮我搜一下" });
324
+ expect(firstReply.intent).toBe("clarify");
325
+
326
+ const secondReply = await service.processMessage({ userId: "u3", text: "cesium" });
327
+ expect(secondReply.intent).toBe("not_found");
328
+ expect(secondReply.reply).toContain("cesium");
329
+ });
330
+ });