@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/ai-write-plans.js +136 -48
- package/dist/ai-write-plans.js.map +1 -1
- package/dist/ai.js +404 -100
- package/dist/ai.js.map +1 -1
- package/dist/app.js +366 -2
- package/dist/app.js.map +1 -1
- package/dist/cli-core.js +95 -3
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +549 -4
- package/dist/database.js.map +1 -1
- package/dist/im-orchestrator.js +1317 -0
- package/dist/im-orchestrator.js.map +1 -0
- package/dist/im.js +1690 -0
- package/dist/im.js.map +1 -0
- package/dist/public/ai-interactive.js +14 -13
- package/dist/public/app.js +279 -82
- package/dist/public/im.d.ts +71 -0
- package/dist/public/im.js +1899 -0
- package/dist/public/index.html +108 -5
- package/dist/public/page-route.d.ts +2 -1
- package/dist/public/page-route.js +4 -1
- package/dist/public/styles.css +335 -3
- package/dist/s3-backup.js +5 -1
- package/dist/s3-backup.js.map +1 -1
- package/dist/security.js +1 -0
- package/dist/security.js.map +1 -1
- package/dist/store.js +4 -1
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +9 -4
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|