luckytri 1.2.2 → 1.2.4

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 (47) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/docs/en/CHANGELOG.md +11 -0
  4. package/docs/en/architecture.md +10 -0
  5. package/docs/zh/CHANGELOG.md +11 -0
  6. package/docs/zh/architecture.md +10 -0
  7. package/package.json +2 -2
  8. package/public/app/assets/{ChatsPage-GGOaUl3H.js → ChatsPage-C_gKz4mJ.js} +1 -1
  9. package/public/app/assets/{Empty-BSmsWoGM.js → Empty-y1vXDIjQ.js} +1 -1
  10. package/public/app/assets/{Field-tUGp0-YL.js → Field-CJG5lERx.js} +1 -1
  11. package/public/app/assets/{HeartPage-B5_e1hI9.js → HeartPage-5y3avjSy.js} +1 -1
  12. package/public/app/assets/{LifePage-D5P5kTzy.js → LifePage-BtwRMDAM.js} +1 -1
  13. package/public/app/assets/{MemoryPage-BrSPiqOS.js → MemoryPage-54noDhWz.js} +1 -1
  14. package/public/app/assets/{NaturePage-C0BdvfFu.js → NaturePage-DHK5X2Qw.js} +1 -1
  15. package/public/app/assets/{PeoplePage-DBT70lu7.js → PeoplePage-ZsA7gyxP.js} +1 -1
  16. package/public/app/assets/{PluginsPage-QBiQg3Jc.js → PluginsPage-BCRRIAhq.js} +1 -1
  17. package/public/app/assets/SystemPage-DNTD2_nj.css +1 -0
  18. package/public/app/assets/SystemPage-DWr1TG49.js +1 -0
  19. package/public/app/assets/{TimePage-BuNKjCgN.js → TimePage-6MUmtSOT.js} +1 -1
  20. package/public/app/assets/{index-BaXEYeFH.js → index-BokqwNXF.js} +3 -3
  21. package/public/app/assets/{index-BHZcHUMh.css → index-cDDC-1dI.css} +1 -1
  22. package/public/app/assets/{knowledge-COyBves6.js → knowledge-BjD9PkXe.js} +1 -1
  23. package/public/app/assets/{media-8uRD5Ocs.js → media-Ciozo_yb.js} +1 -1
  24. package/public/app/assets/{mind-CQkvddhx.js → mind-KkinyNC6.js} +1 -1
  25. package/public/app/index.html +4 -4
  26. package/public/guide.en.html +3 -3
  27. package/public/guide.html +3 -3
  28. package/server/app.js +9 -1
  29. package/server/channels/onebot/adapter.js +25 -1
  30. package/server/channels/onebot/index.js +5 -2
  31. package/server/core/dialogue-contract.js +1 -1
  32. package/server/core/image-preprocessor.js +41 -1
  33. package/server/core/message-scheduler.js +12 -2
  34. package/server/core/orchestrator.js +14 -0
  35. package/server/core/reply-delivery.js +72 -0
  36. package/server/core/turn-processor.js +25 -0
  37. package/server/core/turn.js +25 -1
  38. package/server/i18n/errors.js +15 -0
  39. package/server/plugins/zip.js +32 -4
  40. package/server/stickers/api.js +170 -0
  41. package/server/stickers/index.js +363 -0
  42. package/server/stickers/library.js +199 -0
  43. package/server/stickers/offer.js +123 -0
  44. package/server/stickers/schema.js +25 -0
  45. package/server/stickers/zip-import.js +76 -0
  46. package/public/app/assets/SystemPage-Cf8lKh5C.css +0 -1
  47. package/public/app/assets/SystemPage-EFOEvom-.js +0 -1
@@ -0,0 +1,363 @@
1
+ import { prepareVisionImage } from "../core/image-preprocessor.js";
2
+ import { storedModels } from "../core/model-manager.js";
3
+ import { normalize, dot } from "../core/embedding.js";
4
+ import { StickerLibrary, stickerText, cleanTags } from "./library.js";
5
+
6
+ const DAY = 86400000;
7
+ const OPEN_MS = 15 * 60000;
8
+ const clip = (v, n) =>
9
+ String(v ?? "")
10
+ .trim()
11
+ .slice(0, n);
12
+ const toBlob = (vector) => Buffer.from(normalize(vector).buffer);
13
+ const fromBlob = (blob) =>
14
+ blob
15
+ ? new Float32Array(
16
+ blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength),
17
+ )
18
+ : null;
19
+ // Bonds fade unless renewed: an old laugh is not a standing preference.
20
+ export const fadedWeight = (weight, updated, now) =>
21
+ (Number(weight) || 0) *
22
+ 0.5 ** (Math.max(0, now - (updated || now)) / (60 * DAY));
23
+
24
+ // How a person answered a sticker she sent. Only a clear warm or cold answer
25
+ // counts; anything else leaves no mark, so sending a lot teaches nothing.
26
+ export function stickerOutcome(text) {
27
+ const t = String(text || "").trim();
28
+ if (!t) return null;
29
+ if (
30
+ /别发了|别再发|无聊|好尬|尬死|看不懂|什么鬼|烦死|不好笑|没意思|发这个干嘛/.test(
31
+ t,
32
+ )
33
+ )
34
+ return "cold";
35
+ if (
36
+ /^\[(?:动画)?表情\]|^\[图片\]|哈{2,}|笑死|hh{2,}|233|绝了|可爱|好家伙|草{1,3}$|乐|牛|笑疯/i.test(
37
+ t,
38
+ )
39
+ )
40
+ return "warm";
41
+ return null;
42
+ }
43
+
44
+ const SYSTEM = `你在给一张表情包写档案,供以后挑表情时检索。只描述看得见的东西和这张表情在中文网络聊天里的常见用法,不编造出处。输出 JSON:{"description":"画面里是什么(30字内)","words":"图上的文字,没有则空","emotion":"传达的情绪(如 无语、得意、委屈、开心、嘲讽、撒娇)","meaning":"这张表情在聊天里表达的意思或梗(40字内)","scenes":"适合在什么情境发(40字内)","tags":["3到8个短标签"],"category":"一个两到四字的分类,如 开心、无语、撒娇、嘲讽、安慰、问候"}`;
45
+
46
+ // Her sticker kernel: the library, how each sticker is understood, which
47
+ // few fit this moment, and what using one with someone came to mean.
48
+ export class Stickers {
49
+ constructor(repo, { models, embedding, now = () => Date.now() } = {}) {
50
+ this.repo = repo;
51
+ this.db = repo.db;
52
+ this.models = models;
53
+ this.embedding = embedding;
54
+ this.now = now;
55
+ this.library = new StickerLibrary(this.db, { now });
56
+ }
57
+
58
+ settings() {
59
+ return { enabled: true, ...(this.repo.config?.("stickers") || {}) };
60
+ }
61
+
62
+ visionProfile() {
63
+ const wanted = this.settings().taggingModelId;
64
+ const all = storedModels(this.repo);
65
+ const pick =
66
+ all.find((m) => m.id === wanted && m.vision) || all.find((m) => m.vision);
67
+ return pick ? this.models.profile(pick.id) : null;
68
+ }
69
+
70
+ // Look at a newly added sticker once and write what it is for. The owner
71
+ // can edit everything it wrote afterwards.
72
+ async describe(id, { force = false } = {}) {
73
+ const item = this.library.get(id);
74
+ if (!item) throw Error("表情不存在");
75
+ if (item.status === "ready" && !force) return item;
76
+ const profile = this.visionProfile();
77
+ if (!profile) {
78
+ this.db
79
+ .prepare("UPDATE sticker_items SET status='manual',error=? WHERE id=?")
80
+ .run("模型库里还没有开启视觉能力的模型,可以手动填写标签", id);
81
+ return this.library.get(id);
82
+ }
83
+ try {
84
+ const { bytes } = this.library.image(id);
85
+ const prepared = await prepareVisionImage(Buffer.from(bytes));
86
+ const trace = { id: `sticker:${id}`, steps: [], calls: [] };
87
+ const out = await this.models.call(
88
+ profile,
89
+ "vision",
90
+ SYSTEM,
91
+ { task: "为这张表情写档案", name: item.name || "" },
92
+ trace,
93
+ [
94
+ {
95
+ kind: "sticker",
96
+ messageId: "upload",
97
+ speaker: "管理台",
98
+ frameOnly: prepared.frameOnly,
99
+ url: `data:${prepared.mime};base64,${prepared.bytes.toString("base64")}`,
100
+ },
101
+ ],
102
+ );
103
+ const filled = (key, n) => (item[key] ? item[key] : clip(out?.[key], n));
104
+ this.library.update(id, {
105
+ description: filled("description", 200),
106
+ words: filled("words", 100),
107
+ emotion: filled("emotion", 40),
108
+ meaning: filled("meaning", 200),
109
+ scenes: filled("scenes", 200),
110
+ tags: item.tags.length ? item.tags : cleanTags(out?.tags),
111
+ ...(item.category ? {} : { category: clip(out?.category, 24) }),
112
+ status: "ready",
113
+ });
114
+ this.db.prepare("UPDATE sticker_items SET error=NULL WHERE id=?").run(id);
115
+ } catch (error) {
116
+ this.db
117
+ .prepare("UPDATE sticker_items SET status='failed',error=? WHERE id=?")
118
+ .run(clip(error.message, 200), id);
119
+ }
120
+ await this.embedPending(5);
121
+ return this.library.get(id);
122
+ }
123
+
124
+ // Vectors for stickers whose meaning is known; skipped quietly when the
125
+ // embedding service is off, and words still find them.
126
+ async embedPending(limit = 20) {
127
+ if (!this.embedding?.enabled?.()) return 0;
128
+ const rows = this.db
129
+ .prepare(
130
+ `SELECT id,description,words,emotion,meaning,scenes,tags FROM sticker_items
131
+ WHERE status IN ('ready','manual') AND (embedding IS NULL OR embed_signature IS NOT ?) LIMIT ?`,
132
+ )
133
+ .all(this.embedding.signature(), limit);
134
+ if (!rows.length) return 0;
135
+ try {
136
+ const vectors = await this.embedding.embed(
137
+ rows.map((r) =>
138
+ stickerText({ ...r, tags: JSON.parse(r.tags || "[]") }),
139
+ ),
140
+ { purpose: "document" },
141
+ );
142
+ const stmt = this.db.prepare(
143
+ "UPDATE sticker_items SET embedding=?,embed_signature=? WHERE id=?",
144
+ );
145
+ rows.forEach((r, i) => {
146
+ if (vectors[i])
147
+ stmt.run(toBlob(vectors[i]), this.embedding.signature(), r.id);
148
+ });
149
+ return rows.length;
150
+ } catch {
151
+ return 0;
152
+ }
153
+ }
154
+
155
+ // A handful of stickers that could fit this moment, never the library.
156
+ // Vectors narrow it first, words when vectors are unavailable; what she
157
+ // and this person shared, and how stickers landed before, reorder it.
158
+ async candidates({
159
+ session,
160
+ text,
161
+ mood = "",
162
+ addressee = "",
163
+ memories = [],
164
+ limit = 5,
165
+ }) {
166
+ if (!this.settings().enabled) return [];
167
+ const rows = this.db
168
+ .prepare(
169
+ `SELECT id,description,words,emotion,meaning,scenes,tags,embedding,animated FROM sticker_items
170
+ WHERE enabled=1 AND status IN ('ready','manual') AND description<>''`,
171
+ )
172
+ .all();
173
+ if (!rows.length) return [];
174
+ const now = this.now();
175
+ // Vectors and old uses are tidied in passing, at most every ten minutes.
176
+ if (!(this.tidiedAt > now - 600000)) {
177
+ this.tidiedAt = now;
178
+ this.prune();
179
+ this.embedPending().catch(() => 0);
180
+ }
181
+ const query = [text, mood && `我此刻的心情:${mood}`]
182
+ .filter(Boolean)
183
+ .join("\n");
184
+ let qv = null;
185
+ if (this.embedding?.available?.() && rows.some((r) => r.embedding)) {
186
+ try {
187
+ [qv] = await this.embedding.embed([query], {
188
+ purpose: "query",
189
+ timeoutMs: 2500,
190
+ });
191
+ qv = qv && normalize(qv);
192
+ } catch {
193
+ qv = null;
194
+ }
195
+ }
196
+ const bonds = new Map();
197
+ for (const b of this.db
198
+ .prepare(
199
+ "SELECT sticker_id,user_id,weight,meaning,updated FROM sticker_bonds WHERE user_id IN (?, '')",
200
+ )
201
+ .all(String(addressee || "")))
202
+ bonds.set(`${b.sticker_id}|${b.user_id}`, b);
203
+ const recentRows = this.db
204
+ .prepare(
205
+ "SELECT sticker_id, MAX(time) time FROM sticker_uses WHERE session_id=? AND time>? GROUP BY sticker_id",
206
+ )
207
+ .all(session, now - 2 * 3600000);
208
+ // Just sent here: not again so soon. Earlier today: a step back.
209
+ const justUsed = new Set(
210
+ recentRows
211
+ .filter((r) => r.time > now - 30 * 60000)
212
+ .map((r) => r.sticker_id),
213
+ );
214
+ const recent = new Set(recentRows.map((r) => r.sticker_id));
215
+ const grams = (s) => {
216
+ const out = new Set();
217
+ const t = String(s || "").replace(/\s+/g, "");
218
+ for (let i = 0; i < t.length - 1; i++) out.add(t.slice(i, i + 2));
219
+ return out;
220
+ };
221
+ const qGrams = grams(query);
222
+ const memoryGrams = grams(memories.join("\n"));
223
+ const overlap = (set, s) => {
224
+ const g = grams(s);
225
+ if (!g.size || !set.size) return 0;
226
+ let n = 0;
227
+ for (const x of g) if (set.has(x)) n++;
228
+ return n / Math.sqrt(g.size * set.size);
229
+ };
230
+ const scored = rows.map((r) => {
231
+ const item = { ...r, tags: JSON.parse(r.tags || "[]") };
232
+ const words = stickerText(item);
233
+ const v = fromBlob(r.embedding);
234
+ const fit = qv && v ? dot(qv, v) : overlap(qGrams, words) * 1.6;
235
+ const personal = bonds.get(`${r.id}|${addressee}`);
236
+ const own = bonds.get(`${r.id}|`);
237
+ const shared = personal?.meaning
238
+ ? Math.max(
239
+ overlap(qGrams, personal.meaning),
240
+ overlap(memoryGrams, personal.meaning),
241
+ )
242
+ : 0;
243
+ const score =
244
+ fit +
245
+ 0.12 * fadedWeight(personal?.weight, personal?.updated, now) +
246
+ 0.06 * fadedWeight(own?.weight, own?.updated, now) +
247
+ 0.5 * shared -
248
+ (recent.has(r.id) ? 0.35 : 0);
249
+ return { item, score, fit, personal };
250
+ });
251
+ const floor = qv ? 0.3 : 0.12;
252
+ return scored
253
+ .filter(
254
+ (s) =>
255
+ !justUsed.has(s.item.id) && (s.fit >= floor || s.personal?.meaning),
256
+ )
257
+ .sort((a, b) => b.score - a.score)
258
+ .slice(0, limit)
259
+ .map(({ item, personal }, i) => ({
260
+ ref: `s${i + 1}`,
261
+ id: item.id,
262
+ looks: clip(item.description, 60),
263
+ ...(item.words ? { says: clip(item.words, 30) } : {}),
264
+ means: clip(item.meaning || item.emotion, 60),
265
+ ...(item.scenes ? { fits: clip(item.scenes, 50) } : {}),
266
+ ...(personal?.meaning
267
+ ? { withThisPerson: clip(personal.meaning, 60) }
268
+ : {}),
269
+ }));
270
+ }
271
+
272
+ // She sent one: remember with whom and about what, to see how it lands.
273
+ used({ stickerId, session, userId = "", topic = "" }) {
274
+ this.db
275
+ .prepare(
276
+ "INSERT INTO sticker_uses(sticker_id,session_id,user_id,topic,time) VALUES (?,?,?,?,?)",
277
+ )
278
+ .run(
279
+ stickerId,
280
+ session,
281
+ String(userId || ""),
282
+ clip(topic, 40),
283
+ this.now(),
284
+ );
285
+ }
286
+
287
+ // Someone spoke after a sticker of hers. A clear warm or cold answer from
288
+ // the person it was for becomes part of what that sticker means between
289
+ // them; silence or ordinary talk closes the use without a trace.
290
+ observe(message, nameOf = (id) => id) {
291
+ if (!message || message.role === "assistant" || message.simulated) return;
292
+ const now = this.now();
293
+ this.db
294
+ .prepare(
295
+ "UPDATE sticker_uses SET outcome='flat',outcome_time=? WHERE outcome IS NULL AND time<?",
296
+ )
297
+ .run(now, now - OPEN_MS);
298
+ const open = this.db
299
+ .prepare(
300
+ "SELECT * FROM sticker_uses WHERE session_id=? AND outcome IS NULL AND time>? ORDER BY time DESC LIMIT 1",
301
+ )
302
+ .get(message.sessionId, now - OPEN_MS);
303
+ if (!open) return;
304
+ const from = String(message.userId || "");
305
+ if (open.user_id && open.user_id !== from) return;
306
+ const outcome = stickerOutcome(message.text);
307
+ if (!outcome) return;
308
+ this.db
309
+ .prepare(
310
+ "UPDATE sticker_uses SET outcome=?,outcome_time=?,reply=? WHERE id=?",
311
+ )
312
+ .run(outcome, now, clip(message.text, 40), open.id);
313
+ const bond = (userId, delta, meaning) => {
314
+ const old = this.db
315
+ .prepare(
316
+ "SELECT weight,updated,meaning FROM sticker_bonds WHERE sticker_id=? AND user_id=?",
317
+ )
318
+ .get(open.sticker_id, userId);
319
+ const weight = Math.max(
320
+ -2,
321
+ Math.min(2, fadedWeight(old?.weight, old?.updated, now) + delta),
322
+ );
323
+ this.db
324
+ .prepare(
325
+ `INSERT INTO sticker_bonds(sticker_id,user_id,weight,meaning,updated) VALUES (?,?,?,?,?)
326
+ ON CONFLICT(sticker_id,user_id) DO UPDATE SET weight=excluded.weight,meaning=excluded.meaning,updated=excluded.updated`,
327
+ )
328
+ .run(
329
+ open.sticker_id,
330
+ userId,
331
+ weight,
332
+ meaning ?? old?.meaning ?? "",
333
+ now,
334
+ );
335
+ };
336
+ const who = nameOf(from) || "对方";
337
+ const about = open.topic ? `聊「${open.topic}」时` : "";
338
+ if (outcome === "warm") {
339
+ bond(
340
+ from,
341
+ 0.3,
342
+ `${about}发给${who}过,${who}回了「${clip(message.text, 16)}」`,
343
+ );
344
+ bond("", 0.1);
345
+ } else {
346
+ bond(from, -0.5, `${about}发给${who}过,${who}不太买账`);
347
+ bond("", -0.2);
348
+ }
349
+ }
350
+
351
+ // Old closed uses that left no mark are dropped; a library of uses must
352
+ // not grow with every send.
353
+ prune() {
354
+ this.db
355
+ .prepare(
356
+ "DELETE FROM sticker_uses WHERE time<? AND (outcome IS NULL OR outcome='flat')",
357
+ )
358
+ .run(this.now() - 30 * DAY);
359
+ this.db
360
+ .prepare("DELETE FROM sticker_uses WHERE time<?")
361
+ .run(this.now() - 180 * DAY);
362
+ }
363
+ }
@@ -0,0 +1,199 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import sharp from "sharp";
3
+ import { imageMime } from "../core/image-preprocessor.js";
4
+ import { ensureStickerSchema } from "./schema.js";
5
+
6
+ export const STICKER_MAX_BYTES = 2 * 1024 * 1024;
7
+ const ALLOWED = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
8
+ const clip = (value, n) =>
9
+ String(value ?? "")
10
+ .trim()
11
+ .slice(0, n);
12
+ export const cleanTags = (tags) =>
13
+ [
14
+ ...new Set(
15
+ (Array.isArray(tags) ? tags : String(tags || "").split(/[,,、\s]+/))
16
+ .map((t) => clip(t, 16))
17
+ .filter(Boolean),
18
+ ),
19
+ ].slice(0, 16);
20
+
21
+ // Columns the studio may show; the image bytes are never part of a list.
22
+ const LIST = `id,mime,width,height,animated,size,name,category,tags,description,words,emotion,meaning,scenes,status,error,enabled,created,updated`;
23
+ const row = (r) =>
24
+ r && {
25
+ ...r,
26
+ animated: !!r.animated,
27
+ enabled: !!r.enabled,
28
+ tags: JSON.parse(r.tags || "[]"),
29
+ };
30
+
31
+ export class StickerLibrary {
32
+ constructor(db, { now = () => Date.now() } = {}) {
33
+ this.db = db;
34
+ this.now = now;
35
+ ensureStickerSchema(db);
36
+ }
37
+
38
+ // Checks the bytes themselves, never the file name or a claimed type.
39
+ async add(bytes, { name = "", category = "" } = {}) {
40
+ if (!Buffer.isBuffer(bytes) || !bytes.length) throw Error("文件为空");
41
+ if (bytes.length > STICKER_MAX_BYTES)
42
+ throw Error("表情超过 2MB,请压缩后再上传");
43
+ const mime = imageMime(bytes);
44
+ if (!ALLOWED.has(mime)) throw Error("只支持 PNG、JPEG、GIF、WebP 图片");
45
+ let meta;
46
+ try {
47
+ meta = await sharp(bytes, { limitInputPixels: 40_000_000 }).metadata();
48
+ } catch {
49
+ throw Error("图片读取失败");
50
+ }
51
+ if (!meta.width || !meta.height || meta.width > 4096 || meta.height > 4096)
52
+ throw Error("表情尺寸应在 4096 像素以内");
53
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
54
+ const existing = this.db
55
+ .prepare("SELECT id FROM sticker_items WHERE sha256=?")
56
+ .get(sha256);
57
+ if (existing) return { ...this.get(existing.id), duplicate: true };
58
+ const id = randomUUID();
59
+ const now = this.now();
60
+ this.db
61
+ .prepare(
62
+ `INSERT INTO sticker_items(id,sha256,mime,bytes,width,height,animated,size,name,category,status,created,updated)
63
+ VALUES (?,?,?,?,?,?,?,?,?,?,'pending',?,?)`,
64
+ )
65
+ .run(
66
+ id,
67
+ sha256,
68
+ mime,
69
+ bytes,
70
+ meta.width,
71
+ meta.height,
72
+ (meta.pages || 1) > 1 ? 1 : 0,
73
+ bytes.length,
74
+ clip(name, 60),
75
+ clip(category, 24),
76
+ now,
77
+ now,
78
+ );
79
+ return this.get(id);
80
+ }
81
+
82
+ get(id) {
83
+ return row(
84
+ this.db.prepare(`SELECT ${LIST} FROM sticker_items WHERE id=?`).get(id),
85
+ );
86
+ }
87
+
88
+ image(id) {
89
+ return this.db
90
+ .prepare("SELECT mime,bytes FROM sticker_items WHERE id=?")
91
+ .get(id);
92
+ }
93
+
94
+ list({ q = "", category = "", limit = 500 } = {}) {
95
+ const where = [];
96
+ const args = [];
97
+ if (category) {
98
+ where.push("category=?");
99
+ args.push(category);
100
+ }
101
+ const words = String(q).trim().split(/\s+/).filter(Boolean).slice(0, 5);
102
+ for (const w of words) {
103
+ where.push(
104
+ "(name LIKE ? OR tags LIKE ? OR description LIKE ? OR words LIKE ? OR emotion LIKE ? OR meaning LIKE ? OR scenes LIKE ? OR category LIKE ?)",
105
+ );
106
+ args.push(...Array(8).fill(`%${w.replace(/[%_]/g, "")}%`));
107
+ }
108
+ return this.db
109
+ .prepare(
110
+ `SELECT ${LIST} FROM sticker_items ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY created DESC LIMIT ?`,
111
+ )
112
+ .all(...args, Math.min(1000, Number(limit) || 500))
113
+ .map(row);
114
+ }
115
+
116
+ categories() {
117
+ return this.db
118
+ .prepare(
119
+ "SELECT category, COUNT(*) n FROM sticker_items WHERE category<>'' GROUP BY category ORDER BY n DESC",
120
+ )
121
+ .all();
122
+ }
123
+
124
+ // An edit by the owner. Changing what it means invalidates its vector.
125
+ update(id, patch = {}) {
126
+ const current = this.get(id);
127
+ if (!current) throw Error("表情不存在");
128
+ const next = {
129
+ name: patch.name !== undefined ? clip(patch.name, 60) : current.name,
130
+ category:
131
+ patch.category !== undefined
132
+ ? clip(patch.category, 24)
133
+ : current.category,
134
+ tags: patch.tags !== undefined ? cleanTags(patch.tags) : current.tags,
135
+ description:
136
+ patch.description !== undefined
137
+ ? clip(patch.description, 200)
138
+ : current.description,
139
+ words: patch.words !== undefined ? clip(patch.words, 100) : current.words,
140
+ emotion:
141
+ patch.emotion !== undefined ? clip(patch.emotion, 40) : current.emotion,
142
+ meaning:
143
+ patch.meaning !== undefined
144
+ ? clip(patch.meaning, 200)
145
+ : current.meaning,
146
+ scenes:
147
+ patch.scenes !== undefined ? clip(patch.scenes, 200) : current.scenes,
148
+ enabled: patch.enabled !== undefined ? !!patch.enabled : current.enabled,
149
+ };
150
+ const semantic = [
151
+ "tags",
152
+ "description",
153
+ "words",
154
+ "emotion",
155
+ "meaning",
156
+ "scenes",
157
+ ].some((k) => patch[k] !== undefined);
158
+ this.db
159
+ .prepare(
160
+ `UPDATE sticker_items SET name=?,category=?,tags=?,description=?,words=?,emotion=?,meaning=?,scenes=?,enabled=?,updated=?
161
+ ${semantic ? ",embedding=NULL,embed_signature=NULL" : ""}${patch.status ? ",status=?" : ""} WHERE id=?`,
162
+ )
163
+ .run(
164
+ next.name,
165
+ next.category,
166
+ JSON.stringify(next.tags),
167
+ next.description,
168
+ next.words,
169
+ next.emotion,
170
+ next.meaning,
171
+ next.scenes,
172
+ next.enabled ? 1 : 0,
173
+ this.now(),
174
+ ...(patch.status ? [patch.status] : []),
175
+ id,
176
+ );
177
+ return this.get(id);
178
+ }
179
+
180
+ remove(id) {
181
+ this.db.prepare("DELETE FROM sticker_items WHERE id=?").run(id);
182
+ this.db.prepare("DELETE FROM sticker_bonds WHERE sticker_id=?").run(id);
183
+ this.db.prepare("DELETE FROM sticker_uses WHERE sticker_id=?").run(id);
184
+ }
185
+ }
186
+
187
+ // The text a sticker is found by: what it shows, says and is for.
188
+ export function stickerText(s) {
189
+ return [
190
+ s.description,
191
+ s.words && `图上的字:${s.words}`,
192
+ s.emotion && `情绪:${s.emotion}`,
193
+ s.meaning && `意思:${s.meaning}`,
194
+ s.scenes && `适合:${s.scenes}`,
195
+ s.tags?.length && `标签:${s.tags.join("、")}`,
196
+ ]
197
+ .filter(Boolean)
198
+ .join(";");
199
+ }
@@ -0,0 +1,123 @@
1
+ import { isGroupSession, parseSessionKey } from "../channels/session-key.js";
2
+
3
+ // Only OneBot can carry an @ segment or a sticker image.
4
+ export function expressiveChannel(session) {
5
+ try {
6
+ return parseSessionKey(session).channel === "onebot";
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+
12
+ // Who she could call by name in this group: people who spoke recently. A
13
+ // name nobody here has used is never offered, so an @ cannot land on a
14
+ // stranger or on someone the room has not seen.
15
+ export function atCandidates(snapshot, session) {
16
+ if (!isGroupSession(session)) return [];
17
+ const seen = new Map();
18
+ for (const m of [...(snapshot.messages || [])].reverse().slice(0, 30)) {
19
+ const id = String(m.speaker ?? m.userId ?? "");
20
+ if (!id || id === "bot" || m.role === "assistant" || seen.has(id)) continue;
21
+ if (!/^\d{5,12}$/.test(id)) continue;
22
+ seen.set(id, { userId: id, name: m.name || id });
23
+ if (seen.size >= 8) break;
24
+ }
25
+ return [...seen.values()];
26
+ }
27
+
28
+ // The @s she wrote, kept only when they make sense: someone offered, at
29
+ // most two, and not the one person she is already talking with one to one
30
+ // (in a two-person exchange the @ only repeats what the reply says).
31
+ export function chosenAt(raw, snapshot, session, targets = []) {
32
+ const offered = new Map((snapshot.canAt || []).map((p) => [p.userId, p]));
33
+ if (!offered.size || !Array.isArray(raw)) return [];
34
+ let picked = [
35
+ ...new Set(
36
+ raw
37
+ .map((x) => String(x?.userId ?? x ?? ""))
38
+ .filter((id) => offered.has(id)),
39
+ ),
40
+ ].slice(0, 2);
41
+ // Someone who just called on her will see her answer; an @ back at them
42
+ // only repeats it. Unless others have spoken since, burying their line.
43
+ const messages = snapshot.messages || [];
44
+ const answered = new Set();
45
+ for (const id of targets) {
46
+ const at = messages.findIndex((m) => m.id === id);
47
+ if (at < 0 || messages[at].relation !== "direct") continue;
48
+ const author = String(messages[at].speaker ?? messages[at].userId ?? "");
49
+ const later = messages
50
+ .slice(at + 1)
51
+ .some(
52
+ (m) =>
53
+ m.role !== "assistant" && String(m.speaker ?? m.userId) !== author,
54
+ );
55
+ if (!later) answered.add(author);
56
+ }
57
+ picked = picked.filter((id) => !answered.has(id));
58
+ if (picked.length === 1) {
59
+ const recent = (snapshot.messages || [])
60
+ .slice(-6)
61
+ .filter((m) => m.role !== "assistant")
62
+ .map((m) => String(m.speaker ?? m.userId ?? ""));
63
+ if (recent.length && recent.every((id) => id === picked[0])) return [];
64
+ }
65
+ return picked.map((id) => offered.get(id));
66
+ }
67
+
68
+ export function chosenSticker(raw, snapshot) {
69
+ const ref = String(raw?.ref ?? raw?.id ?? raw ?? "").trim();
70
+ if (!ref) return null;
71
+ return (
72
+ (snapshot.stickers || []).find((s) => s.ref === ref || s.id === ref) || null
73
+ );
74
+ }
75
+
76
+ // What reaches her about stickers and @ this turn, only when there is
77
+ // something to choose from.
78
+ export function expressionInput(snapshot) {
79
+ const stickers = snapshot.stickers || [];
80
+ const canAt = snapshot.canAt || [];
81
+ if (!stickers.length && !canAt.length) return {};
82
+ return {
83
+ expression: {
84
+ ...(stickers.length
85
+ ? {
86
+ stickers: stickers.map(({ id: _id, ...s }) => s),
87
+ stickerNote:
88
+ '这些是你收藏的表情里此刻可能合适的几张。真想用时返回 sticker:"ref"(一次最多一张,可以配文字,也可以只发表情,此时 say 留空);不合适就不用,不必为用而用。withThisPerson 是你和这个人用它的经历。',
89
+ }
90
+ : {}),
91
+ ...(canAt.length
92
+ ? {
93
+ canAt,
94
+ atNote:
95
+ "群里要叫住某个人、让他注意到这句话时,可返回 at:[userId],最多两人;正在一对一接话、对方自然会看到时不用@。",
96
+ }
97
+ : {}),
98
+ },
99
+ };
100
+ }
101
+
102
+ // "@小红" typed as text is not an @ on QQ. A name she wrote that way for
103
+ // someone offered becomes a real @, and the typed copy is removed so the
104
+ // message does not show the name twice.
105
+ export function typedAt(bubbles, at, snapshot) {
106
+ const offered = snapshot.canAt || [];
107
+ const chosen = new Map(at.map((p) => [p.userId, p]));
108
+ const out = bubbles.map((line, i) => {
109
+ let text = line;
110
+ for (const p of offered) {
111
+ const mark = `@${p.name}`;
112
+ if (!p.name || !text.includes(mark)) continue;
113
+ if (i === 0 && chosen.size < 2) chosen.set(p.userId, p);
114
+ if (chosen.has(p.userId))
115
+ text = text
116
+ .split(mark)
117
+ .join("")
118
+ .replace(/^[\s,,]+/, "");
119
+ }
120
+ return text.trim();
121
+ });
122
+ return { bubbles: out.filter(Boolean), at: [...chosen.values()] };
123
+ }