@musnows/scriverse 0.9.3 → 0.9.5
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 +2279 -0
- package/dist/ai-write-plans.js.map +1 -0
- package/dist/ai.js +1236 -91
- package/dist/ai.js.map +1 -1
- package/dist/app.js +190 -3
- 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/database.js +338 -3
- package/dist/database.js.map +1 -1
- package/dist/public/ai-context-meter.js +10 -5
- package/dist/public/ai-interactive.js +483 -0
- package/dist/public/app.js +1187 -86
- package/dist/public/chapter-editor-behavior.js +40 -0
- package/dist/public/chapter-line-id-tracker.d.ts +19 -0
- package/dist/public/chapter-line-id-tracker.js +127 -0
- package/dist/public/index.html +59 -10
- package/dist/public/styles.css +237 -54
- package/dist/public/theme-init.js +25 -1
- package/dist/roleplay-memory.js +53 -0
- package/dist/roleplay-memory.js.map +1 -0
- package/dist/store.js +454 -24
- 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 +22 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +0 -8
- package/dist/utils.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -1
package/dist/store.js
CHANGED
|
@@ -9,11 +9,13 @@ import { accountReference, logger } from "./logger.js";
|
|
|
9
9
|
import { paginated, paginationSql } from "./pagination.js";
|
|
10
10
|
import { currentRequestActor } from "./request-context.js";
|
|
11
11
|
import { canWriteWorkModule, classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
|
|
12
|
-
import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, normalizeDocumentSearchText,
|
|
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 { normalizeRoleplayMemoryContent, roleplayMemoryCandidateIsSafe } from "./roleplay-memory.js";
|
|
17
19
|
const WORK_LIST_BATCH_SIZE = 500;
|
|
18
20
|
const ENTITY_LIST_BATCH_SIZE = 400;
|
|
19
21
|
export const RECYCLE_BIN_RETENTION_DAYS = 30;
|
|
@@ -534,6 +536,8 @@ export class Store {
|
|
|
534
536
|
language: entity.language,
|
|
535
537
|
coverUrl: entity.coverUrl,
|
|
536
538
|
tags: entity.tags,
|
|
539
|
+
editorAutoIndentEnabled: entity.editorAutoIndentEnabled,
|
|
540
|
+
editorTypewriterModeEnabled: entity.editorTypewriterModeEnabled,
|
|
537
541
|
ownerUserId: entity.ownerUserId
|
|
538
542
|
};
|
|
539
543
|
if (type === "volume")
|
|
@@ -802,8 +806,9 @@ export class Store {
|
|
|
802
806
|
return this.db.transaction(() => {
|
|
803
807
|
const ownerUserId = this.resolveWorkOwnerUserId(typeof snapshot.ownerUserId === "string" ? snapshot.ownerUserId : null, true);
|
|
804
808
|
const timestamp = now();
|
|
805
|
-
this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
|
|
806
|
-
|
|
809
|
+
this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
|
|
810
|
+
editor_auto_indent_enabled, editor_typewriter_mode_enabled, version_no, created_at, updated_at, owner_user_id)
|
|
811
|
+
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
812
|
if (ownerUserId !== SYSTEM_USER_ID) {
|
|
808
813
|
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
814
|
}
|
|
@@ -868,8 +873,9 @@ export class Store {
|
|
|
868
873
|
const timestamp = now();
|
|
869
874
|
const resolvedOwnerUserId = this.resolveWorkOwnerUserId(ownerUserId);
|
|
870
875
|
this.db.transaction(() => {
|
|
871
|
-
this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
|
|
872
|
-
|
|
876
|
+
this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
|
|
877
|
+
editor_auto_indent_enabled, editor_typewriter_mode_enabled, created_at, updated_at, owner_user_id)
|
|
878
|
+
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
879
|
if (resolvedOwnerUserId !== SYSTEM_USER_ID) {
|
|
874
880
|
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
881
|
}
|
|
@@ -1201,8 +1207,9 @@ export class Store {
|
|
|
1201
1207
|
const current = this.getWork(workId);
|
|
1202
1208
|
this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
|
|
1203
1209
|
const timestamp = now();
|
|
1204
|
-
this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?,
|
|
1205
|
-
|
|
1210
|
+
this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?,
|
|
1211
|
+
editor_auto_indent_enabled = ?, editor_typewriter_mode_enabled = ?, version_no = version_no + 1, updated_at = ?
|
|
1212
|
+
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
1213
|
this.recordEntityVersion("work", workId, source, sourceRef, changeNote || "更新作品信息", timestamp);
|
|
1207
1214
|
this.audit(workId, "work.updated", "work", workId, { fields: Object.keys(input), versionNo: Number(current.versionNo) + 1, source, sourceRef, changeNote });
|
|
1208
1215
|
});
|
|
@@ -2103,9 +2110,10 @@ export class Store {
|
|
|
2103
2110
|
const current = this.getChapter(chapterId);
|
|
2104
2111
|
this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(current.versionNo));
|
|
2105
2112
|
const nextTitle = input.title ?? String(current.title);
|
|
2106
|
-
const nextContent = input.content === undefined ? String(current.content) :
|
|
2113
|
+
const nextContent = input.content === undefined ? String(current.content) : input.content;
|
|
2107
2114
|
const nextExcluded = input.excludedFromAnalysis ?? Boolean(current.excludedFromAnalysis);
|
|
2108
2115
|
const nextChapterType = input.chapterType ?? String(current.chapterType);
|
|
2116
|
+
const hasContentChange = nextContent !== current.content;
|
|
2109
2117
|
const hasTextChange = nextTitle !== current.title || nextContent !== current.content;
|
|
2110
2118
|
const hasTypeChange = nextChapterType !== current.chapterType;
|
|
2111
2119
|
const hasOtherChange = nextExcluded !== current.excludedFromAnalysis || hasTypeChange;
|
|
@@ -2116,12 +2124,23 @@ export class Store {
|
|
|
2116
2124
|
this.db.transaction(() => {
|
|
2117
2125
|
const lockedCurrent = this.getChapter(chapterId);
|
|
2118
2126
|
this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedCurrent.versionNo));
|
|
2127
|
+
const storedLineIds = parseChapterLineIds(lockedCurrent.lineIds, String(lockedCurrent.content));
|
|
2128
|
+
const beforeLineIds = storedLineIds.length > 0
|
|
2129
|
+
? storedLineIds
|
|
2130
|
+
: createChapterLineIds(String(lockedCurrent.content), () => id("chapterLine"));
|
|
2131
|
+
const nextLineIds = hasContentChange
|
|
2132
|
+
? reconcileChapterLineIds(String(lockedCurrent.content), nextContent, beforeLineIds, input.lineIds, () => id("chapterLine"))
|
|
2133
|
+
: beforeLineIds;
|
|
2134
|
+
if (!nextLineIds)
|
|
2135
|
+
throw new AppError(400, "CHAPTER_LINE_IDS_INVALID", "正文行身份与当前版本不匹配,请刷新后重试");
|
|
2119
2136
|
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);
|
|
2137
|
+
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
2138
|
if (hasTextChange)
|
|
2122
2139
|
this.syncChapterParagraphSearch(String(current.workId), chapterId, nextContent);
|
|
2123
2140
|
else if (hasTypeChange)
|
|
2124
2141
|
this.syncChapterParagraphSearchVersion(chapterId, versionNo);
|
|
2142
|
+
if (hasContentChange)
|
|
2143
|
+
this.reanchorChapterAnnotations(String(current.workId), chapterId, String(lockedCurrent.content), nextContent, nextLineIds, source, timestamp);
|
|
2125
2144
|
if (hasTextChange || hasTypeChange) {
|
|
2126
2145
|
this.insertChapterVersionRow({
|
|
2127
2146
|
workId: String(current.workId),
|
|
@@ -2145,6 +2164,36 @@ export class Store {
|
|
|
2145
2164
|
});
|
|
2146
2165
|
return this.getChapter(chapterId);
|
|
2147
2166
|
}
|
|
2167
|
+
reanchorChapterAnnotations(workId, chapterId, beforeContent, afterContent, afterLineIds, source, timestamp) {
|
|
2168
|
+
const annotations = this.db.all(`SELECT id, start_line, end_line, quote, line_hashes_json, anchor_line_ids_json
|
|
2169
|
+
FROM chapter_annotations
|
|
2170
|
+
WHERE chapter_id = ? AND deleted_at IS NULL`, chapterId).map((row) => ({
|
|
2171
|
+
id: requiredString(row, "id"),
|
|
2172
|
+
startLine: numberValue(row, "start_line"),
|
|
2173
|
+
endLine: numberValue(row, "end_line"),
|
|
2174
|
+
quote: requiredString(row, "quote"),
|
|
2175
|
+
lineHashes: parseChapterAnnotationLineHashes(row.line_hashes_json, requiredString(row, "quote")),
|
|
2176
|
+
lineIds: parseChapterAnnotationLineIds(row.anchor_line_ids_json)
|
|
2177
|
+
}));
|
|
2178
|
+
for (const annotation of reanchorChapterAnnotations(beforeContent, afterContent, annotations, afterLineIds)) {
|
|
2179
|
+
if (!annotation.changed)
|
|
2180
|
+
continue;
|
|
2181
|
+
this.db.run(`UPDATE chapter_annotations
|
|
2182
|
+
SET start_line = ?, end_line = ?, quote = ?, line_hashes_json = ?, anchor_line_ids_json = ?, version_no = version_no + 1
|
|
2183
|
+
WHERE id = ?`, annotation.startLine, annotation.endLine, annotation.quote, JSON.stringify(annotation.lineHashes), JSON.stringify(annotation.lineIds), annotation.id);
|
|
2184
|
+
const updated = this.getChapterAnnotation(annotation.id);
|
|
2185
|
+
this.recordChapterAnnotationVersion(updated, "reanchor", timestamp);
|
|
2186
|
+
this.audit(workId, "chapter.annotation.updated", "chapter-annotation", annotation.id, {
|
|
2187
|
+
chapterId,
|
|
2188
|
+
startLine: annotation.startLine,
|
|
2189
|
+
endLine: annotation.endLine,
|
|
2190
|
+
versionNo: updated.versionNo,
|
|
2191
|
+
anchorStrategy: annotation.anchorStrategy,
|
|
2192
|
+
reason: "reanchor",
|
|
2193
|
+
source
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2148
2197
|
replaceWorkText(workId, input) {
|
|
2149
2198
|
const find = input.find;
|
|
2150
2199
|
if (!find)
|
|
@@ -2280,10 +2329,11 @@ export class Store {
|
|
|
2280
2329
|
? 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
2330
|
: numberValue(version, "sort_order");
|
|
2282
2331
|
const timestamp = now();
|
|
2332
|
+
const lineIds = createChapterLineIds(content, () => id("chapterLine"));
|
|
2283
2333
|
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
2334
|
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);
|
|
2335
|
+
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)
|
|
2336
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, JSON.stringify(lineIds), chapterType, sortOrder, countWords(content), nextVersionNo, timestamp, timestamp);
|
|
2287
2337
|
this.syncChapterParagraphSearch(workId, chapterId, content);
|
|
2288
2338
|
this.insertChapterVersionRow({
|
|
2289
2339
|
workId,
|
|
@@ -2437,11 +2487,14 @@ export class Store {
|
|
|
2437
2487
|
};
|
|
2438
2488
|
}
|
|
2439
2489
|
chapterAnnotationSnapshot(annotation) {
|
|
2490
|
+
const anchor = this.db.get("SELECT anchor_line_ids_json FROM chapter_annotations WHERE id = ?", String(annotation.id));
|
|
2440
2491
|
return {
|
|
2441
2492
|
kind: annotation.kind,
|
|
2442
2493
|
startLine: annotation.startLine,
|
|
2443
2494
|
endLine: annotation.endLine,
|
|
2444
2495
|
quote: annotation.quote,
|
|
2496
|
+
lineHashes: chapterAnnotationLineHashes(String(annotation.quote)),
|
|
2497
|
+
lineIds: parseChapterAnnotationLineIds(anchor?.anchor_line_ids_json),
|
|
2445
2498
|
note: annotation.note,
|
|
2446
2499
|
status: annotation.status,
|
|
2447
2500
|
deletedAt: annotation.deletedAt ?? null
|
|
@@ -2555,9 +2608,15 @@ export class Store {
|
|
|
2555
2608
|
const annotationId = id("chapterAnnotation");
|
|
2556
2609
|
const timestamp = now();
|
|
2557
2610
|
const actorId = currentRequestActor()?.userId ?? null;
|
|
2611
|
+
const quote = lines.slice(input.startLine - 1, input.endLine).join("\n");
|
|
2612
|
+
const chapterLineIds = parseChapterLineIds(chapter.lineIds, String(chapter.content));
|
|
2613
|
+
if (lines.length <= MAX_CHAPTER_LINE_IDS && chapterLineIds.length !== lines.length) {
|
|
2614
|
+
throw new AppError(409, "CHAPTER_LINE_IDS_MISSING", "正文行身份尚未初始化,请重新保存正文后再添加评论");
|
|
2615
|
+
}
|
|
2616
|
+
const anchorLineIds = chapterLineIds.slice(input.startLine - 1, input.endLine);
|
|
2558
2617
|
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,
|
|
2618
|
+
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)
|
|
2619
|
+
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
2620
|
const annotation = this.getChapterAnnotation(annotationId);
|
|
2562
2621
|
this.recordChapterAnnotationVersion(annotation, "create", timestamp);
|
|
2563
2622
|
this.audit(String(chapter.workId), "chapter.annotation.created", "chapter-annotation", annotationId, { kind: input.kind, chapterId, startLine: input.startLine, endLine: input.endLine });
|
|
@@ -2787,16 +2846,16 @@ export class Store {
|
|
|
2787
2846
|
insertChapter(workId, volumeId, title, content, sortOrder, source, sourceRef, chapterType = "正文") {
|
|
2788
2847
|
const chapterId = id("chapter");
|
|
2789
2848
|
const timestamp = now();
|
|
2790
|
-
const
|
|
2791
|
-
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)
|
|
2792
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`, chapterId, workId, volumeId, title,
|
|
2793
|
-
this.syncChapterParagraphSearch(workId, chapterId,
|
|
2849
|
+
const lineIds = createChapterLineIds(content, () => id("chapterLine"));
|
|
2850
|
+
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)
|
|
2851
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, JSON.stringify(lineIds), chapterType, sortOrder, countWords(content), timestamp, timestamp);
|
|
2852
|
+
this.syncChapterParagraphSearch(workId, chapterId, content);
|
|
2794
2853
|
this.insertChapterVersionRow({
|
|
2795
2854
|
workId,
|
|
2796
2855
|
chapterId,
|
|
2797
2856
|
versionNo: 1,
|
|
2798
2857
|
title,
|
|
2799
|
-
content
|
|
2858
|
+
content,
|
|
2800
2859
|
volumeId,
|
|
2801
2860
|
sortOrder,
|
|
2802
2861
|
chapterType,
|
|
@@ -3092,6 +3151,8 @@ export class Store {
|
|
|
3092
3151
|
: optionalString(row, "cover_url"),
|
|
3093
3152
|
tags: json(requiredString(row, "tags_json"), []),
|
|
3094
3153
|
offlineAccessEnabled: numberValue(row, "offline_access_enabled") === 1,
|
|
3154
|
+
editorAutoIndentEnabled: numberValue(row, "editor_auto_indent_enabled") === 1,
|
|
3155
|
+
editorTypewriterModeEnabled: numberValue(row, "editor_typewriter_mode_enabled") === 1,
|
|
3095
3156
|
versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", workId),
|
|
3096
3157
|
ownerUserId,
|
|
3097
3158
|
accessRole,
|
|
@@ -3124,7 +3185,8 @@ export class Store {
|
|
|
3124
3185
|
mapChapter(row) {
|
|
3125
3186
|
return {
|
|
3126
3187
|
...this.mapChapterDirectoryEntry(row),
|
|
3127
|
-
content: requiredString(row, "content")
|
|
3188
|
+
content: requiredString(row, "content"),
|
|
3189
|
+
lineIds: parseChapterLineIds(row.line_ids_json, requiredString(row, "content"))
|
|
3128
3190
|
};
|
|
3129
3191
|
}
|
|
3130
3192
|
mapChapterDirectoryEntry(row) {
|
|
@@ -5579,6 +5641,7 @@ export class Store {
|
|
|
5579
5641
|
const timelineEvents = this.listTimelineEvents(workId).filter((event) => event.participantIds.includes(sourceId));
|
|
5580
5642
|
const sourceMemberships = this.db.all("SELECT * FROM character_organization_memberships WHERE character_id = ? ORDER BY organization_id", sourceId);
|
|
5581
5643
|
const referenceSnapshot = { relationships: sourceRelationships, timelineEvents, memberships: sourceMemberships };
|
|
5644
|
+
let roleplayMemoryMerge = { migrated: 0, deduplicated: 0 };
|
|
5582
5645
|
this.db.transaction(() => {
|
|
5583
5646
|
const lockedTarget = this.getCharacter(targetId);
|
|
5584
5647
|
const lockedSource = this.getCharacter(sourceId);
|
|
@@ -5646,6 +5709,7 @@ export class Store {
|
|
|
5646
5709
|
this.db.run("UPDATE character_profile_sections SET character_id = ?, updated_at = ? WHERE character_id = ?", targetId, timestamp, sourceId);
|
|
5647
5710
|
this.db.run("UPDATE character_profile_section_versions SET character_id = ? WHERE character_id = ?", targetId, sourceId);
|
|
5648
5711
|
this.db.run("UPDATE character_profile_section_search SET character_id = ? WHERE character_id = ?", targetId, sourceId);
|
|
5712
|
+
roleplayMemoryMerge = this.mergeRoleplayMemoriesForCharacters(workId, targetId, sourceId, timestamp);
|
|
5649
5713
|
const sourceVersionNo = Number(source.versionNo) + 1;
|
|
5650
5714
|
this.db.run("UPDATE characters SET merged_into_character_id = ?, merged_at = ?, version_no = ?, updated_at = ? WHERE id = ?", targetId, timestamp, sourceVersionNo, timestamp, sourceId);
|
|
5651
5715
|
this.insertCharacterVersion(sourceId, sourceVersionNo, "merge", mergeId, `合并至角色“${String(target.name)}”`, timestamp);
|
|
@@ -5658,16 +5722,60 @@ export class Store {
|
|
|
5658
5722
|
this.audit(workId, "character.merged", "character", targetId, {
|
|
5659
5723
|
mergeId,
|
|
5660
5724
|
sourceCharacterId: sourceId,
|
|
5661
|
-
reviewId: input.reviewId
|
|
5725
|
+
reviewId: input.reviewId,
|
|
5726
|
+
roleplayMemoryMerge
|
|
5662
5727
|
});
|
|
5663
5728
|
});
|
|
5664
5729
|
return {
|
|
5665
5730
|
mergeId,
|
|
5666
5731
|
target: this.getCharacter(targetId),
|
|
5667
5732
|
source: this.getCharacter(sourceId),
|
|
5668
|
-
review: input.reviewId ? this.getReviewItem(input.reviewId) : null
|
|
5733
|
+
review: input.reviewId ? this.getReviewItem(input.reviewId) : null,
|
|
5734
|
+
roleplayMemoryMerge
|
|
5669
5735
|
};
|
|
5670
5736
|
}
|
|
5737
|
+
mergeRoleplayMemoriesForCharacters(workId, targetCharacterId, sourceCharacterId, timestamp) {
|
|
5738
|
+
let migrated = 0;
|
|
5739
|
+
let deduplicated = 0;
|
|
5740
|
+
const sourceMemories = this.db.all("SELECT * FROM roleplay_memories WHERE work_id = ? AND character_id = ? ORDER BY created_at, id", workId, sourceCharacterId);
|
|
5741
|
+
for (const sourceMemory of sourceMemories) {
|
|
5742
|
+
const sourceMemoryId = requiredString(sourceMemory, "id");
|
|
5743
|
+
const duplicate = this.db.get("SELECT * FROM roleplay_memories WHERE character_id = ? AND content_hash = ?", targetCharacterId, requiredString(sourceMemory, "content_hash"));
|
|
5744
|
+
if (!duplicate) {
|
|
5745
|
+
this.db.run("UPDATE roleplay_memories SET character_id = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ?", targetCharacterId, currentRequestActor()?.userId ?? null, timestamp, sourceMemoryId);
|
|
5746
|
+
migrated += 1;
|
|
5747
|
+
continue;
|
|
5748
|
+
}
|
|
5749
|
+
const targetMemoryId = requiredString(duplicate, "id");
|
|
5750
|
+
this.db.run("UPDATE roleplay_memories SET superseded_by_memory_id = ? WHERE superseded_by_memory_id = ?", targetMemoryId, sourceMemoryId);
|
|
5751
|
+
for (const source of this.db.all("SELECT * FROM roleplay_memory_sources WHERE memory_id = ? ORDER BY created_at, id", sourceMemoryId)) {
|
|
5752
|
+
this.db.run(`INSERT OR IGNORE INTO roleplay_memory_sources (
|
|
5753
|
+
id, memory_id, conversation_id, message_id, message_role, source_created_by_user_id,
|
|
5754
|
+
source_created_at, evidence_snapshot, created_at
|
|
5755
|
+
) 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"));
|
|
5756
|
+
}
|
|
5757
|
+
let nextVersion = numberValue(duplicate, "version_no");
|
|
5758
|
+
for (const version of this.db.all("SELECT * FROM roleplay_memory_versions WHERE memory_id = ? ORDER BY version_no", sourceMemoryId)) {
|
|
5759
|
+
nextVersion += 1;
|
|
5760
|
+
this.db.run(`INSERT INTO roleplay_memory_versions (
|
|
5761
|
+
id, memory_id, version_no, category, content, importance, certainty,
|
|
5762
|
+
status, is_pinned, action, actor_user_id, created_at
|
|
5763
|
+
) 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"));
|
|
5764
|
+
}
|
|
5765
|
+
nextVersion += 1;
|
|
5766
|
+
this.db.run(`UPDATE roleplay_memories SET is_pinned = CASE WHEN is_pinned = 1 OR ? = 1 THEN 1 ELSE 0 END,
|
|
5767
|
+
status = CASE WHEN status = 'active' OR ? = 'active' THEN 'active' ELSE status END,
|
|
5768
|
+
superseded_by_memory_id = CASE WHEN status = 'active' OR ? = 'active' THEN NULL ELSE superseded_by_memory_id END,
|
|
5769
|
+
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);
|
|
5770
|
+
const mergedTarget = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", targetMemoryId);
|
|
5771
|
+
if (!mergedTarget)
|
|
5772
|
+
throw notFound("角色扮演记忆");
|
|
5773
|
+
this.insertRoleplayMemoryVersion(mergedTarget, "merged");
|
|
5774
|
+
this.db.run("DELETE FROM roleplay_memories WHERE id = ?", sourceMemoryId);
|
|
5775
|
+
deduplicated += 1;
|
|
5776
|
+
}
|
|
5777
|
+
return { migrated, deduplicated };
|
|
5778
|
+
}
|
|
5671
5779
|
resolveCharacterDuplicateReview(reviewId) {
|
|
5672
5780
|
const review = this.getReviewItem(reviewId);
|
|
5673
5781
|
if (review.itemType !== "character-duplicate" || review.status !== "pending") {
|
|
@@ -6311,6 +6419,270 @@ export class Store {
|
|
|
6311
6419
|
messagesPage
|
|
6312
6420
|
};
|
|
6313
6421
|
}
|
|
6422
|
+
roleplayMemoryContentHash(content) {
|
|
6423
|
+
return this.hashContent(normalizeRoleplayMemoryContent(content).toLocaleLowerCase("zh-CN"));
|
|
6424
|
+
}
|
|
6425
|
+
insertRoleplayMemoryVersion(memory, action) {
|
|
6426
|
+
this.db.run(`INSERT INTO roleplay_memory_versions (
|
|
6427
|
+
id, memory_id, version_no, category, content, importance, certainty,
|
|
6428
|
+
status, is_pinned, action, actor_user_id, created_at
|
|
6429
|
+
) 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());
|
|
6430
|
+
}
|
|
6431
|
+
roleplayMemoryListRows(characterId, options = {}) {
|
|
6432
|
+
const cursor = Math.max(0, Math.min(100_000, options.cursor ?? 0));
|
|
6433
|
+
const limit = Math.max(1, Math.min(100, options.limit ?? 20));
|
|
6434
|
+
const where = ["memory.character_id = ?"];
|
|
6435
|
+
const params = [characterId];
|
|
6436
|
+
const statuses = options.statuses?.length ? [...new Set(options.statuses)] : ["active"];
|
|
6437
|
+
where.push(`memory.status IN (${statuses.map(() => "?").join(", ")})`);
|
|
6438
|
+
params.push(...statuses);
|
|
6439
|
+
const categories = options.categories?.length ? [...new Set(options.categories)] : [];
|
|
6440
|
+
if (categories.length > 0) {
|
|
6441
|
+
where.push(`memory.category IN (${categories.map(() => "?").join(", ")})`);
|
|
6442
|
+
params.push(...categories);
|
|
6443
|
+
}
|
|
6444
|
+
const query = normalizeRoleplayMemoryContent(options.query ?? "", 200).toLocaleLowerCase("zh-CN");
|
|
6445
|
+
if (query) {
|
|
6446
|
+
if (Array.from(query).length >= 3) {
|
|
6447
|
+
where.push("memory.id IN (SELECT memory_id FROM roleplay_memory_fts WHERE roleplay_memory_fts MATCH ?)");
|
|
6448
|
+
params.push(JSON.stringify(query));
|
|
6449
|
+
}
|
|
6450
|
+
else {
|
|
6451
|
+
where.push("lower(memory.content) LIKE ? ESCAPE '\\'");
|
|
6452
|
+
params.push(`%${escapeSqlLikePattern(query)}%`);
|
|
6453
|
+
}
|
|
6454
|
+
}
|
|
6455
|
+
const whereSql = where.join(" AND ");
|
|
6456
|
+
const total = Number(this.db.get(`SELECT COUNT(*) AS count FROM roleplay_memories memory WHERE ${whereSql}`, ...params)?.count ?? 0);
|
|
6457
|
+
const rows = this.db.all(`SELECT memory.* FROM roleplay_memories memory
|
|
6458
|
+
WHERE ${whereSql}
|
|
6459
|
+
ORDER BY memory.is_pinned DESC,
|
|
6460
|
+
CASE memory.importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
|
|
6461
|
+
memory.updated_at DESC, memory.id
|
|
6462
|
+
LIMIT ? OFFSET ?`, ...params, limit, cursor);
|
|
6463
|
+
return { rows, total, cursor, limit };
|
|
6464
|
+
}
|
|
6465
|
+
listRoleplayMemories(characterId, options = {}) {
|
|
6466
|
+
const character = this.getCharacter(characterId);
|
|
6467
|
+
const page = this.roleplayMemoryListRows(characterId, options);
|
|
6468
|
+
const nextCursor = page.cursor + page.rows.length < page.total ? page.cursor + page.rows.length : null;
|
|
6469
|
+
return {
|
|
6470
|
+
character: { id: character.id, name: character.name, workId: character.workId },
|
|
6471
|
+
items: page.rows.map((memory) => this.mapRoleplayMemory(memory, true)),
|
|
6472
|
+
pagination: { cursor: page.cursor, limit: page.limit, total: page.total, nextCursor }
|
|
6473
|
+
};
|
|
6474
|
+
}
|
|
6475
|
+
isRoleplayMemorySourceTarget(sourceId, conversationId, messageId) {
|
|
6476
|
+
return Boolean(this.db.get(`SELECT source.id
|
|
6477
|
+
FROM roleplay_memory_sources source
|
|
6478
|
+
INNER JOIN roleplay_memories memory ON memory.id = source.memory_id
|
|
6479
|
+
WHERE source.id = ? AND source.conversation_id = ? AND source.message_id = ?`, sourceId, conversationId, messageId));
|
|
6480
|
+
}
|
|
6481
|
+
getRoleplayMemoryPromptItems(workId, characterId, limit = 12) {
|
|
6482
|
+
const character = this.getCharacter(characterId);
|
|
6483
|
+
if (String(character.workId) !== workId)
|
|
6484
|
+
throw new AppError(400, "ROLEPLAY_CHARACTER_WORK_MISMATCH", "角色不属于当前作品");
|
|
6485
|
+
return this.roleplayMemoryListRows(characterId, {
|
|
6486
|
+
statuses: ["active"],
|
|
6487
|
+
limit: Math.max(1, Math.min(30, limit))
|
|
6488
|
+
}).rows.map((memory) => this.mapRoleplayMemory(memory, false));
|
|
6489
|
+
}
|
|
6490
|
+
recallRoleplayMemories(workId, characterId, query, categories, cursor) {
|
|
6491
|
+
const character = this.getCharacter(characterId);
|
|
6492
|
+
if (String(character.workId) !== workId)
|
|
6493
|
+
throw new AppError(400, "ROLEPLAY_CHARACTER_WORK_MISMATCH", "角色不属于当前作品");
|
|
6494
|
+
const result = this.listRoleplayMemories(characterId, { query, categories, statuses: ["active"], cursor, limit: 20 });
|
|
6495
|
+
return {
|
|
6496
|
+
origin: "roleplay",
|
|
6497
|
+
canonical: false,
|
|
6498
|
+
character: result.character,
|
|
6499
|
+
memories: result.items,
|
|
6500
|
+
pagination: result.pagination
|
|
6501
|
+
};
|
|
6502
|
+
}
|
|
6503
|
+
createRoleplayMemory(characterId, input) {
|
|
6504
|
+
const character = this.getCharacter(characterId);
|
|
6505
|
+
if (character.mergedIntoCharacterId)
|
|
6506
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_CHARACTER_MERGED", "已合并角色不能新增角色扮演记忆");
|
|
6507
|
+
const workId = String(character.workId);
|
|
6508
|
+
const content = normalizeRoleplayMemoryContent(input.content);
|
|
6509
|
+
if (!content)
|
|
6510
|
+
throw new AppError(400, "ROLEPLAY_MEMORY_CONTENT_REQUIRED", "请输入记忆内容");
|
|
6511
|
+
const memoryId = id("roleplay_memory");
|
|
6512
|
+
const contentHash = this.roleplayMemoryContentHash(content);
|
|
6513
|
+
const timestamp = now();
|
|
6514
|
+
this.db.transaction(() => {
|
|
6515
|
+
if (this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND content_hash = ?", characterId, contentHash)) {
|
|
6516
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_DUPLICATE", "该角色已经有相同内容的角色扮演记忆");
|
|
6517
|
+
}
|
|
6518
|
+
this.db.run(`INSERT INTO roleplay_memories (
|
|
6519
|
+
id, work_id, character_id, category, content, content_hash, importance, certainty,
|
|
6520
|
+
status, is_pinned, version_no, source_type, created_by_user_id, updated_by_user_id, created_at, updated_at
|
|
6521
|
+
) 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);
|
|
6522
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6523
|
+
if (!memory)
|
|
6524
|
+
throw notFound("角色扮演记忆");
|
|
6525
|
+
this.insertRoleplayMemoryVersion(memory, "created");
|
|
6526
|
+
this.audit(workId, "roleplay-memory.created", "roleplay-memory", memoryId, { characterId });
|
|
6527
|
+
});
|
|
6528
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6529
|
+
if (!memory)
|
|
6530
|
+
throw notFound("角色扮演记忆");
|
|
6531
|
+
return this.mapRoleplayMemory(memory, true);
|
|
6532
|
+
}
|
|
6533
|
+
commitRoleplayMemoryCandidates(conversationId, assistantMessageId, sourceUserMessageId, candidates) {
|
|
6534
|
+
if (candidates.length === 0)
|
|
6535
|
+
return [];
|
|
6536
|
+
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
6537
|
+
if (!conversation)
|
|
6538
|
+
throw notFound("AI 对话");
|
|
6539
|
+
const characterId = optionalString(conversation, "roleplay_character_id");
|
|
6540
|
+
if (!characterId)
|
|
6541
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_REQUIRES_ROLEPLAY", "只有角色扮演对话可以写入角色扮演记忆");
|
|
6542
|
+
const workId = requiredString(conversation, "work_id");
|
|
6543
|
+
const assistant = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ? AND conversation_id = ? AND role = 'assistant'", assistantMessageId, conversationId);
|
|
6544
|
+
if (!assistant)
|
|
6545
|
+
throw new AppError(400, "ROLEPLAY_MEMORY_ASSISTANT_MISMATCH", "角色扮演记忆来源回复不属于当前对话");
|
|
6546
|
+
const userMessage = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ? AND conversation_id = ? AND role = 'user'", sourceUserMessageId, conversationId);
|
|
6547
|
+
if (!userMessage)
|
|
6548
|
+
throw new AppError(400, "ROLEPLAY_MEMORY_USER_MESSAGE_MISMATCH", "角色扮演记忆来源消息不属于当前对话");
|
|
6549
|
+
const committedIds = [];
|
|
6550
|
+
this.db.transaction(() => {
|
|
6551
|
+
const validCandidates = candidates.slice(0, 8).flatMap((candidate) => {
|
|
6552
|
+
const content = normalizeRoleplayMemoryContent(candidate.content, 500);
|
|
6553
|
+
if (!content || !roleplayMemoryCandidateIsSafe(content))
|
|
6554
|
+
return [];
|
|
6555
|
+
return [{ ...candidate, content }];
|
|
6556
|
+
});
|
|
6557
|
+
const insertable = validCandidates.flatMap((candidate) => {
|
|
6558
|
+
const idempotencyKey = this.hashContent(`${assistantMessageId}:${JSON.stringify(candidate)}`);
|
|
6559
|
+
const contentHash = this.roleplayMemoryContentHash(candidate.content);
|
|
6560
|
+
const existing = this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND (idempotency_key = ? OR content_hash = ?)", characterId, idempotencyKey, contentHash);
|
|
6561
|
+
return existing ? [] : [{ candidate, idempotencyKey, contentHash }];
|
|
6562
|
+
});
|
|
6563
|
+
if (insertable.length === 0)
|
|
6564
|
+
return;
|
|
6565
|
+
const timestamp = now();
|
|
6566
|
+
for (const { candidate, idempotencyKey, contentHash } of insertable) {
|
|
6567
|
+
const memoryId = id("roleplay_memory");
|
|
6568
|
+
const superseded = candidate.supersedesMemoryId
|
|
6569
|
+
? this.db.get("SELECT * FROM roleplay_memories WHERE id = ? AND character_id = ? AND status = 'active'", candidate.supersedesMemoryId, characterId)
|
|
6570
|
+
: undefined;
|
|
6571
|
+
this.db.run(`INSERT INTO roleplay_memories (
|
|
6572
|
+
id, work_id, character_id, category, content, content_hash, importance, certainty, status, is_pinned, version_no,
|
|
6573
|
+
source_type, source_assistant_message_id, idempotency_key,
|
|
6574
|
+
created_by_user_id, updated_by_user_id, created_at, updated_at
|
|
6575
|
+
) 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);
|
|
6576
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6577
|
+
if (!memory)
|
|
6578
|
+
throw notFound("角色扮演记忆");
|
|
6579
|
+
this.insertRoleplayMemoryVersion(memory, "created");
|
|
6580
|
+
for (const source of [userMessage, assistant]) {
|
|
6581
|
+
this.db.run(`INSERT INTO roleplay_memory_sources (
|
|
6582
|
+
id, memory_id, conversation_id, message_id, message_role, source_created_by_user_id,
|
|
6583
|
+
source_created_at, evidence_snapshot, created_at
|
|
6584
|
+
) 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")
|
|
6585
|
+
.replace(/\r\n?/gu, "\n")
|
|
6586
|
+
.replace(/\n{3,}/gu, "\n\n")
|
|
6587
|
+
.trim()).slice(0, 2_000).join(""), timestamp);
|
|
6588
|
+
}
|
|
6589
|
+
if (superseded) {
|
|
6590
|
+
const nextVersion = numberValue(superseded, "version_no") + 1;
|
|
6591
|
+
this.db.run(`UPDATE roleplay_memories SET status = 'superseded', superseded_by_memory_id = ?,
|
|
6592
|
+
version_no = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, memoryId, nextVersion, optionalString(conversation, "created_by_user_id"), timestamp, requiredString(superseded, "id"));
|
|
6593
|
+
const updatedSuperseded = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", requiredString(superseded, "id"));
|
|
6594
|
+
if (updatedSuperseded)
|
|
6595
|
+
this.insertRoleplayMemoryVersion(updatedSuperseded, "superseded");
|
|
6596
|
+
}
|
|
6597
|
+
committedIds.push(memoryId);
|
|
6598
|
+
}
|
|
6599
|
+
this.audit(workId, "roleplay-memory.ai-committed", "character", characterId, {
|
|
6600
|
+
conversationId,
|
|
6601
|
+
assistantMessageId,
|
|
6602
|
+
memoryCount: committedIds.length
|
|
6603
|
+
});
|
|
6604
|
+
});
|
|
6605
|
+
return committedIds.flatMap((memoryId) => {
|
|
6606
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6607
|
+
return memory ? [this.mapRoleplayMemory(memory, true)] : [];
|
|
6608
|
+
});
|
|
6609
|
+
}
|
|
6610
|
+
getRoleplayMemoryAccess(memoryId) {
|
|
6611
|
+
const row = this.db.get("SELECT work_id, character_id FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6612
|
+
if (!row)
|
|
6613
|
+
throw notFound("角色扮演记忆");
|
|
6614
|
+
return { workId: requiredString(row, "work_id"), characterId: requiredString(row, "character_id") };
|
|
6615
|
+
}
|
|
6616
|
+
updateRoleplayMemory(memoryId, input) {
|
|
6617
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6618
|
+
if (!memory)
|
|
6619
|
+
throw notFound("角色扮演记忆");
|
|
6620
|
+
if (numberValue(memory, "version_no") !== input.expectedVersion) {
|
|
6621
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_VERSION_CONFLICT", "记忆已被其他操作更新,请刷新后重试");
|
|
6622
|
+
}
|
|
6623
|
+
const content = input.content === undefined ? requiredString(memory, "content") : normalizeRoleplayMemoryContent(input.content);
|
|
6624
|
+
if (!content)
|
|
6625
|
+
throw new AppError(400, "ROLEPLAY_MEMORY_CONTENT_REQUIRED", "请输入记忆内容");
|
|
6626
|
+
const category = input.category ?? requiredString(memory, "category");
|
|
6627
|
+
const importance = input.importance ?? requiredString(memory, "importance");
|
|
6628
|
+
const certainty = input.certainty ?? requiredString(memory, "certainty");
|
|
6629
|
+
const isPinned = input.isPinned ?? booleanValue(memory, "is_pinned");
|
|
6630
|
+
const contentChanged = content !== requiredString(memory, "content")
|
|
6631
|
+
|| category !== requiredString(memory, "category")
|
|
6632
|
+
|| importance !== requiredString(memory, "importance")
|
|
6633
|
+
|| certainty !== requiredString(memory, "certainty");
|
|
6634
|
+
const pinnedChanged = isPinned !== booleanValue(memory, "is_pinned");
|
|
6635
|
+
if (!contentChanged && !pinnedChanged)
|
|
6636
|
+
return this.mapRoleplayMemory(memory, true);
|
|
6637
|
+
this.db.transaction(() => {
|
|
6638
|
+
const timestamp = now();
|
|
6639
|
+
const contentHash = this.roleplayMemoryContentHash(content);
|
|
6640
|
+
const duplicate = this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND content_hash = ? AND id <> ?", requiredString(memory, "character_id"), contentHash, memoryId);
|
|
6641
|
+
if (duplicate)
|
|
6642
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_DUPLICATE", "该角色已经有相同内容的角色扮演记忆");
|
|
6643
|
+
this.db.run(`UPDATE roleplay_memories SET category = ?, content = ?, content_hash = ?, importance = ?, certainty = ?, is_pinned = ?,
|
|
6644
|
+
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);
|
|
6645
|
+
const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6646
|
+
if (!updated)
|
|
6647
|
+
throw notFound("角色扮演记忆");
|
|
6648
|
+
this.insertRoleplayMemoryVersion(updated, contentChanged ? "edited" : "pinned");
|
|
6649
|
+
this.audit(requiredString(memory, "work_id"), "roleplay-memory.updated", "roleplay-memory", memoryId, {
|
|
6650
|
+
characterId: requiredString(memory, "character_id")
|
|
6651
|
+
});
|
|
6652
|
+
});
|
|
6653
|
+
const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6654
|
+
if (!updated)
|
|
6655
|
+
throw notFound("角色扮演记忆");
|
|
6656
|
+
return this.mapRoleplayMemory(updated, true);
|
|
6657
|
+
}
|
|
6658
|
+
setRoleplayMemoryArchived(memoryId, archived, expectedVersion) {
|
|
6659
|
+
const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6660
|
+
if (!memory)
|
|
6661
|
+
throw notFound("角色扮演记忆");
|
|
6662
|
+
if (numberValue(memory, "version_no") !== expectedVersion) {
|
|
6663
|
+
throw new AppError(409, "ROLEPLAY_MEMORY_VERSION_CONFLICT", "记忆已被其他操作更新,请刷新后重试");
|
|
6664
|
+
}
|
|
6665
|
+
const nextStatus = archived ? "archived" : "active";
|
|
6666
|
+
if (requiredString(memory, "status") === nextStatus)
|
|
6667
|
+
return this.mapRoleplayMemory(memory, true);
|
|
6668
|
+
this.db.transaction(() => {
|
|
6669
|
+
const timestamp = now();
|
|
6670
|
+
this.db.run(`UPDATE roleplay_memories SET status = ?,
|
|
6671
|
+
superseded_by_memory_id = CASE WHEN ? = 'active' THEN NULL ELSE superseded_by_memory_id END,
|
|
6672
|
+
version_no = version_no + 1, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, nextStatus, nextStatus, currentRequestActor()?.userId ?? null, timestamp, memoryId);
|
|
6673
|
+
const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6674
|
+
if (!updated)
|
|
6675
|
+
throw notFound("角色扮演记忆");
|
|
6676
|
+
this.insertRoleplayMemoryVersion(updated, archived ? "archived" : "restored");
|
|
6677
|
+
this.audit(requiredString(memory, "work_id"), archived ? "roleplay-memory.archived" : "roleplay-memory.restored", "roleplay-memory", memoryId, {
|
|
6678
|
+
characterId: requiredString(memory, "character_id")
|
|
6679
|
+
});
|
|
6680
|
+
});
|
|
6681
|
+
const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
|
|
6682
|
+
if (!updated)
|
|
6683
|
+
throw notFound("角色扮演记忆");
|
|
6684
|
+
return this.mapRoleplayMemory(updated, true);
|
|
6685
|
+
}
|
|
6314
6686
|
getAiConversationContext(conversationId, workId, excludeMessageId) {
|
|
6315
6687
|
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
6316
6688
|
if (!conversation)
|
|
@@ -6336,10 +6708,13 @@ export class Store {
|
|
|
6336
6708
|
ORDER BY created_at, rowid LIMIT ?`, conversationId, requiredString(boundary, "created_at"), requiredString(boundary, "created_at"), numberValue(boundary, "rowid"), tailMessageCount);
|
|
6337
6709
|
}
|
|
6338
6710
|
}
|
|
6711
|
+
const roleplayCharacterId = optionalString(conversation, "roleplay_character_id");
|
|
6339
6712
|
return {
|
|
6340
6713
|
workId,
|
|
6341
|
-
|
|
6714
|
+
taskType: (optionalString(conversation, "task_type") ?? (roleplayCharacterId ? "roleplay" : "chat")),
|
|
6715
|
+
roleplayCharacterId,
|
|
6342
6716
|
roleplayUserCharacterId: optionalString(conversation, "roleplay_user_character_id"),
|
|
6717
|
+
roleplayMemories: roleplayCharacterId ? this.getRoleplayMemoryPromptItems(workId, roleplayCharacterId) : [],
|
|
6343
6718
|
summary: requiredString(conversation, "compacted_summary"),
|
|
6344
6719
|
compactedMessageCount,
|
|
6345
6720
|
totalMessageCount,
|
|
@@ -7044,6 +7419,49 @@ export class Store {
|
|
|
7044
7419
|
createdAt: requiredString(row, "created_at")
|
|
7045
7420
|
};
|
|
7046
7421
|
}
|
|
7422
|
+
mapRoleplayMemory(row, includeDetails) {
|
|
7423
|
+
const memoryId = requiredString(row, "id");
|
|
7424
|
+
const actor = currentRequestActor();
|
|
7425
|
+
const sources = includeDetails
|
|
7426
|
+
? this.db.all(`SELECT source.*, conversation.created_by_user_id AS conversation_owner_id
|
|
7427
|
+
FROM roleplay_memory_sources source
|
|
7428
|
+
LEFT JOIN ai_conversations conversation ON conversation.id = source.conversation_id
|
|
7429
|
+
WHERE source.memory_id = ? ORDER BY source.source_created_at, source.id`, memoryId).map((source) => {
|
|
7430
|
+
const sourceOwnerId = optionalString(source, "conversation_owner_id") ?? optionalString(source, "source_created_by_user_id");
|
|
7431
|
+
const canOpen = actor === null || actor.role === "admin" || (sourceOwnerId !== null && sourceOwnerId === actor.userId);
|
|
7432
|
+
return {
|
|
7433
|
+
id: requiredString(source, "id"),
|
|
7434
|
+
role: requiredString(source, "message_role"),
|
|
7435
|
+
sourceType: requiredString(row, "source_type"),
|
|
7436
|
+
sourceAt: requiredString(source, "source_created_at"),
|
|
7437
|
+
canOpen,
|
|
7438
|
+
restricted: !canOpen,
|
|
7439
|
+
conversationId: canOpen ? optionalString(source, "conversation_id") : null,
|
|
7440
|
+
messageId: canOpen ? optionalString(source, "message_id") : null,
|
|
7441
|
+
evidence: canOpen ? requiredString(source, "evidence_snapshot") : null
|
|
7442
|
+
};
|
|
7443
|
+
})
|
|
7444
|
+
: undefined;
|
|
7445
|
+
return {
|
|
7446
|
+
id: memoryId,
|
|
7447
|
+
workId: requiredString(row, "work_id"),
|
|
7448
|
+
characterId: requiredString(row, "character_id"),
|
|
7449
|
+
category: requiredString(row, "category"),
|
|
7450
|
+
content: requiredString(row, "content"),
|
|
7451
|
+
importance: requiredString(row, "importance"),
|
|
7452
|
+
certainty: requiredString(row, "certainty"),
|
|
7453
|
+
origin: "roleplay",
|
|
7454
|
+
canonical: false,
|
|
7455
|
+
status: requiredString(row, "status"),
|
|
7456
|
+
isPinned: booleanValue(row, "is_pinned"),
|
|
7457
|
+
versionNo: numberValue(row, "version_no"),
|
|
7458
|
+
supersededByMemoryId: optionalString(row, "superseded_by_memory_id"),
|
|
7459
|
+
sourceType: requiredString(row, "source_type"),
|
|
7460
|
+
...(sources ? { sources } : {}),
|
|
7461
|
+
createdAt: requiredString(row, "created_at"),
|
|
7462
|
+
updatedAt: requiredString(row, "updated_at")
|
|
7463
|
+
};
|
|
7464
|
+
}
|
|
7047
7465
|
hashContent(content) {
|
|
7048
7466
|
return createHash("sha256").update(content).digest("hex");
|
|
7049
7467
|
}
|
|
@@ -7751,8 +8169,20 @@ export class Store {
|
|
|
7751
8169
|
return [];
|
|
7752
8170
|
}
|
|
7753
8171
|
});
|
|
7754
|
-
|
|
7755
|
-
|
|
8172
|
+
const hasChunkMetrics = typeof result.coveredChapterCount === "number" || typeof result.batchCount === "number";
|
|
8173
|
+
summary = hasChunkMetrics
|
|
8174
|
+
? `覆盖 ${Number(result.coveredChapterCount ?? 0)} 章正文,识别 ${Number(result.rawCandidateCount ?? events.length)} 个原始候选,写入 ${events.length} 个时间轴事件候选。`
|
|
8175
|
+
: `提取并写入 ${events.length} 个时间轴事件候选。`;
|
|
8176
|
+
metrics = [
|
|
8177
|
+
metric("写入事件", events.length),
|
|
8178
|
+
...(hasChunkMetrics ? [
|
|
8179
|
+
metric("覆盖章节", result.coveredChapterCount),
|
|
8180
|
+
metric("正文分片", result.batchCount),
|
|
8181
|
+
metric("归并批次", result.aggregationBatchCount),
|
|
8182
|
+
metric("原始候选", result.rawCandidateCount)
|
|
8183
|
+
] : []),
|
|
8184
|
+
metric("已不存在", Math.max(0, ids.length - events.length))
|
|
8185
|
+
];
|
|
7756
8186
|
storageTargets.unshift({ label: "时间轴候选", entity: "时间轴与事件", key: "timeline", count: events.length, note: "以候选状态写入,等待作者确认。" });
|
|
7757
8187
|
sections = [section("事件候选", events, "没有形成可写入的时间轴事件。")];
|
|
7758
8188
|
}
|