@musnows/scriverse 0.9.4 → 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 +3 -3
- package/dist/public/ai-interactive.js +483 -0
- package/dist/public/app.js +1179 -59
- 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 +230 -49
- 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 +450 -19
- 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/version.js +1 -1
- package/package.json +3 -1
package/dist/store.js
CHANGED
|
@@ -14,6 +14,8 @@ 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
|
});
|
|
@@ -2106,6 +2113,7 @@ export class Store {
|
|
|
2106
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,8 +2846,9 @@ 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
|
-
|
|
2791
|
-
|
|
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);
|
|
2792
2852
|
this.syncChapterParagraphSearch(workId, chapterId, content);
|
|
2793
2853
|
this.insertChapterVersionRow({
|
|
2794
2854
|
workId,
|
|
@@ -3091,6 +3151,8 @@ export class Store {
|
|
|
3091
3151
|
: optionalString(row, "cover_url"),
|
|
3092
3152
|
tags: json(requiredString(row, "tags_json"), []),
|
|
3093
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,
|
|
3094
3156
|
versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", workId),
|
|
3095
3157
|
ownerUserId,
|
|
3096
3158
|
accessRole,
|
|
@@ -3123,7 +3185,8 @@ export class Store {
|
|
|
3123
3185
|
mapChapter(row) {
|
|
3124
3186
|
return {
|
|
3125
3187
|
...this.mapChapterDirectoryEntry(row),
|
|
3126
|
-
content: requiredString(row, "content")
|
|
3188
|
+
content: requiredString(row, "content"),
|
|
3189
|
+
lineIds: parseChapterLineIds(row.line_ids_json, requiredString(row, "content"))
|
|
3127
3190
|
};
|
|
3128
3191
|
}
|
|
3129
3192
|
mapChapterDirectoryEntry(row) {
|
|
@@ -5578,6 +5641,7 @@ export class Store {
|
|
|
5578
5641
|
const timelineEvents = this.listTimelineEvents(workId).filter((event) => event.participantIds.includes(sourceId));
|
|
5579
5642
|
const sourceMemberships = this.db.all("SELECT * FROM character_organization_memberships WHERE character_id = ? ORDER BY organization_id", sourceId);
|
|
5580
5643
|
const referenceSnapshot = { relationships: sourceRelationships, timelineEvents, memberships: sourceMemberships };
|
|
5644
|
+
let roleplayMemoryMerge = { migrated: 0, deduplicated: 0 };
|
|
5581
5645
|
this.db.transaction(() => {
|
|
5582
5646
|
const lockedTarget = this.getCharacter(targetId);
|
|
5583
5647
|
const lockedSource = this.getCharacter(sourceId);
|
|
@@ -5645,6 +5709,7 @@ export class Store {
|
|
|
5645
5709
|
this.db.run("UPDATE character_profile_sections SET character_id = ?, updated_at = ? WHERE character_id = ?", targetId, timestamp, sourceId);
|
|
5646
5710
|
this.db.run("UPDATE character_profile_section_versions SET character_id = ? WHERE character_id = ?", targetId, sourceId);
|
|
5647
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);
|
|
5648
5713
|
const sourceVersionNo = Number(source.versionNo) + 1;
|
|
5649
5714
|
this.db.run("UPDATE characters SET merged_into_character_id = ?, merged_at = ?, version_no = ?, updated_at = ? WHERE id = ?", targetId, timestamp, sourceVersionNo, timestamp, sourceId);
|
|
5650
5715
|
this.insertCharacterVersion(sourceId, sourceVersionNo, "merge", mergeId, `合并至角色“${String(target.name)}”`, timestamp);
|
|
@@ -5657,16 +5722,60 @@ export class Store {
|
|
|
5657
5722
|
this.audit(workId, "character.merged", "character", targetId, {
|
|
5658
5723
|
mergeId,
|
|
5659
5724
|
sourceCharacterId: sourceId,
|
|
5660
|
-
reviewId: input.reviewId
|
|
5725
|
+
reviewId: input.reviewId,
|
|
5726
|
+
roleplayMemoryMerge
|
|
5661
5727
|
});
|
|
5662
5728
|
});
|
|
5663
5729
|
return {
|
|
5664
5730
|
mergeId,
|
|
5665
5731
|
target: this.getCharacter(targetId),
|
|
5666
5732
|
source: this.getCharacter(sourceId),
|
|
5667
|
-
review: input.reviewId ? this.getReviewItem(input.reviewId) : null
|
|
5733
|
+
review: input.reviewId ? this.getReviewItem(input.reviewId) : null,
|
|
5734
|
+
roleplayMemoryMerge
|
|
5668
5735
|
};
|
|
5669
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
|
+
}
|
|
5670
5779
|
resolveCharacterDuplicateReview(reviewId) {
|
|
5671
5780
|
const review = this.getReviewItem(reviewId);
|
|
5672
5781
|
if (review.itemType !== "character-duplicate" || review.status !== "pending") {
|
|
@@ -6310,6 +6419,270 @@ export class Store {
|
|
|
6310
6419
|
messagesPage
|
|
6311
6420
|
};
|
|
6312
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
|
+
}
|
|
6313
6686
|
getAiConversationContext(conversationId, workId, excludeMessageId) {
|
|
6314
6687
|
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
6315
6688
|
if (!conversation)
|
|
@@ -6335,10 +6708,13 @@ export class Store {
|
|
|
6335
6708
|
ORDER BY created_at, rowid LIMIT ?`, conversationId, requiredString(boundary, "created_at"), requiredString(boundary, "created_at"), numberValue(boundary, "rowid"), tailMessageCount);
|
|
6336
6709
|
}
|
|
6337
6710
|
}
|
|
6711
|
+
const roleplayCharacterId = optionalString(conversation, "roleplay_character_id");
|
|
6338
6712
|
return {
|
|
6339
6713
|
workId,
|
|
6340
|
-
|
|
6714
|
+
taskType: (optionalString(conversation, "task_type") ?? (roleplayCharacterId ? "roleplay" : "chat")),
|
|
6715
|
+
roleplayCharacterId,
|
|
6341
6716
|
roleplayUserCharacterId: optionalString(conversation, "roleplay_user_character_id"),
|
|
6717
|
+
roleplayMemories: roleplayCharacterId ? this.getRoleplayMemoryPromptItems(workId, roleplayCharacterId) : [],
|
|
6342
6718
|
summary: requiredString(conversation, "compacted_summary"),
|
|
6343
6719
|
compactedMessageCount,
|
|
6344
6720
|
totalMessageCount,
|
|
@@ -7043,6 +7419,49 @@ export class Store {
|
|
|
7043
7419
|
createdAt: requiredString(row, "created_at")
|
|
7044
7420
|
};
|
|
7045
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
|
+
}
|
|
7046
7465
|
hashContent(content) {
|
|
7047
7466
|
return createHash("sha256").update(content).digest("hex");
|
|
7048
7467
|
}
|
|
@@ -7750,8 +8169,20 @@ export class Store {
|
|
|
7750
8169
|
return [];
|
|
7751
8170
|
}
|
|
7752
8171
|
});
|
|
7753
|
-
|
|
7754
|
-
|
|
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
|
+
];
|
|
7755
8186
|
storageTargets.unshift({ label: "时间轴候选", entity: "时间轴与事件", key: "timeline", count: events.length, note: "以候选状态写入,等待作者确认。" });
|
|
7756
8187
|
sections = [section("事件候选", events, "没有形成可写入的时间轴事件。")];
|
|
7757
8188
|
}
|