@musnows/scriverse 0.9.4 → 0.9.6
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-protocol.js +6 -0
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-skills.js +134 -0
- package/dist/ai-skills.js.map +1 -0
- package/dist/ai-write-plans.js +2279 -0
- package/dist/ai-write-plans.js.map +1 -0
- package/dist/ai.js +2781 -154
- package/dist/ai.js.map +1 -1
- package/dist/app.js +328 -17
- package/dist/app.js.map +1 -1
- package/dist/chapter-annotation-anchor.js +327 -0
- package/dist/chapter-annotation-anchor.js.map +1 -0
- package/dist/chapter-title-numbering.js +56 -0
- package/dist/chapter-title-numbering.js.map +1 -0
- package/dist/database.js +478 -3
- package/dist/database.js.map +1 -1
- package/dist/public/ai-context-meter.js +3 -3
- package/dist/public/ai-interactive.js +483 -0
- package/dist/public/app.js +1991 -268
- package/dist/public/chapter-editor-behavior.js +40 -0
- package/dist/public/chapter-line-id-tracker.d.ts +25 -0
- package/dist/public/chapter-line-id-tracker.js +151 -0
- package/dist/public/index.html +79 -19
- package/dist/public/model-config.d.ts +1 -0
- package/dist/public/model-config.js +9 -4
- package/dist/public/styles.css +337 -57
- package/dist/public/theme-init.js +25 -1
- package/dist/remote-mcp.js +496 -0
- package/dist/remote-mcp.js.map +1 -0
- package/dist/roleplay-memory.js +53 -0
- package/dist/roleplay-memory.js.map +1 -0
- package/dist/security.js +4 -0
- package/dist/security.js.map +1 -1
- package/dist/semantic-search.js +225 -0
- package/dist/semantic-search.js.map +1 -0
- package/dist/skills/continue-writing/SKILL.md +23 -0
- package/dist/skills/polish-writing/SKILL.md +23 -0
- package/dist/store.js +695 -51
- package/dist/store.js.map +1 -1
- package/dist/ui-module-preload.js +27 -0
- package/dist/ui-module-preload.js.map +1 -0
- package/dist/user-auth.js +33 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -1
package/dist/store.js
CHANGED
|
@@ -4,34 +4,70 @@ import { ENTITY_VERSION_BASELINE_MIGRATION_VERSION, PLATFORM_AI_WORK_ID, SYSTEM_
|
|
|
4
4
|
import { exportWorkDocx } from "./docx-export.js";
|
|
5
5
|
import { createEpubArchive } from "./epub-export.js";
|
|
6
6
|
import { AppError, notFound } from "./errors.js";
|
|
7
|
-
import { documentParagraphLineRanges, normalizeWorkSearchQuery } from "./hybrid-search.js";
|
|
7
|
+
import { documentParagraphLineRanges, hybridSearchPermissionModule, normalizeWorkSearchQuery } from "./hybrid-search.js";
|
|
8
8
|
import { accountReference, logger } from "./logger.js";
|
|
9
9
|
import { paginated, paginationSql } from "./pagination.js";
|
|
10
10
|
import { currentRequestActor } from "./request-context.js";
|
|
11
|
-
import { canWriteWorkModule, classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
|
|
11
|
+
import { canReadWorkModule, canWriteWorkModule, classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
|
|
12
12
|
import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, normalizeDocumentSearchText, now, splitDocumentParagraphs } from "./utils.js";
|
|
13
13
|
import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
|
|
14
14
|
import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
|
|
15
15
|
import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
|
|
16
16
|
import { normalizeRoleplayScenePin, roleplayUserTurnDisplayText, roleplayUserTurnTitleSource } from "./roleplay-turn.js";
|
|
17
|
+
import { chapterAnnotationLineHashes, createChapterLineIds, MAX_CHAPTER_LINE_IDS, parseChapterAnnotationLineIds, parseChapterAnnotationLineHashes, parseChapterLineIds, reconcileChapterLineIds, reanchorChapterAnnotations } from "./chapter-annotation-anchor.js";
|
|
18
|
+
import { renumberChapterTitle } from "./chapter-title-numbering.js";
|
|
19
|
+
import { normalizeRoleplayMemoryContent, roleplayMemoryCandidateIsSafe } from "./roleplay-memory.js";
|
|
17
20
|
const WORK_LIST_BATCH_SIZE = 500;
|
|
18
21
|
const ENTITY_LIST_BATCH_SIZE = 400;
|
|
19
22
|
export const RECYCLE_BIN_RETENTION_DAYS = 30;
|
|
20
23
|
function recycleBinExpiresAt(deletedAt) {
|
|
21
24
|
return new Date(new Date(deletedAt).getTime() + RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
|
|
22
25
|
}
|
|
26
|
+
function chapterAnnotationListFilter(kinds, filters = {}) {
|
|
27
|
+
const where = [];
|
|
28
|
+
const params = [];
|
|
29
|
+
if (kinds !== undefined) {
|
|
30
|
+
if (kinds.length > 0) {
|
|
31
|
+
where.push(`annotation.kind IN (${kinds.map(() => "?").join(",")})`);
|
|
32
|
+
params.push(...kinds);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
where.push("1 = 0");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (filters.chapterId) {
|
|
39
|
+
where.push("annotation.chapter_id = ?");
|
|
40
|
+
params.push(filters.chapterId);
|
|
41
|
+
}
|
|
42
|
+
const normalizedQuery = filters.query?.normalize("NFKC").trim().slice(0, 100) ?? "";
|
|
43
|
+
if (normalizedQuery) {
|
|
44
|
+
const pattern = `%${escapeSqlLikePattern(normalizedQuery)}%`;
|
|
45
|
+
where.push(`(
|
|
46
|
+
annotation.note LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
47
|
+
OR annotation.quote LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
48
|
+
OR chapter.title LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
49
|
+
OR volume.title LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
50
|
+
)`);
|
|
51
|
+
params.push(pattern, pattern, pattern, pattern);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
sql: where.map((clause) => ` AND ${clause}`).join(""),
|
|
55
|
+
params
|
|
56
|
+
};
|
|
57
|
+
}
|
|
23
58
|
export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations", "ai-chat"];
|
|
24
59
|
export const WORK_AGENT_TOOL_IDS = [
|
|
25
60
|
"story_index",
|
|
26
61
|
"read_chapters",
|
|
27
62
|
"grep",
|
|
28
63
|
"search_story_entities",
|
|
64
|
+
"semantic_search_story",
|
|
29
65
|
"read_character_sections",
|
|
30
66
|
"search_drafts",
|
|
31
67
|
"image",
|
|
32
68
|
"calculate_time"
|
|
33
69
|
];
|
|
34
|
-
const DEFAULT_WORK_AGENT_TOOLS =
|
|
70
|
+
const DEFAULT_WORK_AGENT_TOOLS = WORK_AGENT_TOOL_IDS.filter((toolId) => toolId !== "semantic_search_story");
|
|
35
71
|
const LEGACY_DEFAULT_WORK_AGENT_TOOLS = [
|
|
36
72
|
"story_index",
|
|
37
73
|
"read_chapters",
|
|
@@ -217,7 +253,12 @@ const legacyFavoriteTables = {
|
|
|
217
253
|
organization: "organizations"
|
|
218
254
|
};
|
|
219
255
|
export const AI_CONVERSATION_STREAM_REQUEST_LEASE_MS = 3 * 60_000;
|
|
220
|
-
export const aiConversationTaskTypes = ["chat", "roleplay"
|
|
256
|
+
export const aiConversationTaskTypes = ["chat", "roleplay"];
|
|
257
|
+
function normalizeAiConversationTaskType(value, roleplayCharacterId) {
|
|
258
|
+
if (roleplayCharacterId || value === "roleplay")
|
|
259
|
+
return "roleplay";
|
|
260
|
+
return "chat";
|
|
261
|
+
}
|
|
221
262
|
export function defaultAiConversationTitle(prompt) {
|
|
222
263
|
const normalized = roleplayUserTurnTitleSource(prompt).replace(/\s+/gu, " ").trim();
|
|
223
264
|
return Array.from(normalized).slice(0, 15).join("") || "新对话";
|
|
@@ -534,6 +575,8 @@ export class Store {
|
|
|
534
575
|
language: entity.language,
|
|
535
576
|
coverUrl: entity.coverUrl,
|
|
536
577
|
tags: entity.tags,
|
|
578
|
+
editorAutoIndentEnabled: entity.editorAutoIndentEnabled,
|
|
579
|
+
editorTypewriterModeEnabled: entity.editorTypewriterModeEnabled,
|
|
537
580
|
ownerUserId: entity.ownerUserId
|
|
538
581
|
};
|
|
539
582
|
if (type === "volume")
|
|
@@ -802,8 +845,9 @@ export class Store {
|
|
|
802
845
|
return this.db.transaction(() => {
|
|
803
846
|
const ownerUserId = this.resolveWorkOwnerUserId(typeof snapshot.ownerUserId === "string" ? snapshot.ownerUserId : null, true);
|
|
804
847
|
const timestamp = now();
|
|
805
|
-
this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
|
|
806
|
-
|
|
848
|
+
this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
|
|
849
|
+
editor_auto_indent_enabled, editor_typewriter_mode_enabled, version_no, created_at, updated_at, owner_user_id)
|
|
850
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, entityId, String(snapshot.title ?? "未命名作品"), String(snapshot.author ?? ""), String(snapshot.description ?? ""), String(snapshot.language ?? "zh-CN"), snapshot.coverUrl ?? null, JSON.stringify(Array.isArray(snapshot.tags) ? snapshot.tags : []), snapshot.editorAutoIndentEnabled === true ? 1 : 0, snapshot.editorTypewriterModeEnabled === true ? 1 : 0, timestamp, timestamp, ownerUserId);
|
|
807
851
|
if (ownerUserId !== SYSTEM_USER_ID) {
|
|
808
852
|
this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", entityId, ownerUserId, ownerUserId, timestamp);
|
|
809
853
|
}
|
|
@@ -868,8 +912,9 @@ export class Store {
|
|
|
868
912
|
const timestamp = now();
|
|
869
913
|
const resolvedOwnerUserId = this.resolveWorkOwnerUserId(ownerUserId);
|
|
870
914
|
this.db.transaction(() => {
|
|
871
|
-
this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
|
|
872
|
-
|
|
915
|
+
this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
|
|
916
|
+
editor_auto_indent_enabled, editor_typewriter_mode_enabled, created_at, updated_at, owner_user_id)
|
|
917
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, workId, input.title, input.author ?? "", input.description ?? "", input.language ?? "zh-CN", input.coverUrl ?? null, JSON.stringify(input.tags ?? []), input.editorAutoIndentEnabled === true ? 1 : 0, input.editorTypewriterModeEnabled === true ? 1 : 0, timestamp, timestamp, resolvedOwnerUserId);
|
|
873
918
|
if (resolvedOwnerUserId !== SYSTEM_USER_ID) {
|
|
874
919
|
this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", workId, resolvedOwnerUserId, resolvedOwnerUserId, timestamp);
|
|
875
920
|
}
|
|
@@ -1065,6 +1110,18 @@ export class Store {
|
|
|
1065
1110
|
titleGenerationModelId: row?.title_generation_model_id === null || row?.title_generation_model_id === undefined
|
|
1066
1111
|
? null
|
|
1067
1112
|
: String(row.title_generation_model_id),
|
|
1113
|
+
semanticSearchEnabled: Number(row?.semantic_search_enabled ?? 0) === 1,
|
|
1114
|
+
semanticEmbeddingModelId: row?.semantic_embedding_model_id === null || row?.semantic_embedding_model_id === undefined
|
|
1115
|
+
? null
|
|
1116
|
+
: String(row.semantic_embedding_model_id),
|
|
1117
|
+
semanticRerankModelId: row?.semantic_rerank_model_id === null || row?.semantic_rerank_model_id === undefined
|
|
1118
|
+
? null
|
|
1119
|
+
: String(row.semantic_rerank_model_id),
|
|
1120
|
+
semanticVectorDimension: Math.min(65_536, Math.max(1, Number(row?.semantic_vector_dimension ?? 1_024) || 1_024)),
|
|
1121
|
+
semanticRecallLimit: Math.min(200, Math.max(1, Number(row?.semantic_recall_limit ?? 20) || 20)),
|
|
1122
|
+
semanticResultLimit: Math.min(100, Math.max(1, Number(row?.semantic_result_limit ?? 12) || 12)),
|
|
1123
|
+
semanticBudgetTokens: Math.min(100_000, Math.max(256, Number(row?.semantic_budget_tokens ?? 4_000) || 4_000)),
|
|
1124
|
+
semanticChannelWeight: Math.min(5, Math.max(0.1, Number(row?.semantic_channel_weight ?? 1) || 1)),
|
|
1068
1125
|
updatedAt: String(row?.updated_at ?? "")
|
|
1069
1126
|
};
|
|
1070
1127
|
}
|
|
@@ -1149,6 +1206,95 @@ export class Store {
|
|
|
1149
1206
|
});
|
|
1150
1207
|
return this.getWorkAiSettings(workId);
|
|
1151
1208
|
}
|
|
1209
|
+
updateWorkSemanticSearchSettings(workId, input) {
|
|
1210
|
+
this.getWork(workId);
|
|
1211
|
+
const current = this.getWorkAiSettings(workId);
|
|
1212
|
+
const enabled = input.enabled ?? Boolean(current.semanticSearchEnabled);
|
|
1213
|
+
const embeddingModelId = input.embeddingModelId === undefined
|
|
1214
|
+
? current.semanticEmbeddingModelId ? String(current.semanticEmbeddingModelId) : null
|
|
1215
|
+
: input.embeddingModelId?.trim() || null;
|
|
1216
|
+
const rerankModelId = input.rerankModelId === undefined
|
|
1217
|
+
? current.semanticRerankModelId ? String(current.semanticRerankModelId) : null
|
|
1218
|
+
: input.rerankModelId?.trim() || null;
|
|
1219
|
+
const vectorDimension = Math.min(65_536, Math.max(1, Math.trunc(input.vectorDimension ?? Number(current.semanticVectorDimension))));
|
|
1220
|
+
const recallLimit = Math.min(200, Math.max(1, Math.trunc(input.recallLimit ?? Number(current.semanticRecallLimit))));
|
|
1221
|
+
const resultLimit = Math.min(100, Math.max(1, Math.trunc(input.resultLimit ?? Number(current.semanticResultLimit))));
|
|
1222
|
+
const budgetTokens = Math.min(100_000, Math.max(256, Math.trunc(input.budgetTokens ?? Number(current.semanticBudgetTokens))));
|
|
1223
|
+
const channelWeight = Math.min(5, Math.max(0.1, Number(input.channelWeight ?? current.semanticChannelWeight)));
|
|
1224
|
+
const timestamp = now();
|
|
1225
|
+
this.db.transaction(() => {
|
|
1226
|
+
this.db.run(`INSERT INTO work_ai_settings (
|
|
1227
|
+
work_id, semantic_search_enabled, semantic_embedding_model_id, semantic_rerank_model_id,
|
|
1228
|
+
semantic_vector_dimension, semantic_recall_limit, semantic_result_limit,
|
|
1229
|
+
semantic_budget_tokens, semantic_channel_weight, updated_at
|
|
1230
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1231
|
+
ON CONFLICT(work_id) DO UPDATE SET
|
|
1232
|
+
semantic_search_enabled = excluded.semantic_search_enabled,
|
|
1233
|
+
semantic_embedding_model_id = excluded.semantic_embedding_model_id,
|
|
1234
|
+
semantic_rerank_model_id = excluded.semantic_rerank_model_id,
|
|
1235
|
+
semantic_vector_dimension = excluded.semantic_vector_dimension,
|
|
1236
|
+
semantic_recall_limit = excluded.semantic_recall_limit,
|
|
1237
|
+
semantic_result_limit = excluded.semantic_result_limit,
|
|
1238
|
+
semantic_budget_tokens = excluded.semantic_budget_tokens,
|
|
1239
|
+
semantic_channel_weight = excluded.semantic_channel_weight,
|
|
1240
|
+
updated_at = excluded.updated_at`, workId, enabled ? 1 : 0, embeddingModelId, rerankModelId, vectorDimension, recallLimit, resultLimit, budgetTokens, channelWeight, timestamp);
|
|
1241
|
+
this.audit(workId, "semantic.settings.updated", "work-ai-settings", workId, {
|
|
1242
|
+
enabled,
|
|
1243
|
+
embeddingModelId,
|
|
1244
|
+
rerankModelId,
|
|
1245
|
+
vectorDimension,
|
|
1246
|
+
recallLimit,
|
|
1247
|
+
resultLimit,
|
|
1248
|
+
budgetTokens,
|
|
1249
|
+
channelWeight
|
|
1250
|
+
});
|
|
1251
|
+
});
|
|
1252
|
+
return this.getWorkAiSettings(workId);
|
|
1253
|
+
}
|
|
1254
|
+
getSemanticContextSnapshot(snapshotId, workId) {
|
|
1255
|
+
const work = this.getWork(workId);
|
|
1256
|
+
const row = this.db.get("SELECT * FROM semantic_context_snapshots WHERE id = ? AND work_id = ?", snapshotId, workId);
|
|
1257
|
+
if (!row)
|
|
1258
|
+
throw notFound("语义上下文快照");
|
|
1259
|
+
const actor = currentRequestActor();
|
|
1260
|
+
const createdByUserId = row.created_by_user_id === null || row.created_by_user_id === undefined
|
|
1261
|
+
? null
|
|
1262
|
+
: String(row.created_by_user_id);
|
|
1263
|
+
if (actor?.userId && createdByUserId && actor.userId !== createdByUserId) {
|
|
1264
|
+
throw new AppError(403, "SEMANTIC_SNAPSHOT_ACCESS_DENIED", "不能读取其他用户创建的语义上下文快照");
|
|
1265
|
+
}
|
|
1266
|
+
const permissions = work.modulePermissions;
|
|
1267
|
+
const items = this.db.all("SELECT * FROM semantic_context_snapshot_items WHERE snapshot_id = ? ORDER BY position", snapshotId).flatMap((item) => {
|
|
1268
|
+
const module = hybridSearchPermissionModule(String(item.source_type));
|
|
1269
|
+
if (!module || !canReadWorkModule(permissions, module))
|
|
1270
|
+
return [];
|
|
1271
|
+
return [{
|
|
1272
|
+
position: Number(item.position),
|
|
1273
|
+
entryId: String(item.entry_id),
|
|
1274
|
+
sourceType: String(item.source_type),
|
|
1275
|
+
sourceId: String(item.source_id),
|
|
1276
|
+
sectionId: String(item.section_id) || null,
|
|
1277
|
+
sourceVersion: String(item.source_version),
|
|
1278
|
+
sourceTitle: String(item.source_title),
|
|
1279
|
+
startLine: Number(item.start_line),
|
|
1280
|
+
endLine: Number(item.end_line),
|
|
1281
|
+
content: String(item.content),
|
|
1282
|
+
estimatedTokens: Number(item.estimated_tokens),
|
|
1283
|
+
matchKinds: json(String(item.match_kinds_json), ["semantic"])
|
|
1284
|
+
}];
|
|
1285
|
+
});
|
|
1286
|
+
return {
|
|
1287
|
+
id: String(row.id),
|
|
1288
|
+
workId: String(row.work_id),
|
|
1289
|
+
conversationId: row.conversation_id === null ? null : String(row.conversation_id),
|
|
1290
|
+
query: String(row.query),
|
|
1291
|
+
scope: json(String(row.scope_json), {}),
|
|
1292
|
+
configFingerprint: String(row.config_fingerprint),
|
|
1293
|
+
createdByUserId,
|
|
1294
|
+
createdAt: String(row.created_at),
|
|
1295
|
+
items
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1152
1298
|
clearAutoRunPause(workId) {
|
|
1153
1299
|
this.getWork(workId);
|
|
1154
1300
|
const current = this.getWorkAiSettings(workId);
|
|
@@ -1201,8 +1347,9 @@ export class Store {
|
|
|
1201
1347
|
const current = this.getWork(workId);
|
|
1202
1348
|
this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
|
|
1203
1349
|
const timestamp = now();
|
|
1204
|
-
this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?,
|
|
1205
|
-
|
|
1350
|
+
this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?,
|
|
1351
|
+
editor_auto_indent_enabled = ?, editor_typewriter_mode_enabled = ?, version_no = version_no + 1, updated_at = ?
|
|
1352
|
+
WHERE id = ?`, input.title ?? String(current.title), input.author ?? String(current.author), input.description ?? String(current.description), input.language ?? String(current.language), input.coverUrl === undefined ? current.coverUrl : input.coverUrl, JSON.stringify(input.tags ?? current.tags), input.editorAutoIndentEnabled === undefined ? (current.editorAutoIndentEnabled ? 1 : 0) : input.editorAutoIndentEnabled ? 1 : 0, input.editorTypewriterModeEnabled === undefined ? (current.editorTypewriterModeEnabled ? 1 : 0) : input.editorTypewriterModeEnabled ? 1 : 0, timestamp, workId);
|
|
1206
1353
|
this.recordEntityVersion("work", workId, source, sourceRef, changeNote || "更新作品信息", timestamp);
|
|
1207
1354
|
this.audit(workId, "work.updated", "work", workId, { fields: Object.keys(input), versionNo: Number(current.versionNo) + 1, source, sourceRef, changeNote });
|
|
1208
1355
|
});
|
|
@@ -2106,6 +2253,7 @@ export class Store {
|
|
|
2106
2253
|
const nextContent = input.content === undefined ? String(current.content) : input.content;
|
|
2107
2254
|
const nextExcluded = input.excludedFromAnalysis ?? Boolean(current.excludedFromAnalysis);
|
|
2108
2255
|
const nextChapterType = input.chapterType ?? String(current.chapterType);
|
|
2256
|
+
const hasContentChange = nextContent !== current.content;
|
|
2109
2257
|
const hasTextChange = nextTitle !== current.title || nextContent !== current.content;
|
|
2110
2258
|
const hasTypeChange = nextChapterType !== current.chapterType;
|
|
2111
2259
|
const hasOtherChange = nextExcluded !== current.excludedFromAnalysis || hasTypeChange;
|
|
@@ -2116,12 +2264,23 @@ export class Store {
|
|
|
2116
2264
|
this.db.transaction(() => {
|
|
2117
2265
|
const lockedCurrent = this.getChapter(chapterId);
|
|
2118
2266
|
this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedCurrent.versionNo));
|
|
2267
|
+
const storedLineIds = parseChapterLineIds(lockedCurrent.lineIds, String(lockedCurrent.content));
|
|
2268
|
+
const beforeLineIds = storedLineIds.length > 0
|
|
2269
|
+
? storedLineIds
|
|
2270
|
+
: createChapterLineIds(String(lockedCurrent.content), () => id("chapterLine"));
|
|
2271
|
+
const nextLineIds = hasContentChange
|
|
2272
|
+
? reconcileChapterLineIds(String(lockedCurrent.content), nextContent, beforeLineIds, input.lineIds, () => id("chapterLine"))
|
|
2273
|
+
: beforeLineIds;
|
|
2274
|
+
if (!nextLineIds)
|
|
2275
|
+
throw new AppError(400, "CHAPTER_LINE_IDS_INVALID", "正文行身份与当前版本不匹配,请刷新后重试");
|
|
2119
2276
|
this.db.run(`UPDATE chapters SET title = ?, content = ?, chapter_type = ?, word_count = ?, version_no = ?, analysis_status = ?,
|
|
2120
|
-
excluded_from_analysis = ?, updated_at = ? WHERE id = ?`, nextTitle, nextContent, nextChapterType, countWords(nextContent), versionNo, hasTextChange || hasTypeChange ? "expired" : String(current.analysisStatus), nextExcluded ? 1 : 0, timestamp, chapterId);
|
|
2277
|
+
excluded_from_analysis = ?, line_ids_json = ?, updated_at = ? WHERE id = ?`, nextTitle, nextContent, nextChapterType, countWords(nextContent), versionNo, hasTextChange || hasTypeChange ? "expired" : String(current.analysisStatus), nextExcluded ? 1 : 0, JSON.stringify(nextLineIds), timestamp, chapterId);
|
|
2121
2278
|
if (hasTextChange)
|
|
2122
2279
|
this.syncChapterParagraphSearch(String(current.workId), chapterId, nextContent);
|
|
2123
2280
|
else if (hasTypeChange)
|
|
2124
2281
|
this.syncChapterParagraphSearchVersion(chapterId, versionNo);
|
|
2282
|
+
if (hasContentChange)
|
|
2283
|
+
this.reanchorChapterAnnotations(String(current.workId), chapterId, String(lockedCurrent.content), nextContent, nextLineIds, source, timestamp);
|
|
2125
2284
|
if (hasTextChange || hasTypeChange) {
|
|
2126
2285
|
this.insertChapterVersionRow({
|
|
2127
2286
|
workId: String(current.workId),
|
|
@@ -2145,6 +2304,36 @@ export class Store {
|
|
|
2145
2304
|
});
|
|
2146
2305
|
return this.getChapter(chapterId);
|
|
2147
2306
|
}
|
|
2307
|
+
reanchorChapterAnnotations(workId, chapterId, beforeContent, afterContent, afterLineIds, source, timestamp) {
|
|
2308
|
+
const annotations = this.db.all(`SELECT id, start_line, end_line, quote, line_hashes_json, anchor_line_ids_json
|
|
2309
|
+
FROM chapter_annotations
|
|
2310
|
+
WHERE chapter_id = ? AND deleted_at IS NULL`, chapterId).map((row) => ({
|
|
2311
|
+
id: requiredString(row, "id"),
|
|
2312
|
+
startLine: numberValue(row, "start_line"),
|
|
2313
|
+
endLine: numberValue(row, "end_line"),
|
|
2314
|
+
quote: requiredString(row, "quote"),
|
|
2315
|
+
lineHashes: parseChapterAnnotationLineHashes(row.line_hashes_json, requiredString(row, "quote")),
|
|
2316
|
+
lineIds: parseChapterAnnotationLineIds(row.anchor_line_ids_json)
|
|
2317
|
+
}));
|
|
2318
|
+
for (const annotation of reanchorChapterAnnotations(beforeContent, afterContent, annotations, afterLineIds)) {
|
|
2319
|
+
if (!annotation.changed)
|
|
2320
|
+
continue;
|
|
2321
|
+
this.db.run(`UPDATE chapter_annotations
|
|
2322
|
+
SET start_line = ?, end_line = ?, quote = ?, line_hashes_json = ?, anchor_line_ids_json = ?, version_no = version_no + 1
|
|
2323
|
+
WHERE id = ?`, annotation.startLine, annotation.endLine, annotation.quote, JSON.stringify(annotation.lineHashes), JSON.stringify(annotation.lineIds), annotation.id);
|
|
2324
|
+
const updated = this.getChapterAnnotation(annotation.id);
|
|
2325
|
+
this.recordChapterAnnotationVersion(updated, "reanchor", timestamp);
|
|
2326
|
+
this.audit(workId, "chapter.annotation.updated", "chapter-annotation", annotation.id, {
|
|
2327
|
+
chapterId,
|
|
2328
|
+
startLine: annotation.startLine,
|
|
2329
|
+
endLine: annotation.endLine,
|
|
2330
|
+
versionNo: updated.versionNo,
|
|
2331
|
+
anchorStrategy: annotation.anchorStrategy,
|
|
2332
|
+
reason: "reanchor",
|
|
2333
|
+
source
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2148
2337
|
replaceWorkText(workId, input) {
|
|
2149
2338
|
const find = input.find;
|
|
2150
2339
|
if (!find)
|
|
@@ -2280,10 +2469,11 @@ export class Store {
|
|
|
2280
2469
|
? numberValue(this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS sort_order FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", volumeId) ?? {}, "sort_order") + 1
|
|
2281
2470
|
: numberValue(version, "sort_order");
|
|
2282
2471
|
const timestamp = now();
|
|
2472
|
+
const lineIds = createChapterLineIds(content, () => id("chapterLine"));
|
|
2283
2473
|
const nextVersionNo = numberValue(this.db.get("SELECT COALESCE(MAX(version_no), 0) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no") + 1;
|
|
2284
2474
|
this.db.transaction(() => {
|
|
2285
|
-
this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
|
|
2286
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, chapterType, sortOrder, countWords(content), nextVersionNo, timestamp, timestamp);
|
|
2475
|
+
this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, line_ids_json, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
|
|
2476
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, JSON.stringify(lineIds), chapterType, sortOrder, countWords(content), nextVersionNo, timestamp, timestamp);
|
|
2287
2477
|
this.syncChapterParagraphSearch(workId, chapterId, content);
|
|
2288
2478
|
this.insertChapterVersionRow({
|
|
2289
2479
|
workId,
|
|
@@ -2437,11 +2627,14 @@ export class Store {
|
|
|
2437
2627
|
};
|
|
2438
2628
|
}
|
|
2439
2629
|
chapterAnnotationSnapshot(annotation) {
|
|
2630
|
+
const anchor = this.db.get("SELECT anchor_line_ids_json FROM chapter_annotations WHERE id = ?", String(annotation.id));
|
|
2440
2631
|
return {
|
|
2441
2632
|
kind: annotation.kind,
|
|
2442
2633
|
startLine: annotation.startLine,
|
|
2443
2634
|
endLine: annotation.endLine,
|
|
2444
2635
|
quote: annotation.quote,
|
|
2636
|
+
lineHashes: chapterAnnotationLineHashes(String(annotation.quote)),
|
|
2637
|
+
lineIds: parseChapterAnnotationLineIds(anchor?.anchor_line_ids_json),
|
|
2445
2638
|
note: annotation.note,
|
|
2446
2639
|
status: annotation.status,
|
|
2447
2640
|
deletedAt: annotation.deletedAt ?? null
|
|
@@ -2484,7 +2677,8 @@ export class Store {
|
|
|
2484
2677
|
return this.db.all(`WITH RECURSIVE annotation_lines(line, end_line) AS (
|
|
2485
2678
|
SELECT annotation.start_line, annotation.end_line
|
|
2486
2679
|
FROM chapter_annotations annotation
|
|
2487
|
-
WHERE annotation.chapter_id = ? AND annotation.deleted_at IS NULL
|
|
2680
|
+
WHERE annotation.chapter_id = ? AND annotation.deleted_at IS NULL
|
|
2681
|
+
AND NOT (annotation.kind = 'todo' AND annotation.status = 'resolved')${kindFilter.sql}
|
|
2488
2682
|
UNION ALL
|
|
2489
2683
|
SELECT line + 1, end_line
|
|
2490
2684
|
FROM annotation_lines
|
|
@@ -2495,55 +2689,69 @@ export class Store {
|
|
|
2495
2689
|
GROUP BY line
|
|
2496
2690
|
ORDER BY line`, chapterId, ...kindFilter.params).map((row) => ({ line: Number(row.line), count: Number(row.count) }));
|
|
2497
2691
|
}
|
|
2498
|
-
listWorkChapterAnnotations(workId, kinds) {
|
|
2692
|
+
listWorkChapterAnnotations(workId, kinds, filters = {}) {
|
|
2499
2693
|
this.getWork(workId);
|
|
2500
|
-
const
|
|
2501
|
-
? { sql: "", params: [] }
|
|
2502
|
-
: kinds.length > 0
|
|
2503
|
-
? { sql: ` AND annotation.kind IN (${kinds.map(() => "?").join(",")})`, params: [...kinds] }
|
|
2504
|
-
: { sql: " AND 1 = 0", params: [] };
|
|
2694
|
+
const filter = chapterAnnotationListFilter(kinds, filters);
|
|
2505
2695
|
return this.db.all(`SELECT annotation.*, user.display_name AS actor_display_name, user.username AS actor_username,
|
|
2506
2696
|
chapter.title AS chapter_title, volume.title AS volume_title
|
|
2507
2697
|
FROM chapter_annotations annotation
|
|
2508
2698
|
JOIN chapters chapter ON chapter.id = annotation.chapter_id
|
|
2509
2699
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2510
2700
|
LEFT JOIN users user ON user.id = annotation.updated_by_user_id
|
|
2511
|
-
WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${
|
|
2512
|
-
ORDER BY CASE
|
|
2701
|
+
WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${filter.sql}
|
|
2702
|
+
ORDER BY CASE
|
|
2703
|
+
WHEN annotation.kind = 'todo' AND annotation.status = 'resolved' THEN 2
|
|
2704
|
+
WHEN annotation.status = 'open' THEN 0
|
|
2705
|
+
ELSE 1
|
|
2706
|
+
END,
|
|
2513
2707
|
volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at,
|
|
2514
|
-
annotation.start_line, annotation.created_at`, workId, ...
|
|
2708
|
+
annotation.start_line, annotation.created_at`, workId, ...filter.params).map((row) => ({
|
|
2515
2709
|
...this.mapChapterAnnotation(row),
|
|
2516
2710
|
volumeTitle: requiredString(row, "volume_title"),
|
|
2517
2711
|
chapterTitle: requiredString(row, "chapter_title")
|
|
2518
2712
|
}));
|
|
2519
2713
|
}
|
|
2520
|
-
listWorkChapterAnnotationsPage(workId, pagination, kinds) {
|
|
2714
|
+
listWorkChapterAnnotationsPage(workId, pagination, kinds, filters = {}) {
|
|
2521
2715
|
this.getWork(workId);
|
|
2522
2716
|
const page = paginationSql(pagination);
|
|
2523
|
-
const
|
|
2524
|
-
|
|
2525
|
-
: kinds.length > 0
|
|
2526
|
-
? { sql: ` AND annotation.kind IN (${kinds.map(() => "?").join(",")})`, params: [...kinds] }
|
|
2527
|
-
: { sql: " AND 1 = 0", params: [] };
|
|
2717
|
+
const filter = chapterAnnotationListFilter(kinds, filters);
|
|
2718
|
+
const optionFilter = chapterAnnotationListFilter(kinds);
|
|
2528
2719
|
const rows = this.db.all(`SELECT annotation.*, user.display_name AS actor_display_name, user.username AS actor_username,
|
|
2529
2720
|
chapter.title AS chapter_title, volume.title AS volume_title
|
|
2530
2721
|
FROM chapter_annotations annotation
|
|
2531
2722
|
JOIN chapters chapter ON chapter.id = annotation.chapter_id
|
|
2532
2723
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2533
2724
|
LEFT JOIN users user ON user.id = annotation.updated_by_user_id
|
|
2534
|
-
WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${
|
|
2535
|
-
ORDER BY CASE
|
|
2725
|
+
WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${filter.sql}
|
|
2726
|
+
ORDER BY CASE
|
|
2727
|
+
WHEN annotation.kind = 'todo' AND annotation.status = 'resolved' THEN 2
|
|
2728
|
+
WHEN annotation.status = 'open' THEN 0
|
|
2729
|
+
ELSE 1
|
|
2730
|
+
END,
|
|
2536
2731
|
volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at,
|
|
2537
|
-
annotation.start_line, annotation.created_at${page.sql}`, workId, ...
|
|
2732
|
+
annotation.start_line, annotation.created_at${page.sql}`, workId, ...filter.params, ...page.params);
|
|
2538
2733
|
const total = numberValue(this.db.get(`SELECT COUNT(*) AS count
|
|
2539
2734
|
FROM chapter_annotations annotation
|
|
2540
2735
|
JOIN chapters chapter ON chapter.id = annotation.chapter_id
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2736
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2737
|
+
WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${filter.sql}`, workId, ...filter.params) ?? {}, "count");
|
|
2738
|
+
const chapterOptions = this.db.all(`SELECT DISTINCT chapter.id, chapter.title, volume.title AS volume_title,
|
|
2739
|
+
volume.sort_order AS volume_sort_order, volume.created_at AS volume_created_at,
|
|
2740
|
+
chapter.sort_order AS chapter_sort_order, chapter.created_at AS chapter_created_at
|
|
2741
|
+
FROM chapter_annotations annotation
|
|
2742
|
+
JOIN chapters chapter ON chapter.id = annotation.chapter_id
|
|
2743
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2744
|
+
WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${optionFilter.sql}
|
|
2745
|
+
ORDER BY volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at`, workId, ...optionFilter.params).map((row) => ({
|
|
2746
|
+
id: requiredString(row, "id"),
|
|
2747
|
+
title: requiredString(row, "title"),
|
|
2748
|
+
volumeTitle: requiredString(row, "volume_title")
|
|
2749
|
+
}));
|
|
2750
|
+
return { ...paginated(rows.map((row) => ({
|
|
2751
|
+
...this.mapChapterAnnotation(row),
|
|
2752
|
+
volumeTitle: requiredString(row, "volume_title"),
|
|
2753
|
+
chapterTitle: requiredString(row, "chapter_title")
|
|
2754
|
+
})), pagination, total), chapterOptions };
|
|
2547
2755
|
}
|
|
2548
2756
|
createChapterAnnotation(chapterId, input) {
|
|
2549
2757
|
const chapter = this.getChapter(chapterId);
|
|
@@ -2555,9 +2763,15 @@ export class Store {
|
|
|
2555
2763
|
const annotationId = id("chapterAnnotation");
|
|
2556
2764
|
const timestamp = now();
|
|
2557
2765
|
const actorId = currentRequestActor()?.userId ?? null;
|
|
2766
|
+
const quote = lines.slice(input.startLine - 1, input.endLine).join("\n");
|
|
2767
|
+
const chapterLineIds = parseChapterLineIds(chapter.lineIds, String(chapter.content));
|
|
2768
|
+
if (lines.length <= MAX_CHAPTER_LINE_IDS && chapterLineIds.length !== lines.length) {
|
|
2769
|
+
throw new AppError(409, "CHAPTER_LINE_IDS_MISSING", "正文行身份尚未初始化,请重新保存正文后再添加评论");
|
|
2770
|
+
}
|
|
2771
|
+
const anchorLineIds = chapterLineIds.slice(input.startLine - 1, input.endLine);
|
|
2558
2772
|
this.db.transaction(() => {
|
|
2559
|
-
this.db.run(`INSERT INTO chapter_annotations (id, work_id, chapter_id, kind, start_line, end_line, quote, note, status, version_no, created_at, updated_at, created_by_user_id, updated_by_user_id)
|
|
2560
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', 1, ?, ?, ?, ?)`, annotationId, String(chapter.workId), chapterId, input.kind, input.startLine, input.endLine,
|
|
2773
|
+
this.db.run(`INSERT INTO chapter_annotations (id, work_id, chapter_id, kind, start_line, end_line, quote, line_hashes_json, anchor_line_ids_json, note, status, version_no, created_at, updated_at, created_by_user_id, updated_by_user_id)
|
|
2774
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', 1, ?, ?, ?, ?)`, annotationId, String(chapter.workId), chapterId, input.kind, input.startLine, input.endLine, quote, JSON.stringify(chapterAnnotationLineHashes(quote)), JSON.stringify(anchorLineIds), input.note.trim(), timestamp, timestamp, actorId, actorId);
|
|
2561
2775
|
const annotation = this.getChapterAnnotation(annotationId);
|
|
2562
2776
|
this.recordChapterAnnotationVersion(annotation, "create", timestamp);
|
|
2563
2777
|
this.audit(String(chapter.workId), "chapter.annotation.created", "chapter-annotation", annotationId, { kind: input.kind, chapterId, startLine: input.startLine, endLine: input.endLine });
|
|
@@ -2603,6 +2817,64 @@ export class Store {
|
|
|
2603
2817
|
return chapter;
|
|
2604
2818
|
});
|
|
2605
2819
|
const timestamp = now();
|
|
2820
|
+
if (action.type === "renumberTitles") {
|
|
2821
|
+
if (action.startAt + currentChapters.length - 1 > 999_999) {
|
|
2822
|
+
throw new AppError(400, "CHAPTER_NUMBER_RANGE", "起始序号与所选章节数量超出最大序号 999999");
|
|
2823
|
+
}
|
|
2824
|
+
const currentById = new Map(currentChapters.map((chapter) => [String(chapter.id), chapter]));
|
|
2825
|
+
const placeholders = currentChapters.map(() => "?").join(", ");
|
|
2826
|
+
const orderedChapters = this.db.all(`SELECT chapter.id FROM chapters chapter
|
|
2827
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2828
|
+
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL
|
|
2829
|
+
AND volume.deleted_at IS NULL AND chapter.id IN (${placeholders})
|
|
2830
|
+
ORDER BY volume.sort_order, volume.created_at, volume.id,
|
|
2831
|
+
chapter.sort_order, chapter.created_at, chapter.id`, workId, ...currentChapters.map((chapter) => String(chapter.id))).map((row) => currentById.get(requiredString(row, "id"))).filter((chapter) => Boolean(chapter));
|
|
2832
|
+
const renumbered = orderedChapters.map((chapter, index) => ({
|
|
2833
|
+
chapter,
|
|
2834
|
+
title: renumberChapterTitle(String(chapter.title), action.startAt + index, action.template, action.numberStyle),
|
|
2835
|
+
sequence: action.startAt + index
|
|
2836
|
+
}));
|
|
2837
|
+
const oversized = renumbered.find((item) => item.title.length > 300);
|
|
2838
|
+
if (oversized) {
|
|
2839
|
+
throw new AppError(400, "CHAPTER_TITLE_TOO_LONG", `章节“${String(oversized.chapter.title).slice(0, 40)}”重排后的标题超过 300 个字符`);
|
|
2840
|
+
}
|
|
2841
|
+
let updated = 0;
|
|
2842
|
+
for (const item of renumbered) {
|
|
2843
|
+
const chapter = item.chapter;
|
|
2844
|
+
if (item.title === chapter.title)
|
|
2845
|
+
continue;
|
|
2846
|
+
const chapterId = String(chapter.id);
|
|
2847
|
+
const versionNo = Number(chapter.versionNo) + 1;
|
|
2848
|
+
this.db.run("UPDATE chapters SET title = ?, version_no = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", item.title, versionNo, timestamp, chapterId);
|
|
2849
|
+
this.syncChapterParagraphSearchVersion(chapterId, versionNo);
|
|
2850
|
+
this.insertChapterVersionRow({
|
|
2851
|
+
workId,
|
|
2852
|
+
chapterId,
|
|
2853
|
+
versionNo,
|
|
2854
|
+
title: item.title,
|
|
2855
|
+
content: String(chapter.content),
|
|
2856
|
+
volumeId: String(chapter.volumeId),
|
|
2857
|
+
sortOrder: Number(chapter.sortOrder),
|
|
2858
|
+
chapterType: String(chapter.chapterType),
|
|
2859
|
+
source: "manual",
|
|
2860
|
+
sourceRef: null,
|
|
2861
|
+
changeNote: "批量重排章节标题序号",
|
|
2862
|
+
timestamp
|
|
2863
|
+
});
|
|
2864
|
+
this.invalidateChapter(workId, chapterId, versionNo);
|
|
2865
|
+
this.audit(workId, "chapter.saved", "chapter", chapterId, {
|
|
2866
|
+
previousTitle: chapter.title,
|
|
2867
|
+
title: item.title,
|
|
2868
|
+
sequence: item.sequence,
|
|
2869
|
+
versionNo,
|
|
2870
|
+
batch: true,
|
|
2871
|
+
renumbered: true
|
|
2872
|
+
});
|
|
2873
|
+
updated += 1;
|
|
2874
|
+
}
|
|
2875
|
+
this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
|
|
2876
|
+
return { processed: currentChapters.length, updated, action: action.type };
|
|
2877
|
+
}
|
|
2606
2878
|
if (action.type === "move") {
|
|
2607
2879
|
const targetVolume = this.getVolume(action.volumeId);
|
|
2608
2880
|
if (targetVolume.workId !== workId)
|
|
@@ -2787,8 +3059,9 @@ export class Store {
|
|
|
2787
3059
|
insertChapter(workId, volumeId, title, content, sortOrder, source, sourceRef, chapterType = "正文") {
|
|
2788
3060
|
const chapterId = id("chapter");
|
|
2789
3061
|
const timestamp = now();
|
|
2790
|
-
|
|
2791
|
-
|
|
3062
|
+
const lineIds = createChapterLineIds(content, () => id("chapterLine"));
|
|
3063
|
+
this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, line_ids_json, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
|
|
3064
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, JSON.stringify(lineIds), chapterType, sortOrder, countWords(content), timestamp, timestamp);
|
|
2792
3065
|
this.syncChapterParagraphSearch(workId, chapterId, content);
|
|
2793
3066
|
this.insertChapterVersionRow({
|
|
2794
3067
|
workId,
|
|
@@ -3091,6 +3364,8 @@ export class Store {
|
|
|
3091
3364
|
: optionalString(row, "cover_url"),
|
|
3092
3365
|
tags: json(requiredString(row, "tags_json"), []),
|
|
3093
3366
|
offlineAccessEnabled: numberValue(row, "offline_access_enabled") === 1,
|
|
3367
|
+
editorAutoIndentEnabled: numberValue(row, "editor_auto_indent_enabled") === 1,
|
|
3368
|
+
editorTypewriterModeEnabled: numberValue(row, "editor_typewriter_mode_enabled") === 1,
|
|
3094
3369
|
versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", workId),
|
|
3095
3370
|
ownerUserId,
|
|
3096
3371
|
accessRole,
|
|
@@ -3123,7 +3398,8 @@ export class Store {
|
|
|
3123
3398
|
mapChapter(row) {
|
|
3124
3399
|
return {
|
|
3125
3400
|
...this.mapChapterDirectoryEntry(row),
|
|
3126
|
-
content: requiredString(row, "content")
|
|
3401
|
+
content: requiredString(row, "content"),
|
|
3402
|
+
lineIds: parseChapterLineIds(row.line_ids_json, requiredString(row, "content"))
|
|
3127
3403
|
};
|
|
3128
3404
|
}
|
|
3129
3405
|
mapChapterDirectoryEntry(row) {
|
|
@@ -5578,6 +5854,7 @@ export class Store {
|
|
|
5578
5854
|
const timelineEvents = this.listTimelineEvents(workId).filter((event) => event.participantIds.includes(sourceId));
|
|
5579
5855
|
const sourceMemberships = this.db.all("SELECT * FROM character_organization_memberships WHERE character_id = ? ORDER BY organization_id", sourceId);
|
|
5580
5856
|
const referenceSnapshot = { relationships: sourceRelationships, timelineEvents, memberships: sourceMemberships };
|
|
5857
|
+
let roleplayMemoryMerge = { migrated: 0, deduplicated: 0 };
|
|
5581
5858
|
this.db.transaction(() => {
|
|
5582
5859
|
const lockedTarget = this.getCharacter(targetId);
|
|
5583
5860
|
const lockedSource = this.getCharacter(sourceId);
|
|
@@ -5645,6 +5922,7 @@ export class Store {
|
|
|
5645
5922
|
this.db.run("UPDATE character_profile_sections SET character_id = ?, updated_at = ? WHERE character_id = ?", targetId, timestamp, sourceId);
|
|
5646
5923
|
this.db.run("UPDATE character_profile_section_versions SET character_id = ? WHERE character_id = ?", targetId, sourceId);
|
|
5647
5924
|
this.db.run("UPDATE character_profile_section_search SET character_id = ? WHERE character_id = ?", targetId, sourceId);
|
|
5925
|
+
roleplayMemoryMerge = this.mergeRoleplayMemoriesForCharacters(workId, targetId, sourceId, timestamp);
|
|
5648
5926
|
const sourceVersionNo = Number(source.versionNo) + 1;
|
|
5649
5927
|
this.db.run("UPDATE characters SET merged_into_character_id = ?, merged_at = ?, version_no = ?, updated_at = ? WHERE id = ?", targetId, timestamp, sourceVersionNo, timestamp, sourceId);
|
|
5650
5928
|
this.insertCharacterVersion(sourceId, sourceVersionNo, "merge", mergeId, `合并至角色“${String(target.name)}”`, timestamp);
|
|
@@ -5657,16 +5935,60 @@ export class Store {
|
|
|
5657
5935
|
this.audit(workId, "character.merged", "character", targetId, {
|
|
5658
5936
|
mergeId,
|
|
5659
5937
|
sourceCharacterId: sourceId,
|
|
5660
|
-
reviewId: input.reviewId
|
|
5938
|
+
reviewId: input.reviewId,
|
|
5939
|
+
roleplayMemoryMerge
|
|
5661
5940
|
});
|
|
5662
5941
|
});
|
|
5663
5942
|
return {
|
|
5664
5943
|
mergeId,
|
|
5665
5944
|
target: this.getCharacter(targetId),
|
|
5666
5945
|
source: this.getCharacter(sourceId),
|
|
5667
|
-
review: input.reviewId ? this.getReviewItem(input.reviewId) : null
|
|
5946
|
+
review: input.reviewId ? this.getReviewItem(input.reviewId) : null,
|
|
5947
|
+
roleplayMemoryMerge
|
|
5668
5948
|
};
|
|
5669
5949
|
}
|
|
5950
|
+
mergeRoleplayMemoriesForCharacters(workId, targetCharacterId, sourceCharacterId, timestamp) {
|
|
5951
|
+
let migrated = 0;
|
|
5952
|
+
let deduplicated = 0;
|
|
5953
|
+
const sourceMemories = this.db.all("SELECT * FROM roleplay_memories WHERE work_id = ? AND character_id = ? ORDER BY created_at, id", workId, sourceCharacterId);
|
|
5954
|
+
for (const sourceMemory of sourceMemories) {
|
|
5955
|
+
const sourceMemoryId = requiredString(sourceMemory, "id");
|
|
5956
|
+
const duplicate = this.db.get("SELECT * FROM roleplay_memories WHERE character_id = ? AND content_hash = ?", targetCharacterId, requiredString(sourceMemory, "content_hash"));
|
|
5957
|
+
if (!duplicate) {
|
|
5958
|
+
this.db.run("UPDATE roleplay_memories SET character_id = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ?", targetCharacterId, currentRequestActor()?.userId ?? null, timestamp, sourceMemoryId);
|
|
5959
|
+
migrated += 1;
|
|
5960
|
+
continue;
|
|
5961
|
+
}
|
|
5962
|
+
const targetMemoryId = requiredString(duplicate, "id");
|
|
5963
|
+
this.db.run("UPDATE roleplay_memories SET superseded_by_memory_id = ? WHERE superseded_by_memory_id = ?", targetMemoryId, sourceMemoryId);
|
|
5964
|
+
for (const source of this.db.all("SELECT * FROM roleplay_memory_sources WHERE memory_id = ? ORDER BY created_at, id", sourceMemoryId)) {
|
|
5965
|
+
this.db.run(`INSERT OR IGNORE INTO roleplay_memory_sources (
|
|
5966
|
+
id, memory_id, conversation_id, message_id, message_role, source_created_by_user_id,
|
|
5967
|
+
source_created_at, evidence_snapshot, created_at
|
|
5968
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("roleplay_memory_source"), targetMemoryId, optionalString(source, "conversation_id"), optionalString(source, "message_id"), requiredString(source, "message_role"), optionalString(source, "source_created_by_user_id"), requiredString(source, "source_created_at"), requiredString(source, "evidence_snapshot"), requiredString(source, "created_at"));
|
|
5969
|
+
}
|
|
5970
|
+
let nextVersion = numberValue(duplicate, "version_no");
|
|
5971
|
+
for (const version of this.db.all("SELECT * FROM roleplay_memory_versions WHERE memory_id = ? ORDER BY version_no", sourceMemoryId)) {
|
|
5972
|
+
nextVersion += 1;
|
|
5973
|
+
this.db.run(`INSERT INTO roleplay_memory_versions (
|
|
5974
|
+
id, memory_id, version_no, category, content, importance, certainty,
|
|
5975
|
+
status, is_pinned, action, actor_user_id, created_at
|
|
5976
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'merged', ?, ?)`, id("roleplay_memory_version"), targetMemoryId, nextVersion, requiredString(version, "category"), requiredString(version, "content"), requiredString(version, "importance"), requiredString(version, "certainty"), requiredString(version, "status"), booleanValue(version, "is_pinned") ? 1 : 0, currentRequestActor()?.userId ?? optionalString(version, "actor_user_id"), requiredString(version, "created_at"));
|
|
5977
|
+
}
|
|
5978
|
+
nextVersion += 1;
|
|
5979
|
+
this.db.run(`UPDATE roleplay_memories SET is_pinned = CASE WHEN is_pinned = 1 OR ? = 1 THEN 1 ELSE 0 END,
|
|
5980
|
+
status = CASE WHEN status = 'active' OR ? = 'active' THEN 'active' ELSE status END,
|
|
5981
|
+
superseded_by_memory_id = CASE WHEN status = 'active' OR ? = 'active' THEN NULL ELSE superseded_by_memory_id END,
|
|
5982
|
+
version_no = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, booleanValue(sourceMemory, "is_pinned") ? 1 : 0, requiredString(sourceMemory, "status"), requiredString(sourceMemory, "status"), nextVersion, currentRequestActor()?.userId ?? null, timestamp, targetMemoryId);
|
|
5983
|
+
const mergedTarget = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", targetMemoryId);
|
|
5984
|
+
if (!mergedTarget)
|
|
5985
|
+
throw notFound("角色扮演记忆");
|
|
5986
|
+
this.insertRoleplayMemoryVersion(mergedTarget, "merged");
|
|
5987
|
+
this.db.run("DELETE FROM roleplay_memories WHERE id = ?", sourceMemoryId);
|
|
5988
|
+
deduplicated += 1;
|
|
5989
|
+
}
|
|
5990
|
+
return { migrated, deduplicated };
|
|
5991
|
+
}
|
|
5670
5992
|
resolveCharacterDuplicateReview(reviewId) {
|
|
5671
5993
|
const review = this.getReviewItem(reviewId);
|
|
5672
5994
|
if (review.itemType !== "character-duplicate" || review.status !== "pending") {
|
|
@@ -6310,6 +6632,270 @@ export class Store {
|
|
|
6310
6632
|
messagesPage
|
|
6311
6633
|
};
|
|
6312
6634
|
}
|
|
6635
|
+
roleplayMemoryContentHash(content) {
|
|
6636
|
+
return this.hashContent(normalizeRoleplayMemoryContent(content).toLocaleLowerCase("zh-CN"));
|
|
6637
|
+
}
|
|
6638
|
+
insertRoleplayMemoryVersion(memory, action) {
|
|
6639
|
+
this.db.run(`INSERT INTO roleplay_memory_versions (
|
|
6640
|
+
id, memory_id, version_no, category, content, importance, certainty,
|
|
6641
|
+
status, is_pinned, action, actor_user_id, created_at
|
|
6642
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("roleplay_memory_version"), requiredString(memory, "id"), numberValue(memory, "version_no"), requiredString(memory, "category"), requiredString(memory, "content"), requiredString(memory, "importance"), requiredString(memory, "certainty"), requiredString(memory, "status"), booleanValue(memory, "is_pinned") ? 1 : 0, action, currentRequestActor()?.userId ?? optionalString(memory, "updated_by_user_id"), now());
|
|
6643
|
+
}
|
|
6644
|
+
roleplayMemoryListRows(characterId, options = {}) {
|
|
6645
|
+
const cursor = Math.max(0, Math.min(100_000, options.cursor ?? 0));
|
|
6646
|
+
const limit = Math.max(1, Math.min(100, options.limit ?? 20));
|
|
6647
|
+
const where = ["memory.character_id = ?"];
|
|
6648
|
+
const params = [characterId];
|
|
6649
|
+
const statuses = options.statuses?.length ? [...new Set(options.statuses)] : ["active"];
|
|
6650
|
+
where.push(`memory.status IN (${statuses.map(() => "?").join(", ")})`);
|
|
6651
|
+
params.push(...statuses);
|
|
6652
|
+
const categories = options.categories?.length ? [...new Set(options.categories)] : [];
|
|
6653
|
+
if (categories.length > 0) {
|
|
6654
|
+
where.push(`memory.category IN (${categories.map(() => "?").join(", ")})`);
|
|
6655
|
+
params.push(...categories);
|
|
6656
|
+
}
|
|
6657
|
+
const query = normalizeRoleplayMemoryContent(options.query ?? "", 200).toLocaleLowerCase("zh-CN");
|
|
6658
|
+
if (query) {
|
|
6659
|
+
if (Array.from(query).length >= 3) {
|
|
6660
|
+
where.push("memory.id IN (SELECT memory_id FROM roleplay_memory_fts WHERE roleplay_memory_fts MATCH ?)");
|
|
6661
|
+
params.push(JSON.stringify(query));
|
|
6662
|
+
}
|
|
6663
|
+
else {
|
|
6664
|
+
where.push("lower(memory.content) LIKE ? ESCAPE '\\'");
|
|
6665
|
+
params.push(`%${escapeSqlLikePattern(query)}%`);
|
|
6666
|
+
}
|
|
6667
|
+
}
|
|
6668
|
+
const whereSql = where.join(" AND ");
|
|
6669
|
+
const total = Number(this.db.get(`SELECT COUNT(*) AS count FROM roleplay_memories memory WHERE ${whereSql}`, ...params)?.count ?? 0);
|
|
6670
|
+
const rows = this.db.all(`SELECT memory.* FROM roleplay_memories memory
|
|
6671
|
+
WHERE ${whereSql}
|
|
6672
|
+
ORDER BY memory.is_pinned DESC,
|
|
6673
|
+
CASE memory.importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
|
|
6674
|
+
memory.updated_at DESC, memory.id
|
|
6675
|
+
LIMIT ? OFFSET ?`, ...params, limit, cursor);
|
|
6676
|
+
return { rows, total, cursor, limit };
|
|
6677
|
+
}
|
|
6678
|
+
listRoleplayMemories(characterId, options = {}) {
|
|
6679
|
+
const character = this.getCharacter(characterId);
|
|
6680
|
+
const page = this.roleplayMemoryListRows(characterId, options);
|
|
6681
|
+
const nextCursor = page.cursor + page.rows.length < page.total ? page.cursor + page.rows.length : null;
|
|
6682
|
+
return {
|
|
6683
|
+
character: { id: character.id, name: character.name, workId: character.workId },
|
|
6684
|
+
items: page.rows.map((memory) => this.mapRoleplayMemory(memory, true)),
|
|
6685
|
+
pagination: { cursor: page.cursor, limit: page.limit, total: page.total, nextCursor }
|
|
6686
|
+
};
|
|
6687
|
+
}
|
|
6688
|
+
isRoleplayMemorySourceTarget(sourceId, conversationId, messageId) {
|
|
6689
|
+
return Boolean(this.db.get(`SELECT source.id
|
|
6690
|
+
FROM roleplay_memory_sources source
|
|
6691
|
+
INNER JOIN roleplay_memories memory ON memory.id = source.memory_id
|
|
6692
|
+
WHERE source.id = ? AND source.conversation_id = ? AND source.message_id = ?`, sourceId, conversationId, messageId));
|
|
6693
|
+
}
|
|
6694
|
+
getRoleplayMemoryPromptItems(workId, characterId, limit = 12) {
|
|
6695
|
+
const character = this.getCharacter(characterId);
|
|
6696
|
+
if (String(character.workId) !== workId)
|
|
6697
|
+
throw new AppError(400, "ROLEPLAY_CHARACTER_WORK_MISMATCH", "角色不属于当前作品");
|
|
6698
|
+
return this.roleplayMemoryListRows(characterId, {
|
|
6699
|
+
statuses: ["active"],
|
|
6700
|
+
limit: Math.max(1, Math.min(30, limit))
|
|
6701
|
+
}).rows.map((memory) => this.mapRoleplayMemory(memory, false));
|
|
6702
|
+
}
|
|
6703
|
+
recallRoleplayMemories(workId, characterId, query, categories, cursor) {
|
|
6704
|
+
const character = this.getCharacter(characterId);
|
|
6705
|
+
if (String(character.workId) !== workId)
|
|
6706
|
+
throw new AppError(400, "ROLEPLAY_CHARACTER_WORK_MISMATCH", "角色不属于当前作品");
|
|
6707
|
+
const result = this.listRoleplayMemories(characterId, { query, categories, statuses: ["active"], cursor, limit: 20 });
|
|
6708
|
+
return {
|
|
6709
|
+
origin: "roleplay",
|
|
6710
|
+
canonical: false,
|
|
6711
|
+
character: result.character,
|
|
6712
|
+
memories: result.items,
|
|
6713
|
+
pagination: result.pagination
|
|
6714
|
+
};
|
|
6715
|
+
}
|
|
6716
|
+
createRoleplayMemory(characterId, input) {
|
|
6717
|
+
const character = this.getCharacter(characterId);
|
|
6718
|
+
if (character.mergedIntoCharacterId)
|
|
6719
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_CHARACTER_MERGED", "已合并角色不能新增角色扮演记忆");
|
|
6720
|
+
const workId = String(character.workId);
|
|
6721
|
+
const content = normalizeRoleplayMemoryContent(input.content);
|
|
6722
|
+
if (!content)
|
|
6723
|
+
throw new AppError(400, "ROLEPLAY_MEMORY_CONTENT_REQUIRED", "请输入记忆内容");
|
|
6724
|
+
const memoryId = id("roleplay_memory");
|
|
6725
|
+
const contentHash = this.roleplayMemoryContentHash(content);
|
|
6726
|
+
const timestamp = now();
|
|
6727
|
+
this.db.transaction(() => {
|
|
6728
|
+
if (this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND content_hash = ?", characterId, contentHash)) {
|
|
6729
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_DUPLICATE", "该角色已经有相同内容的角色扮演记忆");
|
|
6730
|
+
}
|
|
6731
|
+
this.db.run(`INSERT INTO roleplay_memories (
|
|
6732
|
+
id, work_id, character_id, category, content, content_hash, importance, certainty,
|
|
6733
|
+
status, is_pinned, version_no, source_type, created_by_user_id, updated_by_user_id, created_at, updated_at
|
|
6734
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 1, 'manual', ?, ?, ?, ?)`, memoryId, workId, characterId, input.category, content, contentHash, input.importance ?? "medium", input.certainty ?? "experienced", input.isPinned ? 1 : 0, currentRequestActor()?.userId ?? null, currentRequestActor()?.userId ?? null, timestamp, timestamp);
|
|
6735
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6736
|
+
if (!memory)
|
|
6737
|
+
throw notFound("角色扮演记忆");
|
|
6738
|
+
this.insertRoleplayMemoryVersion(memory, "created");
|
|
6739
|
+
this.audit(workId, "roleplay-memory.created", "roleplay-memory", memoryId, { characterId });
|
|
6740
|
+
});
|
|
6741
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6742
|
+
if (!memory)
|
|
6743
|
+
throw notFound("角色扮演记忆");
|
|
6744
|
+
return this.mapRoleplayMemory(memory, true);
|
|
6745
|
+
}
|
|
6746
|
+
commitRoleplayMemoryCandidates(conversationId, assistantMessageId, sourceUserMessageId, candidates) {
|
|
6747
|
+
if (candidates.length === 0)
|
|
6748
|
+
return [];
|
|
6749
|
+
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
6750
|
+
if (!conversation)
|
|
6751
|
+
throw notFound("AI 对话");
|
|
6752
|
+
const characterId = optionalString(conversation, "roleplay_character_id");
|
|
6753
|
+
if (!characterId)
|
|
6754
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_REQUIRES_ROLEPLAY", "只有角色扮演对话可以写入角色扮演记忆");
|
|
6755
|
+
const workId = requiredString(conversation, "work_id");
|
|
6756
|
+
const assistant = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ? AND conversation_id = ? AND role = 'assistant'", assistantMessageId, conversationId);
|
|
6757
|
+
if (!assistant)
|
|
6758
|
+
throw new AppError(400, "ROLEPLAY_MEMORY_ASSISTANT_MISMATCH", "角色扮演记忆来源回复不属于当前对话");
|
|
6759
|
+
const userMessage = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ? AND conversation_id = ? AND role = 'user'", sourceUserMessageId, conversationId);
|
|
6760
|
+
if (!userMessage)
|
|
6761
|
+
throw new AppError(400, "ROLEPLAY_MEMORY_USER_MESSAGE_MISMATCH", "角色扮演记忆来源消息不属于当前对话");
|
|
6762
|
+
const committedIds = [];
|
|
6763
|
+
this.db.transaction(() => {
|
|
6764
|
+
const validCandidates = candidates.slice(0, 8).flatMap((candidate) => {
|
|
6765
|
+
const content = normalizeRoleplayMemoryContent(candidate.content, 500);
|
|
6766
|
+
if (!content || !roleplayMemoryCandidateIsSafe(content))
|
|
6767
|
+
return [];
|
|
6768
|
+
return [{ ...candidate, content }];
|
|
6769
|
+
});
|
|
6770
|
+
const insertable = validCandidates.flatMap((candidate) => {
|
|
6771
|
+
const idempotencyKey = this.hashContent(`${assistantMessageId}:${JSON.stringify(candidate)}`);
|
|
6772
|
+
const contentHash = this.roleplayMemoryContentHash(candidate.content);
|
|
6773
|
+
const existing = this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND (idempotency_key = ? OR content_hash = ?)", characterId, idempotencyKey, contentHash);
|
|
6774
|
+
return existing ? [] : [{ candidate, idempotencyKey, contentHash }];
|
|
6775
|
+
});
|
|
6776
|
+
if (insertable.length === 0)
|
|
6777
|
+
return;
|
|
6778
|
+
const timestamp = now();
|
|
6779
|
+
for (const { candidate, idempotencyKey, contentHash } of insertable) {
|
|
6780
|
+
const memoryId = id("roleplay_memory");
|
|
6781
|
+
const superseded = candidate.supersedesMemoryId
|
|
6782
|
+
? this.db.get("SELECT * FROM roleplay_memories WHERE id = ? AND character_id = ? AND status = 'active'", candidate.supersedesMemoryId, characterId)
|
|
6783
|
+
: undefined;
|
|
6784
|
+
this.db.run(`INSERT INTO roleplay_memories (
|
|
6785
|
+
id, work_id, character_id, category, content, content_hash, importance, certainty, status, is_pinned, version_no,
|
|
6786
|
+
source_type, source_assistant_message_id, idempotency_key,
|
|
6787
|
+
created_by_user_id, updated_by_user_id, created_at, updated_at
|
|
6788
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 0, 1, 'ai', ?, ?, ?, ?, ?, ?)`, memoryId, workId, characterId, candidate.category, candidate.content, contentHash, candidate.importance, candidate.certainty, assistantMessageId, idempotencyKey, optionalString(conversation, "created_by_user_id"), optionalString(conversation, "created_by_user_id"), timestamp, timestamp);
|
|
6789
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6790
|
+
if (!memory)
|
|
6791
|
+
throw notFound("角色扮演记忆");
|
|
6792
|
+
this.insertRoleplayMemoryVersion(memory, "created");
|
|
6793
|
+
for (const source of [userMessage, assistant]) {
|
|
6794
|
+
this.db.run(`INSERT INTO roleplay_memory_sources (
|
|
6795
|
+
id, memory_id, conversation_id, message_id, message_role, source_created_by_user_id,
|
|
6796
|
+
source_created_at, evidence_snapshot, created_at
|
|
6797
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("roleplay_memory_source"), memoryId, conversationId, requiredString(source, "id"), requiredString(source, "role"), optionalString(conversation, "created_by_user_id"), requiredString(source, "created_at"), Array.from(requiredString(source, "content")
|
|
6798
|
+
.replace(/\r\n?/gu, "\n")
|
|
6799
|
+
.replace(/\n{3,}/gu, "\n\n")
|
|
6800
|
+
.trim()).slice(0, 2_000).join(""), timestamp);
|
|
6801
|
+
}
|
|
6802
|
+
if (superseded) {
|
|
6803
|
+
const nextVersion = numberValue(superseded, "version_no") + 1;
|
|
6804
|
+
this.db.run(`UPDATE roleplay_memories SET status = 'superseded', superseded_by_memory_id = ?,
|
|
6805
|
+
version_no = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, memoryId, nextVersion, optionalString(conversation, "created_by_user_id"), timestamp, requiredString(superseded, "id"));
|
|
6806
|
+
const updatedSuperseded = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", requiredString(superseded, "id"));
|
|
6807
|
+
if (updatedSuperseded)
|
|
6808
|
+
this.insertRoleplayMemoryVersion(updatedSuperseded, "superseded");
|
|
6809
|
+
}
|
|
6810
|
+
committedIds.push(memoryId);
|
|
6811
|
+
}
|
|
6812
|
+
this.audit(workId, "roleplay-memory.ai-committed", "character", characterId, {
|
|
6813
|
+
conversationId,
|
|
6814
|
+
assistantMessageId,
|
|
6815
|
+
memoryCount: committedIds.length
|
|
6816
|
+
});
|
|
6817
|
+
});
|
|
6818
|
+
return committedIds.flatMap((memoryId) => {
|
|
6819
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6820
|
+
return memory ? [this.mapRoleplayMemory(memory, true)] : [];
|
|
6821
|
+
});
|
|
6822
|
+
}
|
|
6823
|
+
getRoleplayMemoryAccess(memoryId) {
|
|
6824
|
+
const row = this.db.get("SELECT work_id, character_id FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6825
|
+
if (!row)
|
|
6826
|
+
throw notFound("角色扮演记忆");
|
|
6827
|
+
return { workId: requiredString(row, "work_id"), characterId: requiredString(row, "character_id") };
|
|
6828
|
+
}
|
|
6829
|
+
updateRoleplayMemory(memoryId, input) {
|
|
6830
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6831
|
+
if (!memory)
|
|
6832
|
+
throw notFound("角色扮演记忆");
|
|
6833
|
+
if (numberValue(memory, "version_no") !== input.expectedVersion) {
|
|
6834
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_VERSION_CONFLICT", "记忆已被其他操作更新,请刷新后重试");
|
|
6835
|
+
}
|
|
6836
|
+
const content = input.content === undefined ? requiredString(memory, "content") : normalizeRoleplayMemoryContent(input.content);
|
|
6837
|
+
if (!content)
|
|
6838
|
+
throw new AppError(400, "ROLEPLAY_MEMORY_CONTENT_REQUIRED", "请输入记忆内容");
|
|
6839
|
+
const category = input.category ?? requiredString(memory, "category");
|
|
6840
|
+
const importance = input.importance ?? requiredString(memory, "importance");
|
|
6841
|
+
const certainty = input.certainty ?? requiredString(memory, "certainty");
|
|
6842
|
+
const isPinned = input.isPinned ?? booleanValue(memory, "is_pinned");
|
|
6843
|
+
const contentChanged = content !== requiredString(memory, "content")
|
|
6844
|
+
|| category !== requiredString(memory, "category")
|
|
6845
|
+
|| importance !== requiredString(memory, "importance")
|
|
6846
|
+
|| certainty !== requiredString(memory, "certainty");
|
|
6847
|
+
const pinnedChanged = isPinned !== booleanValue(memory, "is_pinned");
|
|
6848
|
+
if (!contentChanged && !pinnedChanged)
|
|
6849
|
+
return this.mapRoleplayMemory(memory, true);
|
|
6850
|
+
this.db.transaction(() => {
|
|
6851
|
+
const timestamp = now();
|
|
6852
|
+
const contentHash = this.roleplayMemoryContentHash(content);
|
|
6853
|
+
const duplicate = this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND content_hash = ? AND id <> ?", requiredString(memory, "character_id"), contentHash, memoryId);
|
|
6854
|
+
if (duplicate)
|
|
6855
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_DUPLICATE", "该角色已经有相同内容的角色扮演记忆");
|
|
6856
|
+
this.db.run(`UPDATE roleplay_memories SET category = ?, content = ?, content_hash = ?, importance = ?, certainty = ?, is_pinned = ?,
|
|
6857
|
+
version_no = version_no + 1, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, category, content, contentHash, importance, certainty, isPinned ? 1 : 0, currentRequestActor()?.userId ?? null, timestamp, memoryId);
|
|
6858
|
+
const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6859
|
+
if (!updated)
|
|
6860
|
+
throw notFound("角色扮演记忆");
|
|
6861
|
+
this.insertRoleplayMemoryVersion(updated, contentChanged ? "edited" : "pinned");
|
|
6862
|
+
this.audit(requiredString(memory, "work_id"), "roleplay-memory.updated", "roleplay-memory", memoryId, {
|
|
6863
|
+
characterId: requiredString(memory, "character_id")
|
|
6864
|
+
});
|
|
6865
|
+
});
|
|
6866
|
+
const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6867
|
+
if (!updated)
|
|
6868
|
+
throw notFound("角色扮演记忆");
|
|
6869
|
+
return this.mapRoleplayMemory(updated, true);
|
|
6870
|
+
}
|
|
6871
|
+
setRoleplayMemoryArchived(memoryId, archived, expectedVersion) {
|
|
6872
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6873
|
+
if (!memory)
|
|
6874
|
+
throw notFound("角色扮演记忆");
|
|
6875
|
+
if (numberValue(memory, "version_no") !== expectedVersion) {
|
|
6876
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_VERSION_CONFLICT", "记忆已被其他操作更新,请刷新后重试");
|
|
6877
|
+
}
|
|
6878
|
+
const nextStatus = archived ? "archived" : "active";
|
|
6879
|
+
if (requiredString(memory, "status") === nextStatus)
|
|
6880
|
+
return this.mapRoleplayMemory(memory, true);
|
|
6881
|
+
this.db.transaction(() => {
|
|
6882
|
+
const timestamp = now();
|
|
6883
|
+
this.db.run(`UPDATE roleplay_memories SET status = ?,
|
|
6884
|
+
superseded_by_memory_id = CASE WHEN ? = 'active' THEN NULL ELSE superseded_by_memory_id END,
|
|
6885
|
+
version_no = version_no + 1, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, nextStatus, nextStatus, currentRequestActor()?.userId ?? null, timestamp, memoryId);
|
|
6886
|
+
const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6887
|
+
if (!updated)
|
|
6888
|
+
throw notFound("角色扮演记忆");
|
|
6889
|
+
this.insertRoleplayMemoryVersion(updated, archived ? "archived" : "restored");
|
|
6890
|
+
this.audit(requiredString(memory, "work_id"), archived ? "roleplay-memory.archived" : "roleplay-memory.restored", "roleplay-memory", memoryId, {
|
|
6891
|
+
characterId: requiredString(memory, "character_id")
|
|
6892
|
+
});
|
|
6893
|
+
});
|
|
6894
|
+
const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6895
|
+
if (!updated)
|
|
6896
|
+
throw notFound("角色扮演记忆");
|
|
6897
|
+
return this.mapRoleplayMemory(updated, true);
|
|
6898
|
+
}
|
|
6313
6899
|
getAiConversationContext(conversationId, workId, excludeMessageId) {
|
|
6314
6900
|
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
6315
6901
|
if (!conversation)
|
|
@@ -6335,10 +6921,13 @@ export class Store {
|
|
|
6335
6921
|
ORDER BY created_at, rowid LIMIT ?`, conversationId, requiredString(boundary, "created_at"), requiredString(boundary, "created_at"), numberValue(boundary, "rowid"), tailMessageCount);
|
|
6336
6922
|
}
|
|
6337
6923
|
}
|
|
6924
|
+
const roleplayCharacterId = optionalString(conversation, "roleplay_character_id");
|
|
6338
6925
|
return {
|
|
6339
6926
|
workId,
|
|
6340
|
-
|
|
6927
|
+
taskType: normalizeAiConversationTaskType(optionalString(conversation, "task_type"), roleplayCharacterId),
|
|
6928
|
+
roleplayCharacterId,
|
|
6341
6929
|
roleplayUserCharacterId: optionalString(conversation, "roleplay_user_character_id"),
|
|
6930
|
+
roleplayMemories: roleplayCharacterId ? this.getRoleplayMemoryPromptItems(workId, roleplayCharacterId) : [],
|
|
6342
6931
|
summary: requiredString(conversation, "compacted_summary"),
|
|
6343
6932
|
compactedMessageCount,
|
|
6344
6933
|
totalMessageCount,
|
|
@@ -6577,7 +7166,7 @@ export class Store {
|
|
|
6577
7166
|
throw notFound("AI 对话");
|
|
6578
7167
|
const workId = requiredString(conversation, "work_id");
|
|
6579
7168
|
const previousCharacterId = optionalString(conversation, "roleplay_character_id");
|
|
6580
|
-
const previousTaskType = optionalString(conversation, "task_type")
|
|
7169
|
+
const previousTaskType = normalizeAiConversationTaskType(optionalString(conversation, "task_type"), previousCharacterId);
|
|
6581
7170
|
if (previousTaskType === taskType)
|
|
6582
7171
|
return this.getAiConversationSummary(conversationId);
|
|
6583
7172
|
const messageCount = Number(this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE conversation_id = ?", conversationId)?.count ?? 0);
|
|
@@ -6881,7 +7470,7 @@ export class Store {
|
|
|
6881
7470
|
const systemClockText = optionalString(conversation, "system_clock_text") ?? "";
|
|
6882
7471
|
const scenePinJson = optionalString(conversation, "scene_pin_json") ?? "{}";
|
|
6883
7472
|
this.db.transaction(() => {
|
|
6884
|
-
this.db.run("INSERT INTO ai_conversations (id, work_id, roleplay_character_id, roleplay_user_character_id, task_type, context_scope_json, title, compacted_summary, compacted_message_count, agent_tools_json, injected_entities_json, system_clock_text, scene_pin_json, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, workId, optionalString(conversation, "roleplay_character_id"), optionalString(conversation, "roleplay_user_character_id"), optionalString(conversation, "task_type"), optionalString(conversation, "context_scope_json"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
|
|
7473
|
+
this.db.run("INSERT INTO ai_conversations (id, work_id, roleplay_character_id, roleplay_user_character_id, task_type, context_scope_json, title, compacted_summary, compacted_message_count, agent_tools_json, injected_entities_json, system_clock_text, scene_pin_json, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, workId, optionalString(conversation, "roleplay_character_id"), optionalString(conversation, "roleplay_user_character_id"), normalizeAiConversationTaskType(optionalString(conversation, "task_type"), optionalString(conversation, "roleplay_character_id")), optionalString(conversation, "context_scope_json"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
|
|
6885
7474
|
? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools))
|
|
6886
7475
|
: String(conversation.agent_tools_json), injectedEntitiesJson, systemClockText, scenePinJson, timestamp, timestamp, currentRequestActor()?.userId ?? null);
|
|
6887
7476
|
for (const message of messages.slice(0, targetIndex + 1)) {
|
|
@@ -6954,7 +7543,7 @@ export class Store {
|
|
|
6954
7543
|
compactedMessageCount: numberValue(row, "compacted_message_count"),
|
|
6955
7544
|
hasCompactedSummary: Boolean(requiredString(row, "compacted_summary")),
|
|
6956
7545
|
contextWarningPending: Boolean(optionalString(row, "context_warning_at")),
|
|
6957
|
-
taskType: optionalString(row, "task_type")
|
|
7546
|
+
taskType: normalizeAiConversationTaskType(optionalString(row, "task_type"), roleplayCharacterId),
|
|
6958
7547
|
...(lockedModelId ? { modelId: lockedModelId } : {}),
|
|
6959
7548
|
...(hasImageAttachments ? { hasImageAttachments: true, modelLockedByImage: true } : {}),
|
|
6960
7549
|
contextScope: json(optionalString(row, "context_scope_json") ?? "", { type: "none" }),
|
|
@@ -7043,6 +7632,49 @@ export class Store {
|
|
|
7043
7632
|
createdAt: requiredString(row, "created_at")
|
|
7044
7633
|
};
|
|
7045
7634
|
}
|
|
7635
|
+
mapRoleplayMemory(row, includeDetails) {
|
|
7636
|
+
const memoryId = requiredString(row, "id");
|
|
7637
|
+
const actor = currentRequestActor();
|
|
7638
|
+
const sources = includeDetails
|
|
7639
|
+
? this.db.all(`SELECT source.*, conversation.created_by_user_id AS conversation_owner_id
|
|
7640
|
+
FROM roleplay_memory_sources source
|
|
7641
|
+
LEFT JOIN ai_conversations conversation ON conversation.id = source.conversation_id
|
|
7642
|
+
WHERE source.memory_id = ? ORDER BY source.source_created_at, source.id`, memoryId).map((source) => {
|
|
7643
|
+
const sourceOwnerId = optionalString(source, "conversation_owner_id") ?? optionalString(source, "source_created_by_user_id");
|
|
7644
|
+
const canOpen = actor === null || actor.role === "admin" || (sourceOwnerId !== null && sourceOwnerId === actor.userId);
|
|
7645
|
+
return {
|
|
7646
|
+
id: requiredString(source, "id"),
|
|
7647
|
+
role: requiredString(source, "message_role"),
|
|
7648
|
+
sourceType: requiredString(row, "source_type"),
|
|
7649
|
+
sourceAt: requiredString(source, "source_created_at"),
|
|
7650
|
+
canOpen,
|
|
7651
|
+
restricted: !canOpen,
|
|
7652
|
+
conversationId: canOpen ? optionalString(source, "conversation_id") : null,
|
|
7653
|
+
messageId: canOpen ? optionalString(source, "message_id") : null,
|
|
7654
|
+
evidence: canOpen ? requiredString(source, "evidence_snapshot") : null
|
|
7655
|
+
};
|
|
7656
|
+
})
|
|
7657
|
+
: undefined;
|
|
7658
|
+
return {
|
|
7659
|
+
id: memoryId,
|
|
7660
|
+
workId: requiredString(row, "work_id"),
|
|
7661
|
+
characterId: requiredString(row, "character_id"),
|
|
7662
|
+
category: requiredString(row, "category"),
|
|
7663
|
+
content: requiredString(row, "content"),
|
|
7664
|
+
importance: requiredString(row, "importance"),
|
|
7665
|
+
certainty: requiredString(row, "certainty"),
|
|
7666
|
+
origin: "roleplay",
|
|
7667
|
+
canonical: false,
|
|
7668
|
+
status: requiredString(row, "status"),
|
|
7669
|
+
isPinned: booleanValue(row, "is_pinned"),
|
|
7670
|
+
versionNo: numberValue(row, "version_no"),
|
|
7671
|
+
supersededByMemoryId: optionalString(row, "superseded_by_memory_id"),
|
|
7672
|
+
sourceType: requiredString(row, "source_type"),
|
|
7673
|
+
...(sources ? { sources } : {}),
|
|
7674
|
+
createdAt: requiredString(row, "created_at"),
|
|
7675
|
+
updatedAt: requiredString(row, "updated_at")
|
|
7676
|
+
};
|
|
7677
|
+
}
|
|
7046
7678
|
hashContent(content) {
|
|
7047
7679
|
return createHash("sha256").update(content).digest("hex");
|
|
7048
7680
|
}
|
|
@@ -7750,8 +8382,20 @@ export class Store {
|
|
|
7750
8382
|
return [];
|
|
7751
8383
|
}
|
|
7752
8384
|
});
|
|
7753
|
-
|
|
7754
|
-
|
|
8385
|
+
const hasChunkMetrics = typeof result.coveredChapterCount === "number" || typeof result.batchCount === "number";
|
|
8386
|
+
summary = hasChunkMetrics
|
|
8387
|
+
? `覆盖 ${Number(result.coveredChapterCount ?? 0)} 章正文,识别 ${Number(result.rawCandidateCount ?? events.length)} 个原始候选,写入 ${events.length} 个时间轴事件候选。`
|
|
8388
|
+
: `提取并写入 ${events.length} 个时间轴事件候选。`;
|
|
8389
|
+
metrics = [
|
|
8390
|
+
metric("写入事件", events.length),
|
|
8391
|
+
...(hasChunkMetrics ? [
|
|
8392
|
+
metric("覆盖章节", result.coveredChapterCount),
|
|
8393
|
+
metric("正文分片", result.batchCount),
|
|
8394
|
+
metric("归并批次", result.aggregationBatchCount),
|
|
8395
|
+
metric("原始候选", result.rawCandidateCount)
|
|
8396
|
+
] : []),
|
|
8397
|
+
metric("已不存在", Math.max(0, ids.length - events.length))
|
|
8398
|
+
];
|
|
7755
8399
|
storageTargets.unshift({ label: "时间轴候选", entity: "时间轴与事件", key: "timeline", count: events.length, note: "以候选状态写入,等待作者确认。" });
|
|
7756
8400
|
sections = [section("事件候选", events, "没有形成可写入的时间轴事件。")];
|
|
7757
8401
|
}
|