luckytri 1.0.6 → 1.0.7

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.
Files changed (155) hide show
  1. package/LICENSE +21 -21
  2. package/docs/en/CHANGELOG.md +25 -16
  3. package/docs/en/chat-style.md +28 -9
  4. package/docs/en/guide.md +5 -5
  5. package/docs/en/her-life.md +2 -2
  6. package/docs/en/webui.md +1 -1
  7. package/docs/zh/CHANGELOG.md +25 -16
  8. package/docs/zh/chat-style.md +30 -9
  9. package/docs/zh/guide.md +5 -5
  10. package/docs/zh/her-life.md +2 -2
  11. package/docs/zh/webui.md +1 -1
  12. package/package.json +1 -1
  13. package/public/app/assets/{ChatsPage-DCYMkJ91.js → ChatsPage-CshdtTzV.js} +2 -2
  14. package/public/app/assets/ChatsPage-DfyPCS9N.css +1 -0
  15. package/public/app/assets/{Empty-CLyZkxR5.js → Empty-C7yzOBzy.js} +1 -1
  16. package/public/app/assets/{Empty-DIobYG3x.css → Empty-DmUB2-Bk.css} +1 -1
  17. package/public/app/assets/{Field-BpaRwLI_.js → Field-BjFv4Rfn.js} +1 -1
  18. package/public/app/assets/HeartPage-Bft9wZp5.css +1 -0
  19. package/public/app/assets/{HeartPage-BIOMTiwr.js → HeartPage-eqo6ru16.js} +1 -1
  20. package/public/app/assets/LifePage-BUi5euBF.css +1 -0
  21. package/public/app/assets/{LifePage-BCgM4JkR.js → LifePage-CN8GE5NK.js} +1 -1
  22. package/public/app/assets/{MemoryPage-vShvRqMt.js → MemoryPage-CCpMN4Wg.js} +1 -1
  23. package/public/app/assets/MemoryPage-cZvXfkXb.css +1 -0
  24. package/public/app/assets/{NaturePage-Cy9ptr4G.js → NaturePage-C0JqdBTR.js} +2 -2
  25. package/public/app/assets/NaturePage-CHkpb5NK.css +1 -0
  26. package/public/app/assets/PeoplePage-BTU0H1pM.css +1 -0
  27. package/public/app/assets/{PeoplePage-CUZekB2E.js → PeoplePage-BXGBbO4u.js} +1 -1
  28. package/public/app/assets/{PluginsPage-1RM4rmJP.js → PluginsPage-Dksp5AV5.js} +1 -1
  29. package/public/app/assets/{SystemPage-BI2CB5Ig.css → SystemPage-BenIyTvk.css} +1 -1
  30. package/public/app/assets/{SystemPage-BeT5TmIV.js → SystemPage-Cd7aa8eE.js} +1 -1
  31. package/public/app/assets/TimePage-BOms_n4I.css +1 -0
  32. package/public/app/assets/{TimePage-taTmPMM8.js → TimePage-Bs_zImb4.js} +2 -2
  33. package/public/app/assets/{index-DY5AM0Hz.css → index-BHZcHUMh.css} +1 -1
  34. package/public/app/assets/{index-DdYwC5ZU.js → index-CyRtYxP3.js} +3 -3
  35. package/public/app/assets/{knowledge-DJmfu2KN.js → knowledge-BJOIyuJo.js} +1 -1
  36. package/public/app/assets/{media-BEtnE0Gv.js → media-Bwtx8blz.js} +1 -1
  37. package/public/app/assets/{mind-Dtiw0-QK.js → mind-DtTVrW6Z.js} +1 -1
  38. package/public/app/index.html +5 -6
  39. package/public/guide.en.html +3 -3
  40. package/public/guide.html +3 -3
  41. package/scripts/import-data.js +128 -128
  42. package/scripts/proxy-config.js +167 -167
  43. package/scripts/update-instance.js +201 -201
  44. package/server/channels/adapters.js +27 -27
  45. package/server/channels/onebot/adapter.js +288 -288
  46. package/server/channels/qqbot/adapter.js +253 -253
  47. package/server/channels/qqbot/api.js +199 -199
  48. package/server/channels/qqbot/credentials.js +11 -11
  49. package/server/channels/qqbot/gateway.js +274 -274
  50. package/server/channels/qqbot/index.js +389 -389
  51. package/server/channels/segments.js +40 -40
  52. package/server/channels/session-key.js +153 -153
  53. package/server/channels/state.js +42 -42
  54. package/server/core/api.js +564 -564
  55. package/server/core/context-builder.js +288 -288
  56. package/server/core/context-compactor.js +454 -454
  57. package/server/core/conversation-cues.js +365 -325
  58. package/server/core/conversation-grounding.js +178 -0
  59. package/server/core/conversation-manager.js +87 -87
  60. package/server/core/dialogue-context.js +52 -0
  61. package/server/core/feedback.js +8 -8
  62. package/server/core/group-style.js +72 -72
  63. package/server/core/initiative-context.js +66 -66
  64. package/server/core/input-budget.js +32 -32
  65. package/server/core/local-demo.js +98 -98
  66. package/server/core/message-manager.js +118 -118
  67. package/server/core/message-scheduler.js +78 -78
  68. package/server/core/model-json.js +190 -190
  69. package/server/core/model-presets.js +900 -900
  70. package/server/core/name-call.js +29 -29
  71. package/server/core/network.js +157 -157
  72. package/server/core/person-name.js +22 -22
  73. package/server/core/persona-manager.js +324 -269
  74. package/server/core/reply-delivery.js +472 -404
  75. package/server/core/reply-target-resolver.js +50 -50
  76. package/server/core/response-generator.js +58 -50
  77. package/server/core/response-validator.js +30 -27
  78. package/server/core/retired-prompts.js +51 -51
  79. package/server/core/sessions.js +109 -109
  80. package/server/core/speaker-names.js +92 -92
  81. package/server/core/topic-tracker.js +65 -65
  82. package/server/core/turn.js +42 -8
  83. package/server/http.js +18 -18
  84. package/server/i18n/decisions.js +209 -209
  85. package/server/i18n/index.js +144 -144
  86. package/server/i18n/labels.js +0 -5
  87. package/server/i18n/models.js +111 -111
  88. package/server/knowledge/api.js +342 -342
  89. package/server/knowledge/embedding-profile.js +114 -114
  90. package/server/knowledge/index.js +13 -13
  91. package/server/knowledge/retrieval.js +93 -93
  92. package/server/knowledge/schema.js +158 -158
  93. package/server/knowledge/vector-index.js +90 -90
  94. package/server/mind/affect.js +265 -265
  95. package/server/mind/attention.js +200 -200
  96. package/server/mind/bonds.js +565 -565
  97. package/server/mind/clock.js +83 -83
  98. package/server/mind/context-selection.js +16 -4
  99. package/server/mind/continuity.js +562 -562
  100. package/server/mind/conversation-origin.js +175 -175
  101. package/server/mind/days.js +210 -210
  102. package/server/mind/evidence-read.js +45 -45
  103. package/server/mind/faces.js +373 -373
  104. package/server/mind/guard.js +68 -60
  105. package/server/mind/initiative.js +338 -338
  106. package/server/mind/life/activities.js +21 -21
  107. package/server/mind/life/context.js +330 -330
  108. package/server/mind/life/growth.js +261 -261
  109. package/server/mind/life/presence.js +305 -305
  110. package/server/mind/meetings.js +1069 -1069
  111. package/server/mind/memory-policy.js +48 -48
  112. package/server/mind/memory.js +888 -888
  113. package/server/mind/nature.js +358 -358
  114. package/server/mind/novelty.js +19 -19
  115. package/server/mind/periods.js +118 -118
  116. package/server/mind/reading.js +120 -120
  117. package/server/mind/relationship-context.js +71 -71
  118. package/server/mind/relationships.js +390 -390
  119. package/server/mind/salience.js +201 -201
  120. package/server/mind/schema.js +567 -567
  121. package/server/mind/self.js +496 -484
  122. package/server/mind/thoughts.js +274 -274
  123. package/server/mind/time/agenda.js +242 -242
  124. package/server/mind/time/api.js +200 -200
  125. package/server/mind/time/attention.js +34 -34
  126. package/server/mind/time/delivery-links.js +130 -130
  127. package/server/mind/time/games.js +7 -3
  128. package/server/mind/time/index.js +3 -0
  129. package/server/mind/time/model-material.js +77 -77
  130. package/server/mind/time/presentation.js +35 -51
  131. package/server/mind/time/schedule.js +83 -83
  132. package/server/mind/time/schema.js +84 -84
  133. package/server/mind/time/search.js +383 -383
  134. package/server/mind/time/sharing.js +472 -472
  135. package/server/mind/time/task-links.js +311 -311
  136. package/server/mind/time/works.js +438 -432
  137. package/server/mind/traits.js +191 -191
  138. package/server/mind/view.js +43 -6
  139. package/server/runtime/installation.js +116 -116
  140. package/server/runtime/update-manager.js +140 -140
  141. package/server/storage/data-package.js +298 -298
  142. package/server/storage/data-transfer-api.js +50 -50
  143. package/server/storage/data-transfer-worker.js +18 -18
  144. package/server/storage/data-transfer.js +416 -416
  145. package/server/studio/display-names.js +139 -139
  146. package/server/studio/management.js +102 -102
  147. package/server/studio/updates.js +144 -144
  148. package/server/version.js +10 -10
  149. package/public/app/assets/ChatsPage-CFMVozoG.css +0 -1
  150. package/public/app/assets/HeartPage-C-2jvgzF.css +0 -1
  151. package/public/app/assets/LifePage-CG1OHbRK.css +0 -1
  152. package/public/app/assets/MemoryPage-C_Bu4rfb.css +0 -1
  153. package/public/app/assets/NaturePage-CPZlImT0.css +0 -1
  154. package/public/app/assets/PeoplePage-3RRUITDz.css +0 -1
  155. package/public/app/assets/TimePage-C-guwPk2.css +0 -1
@@ -1,383 +1,383 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { dayKey, hasCredential, text } from "../util.js";
3
- import { describeNetworkError, sanitizeDetail } from "../../core/network.js";
4
- import { modelMaterial, MODEL_MATERIAL_LABEL } from "./model-material.js";
5
- export const SEARCH_DEFAULTS = {
6
- enabled: true,
7
- provider: "tavily",
8
- baseUrl: "https://api.tavily.com/search",
9
- apiKey: "",
10
- timeoutMs: 20000,
11
- maxResults: 5,
12
- dailyLimit: 12,
13
- };
14
- const ENDPOINTS = {
15
- tavily: "https://api.tavily.com/search",
16
- brave: "https://api.search.brave.com/res/v1/web/search",
17
- };
18
- export const SEARCH_TEST_TOPIC = "ATRI 游戏 资料简介";
19
- export class Search {
20
- constructor(time) {
21
- this.time = time;
22
- this.db = time.db;
23
- this.fetch = (...args) => fetch(...args);
24
- this.testing = false;
25
- this.db
26
- .prepare(
27
- "UPDATE mind_time_searches SET state='error',error='实例中断,查询结果未确认' WHERE state='running'",
28
- )
29
- .run();
30
- }
31
- bind(life) {
32
- this.life = life;
33
- this.wake();
34
- }
35
- mode(profile = this.profile()) {
36
- return profile.enabled && profile.apiKey ? "web" : "model";
37
- }
38
- wake() {
39
- const now = this.time.now();
40
- if (this.ready(now)) return;
41
- for (const row of this.db
42
- .prepare(
43
- "SELECT id,ready_at FROM mind_time_tasks WHERE activity='game' AND state='waiting' AND wait_reason IN ('等待独立搜索密钥','独立搜索尚未启用','今日资料查询次数已用完') AND json_extract(checkpoint,'$.mergedInto') IS NULL",
44
- )
45
- .all())
46
- this.time.tasks.control(
47
- row.id,
48
- {
49
- action: "resume",
50
- readyAt: Math.max(now, row.ready_at),
51
- reason: "资料查询条件已恢复,重新按优先级安排",
52
- },
53
- now,
54
- );
55
- }
56
- profile(provider) {
57
- const saved = this.time.mind.repo.config("search-profile", {});
58
- const chosen = provider || saved.provider || "tavily";
59
- const profile = {
60
- ...SEARCH_DEFAULTS,
61
- baseUrl: ENDPOINTS[chosen],
62
- ...saved.profiles?.[chosen],
63
- provider: chosen,
64
- };
65
- // Existing keys may have been pasted with padding before save normalized it.
66
- // Activity reads and connection tests must use the same credential bytes.
67
- return { ...profile, apiKey: profile.apiKey.trim() };
68
- }
69
- public(provider, testedProfile) {
70
- const { apiKey, ...profile } = testedProfile || this.profile(provider);
71
- const usage = this.usage(this.time.now());
72
- return {
73
- ...profile,
74
- hasApiKey: !!apiKey,
75
- mode: this.mode({ ...profile, apiKey }),
76
- materialLabel:
77
- this.mode({ ...profile, apiKey }) === "model"
78
- ? MODEL_MATERIAL_LABEL
79
- : "联网检索资料",
80
- used: usage.used,
81
- usage,
82
- };
83
- }
84
- validated(input = {}) {
85
- const old = this.time.mind.repo.config("search-profile", {}),
86
- provider = input.provider || old.provider || "tavily";
87
- if (!ENDPOINTS[provider]) throw Error("搜索提供方无效");
88
- const next = { ...this.profile(provider), ...input, provider };
89
- if (typeof next.apiKey === "string") next.apiKey = next.apiKey.trim();
90
- if (
91
- typeof next.enabled !== "boolean" ||
92
- typeof next.apiKey !== "string" ||
93
- /[\r\n]/.test(next.apiKey) ||
94
- next.apiKey.length > 2000
95
- )
96
- throw Error("搜索配置格式无效");
97
- if (!next.apiKey) next.apiKey = this.profile(provider).apiKey.trim();
98
- if (input.clearKey) next.apiKey = "";
99
- const url = new URL(next.baseUrl);
100
- if (
101
- !["https:", "http:"].includes(url.protocol) ||
102
- url.username ||
103
- url.password ||
104
- url.hash ||
105
- url.search
106
- )
107
- throw Error("搜索地址无效");
108
- for (const [key, min, max] of [
109
- ["timeoutMs", 1000, 120000],
110
- ["maxResults", 1, 20],
111
- ["dailyLimit", 0, 100000],
112
- ])
113
- if (!Number.isInteger(next[key]) || next[key] < min || next[key] > max)
114
- throw Error(key + " 超出范围");
115
- const profile = Object.fromEntries(
116
- Object.keys(SEARCH_DEFAULTS).map((key) => [key, next[key]]),
117
- );
118
- return { ...profile, provider };
119
- }
120
- save(input) {
121
- const profile = this.validated(input);
122
- const old = this.time.mind.repo.config("search-profile", {});
123
- this.time.mind.repo.saveConfig("search-profile", {
124
- provider: profile.provider,
125
- profiles: { ...old.profiles, [profile.provider]: profile },
126
- });
127
- this.wake();
128
- return this.public();
129
- }
130
- usage(now) {
131
- const day = dayKey(now, this.time.mind.timeZone());
132
- const rows = this.db
133
- .prepare(
134
- "SELECT created,state,purpose FROM mind_time_searches WHERE created<=? AND created>=?",
135
- )
136
- .all(now, now - 2 * 86400000)
137
- .filter((r) => dayKey(r.created, this.time.mind.timeZone()) === day);
138
- const activity = rows.filter((r) => r.purpose !== "diagnostic"),
139
- successful = activity.filter((r) => r.state === "done").length,
140
- running = activity.filter((r) => r.state === "running").length;
141
- return {
142
- used: successful + running,
143
- successful,
144
- running,
145
- failed: activity.filter((r) => r.state === "error").length,
146
- diagnostics: rows.filter((r) => r.purpose === "diagnostic").length,
147
- };
148
- }
149
- used(now) {
150
- return this.usage(now).used;
151
- }
152
- ready(now = this.time.now(), p = this.profile(), diagnostic = false) {
153
- if (!diagnostic && p.dailyLimit > 0 && this.used(now) >= p.dailyLimit)
154
- return "今日资料查询次数已用完";
155
- if (this.mode(p) === "model") {
156
- try {
157
- const model = this.life?.profile();
158
- if (!model || !(model.apiKey || process.env.LLM_API_KEY))
159
- return "等待可用模型";
160
- if (this.life.closed) return "等待可用模型";
161
- } catch {
162
- return "等待可用模型";
163
- }
164
- }
165
- return "";
166
- }
167
- async query(
168
- topic,
169
- {
170
- projectId = null,
171
- now = this.time.now(),
172
- trace,
173
- valid = () => true,
174
- profile = this.profile(),
175
- purpose = "activity",
176
- } = {},
177
- ) {
178
- if (!["activity", "diagnostic"].includes(purpose))
179
- throw Error("查询用途无效");
180
- const why = this.ready(now, profile, purpose === "diagnostic");
181
- if (why) throw Error(why);
182
- topic = text(topic, 180);
183
- if (
184
- !topic ||
185
- hasCredential(topic) ||
186
- /\b\d{5,}\b|(?:群|私聊|账号)\s*[::=]/.test(topic)
187
- )
188
- throw Error("查询只能包含资料主题");
189
- const p = profile,
190
- id = Number(
191
- this.db
192
- .prepare(
193
- "INSERT INTO mind_time_searches(created,provider,query,state,purpose) VALUES (?,?,?,'running',?)",
194
- )
195
- .run(
196
- now,
197
- this.mode(p) === "model" ? "model" : p.provider,
198
- topic,
199
- purpose,
200
- ).lastInsertRowid,
201
- );
202
- const headers =
203
- p.provider === "tavily"
204
- ? {
205
- "Content-Type": "application/json",
206
- Authorization: "Bearer " + p.apiKey,
207
- }
208
- : { "X-Subscription-Token": p.apiKey, Accept: "application/json" };
209
- const endpoint = new URL(p.baseUrl);
210
- let body;
211
- if (p.provider === "tavily")
212
- body = JSON.stringify({
213
- query: topic,
214
- search_depth: "basic",
215
- max_results: p.maxResults,
216
- include_answer: false,
217
- include_raw_content: false,
218
- });
219
- else {
220
- endpoint.searchParams.set("q", topic);
221
- endpoint.searchParams.set("count", String(p.maxResults));
222
- endpoint.searchParams.set("extra_snippets", "true");
223
- }
224
- try {
225
- let results;
226
- if (this.mode(p) === "model") {
227
- results = await modelMaterial(this.life, topic, {
228
- trace,
229
- maxResults: p.maxResults,
230
- });
231
- } else {
232
- const response = await this.fetch(endpoint, {
233
- method: p.provider === "tavily" ? "POST" : "GET",
234
- headers,
235
- ...(body ? { body } : {}),
236
- signal: AbortSignal.timeout(p.timeoutMs),
237
- redirect: "error",
238
- });
239
- if (!response.ok) {
240
- const data = await response.json().catch(() => ({}));
241
- const detail =
242
- data.detail?.error ||
243
- data.error?.message ||
244
- (typeof data.error === "string" ? data.error : "");
245
- const hint = [401, 403].includes(response.status)
246
- ? "请核对独立密钥与权限"
247
- : [429, 432, 433].includes(response.status)
248
- ? "提供方限制了请求频率或账户额度"
249
- : [404, 405].includes(response.status)
250
- ? "请核对提供方与完整搜索地址"
251
- : "请检查搜索服务与请求配置";
252
- throw Error(`搜索接口 HTTP ${response.status}:${detail || hint}`);
253
- }
254
- const data = await response.json().catch(() => {
255
- throw Error("搜索接口未返回有效 JSON,请核对提供方与完整搜索地址");
256
- }),
257
- raw = p.provider === "tavily" ? data?.results : data?.web?.results;
258
- if (
259
- !Array.isArray(raw) &&
260
- !(p.provider === "brave" && data?.type === "search" && !data.web)
261
- )
262
- throw Error(
263
- "搜索返回格式与选定提供方不匹配,请核对提供方与完整搜索地址",
264
- );
265
- results = (Array.isArray(raw) ? raw : [])
266
- .slice(0, p.maxResults)
267
- .map((row) => ({
268
- kind: "web",
269
- model: "",
270
- uncertainty: "",
271
- timing: {},
272
- title: text(row.title, 200),
273
- url: String(row.url || ""),
274
- content: text(
275
- p.provider === "tavily"
276
- ? row.content
277
- : [row.description, ...(row.extra_snippets || [])]
278
- .filter(Boolean)
279
- .join("\n"),
280
- 6000,
281
- ),
282
- }))
283
- .filter((row) => {
284
- try {
285
- return (
286
- ["https:", "http:"].includes(new URL(row.url).protocol) &&
287
- row.content &&
288
- !hasCredential(row.content)
289
- );
290
- } catch {
291
- return false;
292
- }
293
- });
294
- }
295
- if (!valid()) throw Error("任务已经变化,资料结果未提交");
296
- const saved = [];
297
- for (const row of results) {
298
- const hash = createHash("sha256").update(row.content).digest("hex"),
299
- sourceId = randomUUID();
300
- if (projectId) {
301
- this.db
302
- .prepare(
303
- "INSERT OR IGNORE INTO mind_time_sources(id,project_id,created,title,url,content,hash,kind,model,uncertainty,timing) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
304
- )
305
- .run(
306
- sourceId,
307
- projectId,
308
- now,
309
- row.title,
310
- row.url,
311
- row.content,
312
- hash,
313
- row.kind,
314
- row.model,
315
- row.uncertainty,
316
- JSON.stringify(row.timing || {}),
317
- );
318
- saved.push(
319
- this.db
320
- .prepare(
321
- "SELECT * FROM mind_time_sources WHERE project_id=? AND url=? AND hash=?",
322
- )
323
- .get(projectId, row.url, hash),
324
- );
325
- } else saved.push({ ...row, hash, created: now });
326
- }
327
- this.db
328
- .prepare("UPDATE mind_time_searches SET state='done' WHERE id=?")
329
- .run(id);
330
- return saved;
331
- } catch (error) {
332
- const network = describeNetworkError(error);
333
- const message =
334
- network.code === "TIMEOUT"
335
- ? "搜索连接超时,请检查网络、代理或增加超时"
336
- : network.code
337
- ? `搜索连接失败(${network.code}${network.detail ? ":" + network.detail : ""}),请检查网络和代理;代理状态改变后可重启实例`
338
- : String(error.message);
339
- const reason = sanitizeDetail(
340
- p.apiKey ? message.split(p.apiKey).join("[密钥已隐藏]") : message,
341
- 300,
342
- );
343
- this.db
344
- .prepare(
345
- "UPDATE mind_time_searches SET state='error',error=? WHERE id=?",
346
- )
347
- .run(reason, id);
348
- throw Error(reason);
349
- }
350
- }
351
- async test(input = {}) {
352
- if (this.testing) {
353
- const error = Error("正在测试搜索连接,请稍后");
354
- error.status = 429;
355
- throw error;
356
- }
357
- this.testing = true;
358
- try {
359
- const profile = this.validated(input);
360
- const run = () =>
361
- this.query(SEARCH_TEST_TOPIC, { profile, purpose: "diagnostic" });
362
- const results =
363
- this.mode(profile) === "model"
364
- ? await this.time.attention.activity(run)
365
- : await run();
366
- if (results === null) throw Error("正在推进活动步骤,请稍后测试");
367
- return {
368
- results,
369
- profile: this.public(profile.provider, profile),
370
- diagnostic: true,
371
- };
372
- } finally {
373
- this.testing = false;
374
- }
375
- }
376
- sources(projectId) {
377
- return this.db
378
- .prepare(
379
- "SELECT id,created,title,url,length(content) characters,hash,kind,model,uncertainty,timing FROM mind_time_sources WHERE project_id=? ORDER BY created DESC LIMIT 100",
380
- )
381
- .all(projectId);
382
- }
383
- }
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { dayKey, hasCredential, text } from "../util.js";
3
+ import { describeNetworkError, sanitizeDetail } from "../../core/network.js";
4
+ import { modelMaterial, MODEL_MATERIAL_LABEL } from "./model-material.js";
5
+ export const SEARCH_DEFAULTS = {
6
+ enabled: true,
7
+ provider: "tavily",
8
+ baseUrl: "https://api.tavily.com/search",
9
+ apiKey: "",
10
+ timeoutMs: 20000,
11
+ maxResults: 5,
12
+ dailyLimit: 12,
13
+ };
14
+ const ENDPOINTS = {
15
+ tavily: "https://api.tavily.com/search",
16
+ brave: "https://api.search.brave.com/res/v1/web/search",
17
+ };
18
+ export const SEARCH_TEST_TOPIC = "ATRI 游戏 资料简介";
19
+ export class Search {
20
+ constructor(time) {
21
+ this.time = time;
22
+ this.db = time.db;
23
+ this.fetch = (...args) => fetch(...args);
24
+ this.testing = false;
25
+ this.db
26
+ .prepare(
27
+ "UPDATE mind_time_searches SET state='error',error='实例中断,查询结果未确认' WHERE state='running'",
28
+ )
29
+ .run();
30
+ }
31
+ bind(life) {
32
+ this.life = life;
33
+ this.wake();
34
+ }
35
+ mode(profile = this.profile()) {
36
+ return profile.enabled && profile.apiKey ? "web" : "model";
37
+ }
38
+ wake() {
39
+ const now = this.time.now();
40
+ if (this.ready(now)) return;
41
+ for (const row of this.db
42
+ .prepare(
43
+ "SELECT id,ready_at FROM mind_time_tasks WHERE activity='game' AND state='waiting' AND wait_reason IN ('等待独立搜索密钥','独立搜索尚未启用','今日资料查询次数已用完') AND json_extract(checkpoint,'$.mergedInto') IS NULL",
44
+ )
45
+ .all())
46
+ this.time.tasks.control(
47
+ row.id,
48
+ {
49
+ action: "resume",
50
+ readyAt: Math.max(now, row.ready_at),
51
+ reason: "资料查询条件已恢复,重新按优先级安排",
52
+ },
53
+ now,
54
+ );
55
+ }
56
+ profile(provider) {
57
+ const saved = this.time.mind.repo.config("search-profile", {});
58
+ const chosen = provider || saved.provider || "tavily";
59
+ const profile = {
60
+ ...SEARCH_DEFAULTS,
61
+ baseUrl: ENDPOINTS[chosen],
62
+ ...saved.profiles?.[chosen],
63
+ provider: chosen,
64
+ };
65
+ // Existing keys may have been pasted with padding before save normalized it.
66
+ // Activity reads and connection tests must use the same credential bytes.
67
+ return { ...profile, apiKey: profile.apiKey.trim() };
68
+ }
69
+ public(provider, testedProfile) {
70
+ const { apiKey, ...profile } = testedProfile || this.profile(provider);
71
+ const usage = this.usage(this.time.now());
72
+ return {
73
+ ...profile,
74
+ hasApiKey: !!apiKey,
75
+ mode: this.mode({ ...profile, apiKey }),
76
+ materialLabel:
77
+ this.mode({ ...profile, apiKey }) === "model"
78
+ ? MODEL_MATERIAL_LABEL
79
+ : "联网检索资料",
80
+ used: usage.used,
81
+ usage,
82
+ };
83
+ }
84
+ validated(input = {}) {
85
+ const old = this.time.mind.repo.config("search-profile", {}),
86
+ provider = input.provider || old.provider || "tavily";
87
+ if (!ENDPOINTS[provider]) throw Error("搜索提供方无效");
88
+ const next = { ...this.profile(provider), ...input, provider };
89
+ if (typeof next.apiKey === "string") next.apiKey = next.apiKey.trim();
90
+ if (
91
+ typeof next.enabled !== "boolean" ||
92
+ typeof next.apiKey !== "string" ||
93
+ /[\r\n]/.test(next.apiKey) ||
94
+ next.apiKey.length > 2000
95
+ )
96
+ throw Error("搜索配置格式无效");
97
+ if (!next.apiKey) next.apiKey = this.profile(provider).apiKey.trim();
98
+ if (input.clearKey) next.apiKey = "";
99
+ const url = new URL(next.baseUrl);
100
+ if (
101
+ !["https:", "http:"].includes(url.protocol) ||
102
+ url.username ||
103
+ url.password ||
104
+ url.hash ||
105
+ url.search
106
+ )
107
+ throw Error("搜索地址无效");
108
+ for (const [key, min, max] of [
109
+ ["timeoutMs", 1000, 120000],
110
+ ["maxResults", 1, 20],
111
+ ["dailyLimit", 0, 100000],
112
+ ])
113
+ if (!Number.isInteger(next[key]) || next[key] < min || next[key] > max)
114
+ throw Error(key + " 超出范围");
115
+ const profile = Object.fromEntries(
116
+ Object.keys(SEARCH_DEFAULTS).map((key) => [key, next[key]]),
117
+ );
118
+ return { ...profile, provider };
119
+ }
120
+ save(input) {
121
+ const profile = this.validated(input);
122
+ const old = this.time.mind.repo.config("search-profile", {});
123
+ this.time.mind.repo.saveConfig("search-profile", {
124
+ provider: profile.provider,
125
+ profiles: { ...old.profiles, [profile.provider]: profile },
126
+ });
127
+ this.wake();
128
+ return this.public();
129
+ }
130
+ usage(now) {
131
+ const day = dayKey(now, this.time.mind.timeZone());
132
+ const rows = this.db
133
+ .prepare(
134
+ "SELECT created,state,purpose FROM mind_time_searches WHERE created<=? AND created>=?",
135
+ )
136
+ .all(now, now - 2 * 86400000)
137
+ .filter((r) => dayKey(r.created, this.time.mind.timeZone()) === day);
138
+ const activity = rows.filter((r) => r.purpose !== "diagnostic"),
139
+ successful = activity.filter((r) => r.state === "done").length,
140
+ running = activity.filter((r) => r.state === "running").length;
141
+ return {
142
+ used: successful + running,
143
+ successful,
144
+ running,
145
+ failed: activity.filter((r) => r.state === "error").length,
146
+ diagnostics: rows.filter((r) => r.purpose === "diagnostic").length,
147
+ };
148
+ }
149
+ used(now) {
150
+ return this.usage(now).used;
151
+ }
152
+ ready(now = this.time.now(), p = this.profile(), diagnostic = false) {
153
+ if (!diagnostic && p.dailyLimit > 0 && this.used(now) >= p.dailyLimit)
154
+ return "今日资料查询次数已用完";
155
+ if (this.mode(p) === "model") {
156
+ try {
157
+ const model = this.life?.profile();
158
+ if (!model || !(model.apiKey || process.env.LLM_API_KEY))
159
+ return "等待可用模型";
160
+ if (this.life.closed) return "等待可用模型";
161
+ } catch {
162
+ return "等待可用模型";
163
+ }
164
+ }
165
+ return "";
166
+ }
167
+ async query(
168
+ topic,
169
+ {
170
+ projectId = null,
171
+ now = this.time.now(),
172
+ trace,
173
+ valid = () => true,
174
+ profile = this.profile(),
175
+ purpose = "activity",
176
+ } = {},
177
+ ) {
178
+ if (!["activity", "diagnostic"].includes(purpose))
179
+ throw Error("查询用途无效");
180
+ const why = this.ready(now, profile, purpose === "diagnostic");
181
+ if (why) throw Error(why);
182
+ topic = text(topic, 180);
183
+ if (
184
+ !topic ||
185
+ hasCredential(topic) ||
186
+ /\b\d{5,}\b|(?:群|私聊|账号)\s*[::=]/.test(topic)
187
+ )
188
+ throw Error("查询只能包含资料主题");
189
+ const p = profile,
190
+ id = Number(
191
+ this.db
192
+ .prepare(
193
+ "INSERT INTO mind_time_searches(created,provider,query,state,purpose) VALUES (?,?,?,'running',?)",
194
+ )
195
+ .run(
196
+ now,
197
+ this.mode(p) === "model" ? "model" : p.provider,
198
+ topic,
199
+ purpose,
200
+ ).lastInsertRowid,
201
+ );
202
+ const headers =
203
+ p.provider === "tavily"
204
+ ? {
205
+ "Content-Type": "application/json",
206
+ Authorization: "Bearer " + p.apiKey,
207
+ }
208
+ : { "X-Subscription-Token": p.apiKey, Accept: "application/json" };
209
+ const endpoint = new URL(p.baseUrl);
210
+ let body;
211
+ if (p.provider === "tavily")
212
+ body = JSON.stringify({
213
+ query: topic,
214
+ search_depth: "basic",
215
+ max_results: p.maxResults,
216
+ include_answer: false,
217
+ include_raw_content: false,
218
+ });
219
+ else {
220
+ endpoint.searchParams.set("q", topic);
221
+ endpoint.searchParams.set("count", String(p.maxResults));
222
+ endpoint.searchParams.set("extra_snippets", "true");
223
+ }
224
+ try {
225
+ let results;
226
+ if (this.mode(p) === "model") {
227
+ results = await modelMaterial(this.life, topic, {
228
+ trace,
229
+ maxResults: p.maxResults,
230
+ });
231
+ } else {
232
+ const response = await this.fetch(endpoint, {
233
+ method: p.provider === "tavily" ? "POST" : "GET",
234
+ headers,
235
+ ...(body ? { body } : {}),
236
+ signal: AbortSignal.timeout(p.timeoutMs),
237
+ redirect: "error",
238
+ });
239
+ if (!response.ok) {
240
+ const data = await response.json().catch(() => ({}));
241
+ const detail =
242
+ data.detail?.error ||
243
+ data.error?.message ||
244
+ (typeof data.error === "string" ? data.error : "");
245
+ const hint = [401, 403].includes(response.status)
246
+ ? "请核对独立密钥与权限"
247
+ : [429, 432, 433].includes(response.status)
248
+ ? "提供方限制了请求频率或账户额度"
249
+ : [404, 405].includes(response.status)
250
+ ? "请核对提供方与完整搜索地址"
251
+ : "请检查搜索服务与请求配置";
252
+ throw Error(`搜索接口 HTTP ${response.status}:${detail || hint}`);
253
+ }
254
+ const data = await response.json().catch(() => {
255
+ throw Error("搜索接口未返回有效 JSON,请核对提供方与完整搜索地址");
256
+ }),
257
+ raw = p.provider === "tavily" ? data?.results : data?.web?.results;
258
+ if (
259
+ !Array.isArray(raw) &&
260
+ !(p.provider === "brave" && data?.type === "search" && !data.web)
261
+ )
262
+ throw Error(
263
+ "搜索返回格式与选定提供方不匹配,请核对提供方与完整搜索地址",
264
+ );
265
+ results = (Array.isArray(raw) ? raw : [])
266
+ .slice(0, p.maxResults)
267
+ .map((row) => ({
268
+ kind: "web",
269
+ model: "",
270
+ uncertainty: "",
271
+ timing: {},
272
+ title: text(row.title, 200),
273
+ url: String(row.url || ""),
274
+ content: text(
275
+ p.provider === "tavily"
276
+ ? row.content
277
+ : [row.description, ...(row.extra_snippets || [])]
278
+ .filter(Boolean)
279
+ .join("\n"),
280
+ 6000,
281
+ ),
282
+ }))
283
+ .filter((row) => {
284
+ try {
285
+ return (
286
+ ["https:", "http:"].includes(new URL(row.url).protocol) &&
287
+ row.content &&
288
+ !hasCredential(row.content)
289
+ );
290
+ } catch {
291
+ return false;
292
+ }
293
+ });
294
+ }
295
+ if (!valid()) throw Error("任务已经变化,资料结果未提交");
296
+ const saved = [];
297
+ for (const row of results) {
298
+ const hash = createHash("sha256").update(row.content).digest("hex"),
299
+ sourceId = randomUUID();
300
+ if (projectId) {
301
+ this.db
302
+ .prepare(
303
+ "INSERT OR IGNORE INTO mind_time_sources(id,project_id,created,title,url,content,hash,kind,model,uncertainty,timing) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
304
+ )
305
+ .run(
306
+ sourceId,
307
+ projectId,
308
+ now,
309
+ row.title,
310
+ row.url,
311
+ row.content,
312
+ hash,
313
+ row.kind,
314
+ row.model,
315
+ row.uncertainty,
316
+ JSON.stringify(row.timing || {}),
317
+ );
318
+ saved.push(
319
+ this.db
320
+ .prepare(
321
+ "SELECT * FROM mind_time_sources WHERE project_id=? AND url=? AND hash=?",
322
+ )
323
+ .get(projectId, row.url, hash),
324
+ );
325
+ } else saved.push({ ...row, hash, created: now });
326
+ }
327
+ this.db
328
+ .prepare("UPDATE mind_time_searches SET state='done' WHERE id=?")
329
+ .run(id);
330
+ return saved;
331
+ } catch (error) {
332
+ const network = describeNetworkError(error);
333
+ const message =
334
+ network.code === "TIMEOUT"
335
+ ? "搜索连接超时,请检查网络、代理或增加超时"
336
+ : network.code
337
+ ? `搜索连接失败(${network.code}${network.detail ? ":" + network.detail : ""}),请检查网络和代理;代理状态改变后可重启实例`
338
+ : String(error.message);
339
+ const reason = sanitizeDetail(
340
+ p.apiKey ? message.split(p.apiKey).join("[密钥已隐藏]") : message,
341
+ 300,
342
+ );
343
+ this.db
344
+ .prepare(
345
+ "UPDATE mind_time_searches SET state='error',error=? WHERE id=?",
346
+ )
347
+ .run(reason, id);
348
+ throw Error(reason);
349
+ }
350
+ }
351
+ async test(input = {}) {
352
+ if (this.testing) {
353
+ const error = Error("正在测试搜索连接,请稍后");
354
+ error.status = 429;
355
+ throw error;
356
+ }
357
+ this.testing = true;
358
+ try {
359
+ const profile = this.validated(input);
360
+ const run = () =>
361
+ this.query(SEARCH_TEST_TOPIC, { profile, purpose: "diagnostic" });
362
+ const results =
363
+ this.mode(profile) === "model"
364
+ ? await this.time.attention.activity(run)
365
+ : await run();
366
+ if (results === null) throw Error("正在推进活动步骤,请稍后测试");
367
+ return {
368
+ results,
369
+ profile: this.public(profile.provider, profile),
370
+ diagnostic: true,
371
+ };
372
+ } finally {
373
+ this.testing = false;
374
+ }
375
+ }
376
+ sources(projectId) {
377
+ return this.db
378
+ .prepare(
379
+ "SELECT id,created,title,url,length(content) characters,hash,kind,model,uncertainty,timing FROM mind_time_sources WHERE project_id=? ORDER BY created DESC LIMIT 100",
380
+ )
381
+ .all(projectId);
382
+ }
383
+ }