mioku-plugin-chat 2.0.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,184 @@
1
+ import { logger } from "mioki";
2
+ import type { SearxngConfig } from "../../types";
3
+
4
+ type TimeRange = "day" | "month" | "year";
5
+
6
+ interface WebSearchArgs {
7
+ query?: string;
8
+ queries?: string[];
9
+ limit?: number;
10
+ time_range?: TimeRange;
11
+ categories?: string[];
12
+ engines?: string[];
13
+ }
14
+
15
+ interface SearxngResultItem {
16
+ title?: string;
17
+ url?: string;
18
+ content?: string;
19
+ engine?: string;
20
+ }
21
+
22
+ interface WebSearchResult {
23
+ success: boolean;
24
+ query: string;
25
+ results: SearxngResultItem[];
26
+ total?: number;
27
+ error?: string;
28
+ }
29
+
30
+ function normalizeArray(value: unknown): string[] {
31
+ if (!Array.isArray(value)) return [];
32
+ return value.map((item) => String(item || "").trim()).filter(Boolean);
33
+ }
34
+
35
+ function clampLimit(limit: unknown, cfg: SearxngConfig): number {
36
+ const defaultLimit =
37
+ Number.isFinite(cfg.defaultLimit) && cfg.defaultLimit > 0
38
+ ? Math.floor(cfg.defaultLimit)
39
+ : 5;
40
+ const maxLimit =
41
+ Number.isFinite(cfg.maxLimit) && cfg.maxLimit > 0
42
+ ? Math.floor(cfg.maxLimit)
43
+ : 8;
44
+
45
+ const requested =
46
+ Number.isFinite(limit) && Number(limit) > 0
47
+ ? Math.floor(Number(limit))
48
+ : defaultLimit;
49
+
50
+ return Math.max(1, Math.min(requested, maxLimit));
51
+ }
52
+
53
+ function normalizeBaseUrl(baseUrl: string): string {
54
+ return baseUrl.trim().replace(/\/+$/, "");
55
+ }
56
+
57
+ export async function searchWebWithSearxng(
58
+ cfg: SearxngConfig,
59
+ args: WebSearchArgs,
60
+ ): Promise<WebSearchResult> {
61
+ const fallbackQuery = Array.isArray(args.queries)
62
+ ? args.queries.map((item) => String(item || "").trim()).find(Boolean) || ""
63
+ : "";
64
+ const query = String(args.query || fallbackQuery).trim();
65
+ if (!query) {
66
+ return {
67
+ success: false,
68
+ query,
69
+ results: [],
70
+ error: "query is required",
71
+ };
72
+ }
73
+
74
+ const baseUrl = normalizeBaseUrl(cfg.baseUrl || "");
75
+ if (!baseUrl) {
76
+ return {
77
+ success: false,
78
+ query,
79
+ results: [],
80
+ error: "SearXNG baseUrl is not configured",
81
+ };
82
+ }
83
+
84
+ const limit = clampLimit(args.limit, cfg);
85
+ const timeoutMs =
86
+ Number.isFinite(cfg.timeoutMs) && cfg.timeoutMs > 0
87
+ ? Math.floor(cfg.timeoutMs)
88
+ : 8000;
89
+
90
+ const params = new URLSearchParams({
91
+ q: query,
92
+ format: "json",
93
+ });
94
+
95
+ if (args.time_range && ["day", "month", "year"].includes(args.time_range)) {
96
+ params.set("time_range", args.time_range);
97
+ }
98
+
99
+ const categories = normalizeArray(args.categories);
100
+ if (categories.length > 0) {
101
+ params.set("categories", categories.join(","));
102
+ }
103
+
104
+ const engines = normalizeArray(args.engines);
105
+ if (engines.length > 0) {
106
+ params.set("engines", engines.join(","));
107
+ }
108
+
109
+ const headers: Record<string, string> = {
110
+ Accept: "application/json",
111
+ };
112
+
113
+ const controller = new AbortController();
114
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
115
+
116
+ try {
117
+ const url = `${baseUrl}/search?${params.toString()}`;
118
+ logger.info(`[web_search] Query "${query}" -> ${url}`);
119
+
120
+ const response = await fetch(url, {
121
+ method: "GET",
122
+ headers,
123
+ signal: controller.signal,
124
+ });
125
+
126
+ if (!response.ok) {
127
+ const body = await response.text().catch(() => "");
128
+ return {
129
+ success: false,
130
+ query,
131
+ results: [],
132
+ error: `SearXNG request failed: HTTP ${response.status}${body ? ` ${body.slice(0, 200)}` : ""}`,
133
+ };
134
+ }
135
+
136
+ const data: any = await response.json();
137
+ const rawResults = Array.isArray(data?.results) ? data.results : [];
138
+
139
+ const results: SearxngResultItem[] = rawResults
140
+ .slice(0, limit)
141
+ .map((item: any) => {
142
+ const title = String(item?.title || "").trim();
143
+ const url = String(item?.url || "").trim();
144
+ const content = String(item?.content || "").trim();
145
+ const engine = String(
146
+ item?.engine ||
147
+ (Array.isArray(item?.engines) ? item.engines[0] : "") ||
148
+ "",
149
+ ).trim();
150
+
151
+ return {
152
+ title: title || undefined,
153
+ url: url || undefined,
154
+ content: content || undefined,
155
+ engine: engine || undefined,
156
+ };
157
+ })
158
+ .filter(
159
+ (item: SearxngResultItem) => item.title || item.url || item.content,
160
+ );
161
+
162
+ const total =
163
+ typeof data?.number_of_results === "number"
164
+ ? data.number_of_results
165
+ : rawResults.length;
166
+
167
+ return {
168
+ success: true,
169
+ query,
170
+ results,
171
+ total,
172
+ };
173
+ } catch (err) {
174
+ const isAbort = err instanceof Error && err.name === "AbortError";
175
+ return {
176
+ success: false,
177
+ query,
178
+ results: [],
179
+ error: isAbort ? `Request timeout after ${timeoutMs}ms` : String(err),
180
+ };
181
+ } finally {
182
+ clearTimeout(timeout);
183
+ }
184
+ }