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.
- package/README.md +319 -0
- package/package.json +1 -1
- package/packs/default/templates.yaml +6 -6
- package/src/bookmark-digest/chat-integration.ts +49 -0
- package/src/bookmark-digest/service.test.ts +92 -0
- package/src/bookmark-digest/service.ts +573 -0
- package/src/bookmark-digest/store.ts +349 -0
- package/src/bookmark-digest/types.ts +62 -0
- package/src/bookmark-search/chat-integration.ts +56 -0
- package/src/bookmark-search/parser.test.ts +67 -0
- package/src/bookmark-search/parser.ts +235 -0
- package/src/bookmark-search/service.test.ts +330 -0
- package/src/bookmark-search/service.ts +660 -0
- package/src/bookmark-search/shuqianlan-provider.ts +334 -0
- package/src/bookmark-search/types.ts +78 -0
- package/src/config.ts +30 -2
- package/src/debug-log.ts +22 -18
- package/src/request-user.test.ts +29 -0
- package/src/request-user.ts +49 -0
- package/src/routes/admin.ts +53 -0
- package/src/routes/chat-completions.ts +42 -46
- package/src/routes/responses.ts +44 -47
- package/src/server.test.ts +336 -440
- package/src/server.ts +26 -18
- package/readme.md +0 -1219
- package/src/routes/tasks.ts +0 -138
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import { extractBookmarkSearchQuery, inspectBookmarkSearchQuery } from "./parser.js";
|
|
2
|
+
import type {
|
|
3
|
+
BookmarkArticlePage,
|
|
4
|
+
BookmarkCategory,
|
|
5
|
+
BookmarkSearchChatResult,
|
|
6
|
+
BookmarkSearchConversationState,
|
|
7
|
+
BookmarkSearchItem,
|
|
8
|
+
BookmarkSearchMessageInspection,
|
|
9
|
+
BookmarkSearchProvider,
|
|
10
|
+
BookmarkLink,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
type RankedSearchItem = BookmarkSearchItem & {
|
|
14
|
+
matchedTerms: Set<string>;
|
|
15
|
+
firstRank: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const ITEM_DESC_LIMIT = 40;
|
|
19
|
+
const MARKDOWN_HARD_BREAK = " \n";
|
|
20
|
+
|
|
21
|
+
function normalizeSearchTerm(value: string): string {
|
|
22
|
+
return value.normalize("NFKC").trim().toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeCategoryKey(value: string): string {
|
|
26
|
+
return normalizeSearchTerm(value).replace(/\s+/g, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function escapeMarkdownLinkLabel(text: string): string {
|
|
30
|
+
return text.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isArticleItem(item: BookmarkSearchItem): boolean {
|
|
34
|
+
return item.type.includes("文章");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function typePriority(type: string): number {
|
|
38
|
+
if (type.includes("文章")) {
|
|
39
|
+
return 3;
|
|
40
|
+
}
|
|
41
|
+
if (type.includes("二级分类")) {
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
if (type.includes("一级分类")) {
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function summarizeItemDesc(item: BookmarkSearchItem): string | undefined {
|
|
51
|
+
const normalizedDesc = item.desc?.replace(/\s+/g, " ").trim();
|
|
52
|
+
if (!normalizedDesc) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (normalizeSearchTerm(normalizedDesc) === normalizeSearchTerm(item.title)) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return normalizedDesc.length > ITEM_DESC_LIMIT
|
|
61
|
+
? `${normalizedDesc.slice(0, ITEM_DESC_LIMIT).trimEnd()}...`
|
|
62
|
+
: normalizedDesc;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function summarizeItemMeta(item: BookmarkSearchItem): string | undefined {
|
|
66
|
+
const parts: string[] = [];
|
|
67
|
+
if (item.firstCategory && item.secondCategory) {
|
|
68
|
+
parts.push(`${item.firstCategory} > ${item.secondCategory}`);
|
|
69
|
+
} else if (item.firstCategory) {
|
|
70
|
+
parts.push(item.firstCategory);
|
|
71
|
+
} else if (item.secondCategory) {
|
|
72
|
+
parts.push(item.secondCategory);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (item.updatedAt) {
|
|
76
|
+
parts.push(`更新:${item.updatedAt}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderClarifyReply(): string {
|
|
83
|
+
return "书签篮是一个把常用网站和资料按分类整理起来的书签导航,也支持搜索、导入导出和去重。你可以直接输入关键词,比如“百度”“python 教程”或“Notion AI”,我会帮你从书签篮里找相关结果。";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderNotFoundReply(query: string): string {
|
|
87
|
+
return `书签篮里还没找到和“${query}”相关的书签。你可以换更短的关键词再试,比如“python”“cesium”或“教程”。`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderCategoryNotFoundReply(category: string): string {
|
|
91
|
+
return `书签篮里暂时没找到“${category}”这个分类。你可以先让我列出分类列表,再继续看某个分类的文章或常用链接。`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderActionErrorReply(subject: string): string {
|
|
95
|
+
return `书签篮暂时不可用,刚刚没能完成“${subject}”的查询。请稍后重试。`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatIndexedLine(index: number, content: string, numbered: boolean): string {
|
|
99
|
+
return numbered ? `${index + 1}、${content}` : content;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderArticleBlocks(params: {
|
|
103
|
+
items: BookmarkSearchItem[];
|
|
104
|
+
limit: number;
|
|
105
|
+
includeMeta?: boolean;
|
|
106
|
+
}): string[] {
|
|
107
|
+
const visibleItems = params.items.slice(0, params.limit);
|
|
108
|
+
const numbered = visibleItems.length > 1;
|
|
109
|
+
|
|
110
|
+
return visibleItems.map((item, index) => {
|
|
111
|
+
const lines = [
|
|
112
|
+
formatIndexedLine(
|
|
113
|
+
index,
|
|
114
|
+
`[${escapeMarkdownLinkLabel(item.title)}](${item.url})`,
|
|
115
|
+
numbered,
|
|
116
|
+
),
|
|
117
|
+
];
|
|
118
|
+
const desc = summarizeItemDesc(item);
|
|
119
|
+
if (desc) {
|
|
120
|
+
lines.push(`简介:${desc}`);
|
|
121
|
+
}
|
|
122
|
+
if (params.includeMeta !== false) {
|
|
123
|
+
const meta = summarizeItemMeta(item);
|
|
124
|
+
if (meta) {
|
|
125
|
+
lines.push(meta);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return lines.join(MARKDOWN_HARD_BREAK);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderArticleListReply(params: {
|
|
133
|
+
intro: string;
|
|
134
|
+
items: BookmarkSearchItem[];
|
|
135
|
+
limit: number;
|
|
136
|
+
browseLink?: { label: string; url: string };
|
|
137
|
+
includeMeta?: boolean;
|
|
138
|
+
}): string {
|
|
139
|
+
const parts = [params.intro, ...renderArticleBlocks(params)];
|
|
140
|
+
if (params.browseLink) {
|
|
141
|
+
parts.push(`[${escapeMarkdownLinkLabel(params.browseLink.label)}](${params.browseLink.url})`);
|
|
142
|
+
}
|
|
143
|
+
return parts.join("\n\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderSearchReply(params: {
|
|
147
|
+
query: string;
|
|
148
|
+
items: BookmarkSearchItem[];
|
|
149
|
+
limit: number;
|
|
150
|
+
}): string {
|
|
151
|
+
const intro =
|
|
152
|
+
params.items.length > params.limit
|
|
153
|
+
? `找到 ${params.items.length} 条“${params.query}”相关结果,先看前 ${params.limit} 条:`
|
|
154
|
+
: `找到 ${params.items.length} 条“${params.query}”相关结果:`;
|
|
155
|
+
|
|
156
|
+
return renderArticleListReply({
|
|
157
|
+
intro,
|
|
158
|
+
items: params.items,
|
|
159
|
+
limit: params.limit,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderLatestReply(params: {
|
|
164
|
+
page: BookmarkArticlePage;
|
|
165
|
+
limit: number;
|
|
166
|
+
}): string {
|
|
167
|
+
const total = params.page.total ?? params.page.items.length;
|
|
168
|
+
const intro =
|
|
169
|
+
total > params.limit ? `最近更新,先看前 ${params.limit} 篇:` : "最近更新:";
|
|
170
|
+
|
|
171
|
+
return renderArticleListReply({
|
|
172
|
+
intro,
|
|
173
|
+
items: params.page.items,
|
|
174
|
+
limit: params.limit,
|
|
175
|
+
includeMeta: false,
|
|
176
|
+
browseLink: params.page.browseUrl
|
|
177
|
+
? {
|
|
178
|
+
label: "查看全部文章",
|
|
179
|
+
url: params.page.browseUrl,
|
|
180
|
+
}
|
|
181
|
+
: undefined,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function renderCategoryArticlesReply(params: {
|
|
186
|
+
category: BookmarkCategory;
|
|
187
|
+
page: BookmarkArticlePage;
|
|
188
|
+
limit: number;
|
|
189
|
+
}): string {
|
|
190
|
+
const total = params.page.total ?? params.page.items.length;
|
|
191
|
+
const intro =
|
|
192
|
+
total > params.limit
|
|
193
|
+
? `“${params.category.name}”文章,先看前 ${params.limit} 篇:`
|
|
194
|
+
: `“${params.category.name}”文章:`;
|
|
195
|
+
|
|
196
|
+
return renderArticleListReply({
|
|
197
|
+
intro,
|
|
198
|
+
items: params.page.items,
|
|
199
|
+
limit: params.limit,
|
|
200
|
+
browseLink: {
|
|
201
|
+
label: `打开“${params.category.name}”分类页`,
|
|
202
|
+
url: params.page.browseUrl ?? params.category.url,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderCategoryListReply(params: {
|
|
208
|
+
categories: BookmarkCategory[];
|
|
209
|
+
limit: number;
|
|
210
|
+
}): string {
|
|
211
|
+
const visibleCategories = params.categories.slice(0, params.limit);
|
|
212
|
+
const intro =
|
|
213
|
+
params.categories.length > params.limit
|
|
214
|
+
? `一级分类,先看前 ${params.limit} 个:`
|
|
215
|
+
: "一级分类:";
|
|
216
|
+
const numbered = visibleCategories.length > 1;
|
|
217
|
+
|
|
218
|
+
const blocks = visibleCategories.map((category, index) => {
|
|
219
|
+
const suffix = typeof category.articleCount === "number" ? `(${category.articleCount} 篇)` : "";
|
|
220
|
+
return formatIndexedLine(
|
|
221
|
+
index,
|
|
222
|
+
`[${escapeMarkdownLinkLabel(category.name)}](${category.url})${suffix}`,
|
|
223
|
+
numbered,
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return [
|
|
228
|
+
intro,
|
|
229
|
+
...blocks,
|
|
230
|
+
"继续看某个分类,直接说“前端分类”或“前端常用链接”。",
|
|
231
|
+
].join("\n\n");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderCategoryLinksReply(params: {
|
|
235
|
+
category: BookmarkCategory;
|
|
236
|
+
links: BookmarkLink[];
|
|
237
|
+
limit: number;
|
|
238
|
+
}): string {
|
|
239
|
+
const visibleLinks = params.links.slice(0, params.limit);
|
|
240
|
+
const intro =
|
|
241
|
+
params.links.length > params.limit
|
|
242
|
+
? `“${params.category.name}”常用链接,先看前 ${params.limit} 个:`
|
|
243
|
+
: `“${params.category.name}”常用链接:`;
|
|
244
|
+
const numbered = visibleLinks.length > 1;
|
|
245
|
+
|
|
246
|
+
const blocks = visibleLinks.map((link, index) =>
|
|
247
|
+
formatIndexedLine(index, `[${escapeMarkdownLinkLabel(link.name)}](${link.url})`, numbered)
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return [
|
|
251
|
+
intro,
|
|
252
|
+
...blocks,
|
|
253
|
+
`[${escapeMarkdownLinkLabel(`打开“${params.category.name}”分类页`)}](${params.category.url})`,
|
|
254
|
+
].join("\n\n");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function renderEmptyLatestReply(): string {
|
|
258
|
+
return "书签篮暂时还没有可展示的最新文章。";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function renderEmptyCategoryListReply(): string {
|
|
262
|
+
return "书签篮暂时还没有可展示的分类。";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function renderEmptyCategoryLinksReply(category: string): string {
|
|
266
|
+
return `“${category}”分类暂时没有配置常用链接。`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function tokenizeSearchQuery(query: string): string[] {
|
|
270
|
+
const normalized = normalizeSearchTerm(query);
|
|
271
|
+
if (!normalized) {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const tokens = normalized.match(/[a-z0-9]+|[\u3400-\u9fff]+/giu) ?? [];
|
|
276
|
+
const result: string[] = [];
|
|
277
|
+
const seen = new Set<string>();
|
|
278
|
+
|
|
279
|
+
for (const token of tokens) {
|
|
280
|
+
const normalizedToken = normalizeSearchTerm(token);
|
|
281
|
+
if (!normalizedToken) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (seen.has(normalizedToken)) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
seen.add(normalizedToken);
|
|
288
|
+
result.push(normalizedToken);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function mergeTokenSearchResults(
|
|
295
|
+
termResults: Array<{ term: string; items: BookmarkSearchItem[] }>,
|
|
296
|
+
): BookmarkSearchItem[] {
|
|
297
|
+
const merged = new Map<string, RankedSearchItem>();
|
|
298
|
+
let rank = 0;
|
|
299
|
+
|
|
300
|
+
for (const termResult of termResults) {
|
|
301
|
+
for (const item of termResult.items) {
|
|
302
|
+
const existing = merged.get(item.url);
|
|
303
|
+
if (existing) {
|
|
304
|
+
existing.matchedTerms.add(termResult.term);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
merged.set(item.url, {
|
|
309
|
+
...item,
|
|
310
|
+
matchedTerms: new Set([termResult.term]),
|
|
311
|
+
firstRank: rank,
|
|
312
|
+
});
|
|
313
|
+
rank += 1;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const rankedItems = Array.from(merged.values()).sort((left, right) => {
|
|
318
|
+
const matchedDelta = right.matchedTerms.size - left.matchedTerms.size;
|
|
319
|
+
if (matchedDelta !== 0) {
|
|
320
|
+
return matchedDelta;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const typeDelta = typePriority(right.type) - typePriority(left.type);
|
|
324
|
+
if (typeDelta !== 0) {
|
|
325
|
+
return typeDelta;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return left.firstRank - right.firstRank;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const maxMatches = rankedItems[0]?.matchedTerms.size ?? 0;
|
|
332
|
+
const filteredItems =
|
|
333
|
+
maxMatches > 1
|
|
334
|
+
? rankedItems.filter((item) => item.matchedTerms.size === maxMatches)
|
|
335
|
+
: rankedItems;
|
|
336
|
+
|
|
337
|
+
return filteredItems.map(({ matchedTerms: _matchedTerms, firstRank: _firstRank, ...item }) => item);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export class BookmarkSearchService {
|
|
341
|
+
private readonly pending = new Map<string, BookmarkSearchConversationState>();
|
|
342
|
+
|
|
343
|
+
constructor(
|
|
344
|
+
private readonly provider: BookmarkSearchProvider,
|
|
345
|
+
private readonly options: { pendingTtlMs?: number; resultLimit?: number } = {},
|
|
346
|
+
) {}
|
|
347
|
+
|
|
348
|
+
inspectMessage(params: { userId: string; text: string }): BookmarkSearchMessageInspection {
|
|
349
|
+
const directInspection = inspectBookmarkSearchQuery(params.text);
|
|
350
|
+
const pending = this.getPending(params.userId);
|
|
351
|
+
if (!pending) {
|
|
352
|
+
return directInspection;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (directInspection.shouldHandle && directInspection.action !== "search") {
|
|
356
|
+
return directInspection;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (directInspection.shouldHandle && directInspection.query) {
|
|
360
|
+
return directInspection;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const query = this.resolveConversationQuery(params.text);
|
|
364
|
+
if (!query) {
|
|
365
|
+
return {
|
|
366
|
+
shouldHandle: true,
|
|
367
|
+
reason: "conversation",
|
|
368
|
+
action: "search",
|
|
369
|
+
missing: "query",
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
shouldHandle: true,
|
|
375
|
+
reason: "conversation",
|
|
376
|
+
action: "search",
|
|
377
|
+
query,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async processMessage(params: { userId: string; text: string }): Promise<BookmarkSearchChatResult> {
|
|
382
|
+
const inspection = this.inspectMessage(params);
|
|
383
|
+
const action = inspection.action ?? "search";
|
|
384
|
+
const query = inspection.reason === "conversation"
|
|
385
|
+
? this.resolveConversationQuery(params.text)
|
|
386
|
+
: inspection.query;
|
|
387
|
+
const categoryName = inspection.category;
|
|
388
|
+
|
|
389
|
+
if (action === "search" && !query) {
|
|
390
|
+
this.pending.set(params.userId, {
|
|
391
|
+
userId: params.userId,
|
|
392
|
+
updatedAt: Date.now(),
|
|
393
|
+
});
|
|
394
|
+
return {
|
|
395
|
+
reply: renderClarifyReply(),
|
|
396
|
+
intent: "clarify",
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
this.pending.delete(params.userId);
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
switch (action) {
|
|
404
|
+
case "latest": {
|
|
405
|
+
const page = await this.listLatestArticles();
|
|
406
|
+
if (page.items.length === 0) {
|
|
407
|
+
return {
|
|
408
|
+
reply: renderEmptyLatestReply(),
|
|
409
|
+
intent: "not_found",
|
|
410
|
+
total: 0,
|
|
411
|
+
items: [],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
reply: renderLatestReply({
|
|
417
|
+
page,
|
|
418
|
+
limit: this.options.resultLimit ?? 6,
|
|
419
|
+
}),
|
|
420
|
+
intent: "latest_results",
|
|
421
|
+
total: page.total ?? page.items.length,
|
|
422
|
+
items: page.items,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
case "categories": {
|
|
426
|
+
const categories = await this.listTopCategories();
|
|
427
|
+
if (categories.length === 0) {
|
|
428
|
+
return {
|
|
429
|
+
reply: renderEmptyCategoryListReply(),
|
|
430
|
+
intent: "not_found",
|
|
431
|
+
total: 0,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
reply: renderCategoryListReply({
|
|
437
|
+
categories,
|
|
438
|
+
limit: this.options.resultLimit ?? 6,
|
|
439
|
+
}),
|
|
440
|
+
intent: "category_list",
|
|
441
|
+
total: categories.length,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
case "category_articles": {
|
|
445
|
+
const category = categoryName ? await this.findCategory(categoryName) : undefined;
|
|
446
|
+
if (!category) {
|
|
447
|
+
return {
|
|
448
|
+
reply: renderCategoryNotFoundReply(categoryName ?? ""),
|
|
449
|
+
intent: "not_found",
|
|
450
|
+
category: categoryName,
|
|
451
|
+
total: 0,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const page = await this.listCategoryArticles(category);
|
|
456
|
+
if (page.items.length === 0) {
|
|
457
|
+
return {
|
|
458
|
+
reply: renderNotFoundReply(category.name),
|
|
459
|
+
intent: "not_found",
|
|
460
|
+
category: category.name,
|
|
461
|
+
total: 0,
|
|
462
|
+
items: [],
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
reply: renderCategoryArticlesReply({
|
|
468
|
+
category,
|
|
469
|
+
page,
|
|
470
|
+
limit: this.options.resultLimit ?? 6,
|
|
471
|
+
}),
|
|
472
|
+
intent: "category_results",
|
|
473
|
+
category: category.name,
|
|
474
|
+
total: page.total ?? page.items.length,
|
|
475
|
+
items: page.items,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
case "category_links": {
|
|
479
|
+
const category = categoryName ? await this.findCategory(categoryName) : undefined;
|
|
480
|
+
if (!category) {
|
|
481
|
+
return {
|
|
482
|
+
reply: renderCategoryNotFoundReply(categoryName ?? ""),
|
|
483
|
+
intent: "not_found",
|
|
484
|
+
category: categoryName,
|
|
485
|
+
total: 0,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const links = await this.listCategoryLinks(category);
|
|
490
|
+
if (links.length === 0) {
|
|
491
|
+
return {
|
|
492
|
+
reply: renderEmptyCategoryLinksReply(category.name),
|
|
493
|
+
intent: "not_found",
|
|
494
|
+
category: category.name,
|
|
495
|
+
total: 0,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
reply: renderCategoryLinksReply({
|
|
501
|
+
category,
|
|
502
|
+
links,
|
|
503
|
+
limit: this.options.resultLimit ?? 6,
|
|
504
|
+
}),
|
|
505
|
+
intent: "category_links",
|
|
506
|
+
category: category.name,
|
|
507
|
+
total: links.length,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
case "search":
|
|
511
|
+
default:
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const items = await this.searchQuery(query ?? "");
|
|
516
|
+
if (items.length === 0) {
|
|
517
|
+
return {
|
|
518
|
+
reply: renderNotFoundReply(query ?? ""),
|
|
519
|
+
intent: "not_found",
|
|
520
|
+
query,
|
|
521
|
+
total: 0,
|
|
522
|
+
items: [],
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
reply: renderSearchReply({
|
|
528
|
+
query: query ?? "",
|
|
529
|
+
items,
|
|
530
|
+
limit: this.options.resultLimit ?? 6,
|
|
531
|
+
}),
|
|
532
|
+
intent: "search_results",
|
|
533
|
+
query,
|
|
534
|
+
total: items.length,
|
|
535
|
+
items,
|
|
536
|
+
};
|
|
537
|
+
} catch {
|
|
538
|
+
const subject = action === "search"
|
|
539
|
+
? query ?? "搜索"
|
|
540
|
+
: action === "latest"
|
|
541
|
+
? "最新更新"
|
|
542
|
+
: action === "categories"
|
|
543
|
+
? "分类列表"
|
|
544
|
+
: categoryName ?? "分类";
|
|
545
|
+
return {
|
|
546
|
+
reply: renderActionErrorReply(subject),
|
|
547
|
+
intent: "error",
|
|
548
|
+
query,
|
|
549
|
+
category: categoryName,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private resolveConversationQuery(text: string): string | undefined {
|
|
555
|
+
const directQuery = extractBookmarkSearchQuery(text);
|
|
556
|
+
if (directQuery) {
|
|
557
|
+
return directQuery;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const normalized = text.normalize("NFKC").trim();
|
|
561
|
+
return normalized || undefined;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private async searchQuery(query: string): Promise<BookmarkSearchItem[]> {
|
|
565
|
+
const normalizedQuery = normalizeSearchTerm(query);
|
|
566
|
+
const exactMatches = (await this.provider.searchKeyword({ keyword: normalizedQuery }))
|
|
567
|
+
.filter(isArticleItem);
|
|
568
|
+
if (exactMatches.length > 0) {
|
|
569
|
+
return exactMatches;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const tokens = tokenizeSearchQuery(query).filter((token) => token !== normalizedQuery);
|
|
573
|
+
if (tokens.length === 0) {
|
|
574
|
+
return [];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const termResults = await Promise.all(
|
|
578
|
+
tokens.map(async (term) => ({
|
|
579
|
+
term,
|
|
580
|
+
items: (await this.provider.searchKeyword({ keyword: term })).filter(isArticleItem),
|
|
581
|
+
})),
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
return mergeTokenSearchResults(termResults);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private async listLatestArticles(): Promise<BookmarkArticlePage> {
|
|
588
|
+
if (!this.provider.listLatestArticles) {
|
|
589
|
+
throw new Error("latest articles are unavailable");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return this.provider.listLatestArticles({ page: 1 });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private async listTopCategories(): Promise<BookmarkCategory[]> {
|
|
596
|
+
if (!this.provider.listTopCategories) {
|
|
597
|
+
throw new Error("categories are unavailable");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return this.provider.listTopCategories();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private async listCategoryArticles(category: BookmarkCategory): Promise<BookmarkArticlePage> {
|
|
604
|
+
if (!this.provider.listCategoryArticles) {
|
|
605
|
+
throw new Error("category articles are unavailable");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return this.provider.listCategoryArticles({
|
|
609
|
+
category,
|
|
610
|
+
page: 1,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private async listCategoryLinks(category: BookmarkCategory): Promise<BookmarkLink[]> {
|
|
615
|
+
if (!this.provider.listCategoryLinks) {
|
|
616
|
+
throw new Error("category links are unavailable");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return this.provider.listCategoryLinks({ category });
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private async findCategory(query: string): Promise<BookmarkCategory | undefined> {
|
|
623
|
+
const categories = await this.listTopCategories();
|
|
624
|
+
const normalizedQuery = normalizeCategoryKey(query);
|
|
625
|
+
if (!normalizedQuery) {
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const exactMatch = categories.find((category) => normalizeCategoryKey(category.name) === normalizedQuery);
|
|
630
|
+
if (exactMatch) {
|
|
631
|
+
return exactMatch;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const partialMatches = categories.filter((category) => {
|
|
635
|
+
const normalizedCategory = normalizeCategoryKey(category.name);
|
|
636
|
+
return normalizedCategory.includes(normalizedQuery) || normalizedQuery.includes(normalizedCategory);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
if (partialMatches.length === 0) {
|
|
640
|
+
return undefined;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return partialMatches.sort((left, right) => left.name.length - right.name.length)[0];
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private getPending(userId: string): BookmarkSearchConversationState | undefined {
|
|
647
|
+
const pending = this.pending.get(userId);
|
|
648
|
+
if (!pending) {
|
|
649
|
+
return undefined;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const ttlMs = this.options.pendingTtlMs ?? 10 * 60_000;
|
|
653
|
+
if (Date.now() - pending.updatedAt <= ttlMs) {
|
|
654
|
+
return pending;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
this.pending.delete(userId);
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
}
|