luckytri 1.2.1 → 1.2.3

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 (56) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/docs/en/CHANGELOG.md +16 -0
  4. package/docs/en/architecture.md +10 -0
  5. package/docs/en/guide.md +2 -0
  6. package/docs/en/npm-release.md +6 -0
  7. package/docs/zh/CHANGELOG.md +16 -0
  8. package/docs/zh/architecture.md +10 -0
  9. package/docs/zh/guide.md +2 -0
  10. package/docs/zh/npm-release.md +8 -0
  11. package/package.json +2 -2
  12. package/public/app/assets/{ChatsPage-eDqFIg97.js → ChatsPage-o-exjdFu.js} +1 -1
  13. package/public/app/assets/{Empty-CAGOJsfD.js → Empty-uSyRBXOT.js} +1 -1
  14. package/public/app/assets/{Field-CRz-JmN1.js → Field-Bfa5dCkk.js} +1 -1
  15. package/public/app/assets/{HeartPage-BGWoeMrC.js → HeartPage-Q5ts7Ko6.js} +1 -1
  16. package/public/app/assets/{LifePage-iao3o3wt.js → LifePage-B4AsD_DM.js} +1 -1
  17. package/public/app/assets/{MemoryPage-Dwi2yi7E.js → MemoryPage-ijD6lsxo.js} +1 -1
  18. package/public/app/assets/{NaturePage-DniImo9U.js → NaturePage-B9-dv8vz.js} +1 -1
  19. package/public/app/assets/{PeoplePage-D-MC3JEt.js → PeoplePage-BG_XPian.js} +1 -1
  20. package/public/app/assets/{PluginsPage-D7dJTf96.js → PluginsPage-CPYYMown.js} +1 -1
  21. package/public/app/assets/SystemPage-B9M9g95D.css +1 -0
  22. package/public/app/assets/{SystemPage-C5CUHjGh.js → SystemPage-CTduZhr3.js} +1 -1
  23. package/public/app/assets/{TimePage-Dwhr3-92.js → TimePage-DScn8IN-.js} +1 -1
  24. package/public/app/assets/{index-L0bHax8C.js → index-5FMs9YYQ.js} +3 -3
  25. package/public/app/assets/{index-BHZcHUMh.css → index-cDDC-1dI.css} +1 -1
  26. package/public/app/assets/{knowledge-BTDNafzl.js → knowledge-Ces-Bx70.js} +1 -1
  27. package/public/app/assets/{media-BBzOAfNG.js → media-BsG-i0Lv.js} +1 -1
  28. package/public/app/assets/{mind-CBTkFje2.js → mind-D3mdwFW3.js} +1 -1
  29. package/public/app/index.html +4 -4
  30. package/public/guide.en.html +14 -3
  31. package/public/guide.html +15 -3
  32. package/public/tutorial.en.md +2 -0
  33. package/public/tutorial.md +2 -0
  34. package/scripts/service-supervisor.js +8 -13
  35. package/scripts/start-server.js +8 -1
  36. package/scripts/update-instance.js +93 -27
  37. package/server/app.js +8 -1
  38. package/server/channels/onebot/adapter.js +25 -1
  39. package/server/channels/onebot/index.js +5 -2
  40. package/server/core/dialogue-contract.js +1 -1
  41. package/server/core/image-preprocessor.js +41 -1
  42. package/server/core/message-scheduler.js +12 -2
  43. package/server/core/orchestrator.js +14 -0
  44. package/server/core/reply-delivery.js +72 -0
  45. package/server/core/turn-processor.js +25 -0
  46. package/server/core/turn.js +25 -1
  47. package/server/i18n/errors.js +50 -0
  48. package/server/runtime/update-errors.js +73 -0
  49. package/server/runtime/update-execution.js +13 -8
  50. package/server/runtime/update-manager.js +30 -8
  51. package/server/stickers/api.js +149 -0
  52. package/server/stickers/index.js +363 -0
  53. package/server/stickers/library.js +199 -0
  54. package/server/stickers/offer.js +123 -0
  55. package/server/stickers/schema.js +25 -0
  56. package/public/app/assets/SystemPage-C_Y5M7JU.css +0 -1
@@ -5,6 +5,7 @@ import {
5
5
  writeFileSync,
6
6
  readFileSync,
7
7
  realpathSync,
8
+ writeSync,
8
9
  } from "node:fs";
9
10
  import { dirname, join, delimiter } from "node:path";
10
11
  import { pathToFileURL } from "node:url";
@@ -23,6 +24,21 @@ import {
23
24
  writeUpdateJob,
24
25
  } from "../server/runtime/update-manager.js";
25
26
  import { updateRoot } from "../server/runtime/installation.js";
27
+ import {
28
+ npmFailureCode,
29
+ updateError,
30
+ } from "../server/runtime/update-errors.js";
31
+
32
+ export function requireUpdateNode(version = process.versions.node) {
33
+ const [major, minor] = String(version).split(".").map(Number);
34
+ if (
35
+ !Number.isInteger(major) ||
36
+ !Number.isInteger(minor) ||
37
+ major < 24 ||
38
+ (major === 24 && minor < 5)
39
+ )
40
+ throw updateError("NODE_UNSUPPORTED");
41
+ }
26
42
 
27
43
  export function installEnvironment(env) {
28
44
  const kept = {};
@@ -35,11 +51,11 @@ export function installEnvironment(env) {
35
51
  kept[key] = value;
36
52
  return kept;
37
53
  }
38
- export function findNpmCli(env = process.env) {
54
+ export function findNpmCli(env = process.env, execPath = process.execPath) {
39
55
  const candidates = [
40
56
  env.npm_execpath,
41
- join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"),
42
- join(dirname(process.execPath), "../lib/node_modules/npm/bin/npm-cli.js"),
57
+ join(dirname(execPath), "node_modules/npm/bin/npm-cli.js"),
58
+ join(dirname(execPath), "../lib/node_modules/npm/bin/npm-cli.js"),
43
59
  ...String(env.PATH || env.Path || "")
44
60
  .split(delimiter)
45
61
  .flatMap((dir) => [
@@ -60,31 +76,73 @@ export function findNpmCli(env = process.env) {
60
76
  /* Try the next native Node/npm installation location. */
61
77
  }
62
78
  }
63
- throw Error("未找到 npm");
79
+ throw updateError("NPM_NOT_FOUND");
64
80
  }
65
- function command(args, { cwd, env = process.env, timeout = 600000, log } = {}) {
81
+ export function runUpdateCommand(
82
+ args,
83
+ {
84
+ cwd,
85
+ env = process.env,
86
+ timeout = 600000,
87
+ log,
88
+ failureCode = "STEP_FAILED",
89
+ npmInstall = false,
90
+ } = {},
91
+ ) {
66
92
  return new Promise((resolve, reject) => {
67
93
  const fd = log ? openSync(log, "a", 0o600) : null;
68
- const child = spawn(process.execPath, args, {
69
- cwd,
70
- env,
71
- windowsHide: true,
72
- stdio: fd === null ? "ignore" : ["ignore", fd, fd],
73
- });
74
- if (fd !== null) closeSync(fd);
75
- let timedOut = false;
76
- const timer = setTimeout(() => {
94
+ let timer,
95
+ child,
96
+ settled = false,
97
+ timedOut = false,
98
+ outputError,
99
+ tail = "";
100
+ const finish = (error) => {
101
+ if (settled) return;
102
+ settled = true;
103
+ clearTimeout(timer);
104
+ try {
105
+ if (fd !== null) closeSync(fd);
106
+ } catch (closeError) {
107
+ error ||= closeError;
108
+ }
109
+ if (error) reject(error);
110
+ else resolve();
111
+ };
112
+ try {
113
+ child = spawn(process.execPath, args, {
114
+ cwd,
115
+ env,
116
+ windowsHide: true,
117
+ stdio: ["ignore", "pipe", "pipe"],
118
+ });
119
+ } catch (error) {
120
+ finish(error);
121
+ return;
122
+ }
123
+ const capture = (chunk) => {
124
+ if (settled) return;
125
+ tail = (tail + chunk.toString("utf8")).slice(-16384);
126
+ try {
127
+ if (fd !== null) writeSync(fd, chunk);
128
+ } catch (error) {
129
+ outputError = error;
130
+ // Wait for exit before rollback can rename a migration database.
131
+ child.kill("SIGKILL");
132
+ }
133
+ };
134
+ child.stdout.on("data", capture);
135
+ child.stderr.on("data", capture);
136
+ timer = setTimeout(() => {
77
137
  timedOut = true;
78
- child.kill();
138
+ child.kill("SIGKILL");
79
139
  }, timeout);
80
- child.once("error", (e) => {
81
- clearTimeout(timer);
82
- reject(e);
83
- });
84
- child.once("exit", (code) => {
85
- clearTimeout(timer);
86
- if (timedOut) reject(Error("更新步骤超时"));
87
- else code === 0 ? resolve() : reject(Error("更新程序校验失败"));
140
+ child.once("error", finish);
141
+ child.once("close", (code) => {
142
+ if (outputError) finish(outputError);
143
+ else if (timedOut) finish(updateError("STEP_TIMEOUT"));
144
+ else if (code === 0) finish();
145
+ else finish(updateError(npmInstall ? npmFailureCode(tail) : failureCode));
88
146
  });
89
147
  });
90
148
  }
@@ -94,10 +152,11 @@ export function updateExecution(paths) {
94
152
  const pause = () => new Promise((r) => setTimeout(r, 250));
95
153
  return {
96
154
  async install(slot, version) {
155
+ requireUpdateNode();
97
156
  const cli = findNpmCli();
98
157
  const userconfig = join(updateRoot(paths.home), "install.npmrc");
99
158
  writeFileSync(userconfig, "", { mode: 0o600 });
100
- await command(
159
+ await runUpdateCommand(
101
160
  [
102
161
  cli,
103
162
  "install",
@@ -112,20 +171,26 @@ export function updateExecution(paths) {
112
171
  "--engine-strict",
113
172
  `luckytri@${version}`,
114
173
  ],
115
- { cwd: slot, env: installEnvironment(process.env), log: paths.log },
174
+ {
175
+ cwd: slot,
176
+ env: installEnvironment(process.env),
177
+ log: paths.log,
178
+ npmInstall: true,
179
+ },
116
180
  );
117
181
  },
118
182
  async check(root, version) {
119
183
  const script = `import fs from 'node:fs';import {createRequire} from 'node:module';const p=JSON.parse(fs.readFileSync(${JSON.stringify(join(root, "package.json"))},'utf8'));if(p.version!==${JSON.stringify(version)})process.exit(1);await import(${JSON.stringify(pathToFileURL(join(root, "server/app.js")).href)});const sharp=createRequire(${JSON.stringify(pathToFileURL(join(root, "package.json")).href)})('sharp');await sharp({create:{width:1,height:1,channels:3,background:'#fff'}}).png().toBuffer();`;
120
- await command(["--input-type=module", "-e", script], {
184
+ await runUpdateCommand(["--input-type=module", "-e", script], {
121
185
  cwd: root,
122
186
  env: installEnvironment(process.env),
123
187
  timeout: 30000,
124
188
  log: paths.log,
189
+ failureCode: "PACKAGE_CHECK_FAILED",
125
190
  });
126
191
  },
127
192
  async migrate(root, database, { previous, version, databaseId }) {
128
- await command(
193
+ await runUpdateCommand(
129
194
  [
130
195
  join(root, "scripts", "migrate-instance.js"),
131
196
  database,
@@ -138,6 +203,7 @@ export function updateExecution(paths) {
138
203
  env: installEnvironment(process.env),
139
204
  timeout: 3600000,
140
205
  log: paths.log,
206
+ failureCode: "MIGRATION_FAILED",
141
207
  },
142
208
  );
143
209
  },
package/server/app.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mountStickerUpload, mountStickers } from "./stickers/api.js";
1
2
  import express from "express";
2
3
  import { existsSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -42,7 +43,11 @@ export function createApp({
42
43
  if (
43
44
  ["POST", "PATCH", "PUT"].includes(req.method) &&
44
45
  (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) &&
45
- !["/plugins/import/upload", "/storage/transfer/upload"].includes(req.path)
46
+ ![
47
+ "/plugins/import/upload",
48
+ "/storage/transfer/upload",
49
+ "/core/stickers/upload",
50
+ ].includes(req.path)
46
51
  )
47
52
  return res.status(400).json({ error: "请提交 JSON 对象" });
48
53
  next();
@@ -61,6 +66,7 @@ export function createApp({
61
66
  app.use("/api", objectBody);
62
67
  mountUpdates(app, updates, runtime.updater);
63
68
  mountDataTransfer(app, runtime.transfer);
69
+ if (chatSystem?.stickers) mountStickerUpload(app, chatSystem);
64
70
  if (plugins) {
65
71
  app.post(
66
72
  "/api/plugins/import/upload",
@@ -74,6 +80,7 @@ export function createApp({
74
80
  },
75
81
  );
76
82
  }
83
+ if (chatSystem?.stickers) mountStickers(app, chatSystem);
77
84
  mountStudio(app, store, runtime, chatSystem);
78
85
  mountManagement(app, store, chatSystem);
79
86
  mountCore(app, chatSystem);
@@ -131,8 +131,32 @@ export const onebot = {
131
131
  const reply = /^\d+$/.test(String(options?.replyTo || ""))
132
132
  ? [{ type: "reply", data: { id: String(options.replyTo) } }]
133
133
  : [];
134
+ // A sticker goes as an image of its own, its bytes inline so the QQ client
135
+ // needs no path or URL on this machine.
136
+ const body = options?.image
137
+ ? [
138
+ {
139
+ type: "image",
140
+ data: {
141
+ file: `base64://${options.image.base64}`,
142
+ sub_type: options.image.sticker ? 1 : 0,
143
+ ...(options.image.summary
144
+ ? { summary: options.image.summary }
145
+ : {}),
146
+ },
147
+ },
148
+ ]
149
+ : [
150
+ ...(Array.isArray(options?.at) ? options.at : [])
151
+ .filter((id) => /^\d{5,12}$/.test(String(id)))
152
+ .flatMap((id) => [{ type: "at", data: { qq: String(id) } }]),
153
+ {
154
+ type: "text",
155
+ data: { text: options?.at?.length ? ` ${text}` : text },
156
+ },
157
+ ];
134
158
  return {
135
- message: [...reply, { type: "text", data: { text } }],
159
+ message: [...reply, ...body],
136
160
  [parsed.kind === "group" ? "group_id" : "user_id"]:
137
161
  parsed.kind === "group"
138
162
  ? Number(message.raw?.group_id ?? parsed.nativeId)
@@ -6,7 +6,8 @@ import { defineChannel } from "../contract.js";
6
6
  import { parseSessionKey } from "../session-key.js";
7
7
  import { applyDirectoryNames } from "../../core/sessions.js";
8
8
  import { saveAccountNames } from "../../studio/display-names.js";
9
- import { visionImageLimits } from "../../core/image-preprocessor.js";
9
+ import { ownerImageLimits } from "../../core/image-preprocessor.js";
10
+ import { mediaSettings } from "../../stickers/api.js";
10
11
 
11
12
  const EVENT_MAX_BYTES = 1024 * 1024;
12
13
  const IMAGE_TIMEOUT_MS = 30000;
@@ -168,7 +169,9 @@ export function createOneBotChannel(
168
169
  // Only authenticated OneBot clients reach this server. get_image may carry
169
170
  // base64 rather than a URL, so its frame must fit the source-image budget.
170
171
  // Ordinary events still have the smaller independent limit below.
171
- const imageBytes = visionImageLimits().maxSourceBytes;
172
+ const imageBytes = ownerImageLimits(
173
+ chatSystem?.repo ? mediaSettings(chatSystem.repo) : {},
174
+ ).maxSourceBytes;
172
175
  wss = new WebSocketServer({
173
176
  noServer: true,
174
177
  maxPayload: Math.ceil(imageBytes / 3) * 4 + EVENT_MAX_BYTES,
@@ -20,7 +20,7 @@ export const DIALOGUE_TASK = `读懂眼前这轮聊天,再用自己的话回
20
20
  长短和口吻来自你自己:inner.ownVoice 是你自己一直以来打字的样子,inner.state 是此刻的心情和精力,再加上你和眼前这个人的关系、刚发生的事;别人怎么打字只是在场的情况,不去模仿。像在 QQ 里随手打一句,不写组织好的书面段落;答案不省,意思说够就停,不为活跃气氛加问题。问过的细节对方没接,就回应他继续说的内容,不立刻换词再问。如果这段对话里的人明确要求一段发完,同一话题后续都收进一条消息。列点也可以放在一条消息里换行。被明确要求停止就停止,新的实际问题仍可回应。
21
21
  若一轮里同时有人提出表达偏好、另一人分享新内容,先简短确认偏好,再回应新内容;不能让确认格式代替对内容的回应。
22
22
  停止的范围按对方原话理解:不要方案,就停止给方案;不要纠结某个细节,就放下那个细节,不把整个话题或交流关掉。对方明确说一条旁枝不是他问的,简短承认后回到主线;不要说“我没理清楚”或“先不乱说”,除非那条信息确实是回答主问题所必需的。
23
- 输出 JSON:{"understanding":{"kind":"question|proposal|feeling|sharing|correction|greeting|closing|other","messageIds":[本轮回应的序号],"point":"对方此刻的具体意思","tone":"serious|joking|teasing|ironic|venting","stance":"fact|guess|opinion|joke|hearsay"},"contribution":{"kind":"answer|reaction|question|idea|correction|none","point":"自己这次实际说出的意思,不是收住/接住之类动作"},"choice":"speak|react|decline|silent","targetMessageIds":[batchIds中的序号],"topic":"当前话题","say":"实际说的话","appraisal":"这件事对自己的实际影响,没有则空","reason":"为何这样选择,用名字不用QQ号","feelings":[{"feeling":"两三个字","intensity":0到1,"valence":-1到1,"arousal":0到1,"cause":[消息序号]}],"bonds":[{"userId":"speaker","change":"warmer|closer|trust_up|trust_down|friction|repair|distance","why":"具体变化","evidence":[消息序号]}],"crisis":{"clear":false,"messageIds":[]}}。silent 时 say 为空;react 是一句很短的话;say 是你在输入框里打的字,换行就是按了发送、另起一条消息,说多少、发几次由你此刻想说什么决定;若对方已要求同一段发完,则把连贯补充收在一条消息里,列点也可在同一条里换行。真实自伤危机认真回应眼前安全,crisis.clear=true。understanding 里的 tone 和 stance 可选:所回应的话是认真、玩笑、调侃、反话还是发泄,说的是事实、猜测、观点、玩笑还是转述;拿不准就不写这两项。working.reading 只是字面提示,以前后文为准。choice、targetMessageIds 等其余字段仍在最外层。`;
23
+ 输出 JSON:{"understanding":{"kind":"question|proposal|feeling|sharing|correction|greeting|closing|other","messageIds":[本轮回应的序号],"point":"对方此刻的具体意思","tone":"serious|joking|teasing|ironic|venting","stance":"fact|guess|opinion|joke|hearsay"},"contribution":{"kind":"answer|reaction|question|idea|correction|none","point":"自己这次实际说出的意思,不是收住/接住之类动作"},"choice":"speak|react|decline|silent","targetMessageIds":[batchIds中的序号],"topic":"当前话题","say":"实际说的话","at":["可选,只从 expression.canAt 选 userId"],"sticker":"可选,expression.stickers 里的 ref","appraisal":"这件事对自己的实际影响,没有则空","reason":"为何这样选择,用名字不用QQ号","feelings":[{"feeling":"两三个字","intensity":0到1,"valence":-1到1,"arousal":0到1,"cause":[消息序号]}],"bonds":[{"userId":"speaker","change":"warmer|closer|trust_up|trust_down|friction|repair|distance","why":"具体变化","evidence":[消息序号]}],"crisis":{"clear":false,"messageIds":[]}}。silent 时 say 为空;react 是一句很短的话;say 是你在输入框里打的字,换行就是按了发送、另起一条消息,说多少、发几次由你此刻想说什么决定;若对方已要求同一段发完,则把连贯补充收在一条消息里,列点也可在同一条里换行。真实自伤危机认真回应眼前安全,crisis.clear=true。understanding 里的 tone 和 stance 可选:所回应的话是认真、玩笑、调侃、反话还是发泄,说的是事实、猜测、观点、玩笑还是转述;拿不准就不写这两项。working.reading 只是字面提示,以前后文为准。choice、targetMessageIds 等其余字段仍在最外层。`;
24
24
 
25
25
  export const DIALOGUE_REWRITE = `重新核对 exchange.current 和引用,修改 draft 中被指出的具体问题。decision.understanding 可能也有误,以原话为准。保留成立的内容,直接说这轮真正想说的话;不要把修改写成新的认错套话或内部状态报告。self.livingFor 只作背景,不必提起。输出 {"bubbles":["实际内容"],"reason":"为何这样说"}。`;
26
26
 
@@ -1,6 +1,10 @@
1
1
  import sharp from "sharp";
2
2
 
3
3
  const MIB = 1024 * 1024;
4
+ // Largest picture accepted for understanding; bigger ones are shrunk before
5
+ // any model sees them. VISION_SOURCE_CEILING_MB bounds the OneBot frame.
6
+ export const VISION_SOURCE_DEFAULT_MB = 20;
7
+ export const VISION_SOURCE_CEILING_MB = 64;
4
8
  const TYPES = {
5
9
  jpeg: "image/jpeg",
6
10
  png: "image/png",
@@ -19,7 +23,8 @@ function configured(value, fallback, min, max) {
19
23
  export function visionImageLimits(env = process.env) {
20
24
  return {
21
25
  maxSourceBytes: Math.floor(
22
- configured(env.VISION_SOURCE_MAX_MB, 32, 4, 64) * MIB,
26
+ configured(env.VISION_SOURCE_MAX_MB, VISION_SOURCE_DEFAULT_MB, 1, 64) *
27
+ MIB,
23
28
  ),
24
29
  maxModelBytes: Math.floor(
25
30
  configured(env.VISION_MODEL_MAX_MB, 4, 1, 4) * MIB,
@@ -33,6 +38,41 @@ export function visionImageLimits(env = process.env) {
33
38
  };
34
39
  }
35
40
 
41
+ // The owner's image-understanding size: the long side and the encoded size
42
+ // a picture is brought down to before a model sees it. A big photo is shrunk
43
+ // and re-encoded rather than refused; many providers reject large images or
44
+ // time out on them.
45
+ export function modelImageLimits({
46
+ visionMaxSide = 2048,
47
+ visionMaxMB = 3,
48
+ visionSourceMaxMB = VISION_SOURCE_DEFAULT_MB,
49
+ } = {}) {
50
+ const source = Math.min(
51
+ VISION_SOURCE_CEILING_MB,
52
+ Math.max(1, Number(visionSourceMaxMB) || VISION_SOURCE_DEFAULT_MB),
53
+ );
54
+ const side = Math.min(
55
+ 8192,
56
+ Math.max(512, Math.floor(Number(visionMaxSide) || 2048)),
57
+ );
58
+ const mb = Math.min(4, Math.max(0.5, Number(visionMaxMB) || 3));
59
+ return {
60
+ maxDimension: side,
61
+ maxOutputPixels: side * side,
62
+ maxModelBytes: Math.floor(mb * MIB),
63
+ maxSourceBytes: Math.floor(source * MIB),
64
+ };
65
+ }
66
+
67
+ // The owner's setting, unless the operator pinned the source size in the
68
+ // environment (VISION_SOURCE_MAX_MB), which then wins everywhere.
69
+ export function ownerImageLimits(settings, env = process.env) {
70
+ const limits = modelImageLimits(settings);
71
+ if (env.VISION_SOURCE_MAX_MB != null && env.VISION_SOURCE_MAX_MB !== "")
72
+ limits.maxSourceBytes = visionImageLimits(env).maxSourceBytes;
73
+ return limits;
74
+ }
75
+
36
76
  export function imageMime(bytes) {
37
77
  if (
38
78
  bytes.length >= 3 &&
@@ -16,6 +16,8 @@ export async function deliver(
16
16
  // Platform id of an earlier message the first bubble answers, when the
17
17
  // conversation has moved past it; the channel may show it as a quote.
18
18
  quote = "",
19
+ // People she chose to @, on her first message only, the way one types.
20
+ at = [],
19
21
  } = {},
20
22
  ) {
21
23
  const ids = bubbles.map((text, i) => {
@@ -45,10 +47,16 @@ export async function deliver(
45
47
  .prepare("UPDATE core_outbox SET status='sending',text=? WHERE id=?")
46
48
  .run(outgoing, ids[i]);
47
49
  try {
50
+ const first = i === 0 && (quote || at.length);
48
51
  const result = await send(
49
52
  message,
50
53
  outgoing,
51
- i === 0 && quote ? { replyTo: quote } : undefined,
54
+ first
55
+ ? {
56
+ ...(quote ? { replyTo: quote } : {}),
57
+ ...(at.length ? { at: at.map((p) => p.userId) } : {}),
58
+ }
59
+ : undefined,
52
60
  );
53
61
  repo.db
54
62
  .prepare(
@@ -62,7 +70,9 @@ export async function deliver(
62
70
  traceId: trace.id,
63
71
  replyTargetIds: trace.decision?.targetMessageIds || [],
64
72
  },
65
- outgoing,
73
+ i === 0 && at.length
74
+ ? `${at.map((p) => `@${p.name}`).join(" ")} ${outgoing}`
75
+ : outgoing,
66
76
  result?.message_id,
67
77
  now(),
68
78
  );
@@ -1,3 +1,4 @@
1
+ import { Stickers } from "../stickers/index.js";
1
2
  import { settleAftermath } from "../mind/aftermath.js";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { Repository } from "./repository.js";
@@ -64,6 +65,11 @@ export class ChatSystem {
64
65
  embedding: this.mind.embedding,
65
66
  });
66
67
  this.compactor = new ContextCompactor(this.repo);
68
+ this.stickers = new Stickers(this.repo, {
69
+ models: this.models,
70
+ embedding: this.mind.embedding,
71
+ now: () => this.now(),
72
+ });
67
73
  this.topics = new TopicTracker(this.repo);
68
74
  this.capabilities = new Capabilities(this.repo, { now });
69
75
  this.perceivers = new Perceivers(this.repo.db);
@@ -211,6 +217,14 @@ export class ChatSystem {
211
217
  this.store.settings().memoryCandidates !== false
212
218
  )
213
219
  this.mind.memory.remember({ ...m, ...event });
220
+ if (!m.simulated)
221
+ try {
222
+ this.stickers.observe({ ...m, ...event }, (id) =>
223
+ this.mind.bonds.name(id),
224
+ );
225
+ } catch {
226
+ // A sticker outcome is a side note; never stop a message for it.
227
+ }
214
228
  // Its vector is made while the batch gathers; the turn finds it cached.
215
229
  if (!m.simulated && this.enabled(event.sessionId, { simulated: false }))
216
230
  this.mind.recall.arrive(event.seq, this.now()).catch(() => false);
@@ -1,3 +1,5 @@
1
+ import { typedAt } from "../stickers/offer.js";
2
+ import { persistReply } from "./message-manager.js";
1
3
  import { PRIVATELY_KNOWN } from "../mind/discretion.js";
2
4
  import { replyFocus } from "./conversation-cues.js";
3
5
 
@@ -532,6 +534,19 @@ export class ReplyDelivery {
532
534
  }
533
535
  };
534
536
  if (outdated()) return staleExit();
537
+ if (!turn.bubbles.length && turn.sticker && !turn.crisis?.clear) {
538
+ // Only a sticker: nothing to check in words, it was chosen from hers.
539
+ trace.response = { bubbles: [], sticker: turn.sticker.ref };
540
+ trace.sent = [];
541
+ const sent = await this.sendSticker(
542
+ batch.at(-1) || c.anchor,
543
+ turn,
544
+ trace,
545
+ session,
546
+ isCurrent,
547
+ );
548
+ return finish(sent ? "sent" : "cancelled", turn.reason);
549
+ }
535
550
  let response = turn.bubbles.length
536
551
  ? normalizeResponse(
537
552
  { bubbles: turn.bubbles, reason: turn.reason },
@@ -701,6 +716,10 @@ export class ReplyDelivery {
701
716
  this.owner.now(),
702
717
  snapshot.inner?.currentLife?.current,
703
718
  );
719
+ // A rewritten or generated reply may type an @ as text; it is made real.
720
+ const typed = typedAt(response.bubbles, turn.at || [], snapshot);
721
+ if (typed.bubbles.length)
722
+ response = { ...response, bubbles: typed.bubbles };
704
723
  trace.response = response;
705
724
  trace.sent = await deliver(
706
725
  this.owner.repo,
@@ -712,6 +731,7 @@ export class ReplyDelivery {
712
731
  {
713
732
  now: this.owner.now,
714
733
  quote: buriedTarget(batch, turn.targetMessageIds, session),
734
+ at: typed.at,
715
735
  prepareBubble: (line) =>
716
736
  this.owner.mind.time.reconcile(
717
737
  { bubbles: [line] },
@@ -722,6 +742,14 @@ export class ReplyDelivery {
722
742
  },
723
743
  );
724
744
  if (!trace.sent.length && hasRelevantUpdate()) return staleExit();
745
+ if (trace.sent.length && turn.sticker)
746
+ await this.sendSticker(
747
+ batch.at(-1) || c.anchor,
748
+ turn,
749
+ trace,
750
+ session,
751
+ isCurrent,
752
+ );
725
753
  if (trace.sent.length && !c.simulatedTurn) {
726
754
  if (turn.share?.workId) {
727
755
  try {
@@ -744,6 +772,50 @@ export class ReplyDelivery {
744
772
  }
745
773
  return finish(trace.sent.length ? "sent" : "cancelled", turn.reason);
746
774
  }
775
+ // Her sticker, after her words. It is recorded as something she sent, and
776
+ // the use is kept to learn how it landed with this person.
777
+ async sendSticker(message, turn, trace, session, isCurrent) {
778
+ const owner = this.owner;
779
+ const image = owner.stickers?.library.image(turn.sticker.id);
780
+ if (!image || !isCurrent()) return false;
781
+ try {
782
+ await new Promise((r) => setTimeout(r, owner.stickerPause ?? 600));
783
+ const result = await owner.send(message, "", {
784
+ image: {
785
+ base64: Buffer.from(image.bytes).toString("base64"),
786
+ sticker: true,
787
+ summary: "[动画表情]",
788
+ },
789
+ });
790
+ const text = `[表情:${turn.sticker.looks}]`;
791
+ persistReply(
792
+ owner.repo,
793
+ {
794
+ ...message,
795
+ traceId: trace.id,
796
+ replyTargetIds: turn.targetMessageIds || [],
797
+ },
798
+ text,
799
+ result?.message_id,
800
+ owner.now(),
801
+ );
802
+ (trace.sent ||= []).push(text);
803
+ if (!message?.simulated) {
804
+ const target = (message && (message.userId ?? message.speaker)) || "";
805
+ owner.stickers.used({
806
+ stickerId: turn.sticker.id,
807
+ session,
808
+ userId: target,
809
+ topic: turn.topic || "",
810
+ });
811
+ }
812
+ trace.steps.push(`发了一张表情:${turn.sticker.looks}`);
813
+ return true;
814
+ } catch (error) {
815
+ trace.steps.push(`表情没有发出去:${error.message}`);
816
+ return false;
817
+ }
818
+ }
747
819
  }
748
820
 
749
821
  // In a group, when several other people have spoken since the one message
@@ -1,3 +1,6 @@
1
+ import { mediaSettings } from "../stickers/api.js";
2
+ import { ownerImageLimits } from "./image-preprocessor.js";
3
+ import { atCandidates, expressiveChannel } from "../stickers/offer.js";
1
4
  import { speechLength } from "./speech-length.js";
2
5
  import { parseSessionKey } from "../channels/session-key.js";
3
6
  import { initiativeContext, initiativeSnapshot } from "./initiative-context.js";
@@ -455,6 +458,27 @@ export class TurnProcessor {
455
458
  snapshot.inner = view.inner;
456
459
  }
457
460
  lap("context");
461
+ if (!ownInitiative && this.owner.stickers && expressiveChannel(session)) {
462
+ // A few stickers that could fit, and who she could @; she decides.
463
+ snapshot.canAt = atCandidates(snapshot, session);
464
+ try {
465
+ const last = snapshot.batch.at(-1) || {};
466
+ snapshot.stickers = await this.owner.stickers.candidates({
467
+ session,
468
+ text: snapshot.batch.map((m) => m.text || "").join("\n"),
469
+ mood: view.inner?.state || "",
470
+ addressee: String(last.speaker ?? last.userId ?? ""),
471
+ memories: (snapshot.memories || [])
472
+ .map((m) => m.content || m.text || "")
473
+ .slice(0, 6),
474
+ });
475
+ } catch (error) {
476
+ snapshot.stickers = [];
477
+ trace.steps.push(`表情候选暂不可用:${error.message}`);
478
+ }
479
+ if (!snapshot.canAt.length) delete snapshot.canAt;
480
+ if (!snapshot.stickers.length) delete snapshot.stickers;
481
+ }
458
482
  const visionModel = model.vision
459
483
  ? model
460
484
  : this.owner.fallbackFor(null, model).find((item) => item.vision) ||
@@ -474,6 +498,7 @@ export class TurnProcessor {
474
498
  ? await this.owner.loadVisionImages(early.pending, {
475
499
  sessionId: snapshot.sessionId,
476
500
  fetchImage: this.owner.fetchImage,
501
+ imageLimits: ownerImageLimits(mediaSettings(this.owner.repo)),
477
502
  })
478
503
  : { images: [], unavailable: [] };
479
504
  const planned = classifyLoaded(
@@ -1,3 +1,9 @@
1
+ import {
2
+ chosenAt,
3
+ chosenSticker,
4
+ expressionInput,
5
+ typedAt,
6
+ } from "../stickers/offer.js";
1
7
  import { replyFocus } from "./conversation-cues.js";
2
8
  import { INITIATIVE_PROMPT } from "../mind/initiative.js";
3
9
  import { initiativeContext } from "./initiative-context.js";
@@ -159,6 +165,7 @@ export async function takeTurn(
159
165
  ...(recallWording(snapshot) ? { guidance: recallWording(snapshot) } : {}),
160
166
  ...(!initiating ? { exchange: currentExchange(snapshot) } : {}),
161
167
  ...turnGuides(snapshot),
168
+ ...expressionInput(snapshot),
162
169
  };
163
170
  let raw = await models.call(
164
171
  profile,
@@ -564,8 +571,8 @@ export function normalizeTurn(raw, snapshot, trace, { speech } = {}) {
564
571
  .slice(0, 4),
565
572
  crisis: crisis ? { clear: true, messageIds: crisisIds } : { clear: false },
566
573
  act: botYield || stopped ? null : normalizeAct(raw.act, trace),
567
- bubbles: bubbles.slice(0, bubbleLimit),
568
574
  maxBubbles: bubbleLimit,
575
+ ...expressionChoice(raw, snapshot, choice, bubbles.slice(0, bubbleLimit)),
569
576
  };
570
577
  }
571
578
 
@@ -639,3 +646,20 @@ export function typedSends(text) {
639
646
  }
640
647
  return sends;
641
648
  }
649
+
650
+ // A sticker and @s are part of what she chose, kept only when they point at
651
+ // something she was actually offered this turn.
652
+ function expressionChoice(raw, snapshot, choice, bubbles) {
653
+ if (!["speak", "react"].includes(choice)) return { bubbles };
654
+ const sticker = chosenSticker(raw.sticker, snapshot);
655
+ const typed = typedAt(
656
+ bubbles,
657
+ chosenAt(raw.at, snapshot, snapshot.sessionId, ids(raw.targetMessageIds)),
658
+ snapshot,
659
+ );
660
+ return {
661
+ bubbles: typed.bubbles,
662
+ ...(sticker ? { sticker } : {}),
663
+ ...(typed.at.length ? { at: typed.at } : {}),
664
+ };
665
+ }