luckytri 1.1.2 → 1.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.
Files changed (95) hide show
  1. package/README.en.md +14 -12
  2. package/README.md +4 -2
  3. package/docs/en/CHANGELOG.md +24 -0
  4. package/docs/en/architecture.md +12 -10
  5. package/docs/en/chat-style.md +13 -13
  6. package/docs/en/cognition.md +12 -4
  7. package/docs/en/evolution.md +35 -0
  8. package/docs/en/her-life.md +5 -2
  9. package/docs/en/vision.md +50 -0
  10. package/docs/zh/CHANGELOG.md +24 -0
  11. package/docs/zh/architecture.md +12 -10
  12. package/docs/zh/chat-style.md +17 -17
  13. package/docs/zh/cognition.md +12 -4
  14. package/docs/zh/evolution.md +35 -0
  15. package/docs/zh/her-life.md +5 -2
  16. package/docs/zh/vision.md +50 -0
  17. package/package.json +4 -1
  18. package/public/app/assets/{ChatsPage-IfBgNdaD.js → ChatsPage-eDqFIg97.js} +1 -1
  19. package/public/app/assets/{Empty-DK9nQPCf.js → Empty-CAGOJsfD.js} +1 -1
  20. package/public/app/assets/{Field-BzKFhuFd.js → Field-CRz-JmN1.js} +1 -1
  21. package/public/app/assets/{HeartPage-CKxrbfFQ.js → HeartPage-BGWoeMrC.js} +1 -1
  22. package/public/app/assets/{LifePage-CtxPEi6E.js → LifePage-iao3o3wt.js} +1 -1
  23. package/public/app/assets/{MemoryPage-B8FLXzLi.js → MemoryPage-Dwi2yi7E.js} +1 -1
  24. package/public/app/assets/NaturePage-DniImo9U.js +3 -0
  25. package/public/app/assets/{NaturePage-CHkpb5NK.css → NaturePage-zguDpGWY.css} +1 -1
  26. package/public/app/assets/{PeoplePage-D3yn0HV0.js → PeoplePage-D-MC3JEt.js} +1 -1
  27. package/public/app/assets/{PluginsPage-ojv8zawj.js → PluginsPage-D7dJTf96.js} +1 -1
  28. package/public/app/assets/SystemPage-C5CUHjGh.js +1 -0
  29. package/public/app/assets/{SystemPage-CGVTvNZw.css → SystemPage-C_Y5M7JU.css} +1 -1
  30. package/public/app/assets/{TimePage-Dyd-FFbq.js → TimePage-Dwhr3-92.js} +1 -1
  31. package/public/app/assets/{index-BJfzfgnm.js → index-L0bHax8C.js} +3 -3
  32. package/public/app/assets/{knowledge-C-bND0Ub.js → knowledge-BTDNafzl.js} +1 -1
  33. package/public/app/assets/{media-D7G3Nco6.js → media-BBzOAfNG.js} +1 -1
  34. package/public/app/assets/{mind-ec-G6iNZ.js → mind-CBTkFje2.js} +1 -1
  35. package/public/app/index.html +3 -3
  36. package/public/guide.en.html +3 -3
  37. package/public/guide.html +3 -3
  38. package/scripts/migrate-instance.js +27 -0
  39. package/scripts/update-instance.js +21 -2
  40. package/server/channels/contract.js +5 -1
  41. package/server/channels/hub.js +2 -2
  42. package/server/channels/onebot/adapter.js +291 -288
  43. package/server/channels/onebot/index.js +2 -2
  44. package/server/channels/qqbot/api.js +194 -199
  45. package/server/core/context-builder.js +20 -3
  46. package/server/core/conversation-cues.js +5 -4
  47. package/server/core/conversation-grounding.js +20 -17
  48. package/server/core/conversation-needs.js +32 -0
  49. package/server/core/dialogue-context.js +2 -5
  50. package/server/core/dialogue-contract.js +23 -16
  51. package/server/core/message-scheduler.js +85 -78
  52. package/server/core/model-manager.js +113 -16
  53. package/server/core/model-presets.js +1 -1
  54. package/server/core/orchestrator.js +18 -2
  55. package/server/core/persona-manager.js +14 -5
  56. package/server/core/reader-check.js +47 -42
  57. package/server/core/reply-delivery.js +125 -34
  58. package/server/core/response-validator.js +28 -1
  59. package/server/core/retired-prompts.js +5 -0
  60. package/server/core/speech-length.js +43 -0
  61. package/server/core/turn-processor.js +38 -5
  62. package/server/core/turn.js +70 -16
  63. package/server/i18n/errors.js +30 -0
  64. package/server/i18n/labels.js +5 -0
  65. package/server/index.js +3 -0
  66. package/server/knowledge/embedding-profile.js +126 -29
  67. package/server/knowledge/manager.js +103 -34
  68. package/server/knowledge/schema.js +197 -158
  69. package/server/mind/affect.js +267 -265
  70. package/server/mind/aftermath.js +169 -0
  71. package/server/mind/anticipations.js +2 -0
  72. package/server/mind/clock.js +91 -83
  73. package/server/mind/discretion.js +20 -0
  74. package/server/mind/episodes.js +2 -1
  75. package/server/mind/index.js +17 -2
  76. package/server/mind/initiative.js +29 -14
  77. package/server/mind/memory.js +47 -5
  78. package/server/mind/recall/cue.js +67 -0
  79. package/server/mind/recall/index.js +183 -29
  80. package/server/mind/recall/roots.js +231 -0
  81. package/server/mind/recall/schema.js +2 -0
  82. package/server/mind/recall/store.js +113 -30
  83. package/server/mind/salience.js +22 -1
  84. package/server/mind/schema.js +5 -0
  85. package/server/mind/thoughts.js +276 -274
  86. package/server/mind/view.js +100 -60
  87. package/server/plugins/install.js +1 -6
  88. package/server/plugins/zip.js +18 -4
  89. package/server/runtime/data-migration.js +193 -0
  90. package/server/runtime/update-execution.js +43 -4
  91. package/server/runtime/update-manager.js +4 -1
  92. package/server/storage/archive.js +1 -1
  93. package/server/storage/backup-scheduler.js +15 -3
  94. package/public/app/assets/NaturePage-C7i607ul.js +0 -3
  95. package/public/app/assets/SystemPage-Cv3rFvyT.js +0 -1
@@ -1,7 +1,9 @@
1
+ import { speechLength } from "./speech-length.js";
1
2
  import { parseSessionKey } from "../channels/session-key.js";
2
3
  import { initiativeContext, initiativeSnapshot } from "./initiative-context.js";
3
4
  import { withFallback } from "./model-manager.js";
4
5
  import { activityRecallCue } from "../mind/time/activity-recall.js";
6
+ import { withRecallContext } from "../mind/recall/cue.js";
5
7
  import { replyPrompt, prompts } from "./persona-manager.js";
6
8
  import { buildContext, perceive } from "./context-builder.js";
7
9
  import {
@@ -323,12 +325,14 @@ export class TurnProcessor {
323
325
  /* An unparseable destination has no person to infer. */
324
326
  }
325
327
  }
328
+ // A short follow-up is recalled together with the line it leans on.
329
+ const recallBatch = withRecallContext(batch, resolved);
326
330
  lap("perceive");
327
331
  // Replays exclude mutable memory so later corrections cannot leak into
328
332
  // the past; her inner state is folded as of the replayed moment.
329
333
  const memories =
330
334
  policy.memory && !replay && !ownInitiative
331
- ? await this.owner.mind.recall.memories(session, batch, now, {
335
+ ? await this.owner.mind.recall.memories(session, recallBatch, now, {
332
336
  people,
333
337
  touch: live,
334
338
  forSpeech: true,
@@ -371,7 +375,7 @@ export class TurnProcessor {
371
375
  lap("summaries");
372
376
  const meant =
373
377
  !replay && !ownInitiative
374
- ? await this.owner.mind.recall.relatedOwn(session, batch, now)
378
+ ? await this.owner.mind.recall.relatedOwn(session, recallBatch, now)
375
379
  : null;
376
380
  const view = this.owner.mind.view({
377
381
  session: preview?.viewSession || session,
@@ -402,13 +406,18 @@ export class TurnProcessor {
402
406
  activityCue,
403
407
  }),
404
408
  );
409
+ let recollected = [];
405
410
  if (policy.memory && !replay && !ownInitiative) {
406
411
  const recollections = await this.owner.mind.recall.recollect(
407
412
  session,
408
- batch,
413
+ recallBatch,
409
414
  now,
410
- { people },
415
+ {
416
+ people,
417
+ exclude: new Set((view.recalled || []).map((r) => r.id)),
418
+ },
411
419
  );
420
+ recollected = recollections;
412
421
  if (recollections.length)
413
422
  view.inner.recollections = {
414
423
  note: RECOLLECTION_NOTE,
@@ -485,7 +494,7 @@ export class TurnProcessor {
485
494
  if (!replay)
486
495
  snapshot.topics = this.owner.topics.recent(session, watermark, now);
487
496
  if (!ownInitiative) {
488
- await this.addWorkingMemory(session, snapshot, batch, now, {
497
+ await this.addWorkingMemory(session, snapshot, recallBatch, now, {
489
498
  replay,
490
499
  zone: policy.timeZone,
491
500
  });
@@ -622,6 +631,7 @@ export class TurnProcessor {
622
631
  return finish("stale", "读房间时又有新话,并入下一批");
623
632
  }
624
633
  lap("checks");
634
+ const speech = speechLength(nature);
625
635
  let turn;
626
636
  try {
627
637
  turn = normalizeTurn(
@@ -644,6 +654,7 @@ export class TurnProcessor {
644
654
  ),
645
655
  snapshot,
646
656
  trace,
657
+ { speech },
647
658
  );
648
659
  if (generationImages.length)
649
660
  markVisionSeen(
@@ -716,6 +727,7 @@ export class TurnProcessor {
716
727
  reason: choice === turn.choice ? turn.reason : "没发出去",
717
728
  watermark,
718
729
  occasion: occasion?.type || null,
730
+ recalled: whatCameToMind(memories, recollected, view, trace),
719
731
  time: now,
720
732
  });
721
733
  };
@@ -798,3 +810,24 @@ export class TurnProcessor {
798
810
  }
799
811
  }
800
812
  }
813
+
814
+ // The past that was in front of her this turn, for the permanent record next
815
+ // to her choice. A failure here must never cost the turn.
816
+ function whatCameToMind(memories, recollected, view, trace) {
817
+ try {
818
+ return [
819
+ ...(memories || []).map((m) => ({
820
+ id: `fact:${m.id}`,
821
+ why: m.whySelected,
822
+ })),
823
+ ...(recollected || []).flatMap((r) => [
824
+ { id: r.ref, why: r.why },
825
+ ...(r.linked || []).map((id) => ({ id, why: "linked" })),
826
+ ]),
827
+ ...(view?.recalled || []),
828
+ ];
829
+ } catch (error) {
830
+ trace?.steps?.push(`没能记下这轮想起了什么:${error.message}`);
831
+ return [];
832
+ }
833
+ }
@@ -11,6 +11,8 @@ import {
11
11
  explicitStop,
12
12
  } from "./dialogue-context.js";
13
13
  import { readerQuestions } from "./reader-check.js";
14
+ import { conversationNeeds } from "./conversation-needs.js";
15
+ import { BOT_GUIDE, CREATIVE_GUIDE } from "./dialogue-contract.js";
14
16
  import {
15
17
  botExchange,
16
18
  CONTRIBUTION_CHECK,
@@ -41,9 +43,11 @@ const LEGACY = {
41
43
  SILENT: "silent",
42
44
  };
43
45
 
44
- export function maxBubbles(choice, snapshot) {
46
+ // A ceiling on how many messages one turn may send, never a target. The
47
+ // owner's length setting moves it; someone here asking for one message wins.
48
+ export function maxBubbles(choice, snapshot, speech) {
45
49
  if (choice !== "speak") return 1;
46
- return prefersSingleBubble(snapshot || {}) ? 1 : 3;
50
+ return prefersSingleBubble(snapshot || {}) ? 1 : speech?.maxBubbles || 3;
47
51
  }
48
52
 
49
53
  export function joinBubbleParts(parts) {
@@ -154,6 +158,7 @@ export async function takeTurn(
154
158
  ...(extra.pressure ? { pressure: extra.pressure } : {}),
155
159
  ...(recallWording(snapshot) ? { guidance: recallWording(snapshot) } : {}),
156
160
  ...(!initiating ? { exchange: currentExchange(snapshot) } : {}),
161
+ ...turnGuides(snapshot),
157
162
  };
158
163
  let raw = await models.call(
159
164
  profile,
@@ -163,6 +168,7 @@ export async function takeTurn(
163
168
  trace,
164
169
  images,
165
170
  );
171
+ raw = withSaidMessages(raw);
166
172
  const words = (
167
173
  Array.isArray(raw?.bubbles) ? raw.bubbles : [raw?.bubbles]
168
174
  ).filter((word) => typeof word === "string");
@@ -235,18 +241,20 @@ export async function takeTurn(
235
241
  if (review.hasContribution === false && review.shouldRepair === true) {
236
242
  trace?.steps?.push("新问题或新段落被上一稿漏接,按当前内容重新想一次");
237
243
  try {
238
- const repaired = await models.call(
239
- profile,
240
- "turn",
241
- `${turnSystem}\n${CONTRIBUTION_REPAIR}`,
242
- {
243
- ...turnInput,
244
- contributionRepair: {
245
- previousReply: raw.bubbles || [],
244
+ const repaired = withSaidMessages(
245
+ await models.call(
246
+ profile,
247
+ "turn",
248
+ `${turnSystem}\n${CONTRIBUTION_REPAIR}`,
249
+ {
250
+ ...turnInput,
251
+ contributionRepair: {
252
+ previousReply: raw.bubbles || [],
253
+ },
246
254
  },
247
- },
248
- trace,
249
- images,
255
+ trace,
256
+ images,
257
+ ),
250
258
  );
251
259
  const repairedExchange = botExchange(snapshot, repaired);
252
260
  if (
@@ -343,7 +351,7 @@ function liftMisplaced(raw, trace) {
343
351
  return out;
344
352
  }
345
353
 
346
- export function normalizeTurn(raw, snapshot, trace) {
354
+ export function normalizeTurn(raw, snapshot, trace, { speech } = {}) {
347
355
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
348
356
  throw SyntaxError("回合输出不是 JSON 对象");
349
357
  const known = new Set((snapshot.messages || []).map((m) => m.id));
@@ -393,13 +401,21 @@ export function normalizeTurn(raw, snapshot, trace) {
393
401
  "这是一句完整回应,按正常发言保留,不为短反应标签改写原意",
394
402
  );
395
403
  }
396
- const bubbleLimit = maxBubbles(choice, snapshot);
404
+ const bubbleLimit = maxBubbles(choice, snapshot, speech);
397
405
  if (bubbles.length > bubbleLimit) {
398
406
  if (choice === "speak" && bubbleLimit === 1) {
399
407
  bubbles = [joinBubbleParts(bubbles)];
400
408
  trace?.steps?.push("根据对方的偏好,把连续补充合成一条完整消息");
401
409
  } else {
402
- bubbles = bubbles.slice(0, bubbleLimit);
410
+ // Fold the overflow into the last allowed message instead of losing
411
+ // what she meant to say.
412
+ bubbles =
413
+ choice === "speak"
414
+ ? [
415
+ ...bubbles.slice(0, bubbleLimit - 1),
416
+ joinBubbleParts(bubbles.slice(bubbleLimit - 1)),
417
+ ]
418
+ : bubbles.slice(0, bubbleLimit);
403
419
  trace?.steps?.push(
404
420
  choice === "react"
405
421
  ? "简短反应只留第一条气泡"
@@ -430,6 +446,15 @@ export function normalizeTurn(raw, snapshot, trace) {
430
446
  !crisis &&
431
447
  !snapshot.initiative &&
432
448
  (grounding.botLoop ||
449
+ // She read the other AI's line as a goodbye herself; answering a
450
+ // goodbye from a bot only starts another round of goodbyes.
451
+ // Who is saying goodbye matters, not how long the bots have talked.
452
+ (raw.understanding?.kind === "closing" &&
453
+ grounding.botMessageIds.length > 0 &&
454
+ batch
455
+ .filter((m) => m.role === "user")
456
+ .every((m) => grounding.botMessageIds.includes(m.id)) &&
457
+ !batch.some((m) => m.relation === "direct")) ||
433
458
  (raw.contribution?.kind === "none" &&
434
459
  !raw.act &&
435
460
  !raw.share &&
@@ -556,3 +581,32 @@ export function wordingNotes(turn, snapshot) {
556
581
  .filter(Boolean)
557
582
  .join("\n");
558
583
  }
584
+
585
+ // Writing or bot-conversation guidance, only when the conversation is about
586
+ // those things. How the room types is deliberately not given: her voice comes
587
+ // from her own state and past words, not from imitating the people here.
588
+ function turnGuides(snapshot) {
589
+ const needs = conversationNeeds(snapshot);
590
+ return {
591
+ ...(needs.creative ? { creativeGuide: CREATIVE_GUIDE } : {}),
592
+ ...(needs.bots ? { botGuide: BOT_GUIDE } : {}),
593
+ };
594
+ }
595
+
596
+ // She writes what she would type as one piece of text; a blank line is where
597
+ // she would press send and start another message. Writing one message is the
598
+ // natural default of typing, so how many she sends is a choice she makes,
599
+ // not a list she is handed to fill. An older reply with "bubbles" still works.
600
+ export function saidMessages(text) {
601
+ return String(text)
602
+ .split(/\n[ \t\u3000]*\n+/)
603
+ .map((part) => part.trim())
604
+ .filter(Boolean);
605
+ }
606
+
607
+ export function withSaidMessages(raw) {
608
+ if (!raw || typeof raw !== "object" || typeof raw.say !== "string")
609
+ return raw;
610
+ const { say, ...rest } = raw;
611
+ return { ...rest, bubbles: saidMessages(say) };
612
+ }
@@ -2,6 +2,7 @@
2
2
  // key, exactly as the code writes it. `{0}` stands for a value the code puts
3
3
  // into the message; patterns are used when a message carries one.
4
4
  export const ERRORS = {
5
+ 复审超过截止时间: "The reply review passed its deadline",
5
6
  "JSON 同一对象含重复字段,不能确定哪份内容有效":
6
7
  "A JSON object contains duplicate fields; the intended value is ambiguous",
7
8
  "写日记期间关系记录已变化,本次旧稿未保存,下次按新认识整理":
@@ -33,6 +34,23 @@ export const ERRORS = {
33
34
  "The update did not complete. The original program and data were retained. Check the runtime log before trying again.",
34
35
  "自动恢复未完成,更新前的校验备份已保留,请查看运行日志并手动恢复。":
35
36
  "Automatic recovery did not complete. The verified pre-update backup was retained. Check the runtime log and recover manually.",
37
+ "缺少候选版本的数据迁移器,已停止更新":
38
+ "The candidate release has no data migrator. The update was stopped.",
39
+ 迁移目标不是现有的绝对路径数据库文件:
40
+ "The migration target must be an existing database at an absolute path",
41
+ 迁移目标版本与候选程序不一致:
42
+ "The migration target version does not match the candidate release",
43
+ 迁移目标数据库与更新前备份不匹配:
44
+ "The migration database does not match the pre-update backup",
45
+ 数据库未迁移到候选版本要求的结构:
46
+ "The database was not migrated to the schema required by the candidate release",
47
+ 迁移后数据库身份发生变化: "The database identity changed during migration",
48
+ "迁移后的数据库仍有未关闭的 SQLite 事务":
49
+ "The migrated database still has an open SQLite transaction",
50
+ "旧数据库仍被占用,无法安全切换":
51
+ "The old database is still in use and cannot be switched safely",
52
+ "数据库仍有未关闭的 SQLite 事务文件,请重试更新":
53
+ "SQLite transaction files are still open. Try the update again.",
36
54
  修改机会需要明确的记录与原因:
37
55
  "An edit opportunity needs an explicit record and reason",
38
56
  修改机会记录无效: "Invalid edit opportunity record",
@@ -283,6 +301,8 @@ export const ERRORS = {
283
301
  向量数量与输入不一致: "The vector count does not match the input",
284
302
  向量内容无效: "The vector content is invalid",
285
303
  向量维度不一致: "The vector dimensions are inconsistent",
304
+ "向量服务不支持自动长度,已改用模型自己的长度":
305
+ "The vector service does not support the automatic length; using the model's own length",
286
306
  知识库名称无效: "The collection name is invalid",
287
307
  知识库不存在: "The collection does not exist",
288
308
  "文档内容应为 1–400000 字": "The document must be 1–400000 characters",
@@ -550,7 +570,17 @@ export const ERRORS = {
550
570
  // Messages that carry a value. `{0}`, `{1}` are filled with what the code put
551
571
  // in; the values are passed through the dictionaries again.
552
572
  export const ERROR_PATTERNS = [
573
+ ["记忆索引重建失败:{0}", "Memory index rebuild failed: {0}"],
574
+ ["迁移后缺少原数据表:{0}", "The migration is missing original table: {0}"],
575
+ [
576
+ "迁移后 {0} 记录数减少({1} → {2}),已停止更新",
577
+ "The migration reduced {0} rows ({1} → {2}); the update was stopped",
578
+ ],
553
579
  ["删除失败({0})", "Removal failed ({0})"],
580
+ [
581
+ "向量维度不一致:接口返回 {0} 维,应为 {1} 维",
582
+ "The vector dimensions are inconsistent: the service returned {0}, expected {1}",
583
+ ],
554
584
  ["清理工作异常结束:{0}", "Cleanup ended unexpectedly: {0}"],
555
585
  ["{0} Prompt 无效", "{0} prompt is invalid"],
556
586
  ["模型请求失败 HTTP {0}:{1}", "The model request failed with HTTP {0}: {1}"],
@@ -145,6 +145,11 @@ export const LABELS = {
145
145
  刚才: "just now",
146
146
  今天早些时候: "earlier today",
147
147
  好几天前: "several days ago",
148
+ 一周前: "a week ago",
149
+ 两三周前: "two or three weeks ago",
150
+ 一个多月前: "over a month ago",
151
+ 几个月前: "a few months ago",
152
+ 一年多前: "over a year ago",
148
153
  很久以前: "a long time ago",
149
154
  还未相遇: "Not met yet",
150
155
  "还没有真实聊天,时间暂时没有可以延续的落点。":
package/server/index.js CHANGED
@@ -15,6 +15,8 @@ import { openSync, closeSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { UpdateChecker } from "./studio/updates.js";
17
17
  import { InstanceUpdater } from "./runtime/update-manager.js";
18
+ import { migrateReleaseData } from "./runtime/data-migration.js";
19
+ import { VERSION } from "./version.js";
18
20
 
19
21
  claimServer();
20
22
  process.on("exit", clearStarting);
@@ -32,6 +34,7 @@ const chatSystem = new ChatSystem(store, channel.send, {
32
34
  fetchQuoted: (message) => channel.fetchQuoted(message),
33
35
  fetchImage: (file, sessionId) => channel.fetchImage(file, sessionId),
34
36
  });
37
+ await migrateReleaseData(chatSystem.mind, { version: VERSION });
35
38
  const life = new Life(chatSystem, {
36
39
  online: () => channel.online(),
37
40
  canReach: (session) => channel.canReach(session),
@@ -16,37 +16,94 @@ export const EMBEDDING_DEFAULTS = {
16
16
  };
17
17
  export const EMBEDDING_PROTOCOLS = new Set(["openai", "multimodal"]);
18
18
 
19
- // Defaults when the user gives an address and a key. Dimension 0 means the
20
- // request does not name a length, so the model returns its own. For
21
- // doubao-embedding-vision that is 2048. The model name is filled in only
22
- // when the address is Volcengine Ark.
23
- export function suggestEmbedding(chat = {}, address = "") {
24
- const given = String(address || "")
25
- .trim()
26
- .replace(/\/$/, "");
27
- const chatBase = String(chat.baseUrl || "")
19
+ // Embeddings are any OpenAI-compatible /embeddings endpoint: an address, a
20
+ // model name and a key. Presets only fill in what the user left empty for
21
+ // providers known to need it; none of them is what "embedding" means.
22
+ // `chat: true` keeps the chat address, because that plan's key works only
23
+ // there (an Agent Plan key is refused by the ordinary /api/v3 address).
24
+ export const EMBEDDING_PRESETS = [
25
+ {
26
+ id: "volcengine-agent-plan",
27
+ label: "火山方舟 Agent Plan",
28
+ match: /volces\.com\/api\/plan\//i,
29
+ chat: true,
30
+ model: "doubao-embedding-vision",
31
+ },
32
+ {
33
+ id: "volcengine-ark",
34
+ label: "火山方舟",
35
+ match: /volces\.com|volcengine/i,
36
+ baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
37
+ model: "doubao-embedding-vision",
38
+ },
39
+ ];
40
+
41
+ const trimUrl = (value) =>
42
+ String(value || "")
28
43
  .trim()
29
44
  .replace(/\/$/, "");
45
+ export const embeddingPreset = (address) =>
46
+ EMBEDDING_PRESETS.find((preset) => preset.match.test(trimUrl(address))) ||
47
+ null;
48
+
49
+ // Defaults when the user gives an address and a key. Dimension 0 means
50
+ // automatic: see automaticLength below. Without a preset the address is the
51
+ // chat one and the model must be named.
52
+ export function suggestEmbedding(chat = {}, address = "") {
53
+ const given = trimUrl(address);
54
+ const chatBase = trimUrl(chat.baseUrl);
30
55
  const base = given || chatBase;
31
- const ark = /volces\.com|volcengine/i.test(base);
56
+ const preset = embeddingPreset(base);
32
57
  return {
33
58
  protocol: "openai",
34
59
  dimensions: 0,
35
60
  batchSize: 10,
36
61
  timeoutMs: 90000,
37
- baseUrl:
38
- given || (ark ? "https://ark.cn-beijing.volces.com/api/v3" : chatBase),
39
- model: ark ? "doubao-embedding-vision" : String(chat.embeddingModel || ""),
62
+ baseUrl: given || (preset && !preset.chat ? preset.baseUrl : chatBase),
63
+ model: preset?.model || String(chat.embeddingModel || ""),
64
+ preset: preset?.id || "",
40
65
  };
41
66
  }
42
67
 
68
+ // What "automatic" (dimension 0) asks a model for, per model rather than per
69
+ // provider. Every model is asked for nothing and keeps its own length unless
70
+ // an entry here names a shorter `automatic` one. doubao-embedding-vision has
71
+ // no such entry: on the recall benchmark with real vectors (2026-10-12) 1024
72
+ // ranked slightly worse than its own 2048 (one more miss and one more fact
73
+ // attributed to the wrong person in 59 cases), and a shorter default would
74
+ // make every install that already has 2048 vectors embed everything again.
75
+ // `native` says which explicit length is the same space as asking nothing.
76
+ export const MODEL_LENGTHS = [
77
+ { model: /doubao-embedding-vision/i, native: 2048 },
78
+ ];
79
+ const LENGTHS = MODEL_LENGTHS;
80
+ // Providers seen to refuse or ignore the automatic length, by address and
81
+ // model. Saving the profile again clears it.
82
+ const NATIVE = "embedding-native-length";
83
+
84
+ const modelOf = (profile) => profile.embeddingModel || profile.model || "";
85
+ const lengthsOf = (profile) =>
86
+ LENGTHS.find((entry) => entry.model.test(modelOf(profile)));
87
+ const nativeKey = (profile) => `${profile.baseUrl || ""}|${modelOf(profile)}`;
88
+
89
+ // The length actually requested. 0 means "do not name one".
90
+ export function automaticLength(profile, refused = {}) {
91
+ if (profile.dimensions) return profile.dimensions;
92
+ if (refused[nativeKey(profile)]) return 0;
93
+ return lengthsOf(profile)?.automatic || 0;
94
+ }
95
+
96
+ // Asking for the length a model returns anyway is the same space as not
97
+ // asking, so both share one signature: typing 2048 for doubao finds the
98
+ // vectors made with 0 instead of paying to embed everything twice.
43
99
  export function embeddingSignature(profile) {
100
+ const length = profile.dimensions || 0;
44
101
  return createHash("sha256")
45
102
  .update(
46
103
  JSON.stringify([
47
104
  profile.baseUrl || "",
48
- profile.embeddingModel || profile.model || "",
49
- profile.dimensions || 0,
105
+ modelOf(profile),
106
+ length === lengthsOf(profile)?.native ? 0 : length,
50
107
  ...(profile.protocol === "multimodal" ? ["multimodal"] : []),
51
108
  ]),
52
109
  )
@@ -96,25 +153,35 @@ export function saveEmbeddingProfile(repo, input) {
96
153
  if (!Number.isSafeInteger(next[key]) || next[key] < min || next[key] > max)
97
154
  throw Error(`${key} 超出范围`);
98
155
  repo.saveConfig(CONFIG, next);
156
+ repo.saveConfig(NATIVE, {});
99
157
  return publicEmbeddingProfile(repo);
100
158
  }
101
159
 
102
160
  export function resolveEmbeddingProfile(repo, models) {
103
161
  const saved = repo.config(CONFIG, null);
104
- if (saved) {
105
- const profile = {
106
- ...EMBEDDING_DEFAULTS,
107
- ...saved,
108
- id: "embedding",
109
- embedding: saved.enabled,
110
- embeddingModel: saved.model,
111
- isolatedEmbeddingKey: true,
112
- };
113
- return { profile, signature: embeddingSignature(profile), legacy: false };
114
- }
115
162
  // Existing installations remain compatible until an independent profile is saved.
116
- const profile = models.profile("default");
117
- return { profile, signature: embeddingSignature(profile), legacy: true };
163
+ const base = saved
164
+ ? {
165
+ ...EMBEDDING_DEFAULTS,
166
+ ...saved,
167
+ id: "embedding",
168
+ embedding: saved.enabled,
169
+ embeddingModel: saved.model,
170
+ isolatedEmbeddingKey: true,
171
+ }
172
+ : models.profile("default");
173
+ const refused = repo.config(NATIVE, {}) || {};
174
+ const dimensions = automaticLength(base, refused);
175
+ const profile = { ...base, dimensions };
176
+ if (!base.dimensions && dimensions)
177
+ // A provider that refuses the length, or answers another, is remembered;
178
+ // the next request goes back to the model's own length and signature.
179
+ profile.refuseLength = (reason) =>
180
+ repo.saveConfig(NATIVE, {
181
+ ...(repo.config(NATIVE, {}) || {}),
182
+ [nativeKey(base)]: { at: Date.now(), reason: String(reason) },
183
+ });
184
+ return { profile, signature: embeddingSignature(profile), legacy: !saved };
118
185
  }
119
186
 
120
187
  // A provider may accept fewer inputs per request than configured; its own
@@ -142,6 +209,8 @@ export async function embedBatches(models, profile, texts, options = {}) {
142
209
  batchLimits.set(limitKey, size);
143
210
  continue;
144
211
  }
212
+ if (profile.refuseLength && Number(error?.status) === 400)
213
+ await refusedLength(models, profile, batch, options, error);
145
214
  throw error;
146
215
  }
147
216
  start += batch.length;
@@ -156,9 +225,37 @@ export async function embedBatches(models, profile, texts, options = {}) {
156
225
  )
157
226
  throw Error("向量内容无效");
158
227
  dimension ||= vector.length;
159
- if (vector.length !== dimension) throw Error("向量维度不一致");
228
+ if (vector.length !== dimension) {
229
+ if (profile.refuseLength && vector.length !== profile.dimensions) {
230
+ profile.refuseLength(`answered ${vector.length}`);
231
+ throw lengthChanged();
232
+ }
233
+ throw Error(
234
+ `向量维度不一致:接口返回 ${vector.length} 维,应为 ${dimension} 维`,
235
+ );
236
+ }
160
237
  vectors.push(vector);
161
238
  }
162
239
  }
163
240
  return vectors;
164
241
  }
242
+
243
+ const lengthChanged = () =>
244
+ Object.assign(Error("向量服务不支持自动长度,已改用模型自己的长度"), {
245
+ lengthChanged: true,
246
+ });
247
+
248
+ // A 400 may be the length or anything else (a bad key arrives as 400 too).
249
+ // One request without the length tells which. Vectors already made under
250
+ // the old signature must not be saved, so the caller still gets an error
251
+ // and asks again under the new one.
252
+ async function refusedLength(models, profile, batch, options, error) {
253
+ try {
254
+ const { refuseLength, beforeBatch, ...plain } = profile;
255
+ await models.embed({ ...plain, dimensions: 0 }, batch.slice(0, 1), options);
256
+ } catch {
257
+ return;
258
+ }
259
+ profile.refuseLength(error.detail || error.message);
260
+ throw lengthChanged();
261
+ }