@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.
@@ -0,0 +1,1317 @@
1
+ import { AppError, notFound } from "./errors.js";
2
+ import { estimateAiTokens } from "./ai.js";
3
+ import { runWithRequestActor } from "./request-context.js";
4
+ import { id, json, now } from "./utils.js";
5
+ import { IM_MAX_MENTIONS_PER_MESSAGE, parseImMentions } from "./im.js";
6
+ import { canReadWorkModule } from "./work-permissions.js";
7
+ const IM_USER_CHAIN_CONCURRENCY = 3;
8
+ const IM_MESSAGE_MAX_CHARACTERS = 20_000;
9
+ const IM_EVENT_CONNECTION_LIMIT_PER_USER = 5;
10
+ const IM_RECIPIENT_CACHE_LIMIT = 1_000;
11
+ const IM_CONTEXT_FIXED_RESERVE_TOKENS = 8_192;
12
+ function requiredString(value) {
13
+ return typeof value === "string" ? value : String(value ?? "");
14
+ }
15
+ function optionalString(value) {
16
+ return typeof value === "string" && value.length > 0 ? value : null;
17
+ }
18
+ function publicError(error) {
19
+ if (error instanceof AppError)
20
+ return { code: error.code, message: error.message };
21
+ return { code: "IM_AI_CHAIN_FAILED", message: "IM AI 交流链失败" };
22
+ }
23
+ function effectiveAbortError(signal, error) {
24
+ return signal.aborted && signal.reason instanceof Error ? signal.reason : error;
25
+ }
26
+ function scoreFromContent(content) {
27
+ const candidate = content.match(/\{[\s\S]*\}/u)?.[0] ?? "";
28
+ try {
29
+ const parsed = JSON.parse(candidate);
30
+ const score = Number(parsed.score);
31
+ return Number.isInteger(score) && score >= 0 && score <= 100 ? score : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ export class ImOrchestrator {
38
+ store;
39
+ auth;
40
+ im;
41
+ ai;
42
+ listeners = new Map();
43
+ queuedChainIds = [];
44
+ queuedChainSet = new Set();
45
+ controllers = new Map();
46
+ activeByUser = new Map();
47
+ streamingReplies = new Map();
48
+ recipientCache = new Map();
49
+ activeRunPromises = new Set();
50
+ disposed = false;
51
+ constructor(store, auth, im, ai) {
52
+ this.store = store;
53
+ this.auth = auth;
54
+ this.im = im;
55
+ this.ai = ai;
56
+ }
57
+ get db() {
58
+ return this.store.db;
59
+ }
60
+ subscribe(userId, listener, disconnect = () => undefined, expiresAt) {
61
+ let listeners = this.listeners.get(userId);
62
+ if (!listeners) {
63
+ listeners = new Set();
64
+ this.listeners.set(userId, listeners);
65
+ }
66
+ if (listeners.size >= IM_EVENT_CONNECTION_LIMIT_PER_USER) {
67
+ throw new AppError(429, "IM_EVENT_CONNECTION_LIMIT", "IM 实时连接过多,请关闭其他页面后重试");
68
+ }
69
+ const subscription = { listener, disconnect };
70
+ listeners.add(subscription);
71
+ const scheduleExpiration = () => {
72
+ if (!expiresAt || !listeners?.has(subscription))
73
+ return;
74
+ const remainingMs = Date.parse(expiresAt) - Date.now();
75
+ if (remainingMs <= 0) {
76
+ listeners.delete(subscription);
77
+ if (listeners.size === 0)
78
+ this.listeners.delete(userId);
79
+ disconnect();
80
+ return;
81
+ }
82
+ subscription.expirationTimer = setTimeout(scheduleExpiration, Math.min(remainingMs, 2_147_483_647));
83
+ subscription.expirationTimer.unref();
84
+ };
85
+ scheduleExpiration();
86
+ for (const [turnId, event] of this.streamingReplies) {
87
+ const chainId = optionalString(event.payload.chainId);
88
+ const active = chainId ? this.db.get(`SELECT chain.status AS chain_status, turn.status AS turn_status
89
+ FROM im_chains chain JOIN im_chain_turns turn ON turn.chain_id = chain.id
90
+ WHERE chain.id = ? AND turn.id = ?`, chainId, turnId) : null;
91
+ if (requiredString(active?.chain_status) !== "running" || requiredString(active?.turn_status) !== "running") {
92
+ this.streamingReplies.delete(turnId);
93
+ continue;
94
+ }
95
+ const membership = this.db.get(`SELECT 1 AS present FROM im_human_memberships
96
+ WHERE conversation_id = ? AND user_id = ? AND left_at IS NULL`, event.conversationId, userId);
97
+ if (membership) {
98
+ try {
99
+ listener({ ...event, id: id("imEvent"), createdAt: now() });
100
+ }
101
+ catch (error) {
102
+ listeners.delete(subscription);
103
+ if (listeners.size === 0)
104
+ this.listeners.delete(userId);
105
+ throw error;
106
+ }
107
+ }
108
+ }
109
+ return () => {
110
+ if (subscription.expirationTimer)
111
+ clearTimeout(subscription.expirationTimer);
112
+ listeners?.delete(subscription);
113
+ if (listeners?.size === 0)
114
+ this.listeners.delete(userId);
115
+ };
116
+ }
117
+ disconnectUser(userId) {
118
+ const subscriptions = this.listeners.get(userId);
119
+ if (!subscriptions)
120
+ return;
121
+ this.listeners.delete(userId);
122
+ for (const subscription of subscriptions) {
123
+ if (subscription.expirationTimer)
124
+ clearTimeout(subscription.expirationTimer);
125
+ try {
126
+ subscription.disconnect();
127
+ }
128
+ catch {
129
+ // 连接关闭失败不应阻塞其他订阅释放。
130
+ }
131
+ }
132
+ }
133
+ streamingReplySnapshots(conversationId) {
134
+ return [...this.streamingReplies.values()]
135
+ .filter((event) => event.conversationId === conversationId)
136
+ .map((event) => structuredClone(event.payload));
137
+ }
138
+ publish(conversationId, type, payload) {
139
+ const event = { id: id("imEvent"), type, conversationId, payload, createdAt: now() };
140
+ let userIds = this.recipientCache.get(conversationId);
141
+ if (!userIds) {
142
+ userIds = this.db.all(`SELECT DISTINCT user_id FROM im_human_memberships
143
+ WHERE conversation_id = ? AND left_at IS NULL`, conversationId).map((row) => requiredString(row.user_id));
144
+ if (this.recipientCache.size >= IM_RECIPIENT_CACHE_LIMIT) {
145
+ const oldestConversationId = this.recipientCache.keys().next().value;
146
+ if (oldestConversationId)
147
+ this.recipientCache.delete(oldestConversationId);
148
+ }
149
+ this.recipientCache.set(conversationId, userIds);
150
+ }
151
+ else {
152
+ this.recipientCache.delete(conversationId);
153
+ this.recipientCache.set(conversationId, userIds);
154
+ }
155
+ for (const userId of userIds) {
156
+ this.publishToUser(userId, event);
157
+ }
158
+ }
159
+ publishToUser(userId, event) {
160
+ const listeners = this.listeners.get(userId);
161
+ if (!listeners)
162
+ return;
163
+ for (const subscription of [...listeners]) {
164
+ try {
165
+ subscription.listener(event);
166
+ }
167
+ catch {
168
+ listeners.delete(subscription);
169
+ }
170
+ }
171
+ if (listeners.size === 0)
172
+ this.listeners.delete(userId);
173
+ }
174
+ publishConversation(conversationId) {
175
+ this.recipientCache.delete(conversationId);
176
+ this.publish(conversationId, "conversation", {});
177
+ }
178
+ publishConversationToUser(userId, conversationId) {
179
+ this.publishToUser(userId, {
180
+ id: id("imEvent"),
181
+ type: "conversation",
182
+ conversationId,
183
+ payload: { membershipChanged: true },
184
+ createdAt: now()
185
+ });
186
+ }
187
+ forgetConversation(conversationId) {
188
+ this.recipientCache.delete(conversationId);
189
+ }
190
+ publishMessageResult(result) {
191
+ const message = result.message && typeof result.message === "object" && !Array.isArray(result.message)
192
+ ? result.message
193
+ : null;
194
+ if (!message)
195
+ return;
196
+ const conversationId = requiredString(message.conversationId);
197
+ const chain = result.chain && typeof result.chain === "object" && !Array.isArray(result.chain)
198
+ ? result.chain
199
+ : null;
200
+ this.publish(conversationId, "message", {
201
+ message,
202
+ chain: chain ? { id: requiredString(chain.id), status: requiredString(chain.status) } : null,
203
+ duplicate: result.duplicate === true
204
+ });
205
+ if (chain && requiredString(chain.status) === "queued")
206
+ this.enqueue(requiredString(chain.id));
207
+ }
208
+ enqueue(chainId) {
209
+ if (this.disposed || this.queuedChainSet.has(chainId) || this.controllers.has(chainId))
210
+ return;
211
+ const chain = this.db.get("SELECT status FROM im_chains WHERE id = ?", chainId);
212
+ if (!chain || requiredString(chain.status) !== "queued")
213
+ return;
214
+ this.queuedChainSet.add(chainId);
215
+ this.queuedChainIds.push(chainId);
216
+ this.drain();
217
+ }
218
+ cancelConversation(conversationId, reason = "human_message_received") {
219
+ this.recipientCache.delete(conversationId);
220
+ this.clearStreamingReplies(conversationId);
221
+ for (const [chainId, controller] of this.controllers) {
222
+ const chain = this.db.get("SELECT conversation_id FROM im_chains WHERE id = ?", chainId);
223
+ if (requiredString(chain?.conversation_id) === conversationId) {
224
+ controller.abort(new AppError(499, "IM_CHAIN_CANCELLED", reason));
225
+ }
226
+ }
227
+ const active = this.db.all(`SELECT id FROM im_chains WHERE conversation_id = ? AND status IN ('queued', 'running', 'waiting_config')`, conversationId);
228
+ for (const row of active) {
229
+ const chainId = requiredString(row.id);
230
+ this.controllers.get(chainId)?.abort(new AppError(499, "IM_CHAIN_CANCELLED", reason));
231
+ this.queuedChainSet.delete(chainId);
232
+ }
233
+ const timestamp = now();
234
+ this.db.run(`UPDATE im_chains SET status = 'cancelled', error_code = 'IM_CHAIN_CANCELLED', error_message = ?,
235
+ updated_at = ?, completed_at = ? WHERE conversation_id = ? AND status IN ('queued', 'running', 'waiting_config')`, reason, timestamp, timestamp, conversationId);
236
+ this.publish(conversationId, "chain", { status: "cancelled", reason });
237
+ }
238
+ abortConversationRuns(conversationId, reason, preservedChainId) {
239
+ this.recipientCache.delete(conversationId);
240
+ this.clearStreamingReplies(conversationId, preservedChainId);
241
+ for (const [chainId, controller] of this.controllers) {
242
+ if (chainId === preservedChainId)
243
+ continue;
244
+ const chain = this.db.get("SELECT conversation_id FROM im_chains WHERE id = ?", chainId);
245
+ if (requiredString(chain?.conversation_id) === conversationId) {
246
+ controller.abort(new AppError(499, "IM_CHAIN_CANCELLED", reason));
247
+ }
248
+ }
249
+ for (let index = this.queuedChainIds.length - 1; index >= 0; index -= 1) {
250
+ const chainId = this.queuedChainIds[index];
251
+ if (!chainId || chainId === preservedChainId)
252
+ continue;
253
+ const chain = this.db.get("SELECT conversation_id FROM im_chains WHERE id = ?", chainId);
254
+ if (requiredString(chain?.conversation_id) !== conversationId)
255
+ continue;
256
+ this.queuedChainIds.splice(index, 1);
257
+ this.queuedChainSet.delete(chainId);
258
+ }
259
+ }
260
+ clearStreamingReplies(conversationId, preservedChainId = null) {
261
+ for (const [turnId, event] of this.streamingReplies) {
262
+ if (event.conversationId !== conversationId || optionalString(event.payload.chainId) === preservedChainId)
263
+ continue;
264
+ this.streamingReplies.delete(turnId);
265
+ }
266
+ }
267
+ drain() {
268
+ if (this.disposed)
269
+ return;
270
+ for (let index = 0; index < this.queuedChainIds.length;) {
271
+ const chainId = this.queuedChainIds[index];
272
+ if (!chainId)
273
+ break;
274
+ const chain = this.db.get("SELECT initiator_user_id, status FROM im_chains WHERE id = ?", chainId);
275
+ if (!chain || requiredString(chain.status) !== "queued") {
276
+ this.queuedChainIds.splice(index, 1);
277
+ this.queuedChainSet.delete(chainId);
278
+ continue;
279
+ }
280
+ const userId = requiredString(chain.initiator_user_id);
281
+ if ((this.activeByUser.get(userId) ?? 0) >= IM_USER_CHAIN_CONCURRENCY) {
282
+ index += 1;
283
+ continue;
284
+ }
285
+ this.queuedChainIds.splice(index, 1);
286
+ this.queuedChainSet.delete(chainId);
287
+ this.activeByUser.set(userId, (this.activeByUser.get(userId) ?? 0) + 1);
288
+ const controller = new AbortController();
289
+ this.controllers.set(chainId, controller);
290
+ const run = this.runChain(chainId, controller.signal);
291
+ this.activeRunPromises.add(run);
292
+ void run.finally(() => {
293
+ this.activeRunPromises.delete(run);
294
+ this.controllers.delete(chainId);
295
+ const remaining = Math.max(0, (this.activeByUser.get(userId) ?? 1) - 1);
296
+ if (remaining === 0)
297
+ this.activeByUser.delete(userId);
298
+ else
299
+ this.activeByUser.set(userId, remaining);
300
+ this.drain();
301
+ });
302
+ }
303
+ }
304
+ chainRow(chainId) {
305
+ const row = this.db.get("SELECT * FROM im_chains WHERE id = ?", chainId);
306
+ if (!row)
307
+ throw notFound("IM 交流链");
308
+ return row;
309
+ }
310
+ conversationRow(conversationId) {
311
+ const row = this.db.get("SELECT * FROM im_conversations WHERE id = ?", conversationId);
312
+ if (!row)
313
+ throw notFound("IM 会话");
314
+ return row;
315
+ }
316
+ characterMembership(membershipId) {
317
+ const row = this.db.get("SELECT * FROM im_character_memberships WHERE id = ?", membershipId);
318
+ if (!row)
319
+ throw notFound("IM 角色成员");
320
+ return row;
321
+ }
322
+ activeCharacters(conversationId) {
323
+ return this.db.all(`SELECT * FROM im_character_memberships
324
+ WHERE conversation_id = ? AND left_at IS NULL AND status = 'active' AND character_id IS NOT NULL
325
+ ORDER BY joined_at, id`, conversationId);
326
+ }
327
+ messageRow(messageId) {
328
+ const row = this.db.get("SELECT * FROM im_messages WHERE id = ?", messageId);
329
+ if (!row)
330
+ throw notFound("IM 消息");
331
+ return row;
332
+ }
333
+ mentionedCharacterMembershipIds(messageId, conversationId, senderCharacterId) {
334
+ const membershipIds = this.db.all(`SELECT membership.id FROM im_mentions mention
335
+ JOIN im_character_memberships membership
336
+ ON membership.character_id = mention.target_id AND membership.conversation_id = ?
337
+ WHERE mention.message_id = ? AND mention.target_kind = 'character'
338
+ AND membership.left_at IS NULL AND membership.status = 'active'
339
+ AND (? IS NULL OR membership.character_id <> ?)
340
+ ORDER BY mention.position`, conversationId, messageId, senderCharacterId ?? null, senderCharacterId ?? null).map((row) => requiredString(row.id));
341
+ return [...new Set(membershipIds)];
342
+ }
343
+ participantContext(conversationId, currentSender) {
344
+ const humans = this.db.all(`SELECT user.id, user.username, user.display_name, user.avatar_sha256,
345
+ settings.preferred_name, settings.pronouns, settings.identity_summary, settings.additional_notes
346
+ FROM im_human_memberships membership JOIN users user ON user.id = membership.user_id
347
+ LEFT JOIN im_user_settings settings ON settings.user_id = user.id
348
+ WHERE membership.conversation_id = ? AND membership.left_at IS NULL
349
+ ORDER BY membership.joined_at, membership.id`, conversationId).map((row) => ({
350
+ mentionUri: `mention://user/${requiredString(row.id)}`,
351
+ userId: requiredString(row.id),
352
+ username: requiredString(row.username),
353
+ displayName: requiredString(row.display_name),
354
+ identity: {
355
+ preferredName: optionalString(row.preferred_name) ?? requiredString(row.display_name),
356
+ pronouns: requiredString(row.pronouns),
357
+ identitySummary: requiredString(row.identity_summary),
358
+ additionalNotes: requiredString(row.additional_notes)
359
+ }
360
+ }));
361
+ const characters = this.activeCharacters(conversationId).map((row) => {
362
+ const snapshot = json(requiredString(row.snapshot_json), {});
363
+ return {
364
+ mentionUri: `mention://character/${requiredString(row.character_id)}`,
365
+ characterId: requiredString(row.character_id),
366
+ name: snapshot.name ?? "未知角色",
367
+ workTitle: snapshot.workTitle ?? "未知作品",
368
+ publicSummary: snapshot.publicSummary ?? ""
369
+ };
370
+ });
371
+ return JSON.stringify({ humans, characters, currentSender });
372
+ }
373
+ characterHistory(membership, conversation, throughSequence, maximumHistoryTokens) {
374
+ const membershipId = requiredString(membership.id);
375
+ const contextEpoch = Number(conversation.context_epoch);
376
+ const context = this.db.get(`SELECT summary, summarized_through_sequence FROM im_character_contexts
377
+ WHERE character_membership_id = ? AND context_epoch = ?`, membershipId, contextEpoch);
378
+ const summarizedThroughSequence = Number(context?.summarized_through_sequence ?? 0);
379
+ const rows = this.db.all(`SELECT message.* FROM im_message_deliveries delivery
380
+ JOIN im_messages message ON message.id = delivery.message_id
381
+ WHERE delivery.character_membership_id = ? AND message.context_epoch = ?
382
+ AND message.sequence > ? AND message.sequence <= ?
383
+ ORDER BY message.sequence DESC`, membershipId, contextEpoch, summarizedThroughSequence, throughSequence);
384
+ const lines = [];
385
+ let historyTokens = 0;
386
+ for (const row of rows) {
387
+ const line = this.historyLine(row);
388
+ const additionTokens = estimateAiTokens(`${lines.length ? "\n\n" : ""}${line}`);
389
+ if (historyTokens + additionTokens > maximumHistoryTokens)
390
+ break;
391
+ lines.unshift(line);
392
+ historyTokens += additionTokens;
393
+ }
394
+ return { history: lines.join("\n\n"), summary: requiredString(context?.summary) };
395
+ }
396
+ minimumContextWindow(chain) {
397
+ const modelIds = [optionalString(chain.primary_model_id), optionalString(chain.fallback_model_id)]
398
+ .filter((modelId) => Boolean(modelId));
399
+ const contextWindows = modelIds.map((modelId) => Number(this.db.get("SELECT context_window FROM models WHERE id = ?", modelId)?.context_window ?? 128_000));
400
+ return contextWindows.length ? Math.min(...contextWindows) : 128_000;
401
+ }
402
+ maximumHistoryTokens(chain, participantContext) {
403
+ const participantTokens = estimateAiTokens(participantContext);
404
+ const availableHistoryTokens = this.minimumContextWindow(chain) - IM_CONTEXT_FIXED_RESERVE_TOKENS - participantTokens;
405
+ if (availableHistoryTokens < 1) {
406
+ throw new AppError(409, "IM_PARTICIPANT_CONTEXT_TOO_LARGE", "当前群成员身份信息过长,无法在所选模型上下文内完整暴露;请减少成员或缩短身份信息");
407
+ }
408
+ return availableHistoryTokens;
409
+ }
410
+ historyLine(row) {
411
+ const sender = json(requiredString(row.sender_snapshot_json), {});
412
+ const label = sender.name ?? sender.displayName ?? (requiredString(row.sender_kind) === "system" ? "系统" : "成员");
413
+ return `[${Number(row.sequence)}] ${String(label)}:${requiredString(row.content)}`;
414
+ }
415
+ assertGenerationStillCurrent(chain, sourceMessage, signal) {
416
+ if (signal.aborted) {
417
+ throw signal.reason instanceof Error ? signal.reason : new AppError(499, "IM_CHAIN_CANCELLED", "IM 交流链已取消");
418
+ }
419
+ const currentChain = this.chainRow(requiredString(chain.id));
420
+ const currentConversation = this.conversationRow(requiredString(chain.conversation_id));
421
+ if (requiredString(currentChain.status) !== "running"
422
+ || requiredString(currentConversation.status) !== "active"
423
+ || Number(currentConversation.context_epoch) !== Number(sourceMessage.context_epoch)) {
424
+ throw new AppError(499, "IM_CHAIN_CANCELLED", "IM 交流链已取消或上下文已经变化");
425
+ }
426
+ }
427
+ async maybeCompact(chain, membership, sourceMessage, signal) {
428
+ const conversation = this.conversationRow(requiredString(chain.conversation_id));
429
+ const contextEpoch = Number(conversation.context_epoch);
430
+ const senderSnapshot = json(requiredString(sourceMessage.sender_snapshot_json), {});
431
+ const participantContext = this.participantContext(requiredString(conversation.id), senderSnapshot);
432
+ const historyLimit = this.maximumHistoryTokens(chain, participantContext);
433
+ for (;;) {
434
+ const context = this.db.get(`SELECT summary, summarized_through_sequence FROM im_character_contexts
435
+ WHERE character_membership_id = ? AND context_epoch = ?`, requiredString(membership.id), contextEpoch);
436
+ const summarizedThroughSequence = Number(context?.summarized_through_sequence ?? 0);
437
+ const rows = this.db.all(`SELECT message.* FROM im_message_deliveries delivery
438
+ JOIN im_messages message ON message.id = delivery.message_id
439
+ WHERE delivery.character_membership_id = ? AND message.context_epoch = ?
440
+ AND message.sequence > ? AND message.sequence <= ?
441
+ ORDER BY message.sequence`, requiredString(membership.id), contextEpoch, summarizedThroughSequence, Number(sourceMessage.sequence));
442
+ const totalTokens = rows.reduce((total, row) => total + estimateAiTokens(`${total ? "\n\n" : ""}${this.historyLine(row)}`), 0);
443
+ if (rows.length <= 60 && totalTokens <= historyLimit)
444
+ return;
445
+ let retainedTokens = 0;
446
+ let compactCandidateCount = rows.length;
447
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
448
+ const lineTokens = estimateAiTokens(`${retainedTokens ? "\n\n" : ""}${this.historyLine(rows[index] ?? {})}`);
449
+ if (retainedTokens + lineTokens > historyLimit)
450
+ break;
451
+ retainedTokens += lineTokens;
452
+ compactCandidateCount = index;
453
+ }
454
+ if (compactCandidateCount <= 0 && rows.length > 60)
455
+ compactCandidateCount = Math.max(1, rows.length - 20);
456
+ const compactCandidates = rows.slice(0, Math.max(1, compactCandidateCount));
457
+ const compactLines = [];
458
+ let compactTokens = 0;
459
+ let compactThrough = summarizedThroughSequence;
460
+ for (const row of compactCandidates) {
461
+ const line = this.historyLine(row);
462
+ const additionTokens = estimateAiTokens(`${compactLines.length ? "\n\n" : ""}${line}`);
463
+ if (compactTokens + additionTokens > historyLimit)
464
+ break;
465
+ compactLines.push(line);
466
+ compactTokens += additionTokens;
467
+ compactThrough = Number(row.sequence);
468
+ }
469
+ if (compactThrough <= summarizedThroughSequence) {
470
+ throw new AppError(409, "IM_MESSAGE_CONTEXT_TOO_LARGE", "单条 IM 消息超过所选模型可安全处理的上下文预算");
471
+ }
472
+ const turnId = this.createTurn(requiredString(chain.id), requiredString(membership.id), "compact");
473
+ try {
474
+ const result = await this.invoke(chain, membership, "compact", `把已送达历史压缩为当前角色可继续使用的第一人称 IM 记忆;压缩到消息序号 ${compactThrough},只保留事实、关系变化、承诺、未决事项和重要称呼。`, sourceMessage, signal, undefined, (content) => {
475
+ if (!content.trim())
476
+ throw new AppError(502, "IM_AI_EMPTY_COMPACTION", "AI 返回了空白的角色上下文摘要");
477
+ }, undefined, { history: compactLines.join("\n\n"), summary: requiredString(context?.summary) });
478
+ this.assertGenerationStillCurrent(chain, sourceMessage, signal);
479
+ this.assertCharacterAuthorization(chain, this.characterMembership(requiredString(membership.id)));
480
+ this.db.transaction(() => {
481
+ this.db.run(`INSERT INTO im_character_contexts (
482
+ character_membership_id, context_epoch, summary, summarized_through_sequence, updated_at
483
+ ) VALUES (?, ?, ?, ?, ?)
484
+ ON CONFLICT(character_membership_id, context_epoch) DO UPDATE SET
485
+ summary = excluded.summary,
486
+ summarized_through_sequence = excluded.summarized_through_sequence,
487
+ updated_at = excluded.updated_at`, requiredString(membership.id), contextEpoch, result.content.slice(0, 20_000), compactThrough, now());
488
+ this.finishTurn(turnId, result);
489
+ });
490
+ }
491
+ catch (error) {
492
+ const effectiveError = effectiveAbortError(signal, error);
493
+ this.failTurn(turnId, effectiveError, signal.aborted ? "cancelled" : "failed");
494
+ if (signal.aborted)
495
+ throw effectiveError;
496
+ if (effectiveError instanceof AppError && [
497
+ "IM_CHARACTER_ACCESS_DENIED",
498
+ "IM_CHARACTER_UNAVAILABLE",
499
+ "IM_OWNER_DISABLED",
500
+ "IM_INITIATOR_DISABLED"
501
+ ].includes(effectiveError.code))
502
+ throw effectiveError;
503
+ if (totalTokens > historyLimit) {
504
+ throw new AppError(502, "IM_CONTEXT_COMPACTION_FAILED", "角色上下文压缩失败,无法在不丢失历史的情况下继续回答");
505
+ }
506
+ return;
507
+ }
508
+ }
509
+ }
510
+ assertCharacterAuthorization(chain, membership) {
511
+ if (optionalString(membership.left_at) || requiredString(membership.status) !== "active") {
512
+ throw new AppError(409, "IM_CHARACTER_UNAVAILABLE", "IM 角色已经离开或暂停使用");
513
+ }
514
+ const owner = this.auth.getUser(requiredString(chain.authorization_user_id));
515
+ if (owner.status !== "active")
516
+ throw new AppError(403, "IM_OWNER_DISABLED", "群主账户已停用,角色能力暂停");
517
+ const initiator = this.auth.getUser(requiredString(chain.initiator_user_id));
518
+ if (initiator.status !== "active")
519
+ throw new AppError(403, "IM_INITIATOR_DISABLED", "发起人账户已停用,角色能力暂停");
520
+ const characterId = optionalString(membership.character_id);
521
+ const workId = optionalString(membership.source_work_id);
522
+ if (!characterId || !workId)
523
+ throw new AppError(409, "IM_CHARACTER_UNAVAILABLE", "来源角色或作品已不可用");
524
+ try {
525
+ this.im.assertCharacterAvailable(owner, characterId);
526
+ }
527
+ catch (error) {
528
+ this.db.run("UPDATE im_character_memberships SET status = 'suspended' WHERE id = ?", requiredString(membership.id));
529
+ this.publishConversation(requiredString(chain.conversation_id));
530
+ throw error;
531
+ }
532
+ return {
533
+ owner,
534
+ initiator,
535
+ initiatorPermissions: this.auth.workModulePermissions(initiator, workId, true),
536
+ workId,
537
+ characterId
538
+ };
539
+ }
540
+ publicCharacterPrompt(membership) {
541
+ const snapshot = json(requiredString(membership.snapshot_json), {});
542
+ return [
543
+ "以下 JSON 是群主邀请角色时冻结的公开角色资料。只能依据这些公开字段和当前 IM 历史扮演角色;不得推测、查询或声称知道来源作品中的其他私有内容。",
544
+ JSON.stringify({
545
+ name: snapshot.name ?? "角色",
546
+ code: snapshot.code ?? "",
547
+ workTitle: snapshot.workTitle ?? "",
548
+ publicSummary: snapshot.publicSummary ?? ""
549
+ })
550
+ ].join("\n");
551
+ }
552
+ shouldFailover(error) {
553
+ if (!(error instanceof AppError))
554
+ return true;
555
+ return ![
556
+ "IM_CHAIN_CANCELLED",
557
+ "AI_STREAM_REQUEST_CANCELLED",
558
+ "IM_CHARACTER_ACCESS_DENIED",
559
+ "IM_CHARACTER_UNAVAILABLE",
560
+ "IM_OWNER_DISABLED",
561
+ "IM_INITIATOR_DISABLED",
562
+ "IM_PARTICIPANT_CONTEXT_TOO_LARGE",
563
+ "IM_MESSAGE_CONTEXT_TOO_LARGE",
564
+ "DAILY_TOKEN_QUOTA_EXCEEDED",
565
+ "MONTHLY_TOKEN_QUOTA_EXCEEDED",
566
+ "WORK_ACCESS_DENIED",
567
+ "WORK_MODULE_READ_DENIED",
568
+ "WORK_MODULE_WRITE_DENIED"
569
+ ].includes(error.code);
570
+ }
571
+ resetStreamingReply(turnId) {
572
+ const snapshot = this.streamingReplies.get(turnId);
573
+ if (snapshot)
574
+ snapshot.payload.content = "";
575
+ }
576
+ async invoke(chain, membership, kind, instruction, sourceMessage, signal, onDelta, validateContent, streamTurnId, historyOverride) {
577
+ const conversation = this.conversationRow(requiredString(chain.conversation_id));
578
+ const authorization = this.assertCharacterAuthorization(chain, membership);
579
+ const snapshot = json(requiredString(sourceMessage.sender_snapshot_json), {});
580
+ const participantContext = this.participantContext(requiredString(conversation.id), snapshot);
581
+ const maximumHistoryTokens = this.maximumHistoryTokens(chain, participantContext);
582
+ const context = this.characterHistory(membership, conversation, Number(sourceMessage.sequence), maximumHistoryTokens);
583
+ const fullCharacterPrompt = kind !== "compact" && Boolean(authorization.initiatorPermissions && canReadWorkModule(authorization.initiatorPermissions, "characters"));
584
+ const allowRoleplayMemory = kind !== "compact" && Boolean(authorization.initiatorPermissions
585
+ && canReadWorkModule(authorization.initiatorPermissions, "characters")
586
+ && canReadWorkModule(authorization.initiatorPermissions, "ai-chat"));
587
+ const requiredInitiatorModules = new Set([
588
+ ...(fullCharacterPrompt ? ["characters"] : []),
589
+ ...(allowRoleplayMemory ? ["ai-chat"] : [])
590
+ ]);
591
+ const requiredInitiatorAnyModules = [];
592
+ const common = {
593
+ workId: authorization.workId,
594
+ characterId: authorization.characterId,
595
+ kind,
596
+ instruction,
597
+ participantContext,
598
+ history: historyOverride?.history ?? context.history,
599
+ summary: historyOverride?.summary ?? context.summary,
600
+ characterPrompt: kind === "compact"
601
+ ? this.publicCharacterPrompt(membership)
602
+ : fullCharacterPrompt ? undefined : this.publicCharacterPrompt(membership),
603
+ allowRoleplayMemory,
604
+ retryCount: Number(chain.retry_count),
605
+ createdByUserId: requiredString(chain.initiator_user_id),
606
+ signal,
607
+ beforeRequest: (requirement) => {
608
+ const anyOf = [...new Set(requirement?.anyOf ?? [])];
609
+ if (anyOf.length > 0 && !requiredInitiatorAnyModules.some((group) => (group.length === anyOf.length && group.every((module) => anyOf.includes(module)))))
610
+ requiredInitiatorAnyModules.push(anyOf);
611
+ const currentMembership = this.characterMembership(requiredString(membership.id));
612
+ const currentAuthorization = this.assertCharacterAuthorization(chain, currentMembership);
613
+ this.assertRequiredInitiatorPermissions(currentAuthorization.initiatorPermissions, [...requiredInitiatorModules], requiredInitiatorAnyModules);
614
+ },
615
+ onToolCall: (tool) => {
616
+ if (tool.status !== "completed")
617
+ return;
618
+ for (const module of tool.permissionModules)
619
+ requiredInitiatorModules.add(module);
620
+ }
621
+ };
622
+ const invokeModel = async (modelId, stage, attemptLimit = Number(chain.retry_count)) => {
623
+ const started = process.hrtime.bigint();
624
+ const result = await runWithRequestActor({
625
+ userId: authorization.initiator.userId,
626
+ username: authorization.initiator.username,
627
+ displayName: authorization.initiator.displayName,
628
+ role: authorization.initiator.role,
629
+ authentication: "session"
630
+ }, () => this.ai.generateIm({ ...common, modelId, retryCount: attemptLimit }, onDelta, streamTurnId ? () => {
631
+ this.resetStreamingReply(streamTurnId);
632
+ this.publish(requiredString(chain.conversation_id), "reset", {
633
+ chainId: requiredString(chain.id),
634
+ turnId: streamTurnId,
635
+ reason: "retry",
636
+ modelStage: stage,
637
+ kind,
638
+ characterId: membership.character_id
639
+ });
640
+ } : undefined));
641
+ try {
642
+ validateContent?.(result.content);
643
+ }
644
+ catch (error) {
645
+ if (error instanceof AppError) {
646
+ throw new AppError(error.status, error.code, error.message, {
647
+ ...(error.details && typeof error.details === "object" ? error.details : {}),
648
+ callId: result.callId,
649
+ callIds: [result.callId],
650
+ attemptCount: result.attemptCount,
651
+ failureCount: result.failureCount + 1,
652
+ modelRecordId: result.model.id,
653
+ modelStage: stage
654
+ });
655
+ }
656
+ throw error;
657
+ }
658
+ return {
659
+ callId: result.callId,
660
+ callIds: [result.callId],
661
+ attemptCount: result.attemptCount,
662
+ primaryAttemptCount: 0,
663
+ content: result.content,
664
+ model: result.model,
665
+ stage,
666
+ durationMs: Math.round(Number(process.hrtime.bigint() - started) / 1_000_000),
667
+ requiredInitiatorModules: [...requiredInitiatorModules],
668
+ requiredInitiatorAnyModules: requiredInitiatorAnyModules.map((group) => [...group])
669
+ };
670
+ };
671
+ const errorDetails = (error) => error instanceof AppError
672
+ && error.details && typeof error.details === "object" && !Array.isArray(error.details)
673
+ ? error.details
674
+ : {};
675
+ const errorCallIds = (error) => {
676
+ const details = errorDetails(error);
677
+ return [...new Set([
678
+ ...(Array.isArray(details.callIds) ? details.callIds.filter((callId) => typeof callId === "string") : []),
679
+ ...(typeof details.callId === "string" ? [details.callId] : [])
680
+ ])];
681
+ };
682
+ const recordedAttemptCount = (details, fallback) => (Object.prototype.hasOwnProperty.call(details, "attemptCount") && Number.isFinite(Number(details.attemptCount))
683
+ ? Math.max(0, Number(details.attemptCount))
684
+ : fallback);
685
+ const recordedFailureCount = (details, fallback) => (Object.prototype.hasOwnProperty.call(details, "failureCount") && Number.isFinite(Number(details.failureCount))
686
+ ? Math.max(0, Number(details.failureCount))
687
+ : fallback);
688
+ const enrichedError = (error, stage, callIds, attemptCount, primaryAttemptCount, failureCount, modelRecordId) => {
689
+ const details = errorDetails(error);
690
+ const source = error instanceof AppError
691
+ ? error
692
+ : new AppError(502, "IM_AI_CHAIN_FAILED", "IM AI 交流链失败");
693
+ return new AppError(source.status, source.code, source.message, {
694
+ ...details,
695
+ callId: callIds.at(-1) ?? details.callId,
696
+ callIds,
697
+ attemptCount,
698
+ failureCount,
699
+ primaryAttemptCount,
700
+ fallbackAttemptCount: stage === "fallback" ? Math.max(0, attemptCount - primaryAttemptCount) : 0,
701
+ modelRecordId,
702
+ modelStage: stage
703
+ });
704
+ };
705
+ const invokeValidatedModel = async (modelId, stage) => {
706
+ const semanticAttemptLimit = Math.max(1, Number(chain.retry_count));
707
+ const retryableOutputCodes = new Set([
708
+ "IM_JUDGE_INVALID_SCORE",
709
+ "IM_AI_EMPTY_REPLY",
710
+ "IM_AI_REPLY_TOO_LONG",
711
+ "IM_AI_MENTION_LIMIT_EXCEEDED",
712
+ "IM_AI_MENTION_TARGET_INVALID",
713
+ "IM_AI_EMPTY_COMPACTION",
714
+ "AI_CALL_FAILED"
715
+ ]);
716
+ const started = process.hrtime.bigint();
717
+ const callIds = [];
718
+ let attemptCount = 0;
719
+ let failureCount = 0;
720
+ while (failureCount < semanticAttemptLimit) {
721
+ try {
722
+ const result = await invokeModel(modelId, stage, semanticAttemptLimit - failureCount);
723
+ return {
724
+ ...result,
725
+ callIds: [...callIds, ...result.callIds],
726
+ attemptCount: attemptCount + result.attemptCount,
727
+ durationMs: Math.round(Number(process.hrtime.bigint() - started) / 1_000_000)
728
+ };
729
+ }
730
+ catch (error) {
731
+ const details = errorDetails(error);
732
+ for (const callId of errorCallIds(error))
733
+ if (!callIds.includes(callId))
734
+ callIds.push(callId);
735
+ const consumedAttempts = Math.max(0, Number(details.attemptCount) || 0);
736
+ const consumedFailures = Object.prototype.hasOwnProperty.call(details, "failureCount")
737
+ && Number.isFinite(Number(details.failureCount))
738
+ ? Math.max(0, Number(details.failureCount))
739
+ : consumedAttempts;
740
+ attemptCount += consumedAttempts;
741
+ failureCount += consumedFailures;
742
+ if (error instanceof AppError && retryableOutputCodes.has(error.code)
743
+ && consumedFailures > 0 && failureCount < semanticAttemptLimit) {
744
+ if (streamTurnId) {
745
+ this.resetStreamingReply(streamTurnId);
746
+ this.publish(requiredString(chain.conversation_id), "reset", {
747
+ chainId: requiredString(chain.id),
748
+ turnId: streamTurnId,
749
+ reason: "output_validation_retry",
750
+ modelStage: stage,
751
+ kind,
752
+ characterId: membership.character_id
753
+ });
754
+ }
755
+ continue;
756
+ }
757
+ throw enrichedError(error, stage, callIds, attemptCount, 0, failureCount, modelId);
758
+ }
759
+ }
760
+ throw new AppError(502, "IM_JUDGE_INVALID_SCORE", "AI 没有返回有效的发言意愿分数");
761
+ };
762
+ const primaryModelId = optionalString(chain.primary_model_id);
763
+ const fallbackModelId = optionalString(chain.fallback_model_id);
764
+ if (!primaryModelId) {
765
+ if (!fallbackModelId)
766
+ throw new AppError(409, "IM_MODEL_NOT_CONFIGURED", "主模型和 fallback 模型均不可用");
767
+ this.db.run("UPDATE im_chains SET model_stage = 'fallback', updated_at = ? WHERE id = ?", now(), requiredString(chain.id));
768
+ const fallback = await invokeValidatedModel(fallbackModelId, "fallback");
769
+ return { ...fallback, primaryAttemptCount: 0 };
770
+ }
771
+ try {
772
+ return await invokeValidatedModel(primaryModelId, "primary");
773
+ }
774
+ catch (error) {
775
+ if (!this.shouldFailover(error) || signal.aborted)
776
+ throw error;
777
+ if (!fallbackModelId)
778
+ throw error;
779
+ if (streamTurnId) {
780
+ this.resetStreamingReply(streamTurnId);
781
+ this.publish(requiredString(chain.conversation_id), "reset", {
782
+ chainId: requiredString(chain.id),
783
+ turnId: streamTurnId,
784
+ reason: "fallback",
785
+ modelStage: "fallback",
786
+ kind,
787
+ characterId: membership.character_id
788
+ });
789
+ }
790
+ this.db.run("UPDATE im_chains SET model_stage = 'fallback', updated_at = ? WHERE id = ?", now(), requiredString(chain.id));
791
+ const primaryDetails = errorDetails(error);
792
+ const primaryCallIds = errorCallIds(error);
793
+ const primaryAttemptCount = recordedAttemptCount(primaryDetails, Number(chain.retry_count));
794
+ const primaryFailureCount = recordedFailureCount(primaryDetails, Number(chain.retry_count));
795
+ try {
796
+ const fallback = await invokeValidatedModel(fallbackModelId, "fallback");
797
+ return {
798
+ ...fallback,
799
+ callIds: [...primaryCallIds, ...fallback.callIds],
800
+ primaryAttemptCount
801
+ };
802
+ }
803
+ catch (fallbackError) {
804
+ const fallbackDetails = errorDetails(fallbackError);
805
+ const fallbackCallIds = errorCallIds(fallbackError);
806
+ const fallbackAttemptCount = recordedAttemptCount(fallbackDetails, Number(chain.retry_count));
807
+ const fallbackFailureCount = recordedFailureCount(fallbackDetails, fallbackAttemptCount);
808
+ throw enrichedError(fallbackError, "fallback", [...primaryCallIds, ...fallbackCallIds], primaryAttemptCount + fallbackAttemptCount, primaryAttemptCount, primaryFailureCount + fallbackFailureCount, fallbackModelId);
809
+ }
810
+ }
811
+ }
812
+ createTurn(chainId, membershipId, kind, status = "running") {
813
+ const turnId = id("imTurn");
814
+ this.db.run(`INSERT INTO im_chain_turns (id, chain_id, character_membership_id, kind, status, created_at)
815
+ VALUES (?, ?, ?, ?, ?, ?)`, turnId, chainId, membershipId, kind, status, now());
816
+ return turnId;
817
+ }
818
+ replyTurnPayload(chain, membership, turnId, status, error) {
819
+ const conversationId = requiredString(chain.conversation_id);
820
+ const snapshot = json(requiredString(membership.snapshot_json), {});
821
+ const characterId = optionalString(membership.character_id) ?? requiredString(snapshot.id);
822
+ const avatar = characterId ? this.db.get("SELECT sha256 FROM character_avatars WHERE character_id = ?", characterId) : null;
823
+ const avatarSha256 = optionalString(avatar?.sha256);
824
+ return {
825
+ chainId: requiredString(chain.id),
826
+ turnId,
827
+ kind: "reply",
828
+ status,
829
+ characterId,
830
+ character: {
831
+ characterId,
832
+ name: snapshot.name ?? "角色",
833
+ avatarUrl: characterId && avatarSha256
834
+ ? `/api/im/conversations/${encodeURIComponent(conversationId)}/characters/${encodeURIComponent(characterId)}/avatar?v=${encodeURIComponent(avatarSha256)}`
835
+ : null
836
+ },
837
+ ...(error ? { error } : {})
838
+ };
839
+ }
840
+ publishReplyTurn(chain, membership, turnId, status, error) {
841
+ this.publish(requiredString(chain.conversation_id), "turn", this.replyTurnPayload(chain, membership, turnId, status, error));
842
+ }
843
+ planReplyTurn(chain, membershipId, sourceMessageId) {
844
+ const membership = this.characterMembership(membershipId);
845
+ const turnId = this.createTurn(requiredString(chain.id), membershipId, "reply", "pending");
846
+ this.publishReplyTurn(chain, membership, turnId, "pending");
847
+ return { membershipId, turnId, sourceMessageId };
848
+ }
849
+ settlePendingReplyTurns(chain, status, error) {
850
+ const turns = this.db.all(`SELECT turn.id AS turn_id, membership.* FROM im_chain_turns turn
851
+ JOIN im_character_memberships membership ON membership.id = turn.character_membership_id
852
+ WHERE turn.chain_id = ? AND turn.kind = 'reply' AND turn.status = 'pending'
853
+ ORDER BY turn.created_at, turn.id`, requiredString(chain.id));
854
+ for (const turn of turns) {
855
+ this.db.run("UPDATE im_chain_turns SET status = ?, failure = ?, completed_at = ? WHERE id = ?", status, `${error.code}: ${error.message}`.slice(0, 2000), now(), requiredString(turn.turn_id));
856
+ this.publishReplyTurn(chain, turn, requiredString(turn.turn_id), status, error);
857
+ }
858
+ }
859
+ finishTurn(turnId, result, score, selected = false) {
860
+ this.db.run(`UPDATE im_chain_turns SET status = 'completed', score = ?, selected = ?, model_id = ?, model_stage = ?,
861
+ attempt_count = ?, duration_ms = ?, ai_call_ids_json = ?, completed_at = ?
862
+ WHERE id = ? AND status IN ('pending', 'running')`, score ?? null, selected ? 1 : 0, requiredString(result.model.id), result.stage, result.primaryAttemptCount + result.attemptCount, result.durationMs, JSON.stringify(result.callIds), now(), turnId);
863
+ }
864
+ assertInvocationInitiatorPermissions(permissions, invocation) {
865
+ this.assertRequiredInitiatorPermissions(permissions, invocation.requiredInitiatorModules, invocation.requiredInitiatorAnyModules);
866
+ }
867
+ assertRequiredInitiatorPermissions(permissions, requiredModules, requiredAnyModules = []) {
868
+ if (requiredModules.every((module) => permissions && canReadWorkModule(permissions, module))
869
+ && requiredAnyModules.every((group) => group.some((module) => permissions && canReadWorkModule(permissions, module))))
870
+ return;
871
+ throw new AppError(403, "IM_CHARACTER_ACCESS_DENIED", "发起人的作品权限已变化,包含私有资料的角色结果未写入群聊");
872
+ }
873
+ failTurn(turnId, error, status = "failed") {
874
+ const failure = publicError(error);
875
+ const details = error instanceof AppError && error.details && typeof error.details === "object" && !Array.isArray(error.details)
876
+ ? error.details
877
+ : {};
878
+ const callIds = [...new Set([
879
+ ...(Array.isArray(details.callIds) ? details.callIds.filter((callId) => typeof callId === "string") : []),
880
+ ...(typeof details.callId === "string" ? [details.callId] : [])
881
+ ])];
882
+ this.db.run(`UPDATE im_chain_turns SET status = ?, failure = ?, model_id = ?, model_stage = ?, attempt_count = ?,
883
+ ai_call_ids_json = ?, completed_at = ? WHERE id = ? AND status IN ('pending', 'running')`, status, `${failure.code}: ${failure.message}`.slice(0, 2000), optionalString(details.modelRecordId), optionalString(details.modelStage), Math.max(0, Number(details.attemptCount) || 0), JSON.stringify(callIds), now(), turnId);
884
+ }
885
+ async judge(chain, membership, sourceMessage, signal) {
886
+ await this.maybeCompact(chain, membership, sourceMessage, signal);
887
+ const turnId = this.createTurn(requiredString(chain.id), requiredString(membership.id), "judge");
888
+ try {
889
+ const result = await this.invoke(chain, membership, "judge", "根据最新已送达消息和当前角色立场,判断自己现在是否需要发言。", sourceMessage, signal, undefined, (content) => {
890
+ if (scoreFromContent(content) === null)
891
+ throw new AppError(502, "IM_JUDGE_INVALID_SCORE", "AI 没有返回有效的发言意愿分数");
892
+ });
893
+ const score = scoreFromContent(result.content);
894
+ if (score === null)
895
+ throw new AppError(502, "IM_JUDGE_INVALID_SCORE", "AI 没有返回有效的发言意愿分数");
896
+ this.assertGenerationStillCurrent(chain, sourceMessage, signal);
897
+ const currentMembership = this.characterMembership(requiredString(membership.id));
898
+ const currentAuthorization = this.assertCharacterAuthorization(chain, currentMembership);
899
+ this.assertInvocationInitiatorPermissions(currentAuthorization.initiatorPermissions, result);
900
+ this.finishTurn(turnId, result, score, false);
901
+ this.publishToUser(requiredString(chain.authorization_user_id), {
902
+ id: id("imEvent"),
903
+ type: "turn",
904
+ conversationId: requiredString(chain.conversation_id),
905
+ payload: {
906
+ chainId: requiredString(chain.id),
907
+ turnId,
908
+ kind: "judge",
909
+ characterId: membership.character_id,
910
+ score
911
+ },
912
+ createdAt: now()
913
+ });
914
+ return { score, turnId };
915
+ }
916
+ catch (error) {
917
+ const effectiveError = effectiveAbortError(signal, error);
918
+ this.failTurn(turnId, effectiveError, signal.aborted ? "cancelled" : "failed");
919
+ if (signal.aborted)
920
+ throw effectiveError;
921
+ if (effectiveError instanceof AppError && [
922
+ "IM_CHARACTER_ACCESS_DENIED",
923
+ "IM_CHARACTER_UNAVAILABLE",
924
+ "IM_OWNER_DISABLED",
925
+ "IM_INITIATOR_DISABLED"
926
+ ].includes(effectiveError.code))
927
+ throw effectiveError;
928
+ return null;
929
+ }
930
+ }
931
+ validatedOutputMentions(conversationId, content) {
932
+ const mentions = parseImMentions(content);
933
+ if (mentions.length > IM_MAX_MENTIONS_PER_MESSAGE) {
934
+ throw new AppError(502, "IM_AI_MENTION_LIMIT_EXCEEDED", `AI 回复超过 ${IM_MAX_MENTIONS_PER_MESSAGE} 个 mention,未写入会话`);
935
+ }
936
+ const characterIds = [...new Set(mentions.filter((mention) => mention.kind === "character").map((mention) => mention.id))];
937
+ const userIds = [...new Set(mentions.filter((mention) => mention.kind === "user").map((mention) => mention.id))];
938
+ const characters = new Map();
939
+ if (characterIds.length > 0) {
940
+ const placeholders = characterIds.map(() => "?").join(", ");
941
+ for (const row of this.db.all(`SELECT * FROM im_character_memberships
942
+ WHERE conversation_id = ? AND character_id IN (${placeholders}) AND left_at IS NULL AND status = 'active'`, conversationId, ...characterIds))
943
+ characters.set(requiredString(row.character_id), row);
944
+ }
945
+ const users = new Map();
946
+ if (userIds.length > 0) {
947
+ const placeholders = userIds.map(() => "?").join(", ");
948
+ for (const row of this.db.all(`SELECT user.id, user.username, user.display_name, user.avatar_sha256
949
+ FROM im_human_memberships membership JOIN users user ON user.id = membership.user_id
950
+ WHERE membership.conversation_id = ? AND membership.user_id IN (${placeholders}) AND membership.left_at IS NULL`, conversationId, ...userIds))
951
+ users.set(requiredString(row.id), row);
952
+ }
953
+ const result = [];
954
+ for (const mention of mentions) {
955
+ if (mention.kind === "character") {
956
+ const row = characters.get(mention.id);
957
+ if (row)
958
+ result.push({
959
+ kind: mention.kind,
960
+ id: mention.id,
961
+ membershipId: requiredString(row.id),
962
+ snapshot: json(requiredString(row.snapshot_json), {})
963
+ });
964
+ continue;
965
+ }
966
+ const row = users.get(mention.id);
967
+ if (row)
968
+ result.push({ kind: mention.kind, id: mention.id, snapshot: {
969
+ userId: requiredString(row.id),
970
+ username: requiredString(row.username),
971
+ displayName: requiredString(row.display_name),
972
+ avatarUrl: null,
973
+ avatarSha256: optionalString(row.avatar_sha256)
974
+ } });
975
+ }
976
+ if (result.length !== mentions.length) {
977
+ throw new AppError(502, "IM_AI_MENTION_TARGET_INVALID", "AI 回复包含已经离开或不可用的 mention 目标,未写入会话");
978
+ }
979
+ return result;
980
+ }
981
+ appendCharacterMessage(chain, membership, invocation, turnId) {
982
+ const conversationId = requiredString(chain.conversation_id);
983
+ const conversation = this.conversationRow(conversationId);
984
+ const messageId = id("imMessage");
985
+ const sequence = Number(this.db.get("SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence FROM im_messages WHERE conversation_id = ?", conversationId)?.sequence ?? 1);
986
+ const mentions = this.validatedOutputMentions(conversationId, invocation.content);
987
+ const snapshot = json(requiredString(membership.snapshot_json), {});
988
+ const senderCharacterId = optionalString(membership.character_id);
989
+ const avatar = senderCharacterId
990
+ ? this.db.get("SELECT sha256 FROM character_avatars WHERE character_id = ?", senderCharacterId)
991
+ : undefined;
992
+ const avatarSha256 = optionalString(avatar?.sha256);
993
+ snapshot.avatarUrl = senderCharacterId && avatarSha256
994
+ ? `/api/im/conversations/${encodeURIComponent(conversationId)}/characters/${encodeURIComponent(senderCharacterId)}/avatar?v=${encodeURIComponent(avatarSha256)}`
995
+ : null;
996
+ snapshot.avatarSha256 = avatarSha256;
997
+ const metadata = {
998
+ modelId: requiredString(invocation.model.id),
999
+ modelDisplayName: requiredString(invocation.model.displayName),
1000
+ modelStage: invocation.stage,
1001
+ durationMs: invocation.durationMs,
1002
+ callId: invocation.callId,
1003
+ callIds: invocation.callIds,
1004
+ retryCount: Number(chain.retry_count),
1005
+ attemptCount: invocation.primaryAttemptCount + invocation.attemptCount,
1006
+ primaryAttemptCount: invocation.primaryAttemptCount,
1007
+ fallbackAttemptCount: invocation.stage === "fallback" ? invocation.attemptCount : 0
1008
+ };
1009
+ const timestamp = now();
1010
+ this.db.transaction(() => {
1011
+ if (senderCharacterId)
1012
+ this.im.captureCharacterAvatarVersion(conversationId, senderCharacterId, timestamp);
1013
+ this.db.run(`INSERT INTO im_messages (
1014
+ id, conversation_id, sequence, context_epoch, sender_kind, sender_character_id,
1015
+ sender_snapshot_json, content, chain_id, metadata_json, created_at
1016
+ ) VALUES (?, ?, ?, ?, 'character', ?, ?, ?, ?, ?, ?)`, messageId, conversationId, sequence, Number(conversation.context_epoch), senderCharacterId, JSON.stringify(snapshot), invocation.content, requiredString(chain.id), JSON.stringify(metadata), timestamp);
1017
+ mentions.forEach((mention, position) => this.db.run(`INSERT INTO im_mentions (message_id, position, target_kind, target_id, target_snapshot_json)
1018
+ VALUES (?, ?, ?, ?, ?)`, messageId, position, mention.kind, mention.id, JSON.stringify(mention.snapshot)));
1019
+ const deliveryIds = new Set([requiredString(membership.id)]);
1020
+ if (requiredString(chain.mode) === "proactive") {
1021
+ for (const participant of this.activeCharacters(conversationId))
1022
+ deliveryIds.add(requiredString(participant.id));
1023
+ }
1024
+ else if (requiredString(chain.mode) === "mention") {
1025
+ for (const mention of mentions)
1026
+ if (mention.membershipId)
1027
+ deliveryIds.add(mention.membershipId);
1028
+ }
1029
+ for (const membershipId of deliveryIds) {
1030
+ this.db.run("INSERT INTO im_message_deliveries (message_id, character_membership_id, delivered_at) VALUES (?, ?, ?)", messageId, membershipId, timestamp);
1031
+ }
1032
+ this.finishTurn(turnId, invocation, undefined, true);
1033
+ this.db.run("UPDATE im_chains SET generated_count = generated_count + 1, updated_at = ? WHERE id = ?", timestamp, requiredString(chain.id));
1034
+ this.db.run("UPDATE im_conversations SET updated_at = ? WHERE id = ?", timestamp, conversationId);
1035
+ });
1036
+ return {
1037
+ id: messageId,
1038
+ conversationId,
1039
+ sequence,
1040
+ contextEpoch: Number(conversation.context_epoch),
1041
+ senderKind: "character",
1042
+ senderCharacterId: membership.character_id ?? snapshot.id ?? null,
1043
+ sender: snapshot,
1044
+ content: invocation.content,
1045
+ mentions: mentions.map((mention, position) => ({ kind: mention.kind, id: mention.id, position, snapshot: mention.snapshot })),
1046
+ chainId: requiredString(chain.id),
1047
+ metadata,
1048
+ createdAt: timestamp
1049
+ };
1050
+ }
1051
+ async reply(chain, membershipId, turnId, sourceMessage, signal) {
1052
+ const membership = this.characterMembership(membershipId);
1053
+ let streamed = "";
1054
+ let result = null;
1055
+ try {
1056
+ await this.maybeCompact(chain, membership, sourceMessage, signal);
1057
+ this.db.run("UPDATE im_chain_turns SET status = 'running' WHERE id = ?", turnId);
1058
+ this.publishReplyTurn(chain, membership, turnId, "running");
1059
+ this.streamingReplies.set(turnId, {
1060
+ id: id("imEvent"),
1061
+ type: "turn",
1062
+ conversationId: requiredString(chain.conversation_id),
1063
+ payload: { ...this.replyTurnPayload(chain, membership, turnId, "running"), content: "" },
1064
+ createdAt: now()
1065
+ });
1066
+ result = await this.invoke(chain, membership, "reply", "回复最新收到的 IM 消息;只有确实需要点名时才使用 canonical mention URI。", sourceMessage, signal, (delta) => {
1067
+ streamed += delta;
1068
+ }, (content) => {
1069
+ if (!content.trim())
1070
+ throw new AppError(502, "IM_AI_EMPTY_REPLY", "AI 返回了空消息");
1071
+ if (Array.from(content).length > IM_MESSAGE_MAX_CHARACTERS) {
1072
+ throw new AppError(502, "IM_AI_REPLY_TOO_LONG", `AI 回复超过 ${IM_MESSAGE_MAX_CHARACTERS} 字符,未写入会话`);
1073
+ }
1074
+ if (parseImMentions(content).length > IM_MAX_MENTIONS_PER_MESSAGE) {
1075
+ throw new AppError(502, "IM_AI_MENTION_LIMIT_EXCEEDED", `AI 回复超过 ${IM_MAX_MENTIONS_PER_MESSAGE} 个 mention,未写入会话`);
1076
+ }
1077
+ this.validatedOutputMentions(requiredString(chain.conversation_id), content);
1078
+ }, turnId);
1079
+ if (signal.aborted) {
1080
+ throw signal.reason instanceof Error ? signal.reason : new AppError(499, "IM_CHAIN_CANCELLED", "IM 交流链已取消");
1081
+ }
1082
+ const currentChain = this.chainRow(requiredString(chain.id));
1083
+ const currentConversation = this.conversationRow(requiredString(chain.conversation_id));
1084
+ if (requiredString(currentChain.status) !== "running"
1085
+ || requiredString(currentConversation.status) !== "active"
1086
+ || Number(currentConversation.context_epoch) !== Number(sourceMessage.context_epoch)) {
1087
+ throw new AppError(499, "IM_CHAIN_CANCELLED", "IM 交流链已取消或上下文已经变化");
1088
+ }
1089
+ if (!result.content.trim())
1090
+ throw new AppError(502, "IM_AI_EMPTY_REPLY", "AI 返回了空消息");
1091
+ if (Array.from(result.content).length > IM_MESSAGE_MAX_CHARACTERS) {
1092
+ throw new AppError(502, "IM_AI_REPLY_TOO_LONG", `AI 回复超过 ${IM_MESSAGE_MAX_CHARACTERS} 字符,未写入会话`);
1093
+ }
1094
+ const currentMembership = this.characterMembership(membershipId);
1095
+ const currentAuthorization = this.assertCharacterAuthorization(chain, currentMembership);
1096
+ this.assertInvocationInitiatorPermissions(currentAuthorization.initiatorPermissions, result);
1097
+ const snapshot = this.streamingReplies.get(turnId);
1098
+ if (snapshot)
1099
+ snapshot.payload.content = result.content;
1100
+ this.publish(requiredString(chain.conversation_id), "delta", {
1101
+ chainId: requiredString(chain.id),
1102
+ turnId,
1103
+ characterId: currentMembership.character_id,
1104
+ delta: result.content
1105
+ });
1106
+ const message = this.appendCharacterMessage(chain, currentMembership, result, turnId);
1107
+ this.publish(requiredString(chain.conversation_id), "message", { message });
1108
+ this.publishReplyTurn(chain, membership, turnId, "completed");
1109
+ this.streamingReplies.delete(turnId);
1110
+ return this.messageRow(requiredString(message.id));
1111
+ }
1112
+ catch (error) {
1113
+ const effectiveError = effectiveAbortError(signal, error);
1114
+ if (streamed)
1115
+ this.publish(requiredString(chain.conversation_id), "reset", { chainId: requiredString(chain.id), turnId, reason: "retry" });
1116
+ const failure = publicError(effectiveError);
1117
+ const cancelled = signal.aborted || failure.code === "IM_CHAIN_CANCELLED" || failure.code === "AI_STREAM_REQUEST_CANCELLED";
1118
+ const failureForTurn = result ? new AppError(effectiveError instanceof AppError ? effectiveError.status : 502, failure.code, failure.message, {
1119
+ ...(effectiveError instanceof AppError && effectiveError.details && typeof effectiveError.details === "object" ? effectiveError.details : {}),
1120
+ modelRecordId: result.model.id,
1121
+ modelStage: result.stage,
1122
+ attemptCount: result.primaryAttemptCount + result.attemptCount,
1123
+ callId: result.callId,
1124
+ callIds: result.callIds
1125
+ }) : effectiveError;
1126
+ this.failTurn(turnId, failureForTurn, cancelled ? "cancelled" : "failed");
1127
+ this.publishReplyTurn(chain, membership, turnId, cancelled ? "cancelled" : "failed", failure);
1128
+ this.streamingReplies.delete(turnId);
1129
+ throw effectiveError;
1130
+ }
1131
+ }
1132
+ finishChain(chainId, status, error) {
1133
+ const timestamp = now();
1134
+ const chain = this.chainRow(chainId);
1135
+ this.db.run(`UPDATE im_chains SET status = ?, error_code = ?, error_message = ?, updated_at = ?, completed_at = ? WHERE id = ?`, status, error?.code ?? null, error?.message ?? null, timestamp, timestamp, chainId);
1136
+ this.publish(requiredString(chain.conversation_id), "chain", {
1137
+ chainId,
1138
+ status,
1139
+ generatedCount: Number(chain.generated_count),
1140
+ ...(error ? { error } : {})
1141
+ });
1142
+ }
1143
+ async runChain(chainId, signal) {
1144
+ let chain = this.chainRow(chainId);
1145
+ const conversationId = requiredString(chain.conversation_id);
1146
+ try {
1147
+ if (requiredString(chain.status) !== "queued")
1148
+ return;
1149
+ this.im.refreshCharacterAvailability(conversationId);
1150
+ this.db.run("UPDATE im_chains SET status = 'running', updated_at = ? WHERE id = ?", now(), chainId);
1151
+ this.publish(conversationId, "chain", { chainId, status: "running", modelStage: chain.model_stage });
1152
+ let sourceMessage = this.messageRow(requiredString(chain.trigger_message_id));
1153
+ if (requiredString(chain.mode) === "direct") {
1154
+ const target = this.activeCharacters(conversationId)[0];
1155
+ if (!target)
1156
+ throw new AppError(409, "IM_CHARACTER_UNAVAILABLE", "单聊角色已不可用");
1157
+ const planned = this.planReplyTurn(chain, requiredString(target.id), requiredString(sourceMessage.id));
1158
+ await this.reply(chain, planned.membershipId, planned.turnId, sourceMessage, signal);
1159
+ this.finishChain(chainId, "completed");
1160
+ return;
1161
+ }
1162
+ let forcedQueue = this.mentionedCharacterMembershipIds(requiredString(sourceMessage.id), conversationId)
1163
+ .map((membershipId) => this.planReplyTurn(chain, membershipId, requiredString(sourceMessage.id)));
1164
+ let lastJudgedSourceMessageId = null;
1165
+ let lastReplyFailure = null;
1166
+ for (;;) {
1167
+ if (signal.aborted)
1168
+ throw signal.reason;
1169
+ chain = this.chainRow(chainId);
1170
+ if (requiredString(chain.status) !== "running")
1171
+ return;
1172
+ if (Number(chain.generated_count) >= Number(chain.max_ai_messages)) {
1173
+ this.settlePendingReplyTurns(chain, "skipped", { code: "IM_CHAIN_LIMIT", message: "已达到群聊链路上限,未继续生成回答" });
1174
+ this.finishChain(chainId, "limit");
1175
+ return;
1176
+ }
1177
+ let plannedReply = forcedQueue.shift() ?? null;
1178
+ if (!plannedReply && requiredString(chain.mode) === "mention") {
1179
+ const latest = this.chainRow(chainId);
1180
+ if (Number(latest.generated_count) === 0 && lastReplyFailure)
1181
+ this.finishChain(chainId, "failed", lastReplyFailure);
1182
+ else
1183
+ this.finishChain(chainId, "completed");
1184
+ return;
1185
+ }
1186
+ if (!plannedReply) {
1187
+ if (lastJudgedSourceMessageId === requiredString(sourceMessage.id)) {
1188
+ this.finishChain(chainId, "failed", lastReplyFailure ?? { code: "IM_REPLY_ALL_FAILED", message: "所有已选择角色的回答都生成失败" });
1189
+ return;
1190
+ }
1191
+ lastJudgedSourceMessageId = requiredString(sourceMessage.id);
1192
+ const senderCharacterId = optionalString(sourceMessage.sender_character_id);
1193
+ const candidates = this.activeCharacters(conversationId).filter((row) => requiredString(row.character_id) !== senderCharacterId);
1194
+ if (candidates.length === 0) {
1195
+ this.finishChain(chainId, "quiet");
1196
+ return;
1197
+ }
1198
+ const settledScores = await Promise.allSettled(candidates.map(async (candidate) => ({
1199
+ membershipId: requiredString(candidate.id),
1200
+ result: await this.judge(chain, candidate, sourceMessage, signal)
1201
+ })));
1202
+ if (signal.aborted) {
1203
+ throw signal.reason instanceof Error ? signal.reason : new AppError(499, "IM_CHAIN_CANCELLED", "IM 交流链已取消");
1204
+ }
1205
+ const rejectedScore = settledScores.find((result) => result.status === "rejected");
1206
+ if (rejectedScore?.status === "rejected")
1207
+ throw rejectedScore.reason;
1208
+ const scores = settledScores.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
1209
+ const available = scores.flatMap((item) => item.result ? [{
1210
+ membershipId: item.membershipId,
1211
+ score: item.result.score,
1212
+ turnId: item.result.turnId
1213
+ }] : [])
1214
+ .sort((left, right) => right.score - left.score || left.membershipId.localeCompare(right.membershipId));
1215
+ if (available.length === 0)
1216
+ throw new AppError(502, "IM_JUDGE_ALL_FAILED", "所有 AI 角色的发言判断都失败了");
1217
+ const selected = available.filter((item) => item.score >= Number(chain.threshold));
1218
+ if (selected.length === 0) {
1219
+ this.finishChain(chainId, "quiet");
1220
+ return;
1221
+ }
1222
+ for (const item of selected) {
1223
+ const selectedReply = this.planReplyTurn(chain, item.membershipId, requiredString(sourceMessage.id));
1224
+ forcedQueue.push(selectedReply);
1225
+ const selectedMembership = this.characterMembership(selectedReply.membershipId);
1226
+ this.db.run("UPDATE im_chain_turns SET selected = 1 WHERE id = ?", item.turnId);
1227
+ this.publishToUser(requiredString(chain.authorization_user_id), {
1228
+ id: id("imEvent"),
1229
+ type: "turn",
1230
+ conversationId,
1231
+ payload: {
1232
+ chainId,
1233
+ turnId: item.turnId,
1234
+ kind: "judge",
1235
+ characterId: selectedMembership.character_id,
1236
+ score: item.score,
1237
+ selected: true,
1238
+ status: "completed"
1239
+ },
1240
+ createdAt: now()
1241
+ });
1242
+ }
1243
+ plannedReply = forcedQueue.shift() ?? null;
1244
+ if (!plannedReply)
1245
+ throw new AppError(500, "IM_REPLY_QUEUE_EMPTY", "主动交流没有生成可执行的角色回复队列");
1246
+ }
1247
+ const replySourceMessage = this.messageRow(plannedReply.sourceMessageId);
1248
+ try {
1249
+ sourceMessage = await this.reply(chain, plannedReply.membershipId, plannedReply.turnId, replySourceMessage, signal);
1250
+ }
1251
+ catch (error) {
1252
+ const failure = publicError(error);
1253
+ if (signal.aborted || failure.code === "IM_CHAIN_CANCELLED" || failure.code === "AI_STREAM_REQUEST_CANCELLED")
1254
+ throw error;
1255
+ lastReplyFailure = failure;
1256
+ continue;
1257
+ }
1258
+ if (requiredString(chain.mode) === "proactive") {
1259
+ forcedQueue = forcedQueue.map((queuedReply) => ({
1260
+ ...queuedReply,
1261
+ sourceMessageId: requiredString(sourceMessage.id)
1262
+ }));
1263
+ }
1264
+ const newMentions = this.mentionedCharacterMembershipIds(requiredString(sourceMessage.id), conversationId, optionalString(sourceMessage.sender_character_id));
1265
+ const prioritizedMentions = [];
1266
+ for (const membershipId of newMentions) {
1267
+ const existingIndex = forcedQueue.findIndex((item) => item.membershipId === membershipId);
1268
+ if (existingIndex >= 0) {
1269
+ const [existing] = forcedQueue.splice(existingIndex, 1);
1270
+ if (existing)
1271
+ prioritizedMentions.push({ ...existing, sourceMessageId: requiredString(sourceMessage.id) });
1272
+ continue;
1273
+ }
1274
+ prioritizedMentions.push(this.planReplyTurn(chain, membershipId, requiredString(sourceMessage.id)));
1275
+ }
1276
+ forcedQueue.unshift(...prioritizedMentions);
1277
+ }
1278
+ }
1279
+ catch (error) {
1280
+ const effectiveError = effectiveAbortError(signal, error);
1281
+ const failure = publicError(effectiveError);
1282
+ if (failure.code === "IM_CHAIN_RUNTIME_RESTARTED") {
1283
+ this.settlePendingReplyTurns(chain, "cancelled", failure);
1284
+ this.finishChain(chainId, "interrupted", failure);
1285
+ return;
1286
+ }
1287
+ if (signal.aborted || failure.code === "IM_CHAIN_CANCELLED" || failure.code === "AI_STREAM_REQUEST_CANCELLED") {
1288
+ this.settlePendingReplyTurns(chain, "cancelled", failure);
1289
+ this.finishChain(chainId, "cancelled", failure);
1290
+ return;
1291
+ }
1292
+ this.settlePendingReplyTurns(chain, "failed", failure);
1293
+ this.finishChain(chainId, "failed", failure);
1294
+ }
1295
+ }
1296
+ async dispose() {
1297
+ this.disposed = true;
1298
+ const interruption = new AppError(503, "IM_CHAIN_RUNTIME_RESTARTED", "服务关闭导致 IM 交流链中断,可从原消息重试");
1299
+ for (const controller of this.controllers.values())
1300
+ controller.abort(interruption);
1301
+ const timestamp = now();
1302
+ this.db.run(`UPDATE im_chains SET status = 'interrupted', error_code = ?, error_message = ?, updated_at = ?, completed_at = ?
1303
+ WHERE status IN ('queued', 'running')`, interruption.code, interruption.message, timestamp, timestamp);
1304
+ this.db.run(`UPDATE im_chain_turns SET status = 'cancelled', failure = COALESCE(failure, ?), completed_at = ?
1305
+ WHERE chain_id IN (SELECT id FROM im_chains WHERE status = 'interrupted')
1306
+ AND status IN ('pending', 'running')`, `${interruption.code}: ${interruption.message}`, timestamp);
1307
+ await Promise.allSettled([...this.activeRunPromises]);
1308
+ this.controllers.clear();
1309
+ this.streamingReplies.clear();
1310
+ this.recipientCache.clear();
1311
+ this.queuedChainIds.length = 0;
1312
+ this.queuedChainSet.clear();
1313
+ for (const userId of [...this.listeners.keys()])
1314
+ this.disconnectUser(userId);
1315
+ }
1316
+ }
1317
+ //# sourceMappingURL=im-orchestrator.js.map