@musnows/scriverse 0.9.9 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/im.js ADDED
@@ -0,0 +1,1690 @@
1
+ import { PLATFORM_AI_WORK_ID } from "./database.js";
2
+ import { AppError, notFound } from "./errors.js";
3
+ import { canReadWorkModule, canWriteWorkModule, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
4
+ import { id, json, now } from "./utils.js";
5
+ export const IM_MAX_AI_PARTICIPANTS = 10;
6
+ export const IM_MAX_HUMAN_PARTICIPANTS = 50;
7
+ export const IM_DEFAULT_RESPONSE_THRESHOLD = 60;
8
+ export const IM_DEFAULT_MAX_AI_MESSAGES = 20;
9
+ export const IM_DEFAULT_RETRY_COUNT = 3;
10
+ export const IM_MAX_MENTIONS_PER_MESSAGE = 50;
11
+ export const IM_MAX_CHARACTER_DIRECTORY_RESULTS = 100;
12
+ const IM_MENTION_PATTERN = /mention:\/\/(character|user)\/([A-Za-z0-9_.:-]{1,200})/gu;
13
+ function requiredString(value) {
14
+ return typeof value === "string" ? value : String(value ?? "");
15
+ }
16
+ function optionalString(value) {
17
+ return typeof value === "string" && value.length > 0 ? value : null;
18
+ }
19
+ function avatarVersionFromUrl(value) {
20
+ const match = optionalString(value)?.match(/[?&]v=([^&]+)/u);
21
+ if (!match?.[1])
22
+ return null;
23
+ try {
24
+ return decodeURIComponent(match[1]);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function booleanValue(value) {
31
+ return Number(value) === 1;
32
+ }
33
+ function publicCharacterSummary(character) {
34
+ const profile = character.profile && typeof character.profile === "object" && !Array.isArray(character.profile)
35
+ ? character.profile
36
+ : {};
37
+ const attributes = character.attributes && typeof character.attributes === "object" && !Array.isArray(character.attributes)
38
+ ? character.attributes
39
+ : {};
40
+ return [profile.summary, attributes.identity]
41
+ .filter((value) => typeof value === "string" && value.trim().length > 0)
42
+ .join("\n")
43
+ .slice(0, 2000);
44
+ }
45
+ export function parseImMentions(content) {
46
+ const mentions = [];
47
+ for (const match of content.matchAll(IM_MENTION_PATTERN)) {
48
+ const kind = match[1];
49
+ const targetId = match[2];
50
+ if ((kind !== "character" && kind !== "user") || !targetId || match.index === undefined)
51
+ continue;
52
+ mentions.push({ kind, id: targetId, start: match.index, end: match.index + match[0].length });
53
+ }
54
+ return mentions;
55
+ }
56
+ export class ImService {
57
+ store;
58
+ auth;
59
+ constructor(store, auth) {
60
+ this.store = store;
61
+ this.auth = auth;
62
+ }
63
+ get db() {
64
+ return this.store.db;
65
+ }
66
+ assertActiveUser(userId) {
67
+ const user = this.auth.getUser(userId);
68
+ if (user.status !== "active")
69
+ throw new AppError(409, "IM_USER_DISABLED", "不能添加已停用用户");
70
+ return user;
71
+ }
72
+ assertWorkMembership(user, workId) {
73
+ if (user.role === "admin") {
74
+ if (!this.db.get("SELECT 1 AS present FROM works WHERE id = ? AND deleted_at IS NULL AND COALESCE(is_internal, 0) = 0", workId)) {
75
+ throw new AppError(403, "IM_CHARACTER_ACCESS_DENIED", "你不能在 IM 中使用这个角色");
76
+ }
77
+ return;
78
+ }
79
+ const membership = this.db.get(`SELECT 1 AS present FROM works work
80
+ LEFT JOIN work_memberships membership ON membership.work_id = work.id AND membership.user_id = ?
81
+ WHERE work.id = ? AND work.deleted_at IS NULL AND COALESCE(work.is_internal, 0) = 0
82
+ AND (work.owner_user_id = ? OR membership.user_id = ?)`, user.userId, workId, user.userId, user.userId);
83
+ if (!membership)
84
+ throw new AppError(403, "IM_CHARACTER_ACCESS_DENIED", "你不能在 IM 中使用这个角色");
85
+ }
86
+ assertCharacterAvailable(user, characterId) {
87
+ const character = this.store.getCharacter(characterId);
88
+ if (optionalString(character.mergedIntoCharacterId)) {
89
+ throw new AppError(409, "IM_CHARACTER_UNAVAILABLE", "已合并角色不能加入 IM 会话");
90
+ }
91
+ const workId = requiredString(character.workId);
92
+ this.assertWorkMembership(user, workId);
93
+ const permissions = this.auth.workModulePermissions(user, workId, true);
94
+ if (!permissions || !canReadWorkModule(permissions, "characters") || !canWriteWorkModule(permissions, "ai-chat")) {
95
+ throw new AppError(403, "IM_CHARACTER_ACCESS_DENIED", "需要角色读取和 AI 对话写入权限才能在 IM 中使用这个角色");
96
+ }
97
+ return character;
98
+ }
99
+ assertModel(modelId) {
100
+ const model = this.db.get(`SELECT model.id FROM models model JOIN providers provider ON provider.id = model.provider_id
101
+ WHERE model.id = ? AND model.model_kind = 'chat' AND model.enabled = 1
102
+ AND provider.status = 'enabled' AND provider.connection_status = 'success'
103
+ AND provider.work_id = ?`, modelId, PLATFORM_AI_WORK_ID);
104
+ if (!model)
105
+ throw new AppError(400, "IM_MODEL_INVALID", "IM 只能使用平台 Chat 模型");
106
+ }
107
+ getSettings(userId) {
108
+ const user = this.auth.getUser(userId);
109
+ const row = this.db.get("SELECT * FROM im_user_settings WHERE user_id = ?", userId);
110
+ return {
111
+ preferredName: optionalString(row?.preferred_name) ?? user.displayName,
112
+ pronouns: requiredString(row?.pronouns),
113
+ identitySummary: requiredString(row?.identity_summary),
114
+ additionalNotes: requiredString(row?.additional_notes),
115
+ primaryModelId: optionalString(row?.primary_model_id),
116
+ fallbackModelId: optionalString(row?.fallback_model_id),
117
+ retryCount: Number(row?.retry_count ?? IM_DEFAULT_RETRY_COUNT),
118
+ configured: Boolean(row?.primary_model_id && row?.fallback_model_id),
119
+ updatedAt: optionalString(row?.updated_at)
120
+ };
121
+ }
122
+ updateSettings(userId, input) {
123
+ const current = this.getSettings(userId);
124
+ const primaryModelId = input.primaryModelId === undefined ? current.primaryModelId : input.primaryModelId;
125
+ const fallbackModelId = input.fallbackModelId === undefined ? current.fallbackModelId : input.fallbackModelId;
126
+ if (input.primaryModelId !== undefined && primaryModelId !== current.primaryModelId && primaryModelId)
127
+ this.assertModel(primaryModelId);
128
+ if (input.fallbackModelId !== undefined && fallbackModelId !== current.fallbackModelId && fallbackModelId)
129
+ this.assertModel(fallbackModelId);
130
+ if (primaryModelId && fallbackModelId && primaryModelId === fallbackModelId) {
131
+ throw new AppError(400, "IM_FALLBACK_MODEL_DUPLICATE", "主模型和 fallback 模型不能相同");
132
+ }
133
+ const timestamp = now();
134
+ this.db.transaction(() => {
135
+ this.db.run(`INSERT INTO im_user_settings (
136
+ user_id, preferred_name, pronouns, identity_summary, additional_notes,
137
+ primary_model_id, fallback_model_id, retry_count, updated_at
138
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
139
+ ON CONFLICT(user_id) DO UPDATE SET
140
+ preferred_name = excluded.preferred_name,
141
+ pronouns = excluded.pronouns,
142
+ identity_summary = excluded.identity_summary,
143
+ additional_notes = excluded.additional_notes,
144
+ primary_model_id = excluded.primary_model_id,
145
+ fallback_model_id = excluded.fallback_model_id,
146
+ retry_count = excluded.retry_count,
147
+ updated_at = excluded.updated_at`, userId, input.preferredName ?? requiredString(current.preferredName), input.pronouns ?? requiredString(current.pronouns), input.identitySummary ?? requiredString(current.identitySummary), input.additionalNotes ?? requiredString(current.additionalNotes), primaryModelId, fallbackModelId, input.retryCount ?? Number(current.retryCount), timestamp);
148
+ this.store.audit(null, "im.settings.updated", "user", userId, {
149
+ primaryModelId,
150
+ fallbackModelId,
151
+ retryCount: input.retryCount ?? current.retryCount
152
+ });
153
+ });
154
+ return this.getSettings(userId);
155
+ }
156
+ listModels() {
157
+ return this.db.all(`SELECT model.id, model.display_name, model.model_id, model.context_window, model.multimodal_enabled,
158
+ provider.id AS provider_id, provider.name AS provider_name
159
+ FROM models model JOIN providers provider ON provider.id = model.provider_id
160
+ WHERE model.model_kind = 'chat' AND model.enabled = 1
161
+ AND provider.status = 'enabled' AND provider.connection_status = 'success'
162
+ AND provider.work_id = ?
163
+ ORDER BY provider.created_at, model.created_at`, PLATFORM_AI_WORK_ID).map((row) => ({
164
+ id: requiredString(row.id),
165
+ displayName: requiredString(row.display_name),
166
+ modelId: requiredString(row.model_id),
167
+ contextWindow: Number(row.context_window),
168
+ multimodalEnabled: booleanValue(row.multimodal_enabled),
169
+ providerId: requiredString(row.provider_id),
170
+ providerName: requiredString(row.provider_name)
171
+ }));
172
+ }
173
+ catalogWorkPermissions(user, row) {
174
+ if (user.role === "admin" || requiredString(row.owner_user_id) === user.userId)
175
+ return fullWorkModulePermissions();
176
+ if (optionalString(row.membership_user_id) !== user.userId)
177
+ return null;
178
+ return storedWorkModulePermissions(requiredString(row.membership_role), row.membership_permissions_json);
179
+ }
180
+ listAvailableWorks(user) {
181
+ return this.db.all(`SELECT work.id, work.title, work.owner_user_id,
182
+ membership.user_id AS membership_user_id, membership.role AS membership_role,
183
+ membership.permissions_json AS membership_permissions_json, (
184
+ SELECT COUNT(*) FROM characters character
185
+ WHERE character.work_id = work.id AND character.merged_into_character_id IS NULL
186
+ ) AS character_count FROM works work
187
+ LEFT JOIN work_memberships membership ON membership.work_id = work.id AND membership.user_id = ?
188
+ WHERE work.deleted_at IS NULL AND COALESCE(work.is_internal, 0) = 0
189
+ AND (? = 1 OR work.owner_user_id = ? OR membership.user_id = ?)
190
+ ORDER BY work.updated_at DESC, work.title`, user.userId, user.role === "admin" ? 1 : 0, user.userId, user.userId).flatMap((work) => {
191
+ const workId = requiredString(work.id);
192
+ const permissions = this.catalogWorkPermissions(user, work);
193
+ if (!permissions || !canReadWorkModule(permissions, "characters") || !canWriteWorkModule(permissions, "ai-chat"))
194
+ return [];
195
+ return [{
196
+ id: workId,
197
+ title: requiredString(work.title),
198
+ characterCount: Number(work.character_count)
199
+ }];
200
+ }).filter((work) => Number(work.characterCount) > 0);
201
+ }
202
+ listAvailableCharacters(user, query = "", selectedWorkId, limit = IM_MAX_CHARACTER_DIRECTORY_RESULTS, offset = 0) {
203
+ const normalizedQuery = query.normalize("NFKC").trim();
204
+ let authorizedWorkIds;
205
+ if (selectedWorkId) {
206
+ this.assertWorkMembership(user, selectedWorkId);
207
+ const permissions = this.auth.workModulePermissions(user, selectedWorkId, true);
208
+ if (!permissions || !canReadWorkModule(permissions, "characters") || !canWriteWorkModule(permissions, "ai-chat")) {
209
+ throw new AppError(403, "IM_CHARACTER_ACCESS_DENIED", "需要角色读取和 AI 对话写入权限才能浏览这本书的角色");
210
+ }
211
+ authorizedWorkIds = [selectedWorkId];
212
+ }
213
+ else
214
+ authorizedWorkIds = this.listAvailableWorks(user).map((work) => requiredString(work.id));
215
+ if (authorizedWorkIds.length === 0)
216
+ return [];
217
+ const authorizedWorkPlaceholders = authorizedWorkIds.map(() => "?").join(", ");
218
+ const rows = this.db.all(`SELECT character.id, character.work_id, character.name, character.code, character.gender,
219
+ character.is_dead, character.attributes_json, character.profile_json,
220
+ work.title AS work_title, work.owner_user_id,
221
+ membership.user_id AS membership_user_id, membership.role AS membership_role,
222
+ membership.permissions_json AS membership_permissions_json,
223
+ avatar.sha256 AS avatar_sha256,
224
+ EXISTS (
225
+ SELECT 1 FROM work_entity_pins pin
226
+ WHERE pin.work_id = character.work_id AND pin.entity_type = 'character'
227
+ AND pin.entity_id = character.id AND pin.is_pinned = 1
228
+ ) AS is_pinned,
229
+ EXISTS (
230
+ SELECT 1 FROM work_entity_favorites favorite
231
+ WHERE favorite.work_id = character.work_id AND favorite.entity_type = 'character'
232
+ AND favorite.entity_id = character.id AND favorite.user_id = ? AND favorite.is_favorite = 1
233
+ ) AS user_is_favorite
234
+ FROM characters character JOIN works work ON work.id = character.work_id
235
+ LEFT JOIN character_avatars avatar ON avatar.character_id = character.id
236
+ LEFT JOIN work_memberships membership ON membership.work_id = work.id AND membership.user_id = ?
237
+ WHERE work.deleted_at IS NULL AND COALESCE(work.is_internal, 0) = 0
238
+ AND character.merged_into_character_id IS NULL
239
+ AND character.work_id IN (${authorizedWorkPlaceholders})
240
+ AND (? = '' OR character.name LIKE '%' || ? || '%' COLLATE NOCASE OR EXISTS (
241
+ SELECT 1 FROM character_names name
242
+ WHERE name.character_id = character.id AND name.display_name LIKE '%' || ? || '%' COLLATE NOCASE
243
+ ))
244
+ ORDER BY work.updated_at DESC, is_pinned DESC, user_is_favorite DESC, character.name COLLATE NOCASE, character.id
245
+ LIMIT ? OFFSET ?`, user.userId, user.userId, ...authorizedWorkIds, normalizedQuery, normalizedQuery, normalizedQuery, Math.max(1, Math.min(IM_MAX_CHARACTER_DIRECTORY_RESULTS + 1, limit)), Math.max(0, offset));
246
+ return rows.flatMap((row) => {
247
+ const workId = requiredString(row.work_id);
248
+ const permissions = this.catalogWorkPermissions(user, row);
249
+ if (!permissions || !canReadWorkModule(permissions, "characters") || !canWriteWorkModule(permissions, "ai-chat"))
250
+ return [];
251
+ const character = {
252
+ id: requiredString(row.id),
253
+ name: requiredString(row.name),
254
+ code: requiredString(row.code),
255
+ gender: requiredString(row.gender),
256
+ isDead: booleanValue(row.is_dead),
257
+ attributes: json(requiredString(row.attributes_json), {}),
258
+ profile: json(requiredString(row.profile_json), {})
259
+ };
260
+ const avatarSha256 = optionalString(row.avatar_sha256);
261
+ return [{
262
+ id: character.id,
263
+ workId,
264
+ workTitle: requiredString(row.work_title),
265
+ name: requiredString(character.name),
266
+ code: requiredString(character.code),
267
+ gender: requiredString(character.gender),
268
+ isDead: character.isDead,
269
+ isPinned: Boolean(row.is_pinned),
270
+ isFavorite: Boolean(row.user_is_favorite),
271
+ avatarUrl: avatarSha256
272
+ ? `/api/characters/${encodeURIComponent(character.id)}/avatar?v=${encodeURIComponent(avatarSha256)}`
273
+ : null,
274
+ publicSummary: publicCharacterSummary(character)
275
+ }];
276
+ });
277
+ }
278
+ characterSnapshot(character) {
279
+ const work = this.store.getWork(requiredString(character.workId));
280
+ const avatar = this.store.getCharacterAvatar(requiredString(character.id));
281
+ return {
282
+ id: requiredString(character.id),
283
+ name: requiredString(character.name),
284
+ code: requiredString(character.code),
285
+ avatarUrl: character.avatarUrl ?? null,
286
+ avatarSha256: avatar?.sha256 ?? null,
287
+ workId: requiredString(work.id),
288
+ workTitle: requiredString(work.title),
289
+ publicSummary: publicCharacterSummary(character)
290
+ };
291
+ }
292
+ humanSnapshot(user) {
293
+ return {
294
+ userId: user.userId,
295
+ username: user.username,
296
+ displayName: user.displayName,
297
+ avatarUrl: user.avatarUrl,
298
+ avatarSha256: avatarVersionFromUrl(user.avatarUrl)
299
+ };
300
+ }
301
+ activeMembership(conversationId, userId) {
302
+ return this.db.get(`SELECT * FROM im_human_memberships
303
+ WHERE conversation_id = ? AND user_id = ? AND left_at IS NULL`, conversationId, userId);
304
+ }
305
+ assertReadableConversation(conversationId, userId) {
306
+ const conversation = this.db.get("SELECT * FROM im_conversations WHERE id = ?", conversationId);
307
+ if (!conversation)
308
+ throw notFound("IM 会话");
309
+ const membership = this.db.get("SELECT 1 AS present FROM im_human_memberships WHERE conversation_id = ? AND user_id = ? LIMIT 1", conversationId, userId);
310
+ if (!membership)
311
+ throw new AppError(403, "IM_CONVERSATION_ACCESS_DENIED", "你不是这个 IM 会话的成员");
312
+ return conversation;
313
+ }
314
+ assertActiveMembership(conversationId, userId) {
315
+ const conversation = this.assertReadableConversation(conversationId, userId);
316
+ if (requiredString(conversation.status) !== "active")
317
+ throw new AppError(409, "IM_CONVERSATION_DISBANDED", "这个群聊已经解散");
318
+ if (!this.activeMembership(conversationId, userId))
319
+ throw new AppError(403, "IM_MEMBERSHIP_INACTIVE", "你已经退出这个 IM 会话");
320
+ return conversation;
321
+ }
322
+ assertOwner(conversationId, userId) {
323
+ const conversation = this.assertActiveMembership(conversationId, userId);
324
+ if (requiredString(conversation.owner_user_id) !== userId)
325
+ throw new AppError(403, "IM_OWNER_REQUIRED", "该操作仅限群主");
326
+ return conversation;
327
+ }
328
+ nextSequence(conversationId) {
329
+ return Number(this.db.get("SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence FROM im_messages WHERE conversation_id = ?", conversationId)?.sequence ?? 1);
330
+ }
331
+ advanceContextEpoch(conversationId, timestamp = now()) {
332
+ this.db.run("UPDATE im_conversations SET context_epoch = context_epoch + 1, updated_at = ? WHERE id = ?", timestamp, conversationId);
333
+ }
334
+ insertHumanMembership(conversationId, user, role, joinedSequence) {
335
+ this.db.run(`INSERT INTO im_human_memberships (
336
+ id, conversation_id, user_id, role, joined_sequence, last_read_sequence, joined_at
337
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`, id("imHuman"), conversationId, user.userId, role, joinedSequence, joinedSequence, now());
338
+ }
339
+ insertCharacterMembership(conversationId, character, joinedSequence) {
340
+ const timestamp = now();
341
+ this.db.run(`INSERT INTO im_character_memberships (
342
+ id, conversation_id, character_id, source_work_id, snapshot_json, joined_sequence, joined_at
343
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`, id("imCharacter"), conversationId, requiredString(character.id), requiredString(character.workId), JSON.stringify(this.characterSnapshot(character)), joinedSequence, timestamp);
344
+ this.captureCharacterAvatarVersion(conversationId, requiredString(character.id), timestamp);
345
+ }
346
+ createDirect(user, characterId) {
347
+ return this.createDirectResult(user, characterId).conversation;
348
+ }
349
+ createDirectResult(user, characterId) {
350
+ const character = this.assertCharacterAvailable(user, characterId);
351
+ const existing = this.db.get("SELECT id FROM im_conversations WHERE kind = 'direct' AND owner_user_id = ? AND direct_character_id = ?", user.userId, characterId);
352
+ if (existing) {
353
+ const existingConversationId = requiredString(existing.id);
354
+ const changed = this.refreshCharacterAvailability(existingConversationId);
355
+ return { conversation: this.getConversation(existingConversationId, user.userId), created: false, changed };
356
+ }
357
+ const conversationId = id("imConversation");
358
+ const timestamp = now();
359
+ this.db.transaction(() => {
360
+ this.db.run(`INSERT INTO im_conversations (
361
+ id, kind, owner_user_id, direct_character_id, title, reply_mode,
362
+ response_threshold, max_ai_messages, created_at, updated_at
363
+ ) VALUES (?, 'direct', ?, ?, ?, 'mention', ?, ?, ?, ?)`, conversationId, user.userId, characterId, requiredString(character.name).slice(0, 80), IM_DEFAULT_RESPONSE_THRESHOLD, IM_DEFAULT_MAX_AI_MESSAGES, timestamp, timestamp);
364
+ this.insertHumanMembership(conversationId, user, "owner", 0);
365
+ this.insertCharacterMembership(conversationId, character, 0);
366
+ this.store.audit(requiredString(character.workId), "im.direct-created", "im-conversation", conversationId, { characterId });
367
+ });
368
+ return { conversation: this.getConversation(conversationId, user.userId), created: true, changed: true };
369
+ }
370
+ createGroup(owner, input) {
371
+ const characterIds = [...new Set(input.characterIds)];
372
+ const humanIds = [...new Set((input.humanUserIds ?? []).filter((userId) => userId !== owner.userId))];
373
+ if (characterIds.length < 1 || characterIds.length > IM_MAX_AI_PARTICIPANTS) {
374
+ throw new AppError(400, "IM_CHARACTER_COUNT_INVALID", `群聊必须包含 1-${IM_MAX_AI_PARTICIPANTS} 个 AI 角色`);
375
+ }
376
+ if (humanIds.length + 1 > IM_MAX_HUMAN_PARTICIPANTS) {
377
+ throw new AppError(400, "IM_HUMAN_COUNT_INVALID", `群聊最多包含 ${IM_MAX_HUMAN_PARTICIPANTS} 个人类成员`);
378
+ }
379
+ const characters = characterIds.map((characterId) => this.assertCharacterAvailable(owner, characterId));
380
+ const humans = humanIds.map((userId) => this.assertActiveUser(userId));
381
+ const conversationId = id("imConversation");
382
+ const timestamp = now();
383
+ this.db.transaction(() => {
384
+ this.db.run(`INSERT INTO im_conversations (
385
+ id, kind, owner_user_id, title, reply_mode, response_threshold,
386
+ max_ai_messages, created_at, updated_at
387
+ ) VALUES (?, 'group', ?, ?, ?, ?, ?, ?, ?)`, conversationId, owner.userId, input.title, input.replyMode ?? "mention", input.responseThreshold ?? IM_DEFAULT_RESPONSE_THRESHOLD, input.maxAiMessages ?? IM_DEFAULT_MAX_AI_MESSAGES, timestamp, timestamp);
388
+ this.insertHumanMembership(conversationId, owner, "owner", 0);
389
+ for (const human of humans)
390
+ this.insertHumanMembership(conversationId, human, "member", 0);
391
+ for (const character of characters)
392
+ this.insertCharacterMembership(conversationId, character, 0);
393
+ this.store.audit(null, "im.group-created", "im-conversation", conversationId, {
394
+ characterIds,
395
+ humanUserIds: humanIds,
396
+ replyMode: input.replyMode ?? "mention"
397
+ });
398
+ });
399
+ return this.getConversation(conversationId, owner.userId);
400
+ }
401
+ mapHumanMembership(row) {
402
+ return {
403
+ membershipId: requiredString(row.membership_id ?? row.id),
404
+ userId: requiredString(row.user_id),
405
+ username: requiredString(row.username),
406
+ displayName: requiredString(row.display_name),
407
+ avatarUrl: row.avatar_sha256
408
+ ? `/api/user-avatars/${encodeURIComponent(requiredString(row.user_id))}?v=${encodeURIComponent(requiredString(row.avatar_sha256))}`
409
+ : null,
410
+ role: requiredString(row.role),
411
+ joinedAt: requiredString(row.joined_at),
412
+ leftAt: optionalString(row.left_at)
413
+ };
414
+ }
415
+ mapCharacterMembership(row) {
416
+ const snapshot = json(requiredString(row.snapshot_json), {});
417
+ const characterId = optionalString(row.character_id) ?? optionalString(snapshot.id);
418
+ const avatarSha256 = optionalString(row.avatar_sha256);
419
+ return {
420
+ membershipId: requiredString(row.id),
421
+ characterId,
422
+ sourceWorkId: optionalString(row.source_work_id) ?? snapshot.workId ?? null,
423
+ name: snapshot.name ?? "已删除角色",
424
+ code: snapshot.code ?? "",
425
+ avatarUrl: characterId && avatarSha256
426
+ ? `/api/im/conversations/${encodeURIComponent(requiredString(row.conversation_id))}/characters/${encodeURIComponent(characterId)}/avatar?v=${encodeURIComponent(avatarSha256)}`
427
+ : null,
428
+ workTitle: snapshot.workTitle ?? "已删除作品",
429
+ publicSummary: snapshot.publicSummary ?? "",
430
+ status: requiredString(row.status),
431
+ joinedAt: requiredString(row.joined_at),
432
+ leftAt: optionalString(row.left_at)
433
+ };
434
+ }
435
+ conversationParticipants(conversationId, viewerUserId) {
436
+ const viewerMembership = this.db.get(`SELECT joined_sequence, left_sequence, left_at, conversation_snapshot_json FROM im_human_memberships
437
+ WHERE conversation_id = ? AND user_id = ?
438
+ ORDER BY (left_at IS NULL) DESC, joined_sequence DESC, joined_at DESC, rowid DESC LIMIT 1`, conversationId, viewerUserId);
439
+ if (!viewerMembership)
440
+ throw new AppError(403, "IM_CONVERSATION_ACCESS_DENIED", "你不是这个 IM 会话的成员");
441
+ const leftSequence = viewerMembership.left_sequence === null || viewerMembership.left_sequence === undefined
442
+ ? null
443
+ : Number(viewerMembership.left_sequence);
444
+ const viewerLeftAt = optionalString(viewerMembership.left_at);
445
+ if (leftSequence !== null) {
446
+ const snapshot = json(requiredString(viewerMembership.conversation_snapshot_json), {});
447
+ const frozenParticipants = snapshot.participants;
448
+ if (frozenParticipants && typeof frozenParticipants === "object" && !Array.isArray(frozenParticipants)) {
449
+ const record = frozenParticipants;
450
+ if (Array.isArray(record.humans) && Array.isArray(record.characters)) {
451
+ return {
452
+ humans: record.humans.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item))),
453
+ characters: record.characters.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item)))
454
+ };
455
+ }
456
+ }
457
+ }
458
+ const humanVisibility = leftSequence === null
459
+ ? "membership.left_at IS NULL"
460
+ : `(membership.joined_sequence < ? OR (membership.joined_sequence = ? AND membership.joined_at <= ?))
461
+ AND (membership.left_sequence IS NULL OR membership.left_sequence > ? OR (membership.left_sequence = ? AND membership.left_at >= ?))`;
462
+ const characterVisibility = leftSequence === null
463
+ ? "membership.left_at IS NULL"
464
+ : `(membership.joined_sequence < ? OR (membership.joined_sequence = ? AND membership.joined_at <= ?))
465
+ AND (membership.left_sequence IS NULL OR membership.left_sequence > ? OR (membership.left_sequence = ? AND membership.left_at >= ?))`;
466
+ const visibilityParams = leftSequence === null ? [] : [leftSequence, leftSequence, viewerLeftAt, leftSequence, leftSequence, viewerLeftAt];
467
+ const humans = this.db.all(`SELECT membership.id AS membership_id, membership.user_id, membership.role, membership.joined_at, membership.left_at,
468
+ user.username, user.display_name, user.avatar_sha256
469
+ FROM im_human_memberships membership JOIN users user ON user.id = membership.user_id
470
+ WHERE membership.conversation_id = ? AND ${humanVisibility}
471
+ ORDER BY membership.joined_at, membership.id`, conversationId, ...visibilityParams).map((row) => ({ ...this.mapHumanMembership(row), ...(leftSequence === null ? {} : { leftAt: null }) }));
472
+ const characters = this.db.all(`SELECT membership.*,
473
+ CASE WHEN membership.status = 'active' THEN avatar.sha256
474
+ ELSE json_extract(membership.snapshot_json, '$.avatarSha256') END AS avatar_sha256
475
+ FROM im_character_memberships membership
476
+ LEFT JOIN character_avatars avatar ON avatar.character_id = membership.character_id
477
+ WHERE membership.conversation_id = ? AND ${characterVisibility}
478
+ ORDER BY membership.joined_at, membership.id`, conversationId, ...visibilityParams).map((row) => {
479
+ const snapshot = json(requiredString(row.snapshot_json), {});
480
+ const currentAvatarSha256 = optionalString(row.avatar_sha256);
481
+ const frozenAvatarSha256 = optionalString(snapshot.avatarSha256);
482
+ const visibleRow = leftSequence === null || (currentAvatarSha256 && currentAvatarSha256 === frozenAvatarSha256)
483
+ ? row
484
+ : { ...row, avatar_sha256: null };
485
+ return {
486
+ ...this.mapCharacterMembership(visibleRow),
487
+ ...(leftSequence === null ? {} : { leftAt: null, status: "active" })
488
+ };
489
+ });
490
+ return { humans, characters };
491
+ }
492
+ visibleMessagePage(conversationId, userId, viewerActive, limit = 50, beforeSequence, afterSequence) {
493
+ if (afterSequence !== undefined) {
494
+ const rows = this.db.all(`SELECT message.* FROM im_messages message
495
+ WHERE message.conversation_id = ? AND message.sequence > ?
496
+ AND EXISTS (
497
+ SELECT 1 FROM im_human_memberships membership
498
+ WHERE membership.conversation_id = message.conversation_id AND membership.user_id = ?
499
+ AND message.sequence > membership.joined_sequence
500
+ AND (membership.left_sequence IS NULL OR message.sequence <= membership.left_sequence)
501
+ )
502
+ ORDER BY message.sequence ASC LIMIT ?`, conversationId, afterSequence, userId, limit + 1);
503
+ const hasMoreAfter = rows.length > limit;
504
+ const historicalParticipants = viewerActive ? undefined : this.conversationParticipants(conversationId, userId);
505
+ return {
506
+ messages: this.mapMessagePage(rows.slice(0, limit), viewerActive, historicalParticipants),
507
+ hasMore: false,
508
+ hasMoreAfter
509
+ };
510
+ }
511
+ const params = [conversationId, userId];
512
+ const before = beforeSequence === undefined ? "" : " AND message.sequence < ?";
513
+ if (beforeSequence !== undefined)
514
+ params.push(beforeSequence);
515
+ params.push(limit + 1);
516
+ const rows = this.db.all(`SELECT message.* FROM im_messages message
517
+ WHERE message.conversation_id = ?
518
+ AND EXISTS (
519
+ SELECT 1 FROM im_human_memberships membership
520
+ WHERE membership.conversation_id = message.conversation_id AND membership.user_id = ?
521
+ AND message.sequence > membership.joined_sequence
522
+ AND (membership.left_sequence IS NULL OR message.sequence <= membership.left_sequence)
523
+ )${before}
524
+ ORDER BY message.sequence DESC LIMIT ?`, ...params).reverse();
525
+ const hasMore = rows.length > limit;
526
+ const pageRows = hasMore ? rows.slice(rows.length - limit) : rows;
527
+ const historicalParticipants = viewerActive ? undefined : this.conversationParticipants(conversationId, userId);
528
+ return {
529
+ messages: this.mapMessagePage(pageRows, viewerActive, historicalParticipants),
530
+ hasMore,
531
+ hasMoreAfter: false
532
+ };
533
+ }
534
+ mapMessage(row, showCurrentCharacterAvatar = true, historicalParticipants, prepared) {
535
+ const messageId = requiredString(row.id);
536
+ const conversationId = requiredString(row.conversation_id);
537
+ const sender = json(requiredString(row.sender_snapshot_json), {});
538
+ const senderCharacterId = optionalString(row.sender_character_id) ?? optionalString(sender.id);
539
+ if (requiredString(row.sender_kind) === "character" && senderCharacterId) {
540
+ const snapshotAvatarSha256 = optionalString(sender.avatarSha256) ?? avatarVersionFromUrl(sender.avatarUrl);
541
+ const avatarSha256 = snapshotAvatarSha256 ?? (showCurrentCharacterAvatar
542
+ ? prepared
543
+ ? prepared.avatarShaByCharacterId.get(senderCharacterId) ?? null
544
+ : optionalString(this.db.get("SELECT sha256 FROM character_avatars WHERE character_id = ?", senderCharacterId)?.sha256)
545
+ : null);
546
+ sender.avatarUrl = avatarSha256
547
+ ? `/api/im/conversations/${encodeURIComponent(conversationId)}/characters/${encodeURIComponent(senderCharacterId)}/avatar?v=${encodeURIComponent(avatarSha256)}`
548
+ : optionalString(historicalParticipants?.characters
549
+ .find((character) => optionalString(character.characterId) === senderCharacterId)?.avatarUrl);
550
+ }
551
+ else if (requiredString(row.sender_kind) === "human") {
552
+ const senderUserId = optionalString(row.sender_user_id);
553
+ const avatarSha256 = optionalString(sender.avatarSha256) ?? avatarVersionFromUrl(sender.avatarUrl);
554
+ sender.avatarUrl = senderUserId && avatarSha256
555
+ ? `/api/im/conversations/${encodeURIComponent(conversationId)}/users/${encodeURIComponent(senderUserId)}/avatar?v=${encodeURIComponent(avatarSha256)}`
556
+ : !showCurrentCharacterAvatar
557
+ ? optionalString(historicalParticipants?.humans
558
+ .find((human) => optionalString(human.userId) === senderUserId)?.avatarUrl)
559
+ : null;
560
+ }
561
+ const mentionRows = prepared
562
+ ? prepared.mentionsByMessageId.get(messageId) ?? []
563
+ : this.db.all("SELECT * FROM im_mentions WHERE message_id = ? ORDER BY position", messageId);
564
+ const mentions = mentionRows.map((mention) => ({
565
+ kind: requiredString(mention.target_kind),
566
+ id: requiredString(mention.target_id),
567
+ position: Number(mention.position),
568
+ snapshot: json(requiredString(mention.target_snapshot_json), {})
569
+ }));
570
+ return {
571
+ id: messageId,
572
+ conversationId,
573
+ sequence: Number(row.sequence),
574
+ contextEpoch: Number(row.context_epoch),
575
+ senderKind: requiredString(row.sender_kind),
576
+ senderUserId: optionalString(row.sender_user_id),
577
+ senderCharacterId,
578
+ sender,
579
+ content: requiredString(row.content),
580
+ mentions,
581
+ chainId: optionalString(row.chain_id),
582
+ metadata: json(requiredString(row.metadata_json), {}),
583
+ createdAt: requiredString(row.created_at)
584
+ };
585
+ }
586
+ mapMessagePage(rows, showCurrentCharacterAvatars, historicalParticipants) {
587
+ if (rows.length === 0)
588
+ return [];
589
+ const messageIds = rows.map((row) => requiredString(row.id));
590
+ const messagePlaceholders = messageIds.map(() => "?").join(", ");
591
+ const mentionsByMessageId = new Map();
592
+ for (const mention of this.db.all(`SELECT * FROM im_mentions WHERE message_id IN (${messagePlaceholders}) ORDER BY message_id, position`, ...messageIds)) {
593
+ const messageId = requiredString(mention.message_id);
594
+ const mentions = mentionsByMessageId.get(messageId) ?? [];
595
+ mentions.push(mention);
596
+ mentionsByMessageId.set(messageId, mentions);
597
+ }
598
+ const characterIds = showCurrentCharacterAvatars
599
+ ? [...new Set(rows.flatMap((row) => optionalString(row.sender_character_id) ? [requiredString(row.sender_character_id)] : []))]
600
+ : [];
601
+ const avatarShaByCharacterId = new Map();
602
+ if (characterIds.length > 0) {
603
+ const characterPlaceholders = characterIds.map(() => "?").join(", ");
604
+ for (const avatar of this.db.all(`SELECT character_id, sha256 FROM character_avatars WHERE character_id IN (${characterPlaceholders})`, ...characterIds)) {
605
+ avatarShaByCharacterId.set(requiredString(avatar.character_id), requiredString(avatar.sha256));
606
+ }
607
+ }
608
+ const prepared = { mentionsByMessageId, avatarShaByCharacterId };
609
+ return rows.map((row) => this.mapMessage(row, showCurrentCharacterAvatars, historicalParticipants, prepared));
610
+ }
611
+ mapConversation(row, userId, prepared) {
612
+ const conversationId = requiredString(row.id);
613
+ const participants = prepared?.participants ?? this.conversationParticipants(conversationId, userId);
614
+ const avatarCharacters = participants.characters
615
+ .filter((membership) => requiredString(membership.status) === "active")
616
+ .slice(0, 3)
617
+ .map((membership) => ({
618
+ characterId: requiredString(membership.characterId),
619
+ name: requiredString(membership.name),
620
+ avatarUrl: optionalString(membership.avatarUrl)
621
+ }));
622
+ const avatarMembers = [
623
+ ...participants.characters.filter((membership) => requiredString(membership.status) === "active").map((membership) => ({
624
+ kind: "character",
625
+ participantId: requiredString(membership.characterId),
626
+ name: requiredString(membership.name),
627
+ avatarUrl: optionalString(membership.avatarUrl),
628
+ joinedAt: requiredString(membership.joinedAt),
629
+ membershipId: requiredString(membership.membershipId)
630
+ })),
631
+ ...participants.humans.map((membership) => ({
632
+ kind: "user",
633
+ participantId: requiredString(membership.userId),
634
+ name: requiredString(membership.displayName),
635
+ displayName: requiredString(membership.displayName),
636
+ username: requiredString(membership.username),
637
+ avatarUrl: optionalString(membership.avatarUrl),
638
+ joinedAt: requiredString(membership.joinedAt),
639
+ membershipId: requiredString(membership.membershipId)
640
+ }))
641
+ ].sort((left, right) => left.joinedAt.localeCompare(right.joinedAt)
642
+ || left.kind.localeCompare(right.kind)
643
+ || left.membershipId.localeCompare(right.membershipId))
644
+ .slice(0, 9)
645
+ .map((member) => ({
646
+ kind: member.kind,
647
+ participantId: member.participantId,
648
+ name: member.name,
649
+ avatarUrl: member.avatarUrl,
650
+ ...("displayName" in member ? {
651
+ displayName: member.displayName,
652
+ username: "username" in member ? member.username : ""
653
+ } : {})
654
+ }));
655
+ const activeMembership = prepared?.activeMembership ?? (prepared ? undefined : this.activeMembership(conversationId, userId));
656
+ const viewerMembership = prepared?.viewerMembership ?? activeMembership ?? this.db.get(`SELECT * FROM im_human_memberships
657
+ WHERE conversation_id = ? AND user_id = ?
658
+ ORDER BY (left_at IS NULL) DESC, joined_sequence DESC, joined_at DESC, rowid DESC LIMIT 1`, conversationId, userId);
659
+ const historicalSnapshot = activeMembership
660
+ ? {}
661
+ : json(requiredString(viewerMembership?.conversation_snapshot_json), {});
662
+ const conversationValue = (column, snapshotKey) => historicalSnapshot[snapshotKey] ?? row[column];
663
+ const conversationLatestSequence = prepared?.latestSequence ?? Number(this.db.get("SELECT COALESCE(MAX(sequence), 0) AS sequence FROM im_messages WHERE conversation_id = ?", conversationId)?.sequence ?? 0);
664
+ const latestSequence = activeMembership
665
+ ? conversationLatestSequence
666
+ : Math.min(conversationLatestSequence, Number(viewerMembership?.left_sequence ?? conversationLatestSequence));
667
+ const lastReadSequence = Number(activeMembership?.last_read_sequence ?? latestSequence);
668
+ const unread = prepared?.unreadCount ?? (activeMembership ? Number(this.db.get(`SELECT COUNT(*) AS count FROM im_messages message
669
+ WHERE message.conversation_id = ? AND message.sequence > ?
670
+ AND EXISTS (
671
+ SELECT 1 FROM im_human_memberships membership
672
+ WHERE membership.conversation_id = message.conversation_id AND membership.user_id = ?
673
+ AND membership.left_at IS NULL AND message.sequence > membership.joined_sequence
674
+ )`, conversationId, lastReadSequence, userId)?.count ?? 0) : 0);
675
+ const mentionUnread = prepared?.mentionUnreadCount ?? (activeMembership ? Number(this.db.get(`SELECT COUNT(DISTINCT message.id) AS count FROM im_messages message
676
+ JOIN im_mentions mention ON mention.message_id = message.id
677
+ WHERE message.conversation_id = ? AND message.sequence > ?
678
+ AND mention.target_kind = 'user' AND mention.target_id = ?`, conversationId, lastReadSequence, userId)?.count ?? 0) : 0);
679
+ return {
680
+ id: conversationId,
681
+ kind: requiredString(row.kind),
682
+ ownerUserId: requiredString(conversationValue("owner_user_id", "ownerUserId")),
683
+ title: requiredString(conversationValue("title", "title")),
684
+ replyMode: requiredString(conversationValue("reply_mode", "replyMode")),
685
+ responseThreshold: Number(conversationValue("response_threshold", "responseThreshold")),
686
+ maxAiMessages: Number(conversationValue("max_ai_messages", "maxAiMessages")),
687
+ contextEpoch: Number(conversationValue("context_epoch", "contextEpoch")),
688
+ status: requiredString(conversationValue("status", "status")),
689
+ avatarCharacters,
690
+ avatarMembers,
691
+ active: Boolean(activeMembership) && requiredString(row.status) === "active",
692
+ unreadCount: unread,
693
+ mentionUnreadCount: mentionUnread,
694
+ latestSequence,
695
+ createdAt: requiredString(row.created_at),
696
+ updatedAt: requiredString(historicalSnapshot.updatedAt ?? viewerMembership?.left_at ?? row.updated_at)
697
+ };
698
+ }
699
+ listConversations(userId, limit = 50, cursor) {
700
+ const cursorWhere = cursor
701
+ ? `AND (
702
+ effective_updated_at < ?
703
+ OR (effective_updated_at = ? AND created_at < ?)
704
+ OR (effective_updated_at = ? AND created_at = ? AND id > ?)
705
+ )`
706
+ : "";
707
+ const conversations = this.db.all(`WITH ranked_viewers AS (
708
+ SELECT membership.*,
709
+ ROW_NUMBER() OVER (
710
+ PARTITION BY membership.conversation_id
711
+ ORDER BY (membership.left_at IS NULL) DESC, membership.joined_sequence DESC,
712
+ membership.joined_at DESC, membership.rowid DESC
713
+ ) AS rank
714
+ FROM im_human_memberships membership
715
+ WHERE membership.user_id = ?
716
+ ), visible_conversations AS (
717
+ SELECT conversation.*,
718
+ COALESCE(
719
+ CASE WHEN viewer.left_at IS NULL THEN conversation.updated_at
720
+ ELSE json_extract(viewer.conversation_snapshot_json, '$.updatedAt') END,
721
+ viewer.left_at,
722
+ conversation.updated_at
723
+ ) AS effective_updated_at
724
+ FROM ranked_viewers viewer
725
+ JOIN im_conversations conversation ON conversation.id = viewer.conversation_id
726
+ WHERE viewer.rank = 1
727
+ )
728
+ SELECT * FROM visible_conversations
729
+ WHERE 1 = 1 ${cursorWhere}
730
+ ORDER BY effective_updated_at DESC, created_at DESC, id
731
+ LIMIT ?`, userId, ...(cursor ? [cursor.updatedAt, cursor.updatedAt, cursor.createdAt, cursor.updatedAt, cursor.createdAt, cursor.id] : []), limit);
732
+ if (conversations.length === 0)
733
+ return [];
734
+ const conversationIds = conversations.map((conversation) => requiredString(conversation.id));
735
+ const placeholders = conversationIds.map(() => "?").join(", ");
736
+ const viewerMemberships = this.db.all(`SELECT * FROM im_human_memberships WHERE user_id = ?
737
+ AND conversation_id IN (${placeholders})
738
+ ORDER BY conversation_id, (left_at IS NULL) DESC, joined_sequence DESC, joined_at DESC, rowid DESC`, userId, ...conversationIds);
739
+ const viewerByConversation = new Map();
740
+ for (const membership of viewerMemberships) {
741
+ const conversationId = requiredString(membership.conversation_id);
742
+ if (!viewerByConversation.has(conversationId))
743
+ viewerByConversation.set(conversationId, membership);
744
+ }
745
+ const latestByConversation = new Map(this.db.all(`SELECT message.conversation_id, MAX(message.sequence) AS sequence
746
+ FROM im_messages message
747
+ WHERE message.conversation_id IN (${placeholders})
748
+ GROUP BY message.conversation_id`, ...conversationIds).map((row) => [requiredString(row.conversation_id), Number(row.sequence)]));
749
+ const unreadByConversation = new Map(this.db.all(`SELECT membership.conversation_id, COUNT(message.id) AS count
750
+ FROM im_human_memberships membership
751
+ JOIN im_messages message ON message.conversation_id = membership.conversation_id
752
+ AND message.sequence > membership.last_read_sequence
753
+ AND message.sequence > membership.joined_sequence
754
+ WHERE membership.user_id = ? AND membership.left_at IS NULL
755
+ AND membership.conversation_id IN (${placeholders})
756
+ GROUP BY membership.conversation_id`, userId, ...conversationIds).map((row) => [requiredString(row.conversation_id), Number(row.count)]));
757
+ const mentionUnreadByConversation = new Map(this.db.all(`SELECT membership.conversation_id, COUNT(DISTINCT message.id) AS count
758
+ FROM im_human_memberships membership
759
+ JOIN im_messages message ON message.conversation_id = membership.conversation_id
760
+ AND message.sequence > membership.last_read_sequence
761
+ AND message.sequence > membership.joined_sequence
762
+ JOIN im_mentions mention ON mention.message_id = message.id
763
+ AND mention.target_kind = 'user' AND mention.target_id = membership.user_id
764
+ WHERE membership.user_id = ? AND membership.left_at IS NULL
765
+ AND membership.conversation_id IN (${placeholders})
766
+ GROUP BY membership.conversation_id`, userId, ...conversationIds).map((row) => [requiredString(row.conversation_id), Number(row.count)]));
767
+ const humanRows = this.db.all(`SELECT membership.id AS membership_id, membership.conversation_id, membership.user_id, membership.role,
768
+ membership.joined_sequence, membership.left_sequence, membership.joined_at, membership.left_at,
769
+ user.username, user.display_name, user.avatar_sha256
770
+ FROM im_human_memberships membership
771
+ JOIN users user ON user.id = membership.user_id
772
+ WHERE membership.conversation_id IN (${placeholders})
773
+ ORDER BY membership.conversation_id, membership.joined_at, membership.id`, ...conversationIds);
774
+ const characterRows = this.db.all(`SELECT membership.*,
775
+ CASE WHEN membership.status = 'active' THEN avatar.sha256
776
+ ELSE json_extract(membership.snapshot_json, '$.avatarSha256') END AS avatar_sha256
777
+ FROM im_character_memberships membership
778
+ LEFT JOIN character_avatars avatar ON avatar.character_id = membership.character_id
779
+ WHERE membership.conversation_id IN (${placeholders})
780
+ ORDER BY membership.conversation_id, membership.joined_at, membership.id`, ...conversationIds);
781
+ const humanRowsByConversation = new Map();
782
+ for (const membership of humanRows) {
783
+ const conversationId = requiredString(membership.conversation_id);
784
+ const memberships = humanRowsByConversation.get(conversationId) ?? [];
785
+ memberships.push(membership);
786
+ humanRowsByConversation.set(conversationId, memberships);
787
+ }
788
+ const characterRowsByConversation = new Map();
789
+ for (const membership of characterRows) {
790
+ const conversationId = requiredString(membership.conversation_id);
791
+ const memberships = characterRowsByConversation.get(conversationId) ?? [];
792
+ memberships.push(membership);
793
+ characterRowsByConversation.set(conversationId, memberships);
794
+ }
795
+ const visibleDuringViewerTenure = (membership, viewer) => {
796
+ if (viewer.left_sequence === null || viewer.left_sequence === undefined)
797
+ return membership.left_at === null || membership.left_at === undefined;
798
+ const leftSequence = Number(viewer.left_sequence);
799
+ const leftAt = requiredString(viewer.left_at);
800
+ const joinedSequence = Number(membership.joined_sequence);
801
+ const membershipLeftSequence = membership.left_sequence === null || membership.left_sequence === undefined
802
+ ? null
803
+ : Number(membership.left_sequence);
804
+ return (joinedSequence < leftSequence || (joinedSequence === leftSequence && requiredString(membership.joined_at) <= leftAt))
805
+ && (membershipLeftSequence === null || membershipLeftSequence > leftSequence
806
+ || (membershipLeftSequence === leftSequence && requiredString(membership.left_at) >= leftAt));
807
+ };
808
+ return conversations.map((row) => {
809
+ const conversationId = requiredString(row.id);
810
+ const viewer = viewerByConversation.get(conversationId);
811
+ if (!viewer)
812
+ throw new AppError(403, "IM_CONVERSATION_ACCESS_DENIED", "你不是这个 IM 会话的成员");
813
+ const activeMembership = viewer.left_at === null || viewer.left_at === undefined ? viewer : undefined;
814
+ const snapshot = activeMembership ? {} : json(requiredString(viewer.conversation_snapshot_json), {});
815
+ const frozen = snapshot.participants && typeof snapshot.participants === "object" && !Array.isArray(snapshot.participants)
816
+ ? snapshot.participants
817
+ : null;
818
+ let participants;
819
+ if (frozen && Array.isArray(frozen.humans) && Array.isArray(frozen.characters)) {
820
+ participants = {
821
+ humans: frozen.humans.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item))),
822
+ characters: frozen.characters.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item)))
823
+ };
824
+ }
825
+ else {
826
+ const humans = (humanRowsByConversation.get(conversationId) ?? [])
827
+ .filter((membership) => visibleDuringViewerTenure(membership, viewer)).map((membership) => ({
828
+ ...this.mapHumanMembership(membership),
829
+ ...(activeMembership ? {} : { leftAt: null })
830
+ }));
831
+ const characters = (characterRowsByConversation.get(conversationId) ?? [])
832
+ .filter((membership) => visibleDuringViewerTenure(membership, viewer)).map((membership) => {
833
+ const membershipSnapshot = json(requiredString(membership.snapshot_json), {});
834
+ const currentAvatarSha256 = optionalString(membership.avatar_sha256);
835
+ const frozenAvatarSha256 = optionalString(membershipSnapshot.avatarSha256);
836
+ return {
837
+ ...this.mapCharacterMembership(activeMembership || (currentAvatarSha256 && currentAvatarSha256 === frozenAvatarSha256)
838
+ ? membership
839
+ : { ...membership, avatar_sha256: null }),
840
+ ...(activeMembership ? {} : { leftAt: null, status: "active" })
841
+ };
842
+ });
843
+ participants = { humans, characters };
844
+ }
845
+ return this.mapConversation(row, userId, {
846
+ participants,
847
+ activeMembership,
848
+ viewerMembership: viewer,
849
+ latestSequence: latestByConversation.get(conversationId) ?? 0,
850
+ unreadCount: unreadByConversation.get(conversationId) ?? 0,
851
+ mentionUnreadCount: mentionUnreadByConversation.get(conversationId) ?? 0
852
+ });
853
+ })
854
+ .sort((left, right) => requiredString(right.updatedAt).localeCompare(requiredString(left.updatedAt))
855
+ || requiredString(right.createdAt).localeCompare(requiredString(left.createdAt)));
856
+ }
857
+ conversationUnreadTotals(userId) {
858
+ const row = this.db.get(`SELECT
859
+ (SELECT COUNT(message.id)
860
+ FROM im_human_memberships membership
861
+ JOIN im_messages message ON message.conversation_id = membership.conversation_id
862
+ AND message.sequence > membership.last_read_sequence
863
+ AND message.sequence > membership.joined_sequence
864
+ WHERE membership.user_id = ? AND membership.left_at IS NULL) AS unread_count,
865
+ (SELECT COUNT(DISTINCT message.id)
866
+ FROM im_human_memberships membership
867
+ JOIN im_messages message ON message.conversation_id = membership.conversation_id
868
+ AND message.sequence > membership.last_read_sequence
869
+ AND message.sequence > membership.joined_sequence
870
+ JOIN im_mentions mention ON mention.message_id = message.id
871
+ AND mention.target_kind = 'user' AND mention.target_id = membership.user_id
872
+ WHERE membership.user_id = ? AND membership.left_at IS NULL) AS mention_unread_count`, userId, userId);
873
+ return {
874
+ unreadCount: Number(row?.unread_count ?? 0),
875
+ mentionUnreadCount: Number(row?.mention_unread_count ?? 0)
876
+ };
877
+ }
878
+ getConversationSummary(conversationId, userId) {
879
+ return this.mapConversation(this.assertReadableConversation(conversationId, userId), userId);
880
+ }
881
+ getConversation(conversationId, userId, beforeSequence, afterSequence) {
882
+ const row = this.assertReadableConversation(conversationId, userId);
883
+ const viewerMembership = this.db.get(`SELECT joined_sequence, left_sequence, joined_at, left_at, conversation_snapshot_json FROM im_human_memberships
884
+ WHERE conversation_id = ? AND user_id = ?
885
+ ORDER BY (left_at IS NULL) DESC, joined_sequence DESC, joined_at DESC, rowid DESC LIMIT 1`, conversationId, userId);
886
+ if (!viewerMembership)
887
+ throw new AppError(403, "IM_CONVERSATION_ACCESS_DENIED", "你不是这个 IM 会话的成员");
888
+ if (requiredString(row.status) === "active" && !optionalString(viewerMembership.left_at)) {
889
+ this.refreshCharacterAvailability(conversationId, requiredString(row.owner_user_id));
890
+ }
891
+ const viewerLeftSequence = viewerMembership.left_sequence === null || viewerMembership.left_sequence === undefined
892
+ ? null
893
+ : Number(viewerMembership.left_sequence);
894
+ const activeChain = this.db.get(`SELECT chain.id, chain.status, chain.model_stage, chain.generated_count, chain.error_code, chain.error_message,
895
+ chain.created_at, chain.updated_at, trigger.sequence AS trigger_sequence
896
+ FROM im_chains chain JOIN im_messages trigger ON trigger.id = chain.trigger_message_id
897
+ WHERE chain.conversation_id = ? AND trigger.sequence > ?
898
+ AND (? IS NULL OR trigger.sequence <= ?)
899
+ AND chain.created_at >= ?
900
+ AND (? IS NULL OR chain.created_at <= ?)
901
+ ORDER BY chain.created_at DESC, chain.rowid DESC LIMIT 1`, conversationId, Number(viewerMembership.joined_sequence), viewerLeftSequence, viewerLeftSequence, requiredString(viewerMembership.joined_at), optionalString(viewerMembership.left_at), optionalString(viewerMembership.left_at));
902
+ const activeChainId = requiredString(activeChain?.id);
903
+ const replyTurnRows = activeChain ? this.db.all(`SELECT turn.*, membership.character_id, membership.snapshot_json
904
+ FROM im_chain_turns turn JOIN im_character_memberships membership ON membership.id = turn.character_membership_id
905
+ WHERE turn.chain_id = ? AND turn.kind = 'reply' AND membership.joined_sequence <= ?
906
+ AND (membership.left_sequence IS NULL OR membership.left_sequence >= ?)
907
+ ORDER BY turn.created_at, turn.id`, requiredString(activeChain.id), Number(activeChain.trigger_sequence), Number(activeChain.trigger_sequence)) : [];
908
+ const replyCharacterIds = [...new Set(replyTurnRows.flatMap((turn) => optionalString(turn.character_id) ? [requiredString(turn.character_id)] : []))];
909
+ const replyAvatarShaByCharacterId = new Map();
910
+ if (viewerLeftSequence === null && replyCharacterIds.length > 0) {
911
+ const placeholders = replyCharacterIds.map(() => "?").join(", ");
912
+ for (const avatar of this.db.all(`SELECT character_id, sha256 FROM character_avatars WHERE character_id IN (${placeholders})`, ...replyCharacterIds)) {
913
+ replyAvatarShaByCharacterId.set(requiredString(avatar.character_id), requiredString(avatar.sha256));
914
+ }
915
+ }
916
+ const conversationSnapshot = json(requiredString(viewerMembership.conversation_snapshot_json), {});
917
+ const frozenParticipants = conversationSnapshot.participants && typeof conversationSnapshot.participants === "object"
918
+ ? conversationSnapshot.participants
919
+ : {};
920
+ const replyTurns = replyTurnRows.map((turn) => {
921
+ const snapshot = json(requiredString(turn.snapshot_json), {});
922
+ const characterId = optionalString(turn.character_id) ?? requiredString(snapshot.id);
923
+ const currentAvatarSha256 = characterId ? replyAvatarShaByCharacterId.get(characterId) ?? null : null;
924
+ const frozenCharacter = Array.isArray(frozenParticipants.characters)
925
+ ? frozenParticipants.characters.find((item) => item && typeof item === "object" && !Array.isArray(item)
926
+ && optionalString(item.characterId) === characterId)
927
+ : undefined;
928
+ const avatarUrl = viewerLeftSequence === null
929
+ ? characterId && currentAvatarSha256
930
+ ? `/api/im/conversations/${encodeURIComponent(conversationId)}/characters/${encodeURIComponent(characterId)}/avatar?v=${encodeURIComponent(currentAvatarSha256)}`
931
+ : null
932
+ : optionalString(frozenCharacter?.avatarUrl);
933
+ return {
934
+ id: requiredString(turn.id),
935
+ chainId: activeChainId,
936
+ characterId,
937
+ character: {
938
+ characterId,
939
+ name: snapshot.name ?? "角色",
940
+ avatarUrl
941
+ },
942
+ kind: "reply",
943
+ status: requiredString(turn.status),
944
+ failure: optionalString(turn.failure),
945
+ createdAt: requiredString(turn.created_at),
946
+ completedAt: optionalString(turn.completed_at)
947
+ };
948
+ });
949
+ const messagePage = this.visibleMessagePage(conversationId, userId, viewerLeftSequence === null, 50, beforeSequence, afterSequence);
950
+ const visibleMessageIds = messagePage.messages.map((message) => requiredString(message.id));
951
+ const failedReplyRows = visibleMessageIds.length > 0 ? this.db.all(`SELECT turn.*, chain.id AS chain_id, chain.trigger_message_id, trigger.sequence AS trigger_sequence,
952
+ membership.character_id, membership.snapshot_json
953
+ FROM im_chain_turns turn
954
+ JOIN im_chains chain ON chain.id = turn.chain_id
955
+ JOIN im_messages trigger ON trigger.id = chain.trigger_message_id
956
+ JOIN im_character_memberships membership ON membership.id = turn.character_membership_id
957
+ WHERE chain.conversation_id = ? AND chain.trigger_message_id IN (${visibleMessageIds.map(() => "?").join(", ")})
958
+ AND turn.kind = 'reply' AND turn.status IN ('failed', 'skipped')
959
+ ORDER BY trigger.sequence, turn.created_at, turn.id`, conversationId, ...visibleMessageIds) : [];
960
+ const failedReplies = failedReplyRows.map((turn) => {
961
+ const snapshot = json(requiredString(turn.snapshot_json), {});
962
+ const characterId = optionalString(turn.character_id) ?? requiredString(snapshot.id);
963
+ return {
964
+ id: requiredString(turn.id),
965
+ chainId: requiredString(turn.chain_id),
966
+ triggerMessageId: requiredString(turn.trigger_message_id),
967
+ triggerSequence: Number(turn.trigger_sequence),
968
+ characterId,
969
+ character: {
970
+ characterId,
971
+ name: snapshot.name ?? "角色",
972
+ avatarUrl: optionalString(snapshot.avatarUrl)
973
+ },
974
+ status: requiredString(turn.status),
975
+ failure: optionalString(turn.failure),
976
+ createdAt: requiredString(turn.created_at),
977
+ completedAt: optionalString(turn.completed_at)
978
+ };
979
+ });
980
+ return {
981
+ ...this.mapConversation(row, userId),
982
+ participants: this.conversationParticipants(conversationId, userId),
983
+ messages: messagePage.messages,
984
+ failedReplies,
985
+ hasMoreMessages: messagePage.hasMore,
986
+ hasMoreMessagesAfter: messagePage.hasMoreAfter,
987
+ activeChain: activeChain ? {
988
+ id: activeChain.id,
989
+ status: activeChain.status,
990
+ model_stage: activeChain.model_stage,
991
+ generated_count: activeChain.generated_count,
992
+ error_code: activeChain.error_code,
993
+ error_message: activeChain.error_message,
994
+ created_at: activeChain.created_at,
995
+ updated_at: activeChain.updated_at,
996
+ turns: replyTurns
997
+ } : null
998
+ };
999
+ }
1000
+ refreshCharacterAvailability(conversationId, knownOwnerUserId) {
1001
+ const ownerUserId = optionalString(knownOwnerUserId) ?? optionalString(this.db.get("SELECT owner_user_id FROM im_conversations WHERE id = ?", conversationId)?.owner_user_id);
1002
+ if (!ownerUserId)
1003
+ throw notFound("IM 会话");
1004
+ const memberships = this.db.all(`SELECT membership.id, membership.character_id, membership.source_work_id, membership.snapshot_json, membership.status,
1005
+ character.id AS available_character_id, character.work_id AS available_work_id,
1006
+ work.owner_user_id, work_member.user_id AS membership_user_id,
1007
+ work_member.role AS membership_role, work_member.permissions_json AS membership_permissions_json,
1008
+ owner.status AS conversation_owner_status, owner.role AS conversation_owner_role
1009
+ FROM im_character_memberships membership
1010
+ JOIN users owner ON owner.id = ?
1011
+ LEFT JOIN characters character
1012
+ ON character.id = COALESCE(membership.character_id, json_extract(membership.snapshot_json, '$.id'))
1013
+ AND character.merged_into_character_id IS NULL
1014
+ LEFT JOIN works work ON work.id = character.work_id AND work.deleted_at IS NULL
1015
+ LEFT JOIN work_memberships work_member ON work_member.work_id = work.id AND work_member.user_id = ?
1016
+ WHERE membership.conversation_id = ? AND membership.left_at IS NULL
1017
+ ORDER BY CASE WHEN membership.character_id IS NOT NULL AND membership.status = 'active' THEN 0 ELSE 1 END,
1018
+ membership.joined_at, membership.id`, ownerUserId, ownerUserId, conversationId);
1019
+ const owner = {
1020
+ userId: ownerUserId,
1021
+ role: requiredString(memberships[0]?.conversation_owner_role) === "admin" ? "admin" : "user"
1022
+ };
1023
+ let availableCharacterCount = 0;
1024
+ let changed = false;
1025
+ for (const membership of memberships) {
1026
+ const snapshot = json(requiredString(membership.snapshot_json), {});
1027
+ const currentCharacterId = optionalString(membership.character_id);
1028
+ const characterId = currentCharacterId ?? optionalString(snapshot.id);
1029
+ let status = "suspended";
1030
+ let restoredCharacterId = null;
1031
+ const permissions = requiredString(membership.conversation_owner_status) === "active"
1032
+ ? this.catalogWorkPermissions(owner, membership)
1033
+ : null;
1034
+ if (characterId && optionalString(membership.available_character_id) === characterId
1035
+ && permissions && canReadWorkModule(permissions, "characters") && canWriteWorkModule(permissions, "ai-chat")) {
1036
+ const sourceWorkId = optionalString(membership.source_work_id) ?? optionalString(snapshot.workId);
1037
+ if (!sourceWorkId || requiredString(membership.available_work_id) === sourceWorkId) {
1038
+ const duplicate = currentCharacterId ? undefined : memberships.find((candidate) => (requiredString(candidate.id) !== requiredString(membership.id)
1039
+ && optionalString(candidate.character_id) === characterId));
1040
+ if (!duplicate && availableCharacterCount < IM_MAX_AI_PARTICIPANTS) {
1041
+ status = "active";
1042
+ restoredCharacterId = characterId;
1043
+ availableCharacterCount += 1;
1044
+ }
1045
+ }
1046
+ }
1047
+ this.db.run("UPDATE im_character_memberships SET character_id = COALESCE(character_id, ?), status = ? WHERE id = ?", restoredCharacterId, status, requiredString(membership.id));
1048
+ if ((!currentCharacterId && restoredCharacterId) || requiredString(membership.status) !== status)
1049
+ changed = true;
1050
+ }
1051
+ return changed;
1052
+ }
1053
+ getCharacterAvatarAccess(userId, conversationId, characterId) {
1054
+ return this.getCharacterAvatarVersionAccess(userId, conversationId, characterId);
1055
+ }
1056
+ getCharacterAvatarVersionAccess(userId, conversationId, characterId, sha256) {
1057
+ this.assertReadableConversation(conversationId, userId);
1058
+ const visibleCharacter = this.conversationParticipants(conversationId, userId).characters
1059
+ .find((membership) => requiredString(membership.characterId) === characterId);
1060
+ const visibleSha256 = avatarVersionFromUrl(visibleCharacter?.avatarUrl);
1061
+ const requestedSha256 = optionalString(sha256) ?? visibleSha256;
1062
+ const visibleInMessage = requestedSha256
1063
+ ? this.messageAvatarVersionVisible(userId, conversationId, "character", characterId, requestedSha256)
1064
+ : false;
1065
+ const visibleAsCurrent = requiredString(visibleCharacter?.status) === "active" && requestedSha256 === visibleSha256;
1066
+ const visibleDuringMembership = Boolean(requestedSha256) && requestedSha256 === visibleSha256 && Boolean(this.db.get(`SELECT 1 AS present FROM im_avatar_versions avatar
1067
+ JOIN im_human_memberships membership ON membership.conversation_id = avatar.conversation_id
1068
+ AND membership.user_id = ? AND membership.joined_at <= avatar.created_at
1069
+ AND (membership.left_at IS NULL OR membership.left_at >= avatar.created_at)
1070
+ WHERE avatar.conversation_id = ? AND avatar.participant_kind = 'character'
1071
+ AND avatar.participant_id = ? AND avatar.sha256 = ? LIMIT 1`, userId, conversationId, characterId, requestedSha256 ?? ""));
1072
+ if (!requestedSha256 || (!visibleAsCurrent && !visibleDuringMembership && !visibleInMessage)) {
1073
+ throw new AppError(404, "CHARACTER_AVATAR_NOT_FOUND", "该角色头像版本不在你的 IM 可见任期内");
1074
+ }
1075
+ if (requestedSha256) {
1076
+ const version = this.db.get(`SELECT mime_type, byte_length, sha256, storage_key, width, height, created_at
1077
+ FROM im_avatar_versions
1078
+ WHERE conversation_id = ? AND participant_kind = 'character' AND participant_id = ? AND sha256 = ?`, conversationId, characterId, requestedSha256);
1079
+ if (version)
1080
+ return {
1081
+ mimeType: requiredString(version.mime_type),
1082
+ byteLength: Number(version.byte_length),
1083
+ sha256: requiredString(version.sha256),
1084
+ storageKey: requiredString(version.storage_key),
1085
+ width: Number(version.width),
1086
+ height: Number(version.height),
1087
+ updatedAt: requiredString(version.created_at)
1088
+ };
1089
+ }
1090
+ const avatar = this.store.getCharacterAvatar(characterId);
1091
+ if (!avatar)
1092
+ throw new AppError(404, "CHARACTER_AVATAR_NOT_FOUND", "角色头像不存在");
1093
+ if (requestedSha256 !== avatar.sha256) {
1094
+ throw new AppError(404, "CHARACTER_AVATAR_NOT_FOUND", "IM 角色头像版本不存在");
1095
+ }
1096
+ if (!this.activeMembership(conversationId, userId)) {
1097
+ throw new AppError(404, "CHARACTER_AVATAR_NOT_FOUND", "离开群聊后的角色头像版本不存在");
1098
+ }
1099
+ return avatar;
1100
+ }
1101
+ getHumanAvatarVersionAccess(userId, conversationId, targetUserId, sha256) {
1102
+ this.assertReadableConversation(conversationId, userId);
1103
+ const visibleHuman = this.conversationParticipants(conversationId, userId).humans
1104
+ .find((membership) => requiredString(membership.userId) === targetUserId);
1105
+ if (avatarVersionFromUrl(visibleHuman?.avatarUrl) !== sha256
1106
+ && !this.messageAvatarVersionVisible(userId, conversationId, "human", targetUserId, sha256)) {
1107
+ throw new AppError(404, "USER_AVATAR_NOT_FOUND", "该成员头像版本不在你的 IM 可见任期内");
1108
+ }
1109
+ const version = this.db.get(`SELECT mime_type, content, byte_length, sha256, width, height, created_at
1110
+ FROM im_avatar_versions
1111
+ WHERE conversation_id = ? AND participant_kind = 'user' AND participant_id = ? AND sha256 = ?`, conversationId, targetUserId, sha256);
1112
+ if (!version)
1113
+ throw new AppError(404, "USER_AVATAR_NOT_FOUND", "IM 成员头像版本不存在");
1114
+ return {
1115
+ mimeType: requiredString(version.mime_type),
1116
+ content: Buffer.from(version.content),
1117
+ byteLength: Number(version.byte_length),
1118
+ sha256: requiredString(version.sha256),
1119
+ width: Number(version.width),
1120
+ height: Number(version.height),
1121
+ updatedAt: requiredString(version.created_at)
1122
+ };
1123
+ }
1124
+ messageAvatarVersionVisible(userId, conversationId, senderKind, senderId, sha256) {
1125
+ const visibleMessage = (senderCondition, senderParams) => Boolean(this.db.get(`SELECT 1 AS present FROM im_messages message
1126
+ WHERE message.conversation_id = ? AND message.sender_kind = ? AND ${senderCondition}
1127
+ AND COALESCE(
1128
+ NULLIF(json_extract(message.sender_snapshot_json, '$.avatarSha256'), ''),
1129
+ CASE WHEN instr(json_extract(message.sender_snapshot_json, '$.avatarUrl'), '?v=') > 0
1130
+ THEN substr(json_extract(message.sender_snapshot_json, '$.avatarUrl'), instr(json_extract(message.sender_snapshot_json, '$.avatarUrl'), '?v=') + 3)
1131
+ END
1132
+ ) = ?
1133
+ AND EXISTS (
1134
+ SELECT 1 FROM im_human_memberships membership
1135
+ WHERE membership.conversation_id = message.conversation_id AND membership.user_id = ?
1136
+ AND message.sequence > membership.joined_sequence
1137
+ AND (membership.left_sequence IS NULL OR message.sequence <= membership.left_sequence)
1138
+ ) LIMIT 1`, conversationId, senderKind, ...senderParams, sha256, userId));
1139
+ if (senderKind === "human")
1140
+ return visibleMessage("message.sender_user_id = ?", [senderId]);
1141
+ return visibleMessage("message.sender_character_id = ?", [senderId])
1142
+ || visibleMessage("message.sender_character_id IS NULL AND json_extract(message.sender_snapshot_json, '$.id') = ?", [senderId]);
1143
+ }
1144
+ updateGroup(owner, conversationId, input) {
1145
+ const conversation = this.assertOwner(conversationId, owner.userId);
1146
+ if (requiredString(conversation.kind) !== "group")
1147
+ throw new AppError(400, "IM_GROUP_REQUIRED", "单聊不能修改群设置");
1148
+ const next = {
1149
+ title: input.title ?? requiredString(conversation.title),
1150
+ replyMode: input.replyMode ?? requiredString(conversation.reply_mode),
1151
+ responseThreshold: input.responseThreshold ?? Number(conversation.response_threshold),
1152
+ maxAiMessages: input.maxAiMessages ?? Number(conversation.max_ai_messages)
1153
+ };
1154
+ const changed = next.title !== requiredString(conversation.title)
1155
+ || next.replyMode !== requiredString(conversation.reply_mode)
1156
+ || next.responseThreshold !== Number(conversation.response_threshold)
1157
+ || next.maxAiMessages !== Number(conversation.max_ai_messages);
1158
+ if (!changed)
1159
+ return { conversation: this.getConversation(conversationId, owner.userId), changed: false };
1160
+ const timestamp = now();
1161
+ this.db.transaction(() => {
1162
+ this.cancelActiveChain(conversationId, "group_settings_changed");
1163
+ this.db.run(`UPDATE im_conversations SET title = ?, reply_mode = ?, response_threshold = ?,
1164
+ max_ai_messages = ?, updated_at = ? WHERE id = ?`, next.title, next.replyMode, next.responseThreshold, next.maxAiMessages, timestamp, conversationId);
1165
+ this.store.audit(null, "im.group-updated", "im-conversation", conversationId, input);
1166
+ });
1167
+ return { conversation: this.getConversation(conversationId, owner.userId), changed: true };
1168
+ }
1169
+ addHuman(owner, conversationId, userId) {
1170
+ const conversation = this.assertOwner(conversationId, owner.userId);
1171
+ if (requiredString(conversation.kind) !== "group")
1172
+ throw new AppError(400, "IM_GROUP_REQUIRED", "单聊不能添加成员");
1173
+ if (this.activeMembership(conversationId, userId))
1174
+ throw new AppError(409, "IM_MEMBER_EXISTS", "该用户已经在群聊中");
1175
+ const activeCount = Number(this.db.get("SELECT COUNT(*) AS count FROM im_human_memberships WHERE conversation_id = ? AND left_at IS NULL", conversationId)?.count ?? 0);
1176
+ if (activeCount >= IM_MAX_HUMAN_PARTICIPANTS)
1177
+ throw new AppError(409, "IM_HUMAN_LIMIT_REACHED", "群聊人类成员已达到上限");
1178
+ const user = this.assertActiveUser(userId);
1179
+ this.db.transaction(() => {
1180
+ const timestamp = now();
1181
+ const joinedSequence = this.nextSequence(conversationId) - 1;
1182
+ this.cancelActiveChain(conversationId, "human_member_joined");
1183
+ this.insertHumanMembership(conversationId, user, "member", joinedSequence);
1184
+ this.advanceContextEpoch(conversationId, timestamp);
1185
+ this.insertSystemMessage(conversationId, `${user.displayName} 加入了群聊`, { type: "human-joined", user: this.humanSnapshot(user) });
1186
+ this.store.audit(null, "im.human-added", "im-conversation", conversationId, { userId });
1187
+ });
1188
+ return this.getConversation(conversationId, owner.userId);
1189
+ }
1190
+ captureConversationAvatarVersions(conversationId, timestamp) {
1191
+ this.db.run(`INSERT OR IGNORE INTO im_avatar_versions (
1192
+ conversation_id, participant_kind, participant_id, sha256, mime_type, byte_length,
1193
+ storage_key, content, width, height, created_at
1194
+ )
1195
+ SELECT membership.conversation_id, 'character', membership.character_id, avatar.sha256,
1196
+ avatar.mime_type, avatar.byte_length, avatar.storage_key, NULL, avatar.width, avatar.height, ?
1197
+ FROM im_character_memberships membership
1198
+ JOIN character_avatars avatar ON avatar.character_id = membership.character_id
1199
+ WHERE membership.conversation_id = ? AND membership.left_at IS NULL AND membership.character_id IS NOT NULL`, timestamp, conversationId);
1200
+ this.db.run(`INSERT OR IGNORE INTO im_avatar_versions (
1201
+ conversation_id, participant_kind, participant_id, sha256, mime_type, byte_length,
1202
+ storage_key, content, width, height, created_at
1203
+ )
1204
+ SELECT membership.conversation_id, 'user', membership.user_id, avatar.sha256,
1205
+ avatar.mime_type, avatar.byte_length, NULL, avatar.content, avatar.width, avatar.height, ?
1206
+ FROM im_human_memberships membership
1207
+ JOIN user_avatars avatar ON avatar.user_id = membership.user_id
1208
+ WHERE membership.conversation_id = ? AND membership.left_at IS NULL`, timestamp, conversationId);
1209
+ }
1210
+ captureCharacterAvatarVersion(conversationId, characterId, timestamp) {
1211
+ this.db.run(`INSERT OR IGNORE INTO im_avatar_versions (
1212
+ conversation_id, participant_kind, participant_id, sha256, mime_type, byte_length,
1213
+ storage_key, content, width, height, created_at
1214
+ )
1215
+ SELECT ?, 'character', avatar.character_id, avatar.sha256, avatar.mime_type, avatar.byte_length,
1216
+ avatar.storage_key, NULL, avatar.width, avatar.height, ?
1217
+ FROM character_avatars avatar WHERE avatar.character_id = ?`, conversationId, timestamp, characterId);
1218
+ }
1219
+ captureHumanAvatarVersion(conversationId, userId, timestamp) {
1220
+ this.db.run(`INSERT OR IGNORE INTO im_avatar_versions (
1221
+ conversation_id, participant_kind, participant_id, sha256, mime_type, byte_length,
1222
+ storage_key, content, width, height, created_at
1223
+ )
1224
+ SELECT ?, 'user', avatar.user_id, avatar.sha256, avatar.mime_type, avatar.byte_length,
1225
+ NULL, avatar.content, avatar.width, avatar.height, ?
1226
+ FROM user_avatars avatar WHERE avatar.user_id = ?`, conversationId, timestamp, userId);
1227
+ }
1228
+ frozenParticipantSnapshot(conversationId, userId) {
1229
+ const participants = this.conversationParticipants(conversationId, userId);
1230
+ return {
1231
+ humans: participants.humans.map((human) => {
1232
+ const sha256 = avatarVersionFromUrl(human.avatarUrl);
1233
+ return {
1234
+ ...human,
1235
+ avatarUrl: sha256
1236
+ ? `/api/im/conversations/${encodeURIComponent(conversationId)}/users/${encodeURIComponent(requiredString(human.userId))}/avatar?v=${encodeURIComponent(sha256)}`
1237
+ : null
1238
+ };
1239
+ }),
1240
+ characters: participants.characters
1241
+ };
1242
+ }
1243
+ finishMembership(conversationId, userId, actorUserId, action) {
1244
+ const membership = this.activeMembership(conversationId, userId);
1245
+ if (!membership)
1246
+ throw notFound("IM 群成员");
1247
+ const conversation = this.db.get("SELECT * FROM im_conversations WHERE id = ?", conversationId);
1248
+ if (!conversation)
1249
+ throw notFound("IM 会话");
1250
+ const sequence = this.nextSequence(conversationId) - 1;
1251
+ const timestamp = now();
1252
+ this.captureConversationAvatarVersions(conversationId, timestamp);
1253
+ const conversationSnapshot = {
1254
+ ownerUserId: requiredString(conversation.owner_user_id),
1255
+ title: requiredString(conversation.title),
1256
+ replyMode: requiredString(conversation.reply_mode),
1257
+ responseThreshold: Number(conversation.response_threshold),
1258
+ maxAiMessages: Number(conversation.max_ai_messages),
1259
+ contextEpoch: Number(conversation.context_epoch),
1260
+ status: requiredString(conversation.status),
1261
+ participants: this.frozenParticipantSnapshot(conversationId, userId),
1262
+ updatedAt: timestamp
1263
+ };
1264
+ this.db.run(`UPDATE im_human_memberships SET left_sequence = ?, left_at = ?, conversation_snapshot_json = ?
1265
+ WHERE id = ?`, sequence, timestamp, JSON.stringify(conversationSnapshot), requiredString(membership.id));
1266
+ this.cancelActiveChain(conversationId, `human_member_${action}`);
1267
+ this.advanceContextEpoch(conversationId, timestamp);
1268
+ this.store.audit(null, action === "left" ? "im.human-left" : "im.human-removed", "im-conversation", conversationId, {
1269
+ userId,
1270
+ actorUserId
1271
+ });
1272
+ }
1273
+ leaveGroup(user, conversationId) {
1274
+ const conversation = this.assertActiveMembership(conversationId, user.userId);
1275
+ if (requiredString(conversation.kind) !== "group")
1276
+ throw new AppError(400, "IM_GROUP_REQUIRED", "单聊不能退群");
1277
+ if (requiredString(conversation.owner_user_id) === user.userId)
1278
+ throw new AppError(409, "IM_OWNER_TRANSFER_REQUIRED", "群主需要先转让或解散群聊");
1279
+ this.db.transaction(() => this.finishMembership(conversationId, user.userId, user.userId, "left"));
1280
+ }
1281
+ removeHuman(owner, conversationId, userId) {
1282
+ const conversation = this.assertOwner(conversationId, owner.userId);
1283
+ if (requiredString(conversation.kind) !== "group")
1284
+ throw new AppError(400, "IM_GROUP_REQUIRED", "单聊不能移除成员");
1285
+ if (userId === owner.userId)
1286
+ throw new AppError(409, "IM_OWNER_TRANSFER_REQUIRED", "群主不能移除自己");
1287
+ this.db.transaction(() => this.finishMembership(conversationId, userId, owner.userId, "removed"));
1288
+ }
1289
+ addCharacter(owner, conversationId, characterId) {
1290
+ const conversation = this.assertOwner(conversationId, owner.userId);
1291
+ if (requiredString(conversation.kind) !== "group")
1292
+ throw new AppError(400, "IM_GROUP_REQUIRED", "单聊不能添加角色");
1293
+ this.refreshCharacterAvailability(conversationId);
1294
+ const activeCount = Number(this.db.get(`SELECT COUNT(*) AS count FROM im_character_memberships
1295
+ WHERE conversation_id = ? AND left_at IS NULL AND status = 'active' AND character_id IS NOT NULL`, conversationId)?.count ?? 0);
1296
+ if (activeCount >= IM_MAX_AI_PARTICIPANTS)
1297
+ throw new AppError(409, "IM_CHARACTER_LIMIT_REACHED", "群聊 AI 角色已达到上限");
1298
+ if (this.db.get(`SELECT 1 AS present FROM im_character_memberships
1299
+ WHERE conversation_id = ? AND left_at IS NULL
1300
+ AND (character_id = ? OR (character_id IS NULL AND json_extract(snapshot_json, '$.id') = ?))`, conversationId, characterId, characterId))
1301
+ throw new AppError(409, "IM_CHARACTER_EXISTS", "该角色已经在群聊中");
1302
+ const character = this.assertCharacterAvailable(owner, characterId);
1303
+ this.db.transaction(() => {
1304
+ const timestamp = now();
1305
+ this.cancelActiveChain(conversationId, "character_member_joined");
1306
+ this.insertCharacterMembership(conversationId, character, this.nextSequence(conversationId) - 1);
1307
+ this.advanceContextEpoch(conversationId, timestamp);
1308
+ this.insertSystemMessage(conversationId, `${requiredString(character.name)} 加入了群聊`, {
1309
+ type: "character-joined",
1310
+ character: this.characterSnapshot(character)
1311
+ });
1312
+ this.store.audit(requiredString(character.workId), "im.character-added", "im-conversation", conversationId, { characterId });
1313
+ });
1314
+ return this.getConversation(conversationId, owner.userId);
1315
+ }
1316
+ removeCharacter(owner, conversationId, characterId) {
1317
+ const conversation = this.assertOwner(conversationId, owner.userId);
1318
+ if (requiredString(conversation.kind) !== "group")
1319
+ throw new AppError(400, "IM_GROUP_REQUIRED", "单聊不能移除角色");
1320
+ const rows = this.db.all("SELECT * FROM im_character_memberships WHERE conversation_id = ? AND left_at IS NULL", conversationId);
1321
+ const membership = rows.find((row) => {
1322
+ if (optionalString(row.character_id) === characterId)
1323
+ return true;
1324
+ const snapshot = json(requiredString(row.snapshot_json), {});
1325
+ return !optionalString(row.character_id) && optionalString(snapshot.id) === characterId;
1326
+ });
1327
+ if (!membership)
1328
+ throw notFound("IM 群角色");
1329
+ const removesActiveCharacter = optionalString(membership.character_id) !== null && requiredString(membership.status) === "active";
1330
+ const activeCount = rows.filter((row) => optionalString(row.character_id) !== null && requiredString(row.status) === "active").length;
1331
+ if (removesActiveCharacter && activeCount <= 1) {
1332
+ throw new AppError(409, "IM_CHARACTER_REQUIRED", "群聊必须至少保留一个 AI 角色");
1333
+ }
1334
+ const timestamp = now();
1335
+ this.db.transaction(() => {
1336
+ this.cancelActiveChain(conversationId, "character_member_removed");
1337
+ this.captureConversationAvatarVersions(conversationId, timestamp);
1338
+ this.db.run(`UPDATE im_character_memberships SET status = 'removed', left_sequence = ?, left_at = ? WHERE id = ?`, this.nextSequence(conversationId) - 1, timestamp, requiredString(membership.id));
1339
+ this.advanceContextEpoch(conversationId, timestamp);
1340
+ this.store.audit(optionalString(membership.source_work_id), "im.character-removed", "im-conversation", conversationId, { characterId });
1341
+ });
1342
+ }
1343
+ transferGroup(owner, conversationId, nextOwnerUserId) {
1344
+ const conversation = this.assertOwner(conversationId, owner.userId);
1345
+ if (requiredString(conversation.kind) !== "group")
1346
+ throw new AppError(400, "IM_GROUP_REQUIRED", "单聊不能转让");
1347
+ if (!this.activeMembership(conversationId, nextOwnerUserId))
1348
+ throw new AppError(400, "IM_OWNER_NOT_MEMBER", "新群主必须是当前群成员");
1349
+ const nextOwner = this.assertActiveUser(nextOwnerUserId);
1350
+ const characterIds = this.db.all(`SELECT character_id FROM im_character_memberships
1351
+ WHERE conversation_id = ? AND left_at IS NULL AND status = 'active' AND character_id IS NOT NULL`, conversationId).map((row) => requiredString(row.character_id));
1352
+ for (const characterId of characterIds)
1353
+ this.assertCharacterAvailable(nextOwner, characterId);
1354
+ this.db.transaction(() => {
1355
+ const timestamp = now();
1356
+ this.cancelActiveChain(conversationId, "group_owner_transferred");
1357
+ this.db.run("UPDATE im_human_memberships SET role = 'member' WHERE conversation_id = ? AND user_id = ? AND left_at IS NULL", conversationId, owner.userId);
1358
+ this.db.run("UPDATE im_human_memberships SET role = 'owner' WHERE conversation_id = ? AND user_id = ? AND left_at IS NULL", conversationId, nextOwnerUserId);
1359
+ this.db.run("UPDATE im_conversations SET owner_user_id = ?, context_epoch = context_epoch + 1, updated_at = ? WHERE id = ?", nextOwnerUserId, timestamp, conversationId);
1360
+ this.store.audit(null, "im.group-transferred", "im-conversation", conversationId, { previousOwnerUserId: owner.userId, nextOwnerUserId });
1361
+ });
1362
+ return this.getConversation(conversationId, owner.userId);
1363
+ }
1364
+ disbandGroup(owner, conversationId) {
1365
+ const conversation = this.assertOwner(conversationId, owner.userId);
1366
+ if (requiredString(conversation.kind) !== "group")
1367
+ throw new AppError(400, "IM_GROUP_REQUIRED", "单聊不能解散");
1368
+ const timestamp = now();
1369
+ const memberUserIds = this.db.all("SELECT user_id FROM im_human_memberships WHERE conversation_id = ? AND left_at IS NULL", conversationId).map((membership) => requiredString(membership.user_id));
1370
+ this.db.transaction(() => {
1371
+ this.cancelActiveChain(conversationId, "group_disbanded");
1372
+ this.captureConversationAvatarVersions(conversationId, timestamp);
1373
+ const participants = this.frozenParticipantSnapshot(conversationId, owner.userId);
1374
+ const sequence = this.nextSequence(conversationId) - 1;
1375
+ const conversationSnapshot = JSON.stringify({
1376
+ ownerUserId: requiredString(conversation.owner_user_id),
1377
+ title: requiredString(conversation.title),
1378
+ replyMode: requiredString(conversation.reply_mode),
1379
+ responseThreshold: Number(conversation.response_threshold),
1380
+ maxAiMessages: Number(conversation.max_ai_messages),
1381
+ contextEpoch: Number(conversation.context_epoch),
1382
+ status: "disbanded",
1383
+ participants,
1384
+ updatedAt: timestamp
1385
+ });
1386
+ this.db.run(`UPDATE im_human_memberships
1387
+ SET left_sequence = ?, left_at = ?, conversation_snapshot_json = ?
1388
+ WHERE conversation_id = ? AND left_at IS NULL`, sequence, timestamp, conversationSnapshot, conversationId);
1389
+ this.db.run(`UPDATE im_conversations SET status = 'disbanded', context_epoch = context_epoch + 1,
1390
+ disbanded_at = ?, updated_at = ? WHERE id = ?`, timestamp, timestamp, conversationId);
1391
+ this.store.audit(null, "im.group-disbanded", "im-conversation", conversationId);
1392
+ });
1393
+ return memberUserIds;
1394
+ }
1395
+ insertSystemMessage(conversationId, content, metadata) {
1396
+ const timestamp = now();
1397
+ const contextEpoch = Number(this.db.get("SELECT context_epoch FROM im_conversations WHERE id = ?", conversationId)?.context_epoch ?? 1);
1398
+ this.db.run(`INSERT INTO im_messages (
1399
+ id, conversation_id, sequence, context_epoch, sender_kind, content, metadata_json, created_at
1400
+ ) VALUES (?, ?, ?, ?, 'system', ?, ?, ?)`, id("imMessage"), conversationId, this.nextSequence(conversationId), contextEpoch, content, JSON.stringify(metadata), timestamp);
1401
+ this.db.run("UPDATE im_conversations SET updated_at = ? WHERE id = ?", timestamp, conversationId);
1402
+ }
1403
+ validatedMentions(conversationId, content) {
1404
+ const mentions = parseImMentions(content);
1405
+ if (mentions.length > IM_MAX_MENTIONS_PER_MESSAGE) {
1406
+ throw new AppError(400, "IM_MENTION_LIMIT_EXCEEDED", `单条 IM 消息最多允许 ${IM_MAX_MENTIONS_PER_MESSAGE} 个 mention`);
1407
+ }
1408
+ const userIds = [...new Set(mentions.filter((mention) => mention.kind === "user").map((mention) => mention.id))];
1409
+ const characterIds = [...new Set(mentions.filter((mention) => mention.kind === "character").map((mention) => mention.id))];
1410
+ const users = new Map();
1411
+ if (userIds.length > 0) {
1412
+ const placeholders = userIds.map(() => "?").join(", ");
1413
+ for (const row of this.db.all(`SELECT user.id, user.username, user.display_name, user.avatar_sha256
1414
+ FROM im_human_memberships membership JOIN users user ON user.id = membership.user_id
1415
+ WHERE membership.conversation_id = ? AND membership.user_id IN (${placeholders}) AND membership.left_at IS NULL`, conversationId, ...userIds))
1416
+ users.set(requiredString(row.id), row);
1417
+ }
1418
+ const characters = new Map();
1419
+ if (characterIds.length > 0) {
1420
+ const placeholders = characterIds.map(() => "?").join(", ");
1421
+ for (const row of this.db.all(`SELECT * FROM im_character_memberships
1422
+ WHERE conversation_id = ? AND character_id IN (${placeholders}) AND left_at IS NULL AND status = 'active'`, conversationId, ...characterIds))
1423
+ characters.set(requiredString(row.character_id), row);
1424
+ }
1425
+ const result = [];
1426
+ for (const mention of mentions) {
1427
+ if (mention.kind === "user") {
1428
+ const row = users.get(mention.id);
1429
+ if (row)
1430
+ result.push({ ...mention, snapshot: {
1431
+ userId: requiredString(row.id),
1432
+ username: requiredString(row.username),
1433
+ displayName: requiredString(row.display_name),
1434
+ avatarUrl: row.avatar_sha256
1435
+ ? `/api/user-avatars/${encodeURIComponent(requiredString(row.id))}?v=${encodeURIComponent(requiredString(row.avatar_sha256))}`
1436
+ : null,
1437
+ avatarSha256: optionalString(row.avatar_sha256)
1438
+ } });
1439
+ continue;
1440
+ }
1441
+ const row = characters.get(mention.id);
1442
+ if (row)
1443
+ result.push({
1444
+ ...mention,
1445
+ membershipId: requiredString(row.id),
1446
+ snapshot: json(requiredString(row.snapshot_json), {})
1447
+ });
1448
+ }
1449
+ if (result.length !== mentions.length) {
1450
+ throw new AppError(400, "IM_MENTION_TARGET_INVALID", "消息包含已经离开或不可用的 mention 目标,请重新选择群成员");
1451
+ }
1452
+ return result;
1453
+ }
1454
+ cancelActiveChain(conversationId, reason) {
1455
+ const timestamp = now();
1456
+ this.db.run(`UPDATE im_chains SET status = 'cancelled', error_code = 'IM_CHAIN_CANCELLED', error_message = ?,
1457
+ updated_at = ?, completed_at = ?
1458
+ WHERE conversation_id = ? AND status IN ('queued', 'running', 'waiting_config')`, reason, timestamp, timestamp, conversationId);
1459
+ this.db.run(`UPDATE im_chain_turns SET status = 'cancelled', failure = COALESCE(failure, ?), completed_at = ?
1460
+ WHERE chain_id IN (SELECT id FROM im_chains WHERE conversation_id = ? AND status = 'cancelled')
1461
+ AND status IN ('pending', 'running')`, reason, timestamp, conversationId);
1462
+ }
1463
+ sendMessage(user, conversationId, input, afterCreate) {
1464
+ const conversation = this.assertActiveMembership(conversationId, user.userId);
1465
+ this.refreshCharacterAvailability(conversationId);
1466
+ const existing = this.db.get("SELECT * FROM im_messages WHERE conversation_id = ? AND request_id = ?", conversationId, input.requestId);
1467
+ if (existing) {
1468
+ const membership = this.activeMembership(conversationId, user.userId);
1469
+ const sameRequest = requiredString(existing.sender_kind) === "human"
1470
+ && requiredString(existing.sender_user_id) === user.userId
1471
+ && requiredString(existing.content) === input.content
1472
+ && membership
1473
+ && Number(existing.sequence) > Number(membership.joined_sequence);
1474
+ if (!sameRequest)
1475
+ throw new AppError(409, "IM_REQUEST_ID_CONFLICT", "请求标识已被其他 IM 消息、其他成员或不同内容使用");
1476
+ const existingChainId = optionalString(existing.chain_id);
1477
+ const chain = existingChainId ? this.db.get("SELECT * FROM im_chains WHERE id = ?", existingChainId) ?? null : null;
1478
+ return { message: this.mapMessage(existing), chain, duplicate: true };
1479
+ }
1480
+ const activeCharacters = this.db.all(`SELECT * FROM im_character_memberships
1481
+ WHERE conversation_id = ? AND left_at IS NULL AND status = 'active' AND character_id IS NOT NULL`, conversationId);
1482
+ if (activeCharacters.length === 0) {
1483
+ throw new AppError(409, "IM_CHARACTER_UNAVAILABLE", "当前 IM 会话没有可用的 AI 角色,无法发送消息");
1484
+ }
1485
+ const mentions = this.validatedMentions(conversationId, input.content);
1486
+ const settings = this.getSettings(user.userId);
1487
+ const mode = requiredString(conversation.kind) === "direct"
1488
+ ? "direct"
1489
+ : requiredString(conversation.reply_mode) === "proactive" ? "proactive" : "mention";
1490
+ const deliveryIds = mode === "direct" || mode === "proactive"
1491
+ ? activeCharacters.map((row) => requiredString(row.id))
1492
+ : mentions.flatMap((mention) => mention.kind === "character" && mention.membershipId ? [mention.membershipId] : []);
1493
+ const shouldCreateChain = activeCharacters.length > 0
1494
+ && (mode === "direct" || mode === "proactive" || deliveryIds.length > 0);
1495
+ const messageId = id("imMessage");
1496
+ const chainId = shouldCreateChain ? id("imChain") : null;
1497
+ const timestamp = now();
1498
+ const result = this.db.transaction(() => {
1499
+ this.cancelActiveChain(conversationId, "human_message_received");
1500
+ this.captureHumanAvatarVersion(conversationId, user.userId, timestamp);
1501
+ const sequence = this.nextSequence(conversationId);
1502
+ this.db.run(`INSERT INTO im_messages (
1503
+ id, conversation_id, sequence, context_epoch, sender_kind, sender_user_id, sender_snapshot_json,
1504
+ content, chain_id, request_id, created_at
1505
+ ) VALUES (?, ?, ?, ?, 'human', ?, ?, ?, ?, ?, ?)`, messageId, conversationId, sequence, Number(conversation.context_epoch), user.userId, JSON.stringify(this.humanSnapshot(user)), input.content, chainId, input.requestId, timestamp);
1506
+ this.db.run(`UPDATE im_human_memberships SET last_read_sequence = MAX(last_read_sequence, ?)
1507
+ WHERE conversation_id = ? AND user_id = ? AND left_at IS NULL`, sequence, conversationId, user.userId);
1508
+ mentions.forEach((mention, position) => this.db.run(`INSERT INTO im_mentions (message_id, position, target_kind, target_id, target_snapshot_json)
1509
+ VALUES (?, ?, ?, ?, ?)`, messageId, position, mention.kind, mention.id, JSON.stringify(mention.snapshot)));
1510
+ for (const membershipId of new Set(deliveryIds)) {
1511
+ this.db.run("INSERT INTO im_message_deliveries (message_id, character_membership_id, delivered_at) VALUES (?, ?, ?)", messageId, membershipId, timestamp);
1512
+ }
1513
+ let chain = null;
1514
+ if (shouldCreateChain) {
1515
+ const configured = Boolean(settings.primaryModelId && settings.fallbackModelId);
1516
+ this.db.run(`INSERT INTO im_chains (
1517
+ id, conversation_id, initiator_user_id, authorization_user_id, trigger_message_id,
1518
+ mode, threshold, max_ai_messages, retry_count, primary_model_id, fallback_model_id,
1519
+ status, created_at, updated_at
1520
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, chainId, conversationId, user.userId, requiredString(conversation.owner_user_id), messageId, mode, Number(conversation.response_threshold), Number(conversation.max_ai_messages), Number(settings.retryCount), optionalString(settings.primaryModelId), optionalString(settings.fallbackModelId), configured ? "queued" : "waiting_config", timestamp, timestamp);
1521
+ chain = this.db.get("SELECT * FROM im_chains WHERE id = ?", chainId) ?? null;
1522
+ }
1523
+ this.db.run("UPDATE im_conversations SET updated_at = ? WHERE id = ?", timestamp, conversationId);
1524
+ this.store.audit(null, "im.message-sent", "im-message", messageId, { conversationId, sequence, chainId });
1525
+ const message = this.db.get("SELECT * FROM im_messages WHERE id = ?", messageId);
1526
+ if (!message)
1527
+ throw notFound("IM 消息");
1528
+ return { message: this.mapMessage(message), chain, duplicate: false };
1529
+ });
1530
+ afterCreate?.(chainId);
1531
+ return result;
1532
+ }
1533
+ publishAnnouncement(owner, conversationId, input) {
1534
+ const conversation = this.assertOwner(conversationId, owner.userId);
1535
+ if (requiredString(conversation.kind) !== "group")
1536
+ throw new AppError(400, "IM_GROUP_REQUIRED", "单聊不能发布旁白公告");
1537
+ this.refreshCharacterAvailability(conversationId);
1538
+ const existing = this.db.get("SELECT * FROM im_messages WHERE conversation_id = ? AND request_id = ?", conversationId, input.requestId);
1539
+ if (existing) {
1540
+ const metadata = json(requiredString(existing.metadata_json), {});
1541
+ const publishedBy = metadata.publishedBy && typeof metadata.publishedBy === "object" && !Array.isArray(metadata.publishedBy)
1542
+ ? metadata.publishedBy
1543
+ : {};
1544
+ if (metadata.type !== "announcement"
1545
+ || requiredString(existing.sender_kind) !== "system"
1546
+ || requiredString(existing.content) !== input.content
1547
+ || requiredString(publishedBy.userId) !== owner.userId) {
1548
+ throw new AppError(409, "IM_REQUEST_ID_CONFLICT", "请求标识已被其他 IM 消息使用");
1549
+ }
1550
+ return { message: this.mapMessage(existing), chain: null, duplicate: true };
1551
+ }
1552
+ const messageId = id("imMessage");
1553
+ const timestamp = now();
1554
+ return this.db.transaction(() => {
1555
+ const sequence = this.nextSequence(conversationId);
1556
+ this.db.run(`INSERT INTO im_messages (
1557
+ id, conversation_id, sequence, context_epoch, sender_kind, sender_snapshot_json,
1558
+ content, request_id, metadata_json, created_at
1559
+ ) VALUES (?, ?, ?, ?, 'system', ?, ?, ?, ?, ?)`, messageId, conversationId, sequence, Number(conversation.context_epoch), JSON.stringify({ name: "旁白" }), input.content, input.requestId, JSON.stringify({ type: "announcement", publishedBy: this.humanSnapshot(owner) }), timestamp);
1560
+ this.db.run(`UPDATE im_human_memberships SET last_read_sequence = MAX(last_read_sequence, ?)
1561
+ WHERE conversation_id = ? AND user_id = ? AND left_at IS NULL`, sequence, conversationId, owner.userId);
1562
+ const activeCharacters = this.db.all(`SELECT id FROM im_character_memberships
1563
+ WHERE conversation_id = ? AND left_at IS NULL AND status = 'active' AND character_id IS NOT NULL`, conversationId);
1564
+ for (const membership of activeCharacters) {
1565
+ this.db.run("INSERT INTO im_message_deliveries (message_id, character_membership_id, delivered_at) VALUES (?, ?, ?)", messageId, requiredString(membership.id), timestamp);
1566
+ }
1567
+ this.db.run("UPDATE im_conversations SET updated_at = ? WHERE id = ?", timestamp, conversationId);
1568
+ this.store.audit(null, "im.announcement-published", "im-message", messageId, {
1569
+ conversationId,
1570
+ sequence,
1571
+ deliveredCharacterCount: activeCharacters.length
1572
+ });
1573
+ const message = this.db.get("SELECT * FROM im_messages WHERE id = ?", messageId);
1574
+ if (!message)
1575
+ throw notFound("IM 公告");
1576
+ return { message: this.mapMessage(message), chain: null, duplicate: false };
1577
+ });
1578
+ }
1579
+ markRead(userId, conversationId, sequence) {
1580
+ const conversation = this.assertActiveMembership(conversationId, userId);
1581
+ const latest = this.nextSequence(conversationId) - 1;
1582
+ const safeSequence = Math.min(latest, Math.max(0, sequence));
1583
+ const result = this.db.run(`UPDATE im_human_memberships SET last_read_sequence = MAX(last_read_sequence, ?)
1584
+ WHERE conversation_id = ? AND user_id = ? AND left_at IS NULL AND last_read_sequence < ?`, safeSequence, conversationId, userId, safeSequence);
1585
+ return { summary: this.mapConversation(conversation, userId), changed: result.changes > 0 };
1586
+ }
1587
+ stopChain(userId, conversationId) {
1588
+ this.assertActiveMembership(conversationId, userId);
1589
+ this.db.transaction(() => this.cancelActiveChain(conversationId, "stopped_by_user"));
1590
+ }
1591
+ publicChainSummary(chain) {
1592
+ return {
1593
+ id: requiredString(chain.id),
1594
+ status: requiredString(chain.status),
1595
+ modelStage: requiredString(chain.model_stage),
1596
+ generatedCount: Number(chain.generated_count),
1597
+ errorCode: optionalString(chain.error_code),
1598
+ errorMessage: optionalString(chain.error_message),
1599
+ createdAt: requiredString(chain.created_at),
1600
+ updatedAt: requiredString(chain.updated_at)
1601
+ };
1602
+ }
1603
+ retryChain(user, conversationId, sourceChainId, afterCreate) {
1604
+ const conversation = this.assertActiveMembership(conversationId, user.userId);
1605
+ const source = this.db.get("SELECT * FROM im_chains WHERE id = ? AND conversation_id = ?", sourceChainId, conversationId);
1606
+ if (!source)
1607
+ throw notFound("IM 交流链");
1608
+ const triggerMessageId = requiredString(source.trigger_message_id);
1609
+ const trigger = this.db.get(`SELECT message.id, message.context_epoch FROM im_messages message
1610
+ WHERE message.id = ? AND message.conversation_id = ?
1611
+ AND EXISTS (
1612
+ SELECT 1 FROM im_human_memberships membership
1613
+ WHERE membership.conversation_id = message.conversation_id AND membership.user_id = ?
1614
+ AND membership.left_at IS NULL AND message.sequence > membership.joined_sequence
1615
+ )`, triggerMessageId, conversationId, user.userId);
1616
+ if (!trigger)
1617
+ throw new AppError(403, "IM_MESSAGE_ACCESS_DENIED", "不能重试加入群聊前的消息");
1618
+ if (Number(trigger.context_epoch) !== Number(conversation.context_epoch)) {
1619
+ throw new AppError(409, "IM_CHAIN_CONTEXT_CHANGED", "群成员或上下文已经变化,不能重试旧上下文中的交流链");
1620
+ }
1621
+ const existingRetry = this.db.get(`SELECT * FROM im_chains
1622
+ WHERE conversation_id = ? AND retry_source_chain_id = ?`, conversationId, sourceChainId);
1623
+ if (existingRetry)
1624
+ return this.publicChainSummary(existingRetry);
1625
+ if (!["failed", "interrupted", "waiting_config"].includes(requiredString(source.status))) {
1626
+ throw new AppError(409, "IM_CHAIN_NOT_RETRYABLE", "只有失败、中断或等待模型配置的 IM 交流链可以重试");
1627
+ }
1628
+ const settings = this.getSettings(user.userId);
1629
+ const chainId = id("imChain");
1630
+ const timestamp = now();
1631
+ const mode = requiredString(conversation.kind) === "direct"
1632
+ ? "direct"
1633
+ : requiredString(conversation.reply_mode) === "proactive" ? "proactive" : "mention";
1634
+ const result = this.db.transaction(() => {
1635
+ this.cancelActiveChain(conversationId, "manual_retry");
1636
+ const configured = Boolean(settings.primaryModelId && settings.fallbackModelId);
1637
+ this.db.run(`INSERT INTO im_chains (
1638
+ id, conversation_id, initiator_user_id, authorization_user_id, trigger_message_id, retry_source_chain_id,
1639
+ mode, threshold, max_ai_messages, retry_count, primary_model_id, fallback_model_id,
1640
+ status, created_at, updated_at
1641
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, chainId, conversationId, user.userId, requiredString(conversation.owner_user_id), triggerMessageId, sourceChainId, mode, Number(conversation.response_threshold), Number(conversation.max_ai_messages), Number(settings.retryCount), optionalString(settings.primaryModelId), optionalString(settings.fallbackModelId), configured ? "queued" : "waiting_config", timestamp, timestamp);
1642
+ this.db.run("UPDATE im_messages SET chain_id = ? WHERE id = ?", chainId, triggerMessageId);
1643
+ this.store.audit(null, "im.chain-retried", "im-chain", chainId, { sourceChainId, conversationId, triggerMessageId });
1644
+ return this.db.get("SELECT * FROM im_chains WHERE id = ?", chainId) ?? {};
1645
+ });
1646
+ afterCreate?.(chainId);
1647
+ return this.publicChainSummary(result);
1648
+ }
1649
+ getDiagnostics(owner, conversationId) {
1650
+ this.assertOwner(conversationId, owner.userId);
1651
+ const chain = this.db.get("SELECT * FROM im_chains WHERE conversation_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1", conversationId);
1652
+ if (!chain)
1653
+ return { chain: null, turns: [] };
1654
+ const turns = this.db.all(`SELECT turn.*, membership.character_id, membership.snapshot_json
1655
+ FROM im_chain_turns turn JOIN im_character_memberships membership ON membership.id = turn.character_membership_id
1656
+ WHERE turn.chain_id = ? ORDER BY turn.created_at, turn.id`, requiredString(chain.id)).map((turn) => {
1657
+ const snapshot = json(requiredString(turn.snapshot_json), {});
1658
+ return {
1659
+ id: requiredString(turn.id),
1660
+ characterId: optionalString(turn.character_id) ?? snapshot.id ?? null,
1661
+ characterName: snapshot.name ?? "已删除角色",
1662
+ kind: requiredString(turn.kind),
1663
+ score: turn.score === null || turn.score === undefined ? null : Number(turn.score),
1664
+ selected: booleanValue(turn.selected),
1665
+ status: requiredString(turn.status),
1666
+ modelId: optionalString(turn.model_id),
1667
+ modelStage: optionalString(turn.model_stage),
1668
+ attemptCount: Number(turn.attempt_count),
1669
+ durationMs: turn.duration_ms === null || turn.duration_ms === undefined ? null : Number(turn.duration_ms),
1670
+ failure: optionalString(turn.failure),
1671
+ createdAt: requiredString(turn.created_at),
1672
+ completedAt: optionalString(turn.completed_at)
1673
+ };
1674
+ });
1675
+ return {
1676
+ chain: {
1677
+ id: requiredString(chain.id),
1678
+ status: requiredString(chain.status),
1679
+ modelStage: requiredString(chain.model_stage),
1680
+ threshold: Number(chain.threshold),
1681
+ generatedCount: Number(chain.generated_count),
1682
+ maxAiMessages: Number(chain.max_ai_messages),
1683
+ createdAt: requiredString(chain.created_at),
1684
+ completedAt: optionalString(chain.completed_at)
1685
+ },
1686
+ turns
1687
+ };
1688
+ }
1689
+ }
1690
+ //# sourceMappingURL=im.js.map