dsh-cuigengji-plugin 0.4.2 → 0.4.4
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/LICENSE +21 -21
- package/README.md +1 -1
- package/cordis.patch.yml +3 -3
- package/docs/CHANGELOG.md +15 -0
- package/docs/agent-installation.md +19 -12
- package/docs/architecture.md +74 -74
- package/docs/desktop-migration-20260930.md +26 -26
- package/docs/development.md +7 -3
- package/docs/getting-started.md +17 -7
- package/docs/layout-progress.md +31 -31
- package/docs/market-listing.md +5 -3
- package/docs/npm-publishing.md +35 -11
- package/docs/package-name-migration.md +3 -3
- package/docs/planning-board.md +44 -44
- package/docs/presets.md +24 -24
- package/docs/release-process.md +45 -10
- package/docs/tavern-compatibility-audit-20261001.md +109 -109
- package/docs/test-report.md +139 -129
- package/docs/upgrade.md +3 -1
- package/docs/usage-review.md +13 -13
- package/lib/client.js +19 -14
- package/package.json +19 -14
- package/skills/cuigengji-maintenance/SKILL.md +2 -2
- package/src/adapters/dsh/prompt.js +36 -36
- package/src/adapters/dsh/rpc.js +96 -96
- package/src/adapters/dsh/skills.js +17 -17
- package/src/adapters/mcp/actions.js +6 -6
- package/src/adapters/mcp/client.js +172 -172
- package/src/adapters/mcp/server.js +40 -40
- package/src/application/bindings/session.js +72 -72
- package/src/application/queries/context.js +15 -15
- package/src/application/store.js +412 -412
- package/src/client/features/chapters/clipboard.ts +27 -27
- package/src/client/features/chapters/history.ts +11 -11
- package/src/client/features/chapters/index.jsx +118 -118
- package/src/client/features/chapters/sync.ts +155 -155
- package/src/client/features/chapters/useChapterSync.ts +39 -39
- package/src/client/features/context/index.jsx +22 -22
- package/src/client/features/memory/index.jsx +73 -73
- package/src/client/features/planning/Decorations.tsx +1 -1
- package/src/client/features/planning/Pages.tsx +11 -11
- package/src/client/features/planning/data.js +16 -16
- package/src/client/index.tsx +130 -130
- package/src/client/launcher.tsx +78 -78
- package/src/client/shared/creation.tsx +34 -34
- package/src/client/shared/detail-page.tsx +61 -61
- package/src/client/shared/drafts.js +17 -17
- package/src/client/shared/evidence.jsx +11 -11
- package/src/client/shared/groups.tsx +34 -34
- package/src/client/shared/preferences.tsx +104 -104
- package/src/client/shared/select-style.ts +25 -25
- package/src/client/shared/state.js +95 -95
- package/src/client/shared/ui.jsx +19 -19
- package/src/client/shared/workspace.tsx +11 -11
- package/src/client/style.ts +274 -274
- package/src/client/visual.ts +190 -190
- package/src/contracts/actions.js +19 -19
- package/src/contracts/planning.ts +52 -52
- package/src/domain/manuscript.ts +4 -4
- package/src/domain/planning/index.ts +113 -113
- package/src/domain/planning/model.ts +59 -59
- package/src/domain/preset/index.js +89 -89
- package/src/index.js +91 -91
- package/src/infrastructure/migrations/legacy.js +96 -96
- package/src/infrastructure/migrations/tavern.js +78 -78
- package/src/infrastructure/persistence/atomic-store.ts +80 -80
- package/src/infrastructure/persistence/project-layout.ts +1 -1
package/src/application/store.js
CHANGED
|
@@ -1,112 +1,112 @@
|
|
|
1
|
-
import { memoryGroupAction, groupId, validateMemoryGroups } from '../domain/memory/groups.ts';
|
|
2
|
-
import { ensurePlanning, planningAction, validatePlanning } from '../domain/planning/index.ts';
|
|
3
|
-
import { selectReference } from './queries/context.js';
|
|
4
|
-
import { createHandoff } from './queries/handoff.js';
|
|
5
|
-
import { writeFile } from 'node:fs/promises';
|
|
1
|
+
import { memoryGroupAction, groupId, validateMemoryGroups } from '../domain/memory/groups.ts';
|
|
2
|
+
import { ensurePlanning, planningAction, validatePlanning } from '../domain/planning/index.ts';
|
|
3
|
+
import { selectReference } from './queries/context.js';
|
|
4
|
+
import { createHandoff } from './queries/handoff.js';
|
|
5
|
+
import { writeFile } from 'node:fs/promises';
|
|
6
6
|
import { ProjectStore } from '../infrastructure/persistence/project-store.ts';
|
|
7
7
|
import { chapterCounts } from '../domain/chapters/metadata.ts';
|
|
8
|
-
import { join } from 'node:path';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
9
|
import { createHash, randomUUID } from 'node:crypto';
|
|
10
10
|
import { isDeepStrictEqual } from 'node:util';
|
|
11
|
-
import { previewTavern } from '../infrastructure/migrations/tavern.js';
|
|
11
|
+
import { previewTavern } from '../infrastructure/migrations/tavern.js';
|
|
12
12
|
import { checkDirectorySnapshot, directorySnapshot, planChapterMove } from '../domain/chapters/order.ts';
|
|
13
|
-
import { compilePreset, importPreset, validatePreset } from '../domain/preset/index.js';
|
|
14
|
-
import {applyNovelLifecycle, novelLifecycleActions} from './novels/lifecycle.ts';
|
|
15
|
-
|
|
16
|
-
export class StoreError extends Error {
|
|
17
|
-
constructor(code, message) { super(message); this.name = 'StoreError'; this.code = code; }
|
|
18
|
-
}
|
|
19
|
-
const fail = (code, message) => { throw new StoreError(code, message); };
|
|
20
|
-
const clone = value => structuredClone(value);
|
|
21
|
-
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
22
|
-
const now = () => new Date().toISOString();
|
|
23
|
-
const empty = () => ({ schemaVersion: 1, novels: {}, bindings: {}, requests: {} });
|
|
24
|
-
const kinds = new Set(['world_book', 'world_entry', 'character_card']);
|
|
25
|
-
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
26
|
-
const get = (object, key, label) => own(object, key) ? object[key] : fail('NOT_FOUND', `${label}不存在:${key}`);
|
|
27
|
-
function text(value, name, allowEmpty = false) {
|
|
28
|
-
if (typeof value !== 'string' || (!allowEmpty && !value.trim())) fail('INVALID_INPUT', `${name}必须是${allowEmpty ? '' : '非空'}文本`);
|
|
29
|
-
return value;
|
|
30
|
-
}
|
|
31
|
-
function cas(entity, args) {
|
|
32
|
-
if (!Number.isSafeInteger(args.expectedRevision)) fail('REVISION_REQUIRED', '修改前请读取对象并提供 expectedRevision');
|
|
33
|
-
if (entity.revision !== args.expectedRevision) fail('CONFLICT', `版本已变化,当前版本 ${entity.revision}`);
|
|
34
|
-
if (args.expectedHash !== undefined && args.expectedHash !== entity.contentHash) fail('CONFLICT', '正文内容哈希已变化');
|
|
35
|
-
}
|
|
36
|
-
function touch(entity) { entity.revision++; entity.updatedAt = now(); }
|
|
37
|
-
function record(fields) { return { id: randomUUID(), revision: 1, createdAt: now(), updatedAt: now(), ...fields }; }
|
|
13
|
+
import { compilePreset, importPreset, validatePreset } from '../domain/preset/index.js';
|
|
14
|
+
import {applyNovelLifecycle, novelLifecycleActions} from './novels/lifecycle.ts';
|
|
15
|
+
|
|
16
|
+
export class StoreError extends Error {
|
|
17
|
+
constructor(code, message) { super(message); this.name = 'StoreError'; this.code = code; }
|
|
18
|
+
}
|
|
19
|
+
const fail = (code, message) => { throw new StoreError(code, message); };
|
|
20
|
+
const clone = value => structuredClone(value);
|
|
21
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
22
|
+
const now = () => new Date().toISOString();
|
|
23
|
+
const empty = () => ({ schemaVersion: 1, novels: {}, bindings: {}, requests: {} });
|
|
24
|
+
const kinds = new Set(['world_book', 'world_entry', 'character_card']);
|
|
25
|
+
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
26
|
+
const get = (object, key, label) => own(object, key) ? object[key] : fail('NOT_FOUND', `${label}不存在:${key}`);
|
|
27
|
+
function text(value, name, allowEmpty = false) {
|
|
28
|
+
if (typeof value !== 'string' || (!allowEmpty && !value.trim())) fail('INVALID_INPUT', `${name}必须是${allowEmpty ? '' : '非空'}文本`);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function cas(entity, args) {
|
|
32
|
+
if (!Number.isSafeInteger(args.expectedRevision)) fail('REVISION_REQUIRED', '修改前请读取对象并提供 expectedRevision');
|
|
33
|
+
if (entity.revision !== args.expectedRevision) fail('CONFLICT', `版本已变化,当前版本 ${entity.revision}`);
|
|
34
|
+
if (args.expectedHash !== undefined && args.expectedHash !== entity.contentHash) fail('CONFLICT', '正文内容哈希已变化');
|
|
35
|
+
}
|
|
36
|
+
function touch(entity) { entity.revision++; entity.updatedAt = now(); }
|
|
37
|
+
function record(fields) { return { id: randomUUID(), revision: 1, createdAt: now(), updatedAt: now(), ...fields }; }
|
|
38
38
|
function meta(chapter) { const { content, versions, ...metadata } = chapter; return { ...metadata, ...chapterCounts(chapter) }; }
|
|
39
|
-
function source(actor) { return { kind: actor.kind === 'human' ? 'human' : 'agent', sessionId: actor.sessionId || null }; }
|
|
40
|
-
function version(chapter, actor, reason) {
|
|
41
|
-
chapter.contentHash = hash(chapter.content);
|
|
42
|
-
chapter.versions.push({ revision: chapter.revision, content: chapter.content, contentHash: chapter.contentHash,
|
|
43
|
-
title: chapter.title, volumeId: chapter.volumeId, order: chapter.order, deleted: chapter.deleted,
|
|
44
|
-
timestamp: now(), actor: source(actor), reason: reason || '' });
|
|
45
|
-
}
|
|
46
|
-
function stale(novel, chapterId) {
|
|
47
|
-
const entities = [...Object.values(novel.nodes), ...Object.values(novel.edges)];
|
|
48
|
-
const staleIds = new Set(entities.filter(e => e.status === 'stale').map(e => e.id));
|
|
49
|
-
let changed = true;
|
|
50
|
-
while (changed) {
|
|
51
|
-
changed = false;
|
|
52
|
-
for (const entity of entities) {
|
|
53
|
-
if ((entity.sources?.some(s => s.chapterId === chapterId) || entity.dependsOn?.some(id => staleIds.has(id))) && entity.status !== 'stale') {
|
|
54
|
-
entity.status = 'stale'; touch(entity); staleIds.add(entity.id); changed = true;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
function sources(novel, values = []) {
|
|
60
|
-
if (!Array.isArray(values)) fail('INVALID_INPUT', 'sources 必须是数组');
|
|
61
|
-
return values.map(s => {
|
|
62
|
-
const ch = get(novel.chapters, s.chapterId, '来源章节');
|
|
63
|
-
if (!Number.isSafeInteger(s.revision) || !ch.versions.some(v => v.revision === s.revision)) fail('INVALID_INPUT', '来源必须指定存在的章节版本');
|
|
64
|
-
return { chapterId: ch.id, revision: s.revision };
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
function ordering(value) {
|
|
68
|
-
if (!Number.isFinite(value)) fail('INVALID_INPUT', 'order 必须是有限数字');
|
|
69
|
-
return value;
|
|
70
|
-
}
|
|
71
|
-
function nodePosition(args) {
|
|
72
|
-
if (args.position === undefined) return {};
|
|
73
|
-
const p = args.position;
|
|
74
|
-
if (p === null) return { position: null };
|
|
39
|
+
function source(actor) { return { kind: actor.kind === 'human' ? 'human' : 'agent', sessionId: actor.sessionId || null }; }
|
|
40
|
+
function version(chapter, actor, reason) {
|
|
41
|
+
chapter.contentHash = hash(chapter.content);
|
|
42
|
+
chapter.versions.push({ revision: chapter.revision, content: chapter.content, contentHash: chapter.contentHash,
|
|
43
|
+
title: chapter.title, volumeId: chapter.volumeId, order: chapter.order, deleted: chapter.deleted,
|
|
44
|
+
timestamp: now(), actor: source(actor), reason: reason || '' });
|
|
45
|
+
}
|
|
46
|
+
function stale(novel, chapterId) {
|
|
47
|
+
const entities = [...Object.values(novel.nodes), ...Object.values(novel.edges)];
|
|
48
|
+
const staleIds = new Set(entities.filter(e => e.status === 'stale').map(e => e.id));
|
|
49
|
+
let changed = true;
|
|
50
|
+
while (changed) {
|
|
51
|
+
changed = false;
|
|
52
|
+
for (const entity of entities) {
|
|
53
|
+
if ((entity.sources?.some(s => s.chapterId === chapterId) || entity.dependsOn?.some(id => staleIds.has(id))) && entity.status !== 'stale') {
|
|
54
|
+
entity.status = 'stale'; touch(entity); staleIds.add(entity.id); changed = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function sources(novel, values = []) {
|
|
60
|
+
if (!Array.isArray(values)) fail('INVALID_INPUT', 'sources 必须是数组');
|
|
61
|
+
return values.map(s => {
|
|
62
|
+
const ch = get(novel.chapters, s.chapterId, '来源章节');
|
|
63
|
+
if (!Number.isSafeInteger(s.revision) || !ch.versions.some(v => v.revision === s.revision)) fail('INVALID_INPUT', '来源必须指定存在的章节版本');
|
|
64
|
+
return { chapterId: ch.id, revision: s.revision };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function ordering(value) {
|
|
68
|
+
if (!Number.isFinite(value)) fail('INVALID_INPUT', 'order 必须是有限数字');
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function nodePosition(args) {
|
|
72
|
+
if (args.position === undefined) return {};
|
|
73
|
+
const p = args.position;
|
|
74
|
+
if (p === null) return { position: null };
|
|
75
75
|
if (!p || Array.isArray(p) || typeof p !== 'object' || !Number.isFinite(p.x) || !Number.isFinite(p.y)) fail('INVALID_INPUT', 'position 必须是有限坐标或 null');
|
|
76
|
-
return { position: { x: p.x, y: p.y } };
|
|
77
|
-
}
|
|
78
|
-
function memoryFields(args, novel, creating = false, optionalName = false) {
|
|
79
|
-
const result = {};
|
|
80
|
-
for (const field of ['name', 'summary', 'content', 'storyTime']) {
|
|
81
|
-
if (args[field] !== undefined) result[field] = text(args[field], field, field !== 'name' || optionalName);
|
|
82
|
-
else if (creating) result[field] = field === 'name' && !optionalName ? text(args[field], field) : '';
|
|
83
|
-
}
|
|
84
|
-
for (const [field, allowed, fallback] of [
|
|
85
|
-
['status', ['active', 'stale', 'unconfirmed', 'retired'], 'active'],
|
|
86
|
-
['factType', ['fact', 'belief', 'misunderstanding', 'unconfirmed', 'plan'], 'unconfirmed'],
|
|
87
|
-
]) {
|
|
88
|
-
if (args[field] !== undefined && !allowed.includes(args[field])) fail('INVALID_INPUT', `${field}无效`);
|
|
89
|
-
if (args[field] !== undefined || creating) result[field] = args[field] ?? fallback;
|
|
90
|
-
}
|
|
91
|
-
if (args.sources !== undefined || creating) result.sources = sources(novel, args.sources);
|
|
92
|
-
for (const field of ['aliases', 'knownBy', 'dependsOn']) {
|
|
93
|
-
if (args[field] !== undefined || creating) {
|
|
94
|
-
const value = args[field] ?? [];
|
|
95
|
-
if (!Array.isArray(value) || value.some(v => typeof v !== 'string')) fail('INVALID_INPUT', `${field}必须是文本数组`);
|
|
96
|
-
if (field === 'dependsOn') for (const id of value) get(novel.nodes, id, '依赖节点');
|
|
97
|
-
result[field] = value;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return result;
|
|
101
|
-
}
|
|
102
|
-
|
|
76
|
+
return { position: { x: p.x, y: p.y } };
|
|
77
|
+
}
|
|
78
|
+
function memoryFields(args, novel, creating = false, optionalName = false) {
|
|
79
|
+
const result = {};
|
|
80
|
+
for (const field of ['name', 'summary', 'content', 'storyTime']) {
|
|
81
|
+
if (args[field] !== undefined) result[field] = text(args[field], field, field !== 'name' || optionalName);
|
|
82
|
+
else if (creating) result[field] = field === 'name' && !optionalName ? text(args[field], field) : '';
|
|
83
|
+
}
|
|
84
|
+
for (const [field, allowed, fallback] of [
|
|
85
|
+
['status', ['active', 'stale', 'unconfirmed', 'retired'], 'active'],
|
|
86
|
+
['factType', ['fact', 'belief', 'misunderstanding', 'unconfirmed', 'plan'], 'unconfirmed'],
|
|
87
|
+
]) {
|
|
88
|
+
if (args[field] !== undefined && !allowed.includes(args[field])) fail('INVALID_INPUT', `${field}无效`);
|
|
89
|
+
if (args[field] !== undefined || creating) result[field] = args[field] ?? fallback;
|
|
90
|
+
}
|
|
91
|
+
if (args.sources !== undefined || creating) result.sources = sources(novel, args.sources);
|
|
92
|
+
for (const field of ['aliases', 'knownBy', 'dependsOn']) {
|
|
93
|
+
if (args[field] !== undefined || creating) {
|
|
94
|
+
const value = args[field] ?? [];
|
|
95
|
+
if (!Array.isArray(value) || value.some(v => typeof v !== 'string')) fail('INVALID_INPUT', `${field}必须是文本数组`);
|
|
96
|
+
if (field === 'dependsOn') for (const id of value) get(novel.nodes, id, '依赖节点');
|
|
97
|
+
result[field] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
103
|
/** Business commands operate on selectively hydrated project records.
|
|
104
104
|
* Storage owns file partitioning, migration and recoverable batch commits. */
|
|
105
105
|
export class Store extends ProjectStore {
|
|
106
|
-
constructor(root, options = {}) {
|
|
107
|
-
super(root, empty, state => {
|
|
108
|
-
if (!state || state.schemaVersion !== 1 || !state.novels || !state.bindings || !state.requests) fail('CORRUPT_STORE', '不支持的数据格式');
|
|
109
|
-
}, options);
|
|
106
|
+
constructor(root, options = {}) {
|
|
107
|
+
super(root, empty, state => {
|
|
108
|
+
if (!state || state.schemaVersion !== 1 || !state.novels || !state.bindings || !state.requests) fail('CORRUPT_STORE', '不支持的数据格式');
|
|
109
|
+
}, options);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
validateMigration(state) {
|
|
@@ -123,154 +123,154 @@ export class Store extends ProjectStore {
|
|
|
123
123
|
validateProject(novel) {
|
|
124
124
|
this.importNovel({novels: {}}, {backup: {format: 'cuigengji', schemaVersion: 2, novel}});
|
|
125
125
|
}
|
|
126
|
-
|
|
127
|
-
async dispatch(action, args = {}, actor = {}, beforeExecute) {
|
|
128
|
-
if (!args || typeof args !== 'object' || Array.isArray(args)) fail('INVALID_INPUT', '参数必须是对象');
|
|
129
|
-
if (novelLifecycleActions.includes(action) && actor.kind !== 'human') fail('HUMAN_REQUIRED', '整部作品的回收、恢复与永久删除需要作者操作');
|
|
130
|
-
const release = await this.acquire();
|
|
131
|
-
try {
|
|
126
|
+
|
|
127
|
+
async dispatch(action, args = {}, actor = {}, beforeExecute) {
|
|
128
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) fail('INVALID_INPUT', '参数必须是对象');
|
|
129
|
+
if (novelLifecycleActions.includes(action) && actor.kind !== 'human') fail('HUMAN_REQUIRED', '整部作品的回收、恢复与永久删除需要作者操作');
|
|
130
|
+
const release = await this.acquire();
|
|
131
|
+
try {
|
|
132
132
|
const state = await this.loadForAction(action, args, actor);
|
|
133
|
-
// A synchronous host guard sees the latest bindings while the Store lock
|
|
134
|
-
// is held, including Agent tasks that started during disk reads.
|
|
135
|
-
beforeExecute?.(state);
|
|
136
|
-
const signature = hash(JSON.stringify({ action, args, actor: source(actor) }));
|
|
137
|
-
const requestKey = args.requestId === undefined ? null : hash(text(args.requestId, 'requestId'));
|
|
138
|
-
if (requestKey && own(state.requests, requestKey)) {
|
|
139
|
-
const previous = state.requests[requestKey];
|
|
140
|
-
if (previous.signature !== signature) fail('REQUEST_ID_REUSED', 'requestId 已用于其他请求');
|
|
141
|
-
return clone(previous.result);
|
|
142
|
-
}
|
|
143
|
-
const migrationNovelId=args.novelId||state.bindings[hash(actor.sessionId||'')]?.novelId;
|
|
133
|
+
// A synchronous host guard sees the latest bindings while the Store lock
|
|
134
|
+
// is held, including Agent tasks that started during disk reads.
|
|
135
|
+
beforeExecute?.(state);
|
|
136
|
+
const signature = hash(JSON.stringify({ action, args, actor: source(actor) }));
|
|
137
|
+
const requestKey = args.requestId === undefined ? null : hash(text(args.requestId, 'requestId'));
|
|
138
|
+
if (requestKey && own(state.requests, requestKey)) {
|
|
139
|
+
const previous = state.requests[requestKey];
|
|
140
|
+
if (previous.signature !== signature) fail('REQUEST_ID_REUSED', 'requestId 已用于其他请求');
|
|
141
|
+
return clone(previous.result);
|
|
142
|
+
}
|
|
143
|
+
const migrationNovelId=args.novelId||state.bindings[hash(actor.sessionId||'')]?.novelId;
|
|
144
144
|
const migrationNovel=migrationNovelId&&state.novels[migrationNovelId];
|
|
145
145
|
if ((action.startsWith('planning.')||action==='novel.export') && migrationNovel?.plan && !migrationNovel.planning) {
|
|
146
146
|
await this.hydrateNovel(state, migrationNovelId);
|
|
147
147
|
await writeFile(join(this.root, `before-planning-${hash(migrationNovelId)}.json`), JSON.stringify({format:'cuigengji',schemaVersion:1,novel:migrationNovel}), {flag:'wx',mode:0o600}).catch(error=>{if(error.code!=='EEXIST')throw error;});
|
|
148
|
-
}
|
|
149
|
-
const before = JSON.stringify(state);
|
|
150
|
-
const result = this.execute(state, action, args, actor);
|
|
151
|
-
if (requestKey) state.requests[requestKey] = { signature, result: clone(result), timestamp: now(),
|
|
152
|
-
...((migrationNovelId || ['novel.create','novel.import'].includes(action)) ? {novelId: ['novel.create','novel.import'].includes(action) ? result.id : migrationNovelId} : {}) };
|
|
153
|
-
if (JSON.stringify(state) !== before) await this.save(state);
|
|
154
|
-
return clone(result);
|
|
155
|
-
} finally { await release(); }
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
execute(state, action, a, actor) {
|
|
159
|
-
if (action === 'novel.list') return Object.values(state.novels).filter(n => a.includeArchived || !n.archived).map(n => ({ id: n.id, title: n.title, revision: n.revision, archived: n.archived, ...(n.archivedAt ? {archivedAt:n.archivedAt} : {}), updatedAt: n.updatedAt }));
|
|
160
|
-
if (action === 'novel.create') {
|
|
161
|
-
const n = record({ title: text(a.title, 'title'), description: a.description ?? '', archived: false, volumes: {}, chapters: {}, nodes: {}, edges: {}, plan: null });
|
|
162
|
-
state.novels[n.id] = n; return { id: n.id, title: n.title, revision: n.revision };
|
|
163
|
-
}
|
|
164
|
-
if (action === 'novel.import') return this.importNovel(state, a);
|
|
165
|
-
if (action === 'binding.get') {
|
|
166
|
-
const binding = state.bindings[hash(text(actor.sessionId || a.sessionId, 'sessionId'))];
|
|
167
|
-
return binding && own(state.novels, binding.novelId) && !state.novels[binding.novelId].archived ? binding : null;
|
|
168
|
-
}
|
|
169
|
-
if (action === 'binding.set') {
|
|
170
|
-
const sessionId = text(actor.sessionId || a.sessionId, 'sessionId');
|
|
171
|
-
const n = get(state.novels, a.novelId, '小说');
|
|
172
|
-
if (n.archived) fail('NOVEL_RECYCLED', '作品已在回收站,请先恢复作品');
|
|
173
|
-
if (a.chapterId) { const ch = get(n.chapters, a.chapterId, '章节'); if (ch.deleted) fail('DELETED', '章节已删除'); }
|
|
174
|
-
const previous = state.bindings[hash(sessionId)];
|
|
175
|
-
const sameNovel = previous?.novelId === n.id;
|
|
176
|
-
const stage = a.stage ?? (sameNovel ? previous.stage : null) ?? 'discuss';
|
|
177
|
-
if (!['discuss', 'plan', 'write', 'revise', 'memory'].includes(stage)) fail('INVALID_INPUT', 'stage 必须是 discuss、plan、write、revise 或 memory');
|
|
178
|
-
const binding = { sessionId, novelId: n.id, chapterId: a.chapterId === undefined && sameNovel ? previous.chapterId : a.chapterId || null,
|
|
179
|
-
stage, goal: a.goal === undefined ? (sameNovel ? previous.goal || '' : '') : text(a.goal, 'goal', true), updatedAt: now() };
|
|
180
|
-
state.bindings[hash(sessionId)] = binding; return binding;
|
|
181
|
-
}
|
|
182
|
-
const binding = actor.sessionId && state.bindings[hash(actor.sessionId)];
|
|
183
|
-
const novelId = a.novelId || binding?.novelId;
|
|
184
|
-
if (!novelId) fail('NOVEL_REQUIRED', '请先绑定小说或提供 novelId');
|
|
185
|
-
if (actor.kind !== 'human' && binding && binding.novelId !== novelId) fail('NOVEL_MISMATCH', '当前会话已绑定另一本小说');
|
|
186
|
-
const n = get(state.novels, novelId, '小说');
|
|
187
|
-
if (n.archived && !(actor.kind === 'human' && ['novel.get','novel.export',...novelLifecycleActions].includes(action))) fail('NOVEL_RECYCLED', '作品已在回收站,请先恢复作品');
|
|
188
|
-
if (novelLifecycleActions.includes(action)) {
|
|
189
|
-
cas(n, a);
|
|
190
|
-
applyNovelLifecycle(state, n, action, a);
|
|
191
|
-
if (action === 'novel.purge') return {id: n.id, purged: true};
|
|
192
|
-
touch(n); return {id: n.id, title: n.title, revision: n.revision, archived: n.archived, ...(n.archivedAt ? {archivedAt:n.archivedAt} : {})};
|
|
193
|
-
}
|
|
194
|
-
if (action === 'tavern.preview' || action === 'tavern.import') {
|
|
195
|
-
if (actor.kind !== 'human') fail('HUMAN_REQUIRED', '酒馆文件导入需要作者操作');
|
|
196
|
-
const preview = previewTavern(a);
|
|
197
|
-
if (action === 'tavern.preview') {
|
|
198
|
-
const { raw, ...result } = preview;
|
|
199
|
-
return result;
|
|
200
|
-
}
|
|
201
|
-
if (a.confirm !== true) fail('CONFIRM_REQUIRED', '请先预览并确认导入');
|
|
202
|
-
if (a.fingerprint !== preview.fingerprint) fail('CONFLICT', '文件与预览不一致,请重新预览');
|
|
203
|
-
const selected = a.selected ?? preview.nodes.map((_, index) => index);
|
|
204
|
-
if (!Array.isArray(selected) || !selected.length || selected.some(i => !Number.isInteger(i) || !preview.nodes[i])) fail('INVALID_INPUT', '请选择有效的导入条目');
|
|
205
|
-
const imported = [], skipped = [];
|
|
206
|
-
for (const index of new Set(selected)) {
|
|
207
|
-
const sourceIndex=preview.nodes[index].sourceIndex??index;
|
|
208
|
-
const id = `tavern-${preview.fingerprint}-${sourceIndex}`;
|
|
209
|
-
if (own(n.nodes, id)) { skipped.push(id); continue; }
|
|
210
|
-
n.nodes[id] = record({ ...preview.nodes[index], id, deleted:false,
|
|
211
|
-
tavern: { fingerprint:preview.fingerprint, format:preview.format, index:sourceIndex } });
|
|
212
|
-
imported.push(id);
|
|
213
|
-
}
|
|
214
|
-
if (imported.length) {
|
|
215
|
-
n.tavernSources ??= {};
|
|
216
|
-
n.tavernSources[preview.fingerprint] = { format:preview.format, raw:preview.raw };
|
|
217
|
-
touch(n);
|
|
218
|
-
}
|
|
219
|
-
return { imported:imported.length, skipped:skipped.length, nodeIds:imported };
|
|
220
|
-
}
|
|
221
|
-
if (action.startsWith('planning.')) { const previous=n.planning?.sequence; const result=planningAction(n,action,a,actor); if(previous!==n.planning.sequence)touch(n); return result; }
|
|
222
|
-
if (action.startsWith('plan.')) fail('MIGRATED_ACTION', '旧规划接口已迁移,请使用 cuigengji_plan 的 planning.list/get/apply');
|
|
223
|
-
if (action === 'novel.get') return { ...n, tavernSources: undefined, planning: undefined, plan: undefined, volumes: Object.values(n.volumes), chapters: Object.values(n.chapters).map(meta), nodes: Object.values(n.nodes).map(({ content, ...v }) => v), edges: Object.values(n.edges).map(({ content, ...v }) => v) };
|
|
224
|
-
if (action === 'novel.handoff') return createHandoff(n, binding, a);
|
|
225
|
-
if (action === 'novel.export') { ensurePlanning(n); return { format: 'cuigengji', schemaVersion: 2, exportedAt: now(), novel: clone(n) }; }
|
|
226
|
-
if (action === 'novel.update') {
|
|
227
|
-
cas(n, a);
|
|
228
|
-
if (a.title !== undefined) n.title = text(a.title, 'title');
|
|
229
|
-
if (a.description !== undefined) n.description = text(a.description, 'description', true);
|
|
230
|
-
touch(n); return { id: n.id, title: n.title, revision: n.revision, archived: n.archived };
|
|
231
|
-
}
|
|
232
|
-
if (action === 'volume.list') return Object.values(n.volumes).filter(v => a.includeDeleted || !v.deleted).sort((x, y) => x.order - y.order);
|
|
233
|
-
if (action === 'volume.create') {
|
|
234
|
-
const v = record({ title: text(a.title, 'title'), order: ordering(a.order ?? Object.keys(n.volumes).length), deleted: false });
|
|
235
|
-
n.volumes[v.id] = v; touch(n); return v;
|
|
236
|
-
}
|
|
237
|
-
if (action === 'volume.update' || action === 'volume.delete') {
|
|
238
|
-
const v = get(n.volumes, a.volumeId, '卷'); cas(v, a);
|
|
239
|
-
if (action === 'volume.delete') {
|
|
240
|
-
if (a.confirm !== true) fail('CONFIRM_REQUIRED', '删除需要 confirm:true');
|
|
241
|
-
const chapters = Object.values(n.chapters).filter(c => c.volumeId === v.id && !c.deleted);
|
|
242
|
-
if (chapters.length && !['detach', 'delete'].includes(a.chapterPolicy)) fail('CHAPTER_POLICY_REQUIRED', '非空卷必须指定 chapterPolicy: detach 或 delete');
|
|
243
|
-
for (const c of chapters) {
|
|
244
|
-
if (a.chapterPolicy === 'delete') { c.deleted = true; stale(n, c.id); } else c.volumeId = null;
|
|
245
|
-
touch(c); version(c, actor, '卷删除');
|
|
246
|
-
}
|
|
247
|
-
v.deleted = true;
|
|
248
|
-
} else {
|
|
249
|
-
if (a.title !== undefined) v.title = text(a.title, 'title');
|
|
250
|
-
if (a.order !== undefined) v.order = ordering(a.order);
|
|
251
|
-
}
|
|
252
|
-
touch(v); touch(n); return v;
|
|
253
|
-
}
|
|
254
|
-
if (action === 'chapter.list') return Object.values(n.chapters).filter(c => (a.includeDeleted || !c.deleted) && (a.volumeId === undefined || c.volumeId === a.volumeId)).sort((x, y) => x.order - y.order).map(meta);
|
|
255
|
-
if (action === 'chapter.search') {
|
|
256
|
-
const query = text(a.query, 'query');
|
|
257
|
-
const limit = a.limit ?? 30;
|
|
258
|
-
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail('INVALID_INPUT', 'limit 必须在 1 到 100 之间');
|
|
259
|
-
const matches = [];
|
|
260
|
-
for (const c of Object.values(n.chapters)) {
|
|
261
|
-
if (c.deleted || (a.chapterId && a.chapterId !== c.id)) continue;
|
|
262
|
-
let start = c.content.indexOf(query);
|
|
263
|
-
while (start >= 0 && matches.length < limit) {
|
|
264
|
-
matches.push({ chapterId: c.id, title: c.title, revision: c.revision, start, excerpt: c.content.slice(Math.max(0, start - 80), start + query.length + 120) });
|
|
265
|
-
start = c.content.indexOf(query, start + query.length);
|
|
266
|
-
}
|
|
267
|
-
if (matches.length >= limit) break;
|
|
268
|
-
}
|
|
269
|
-
return matches;
|
|
270
|
-
}
|
|
148
|
+
}
|
|
149
|
+
const before = JSON.stringify(state);
|
|
150
|
+
const result = this.execute(state, action, args, actor);
|
|
151
|
+
if (requestKey) state.requests[requestKey] = { signature, result: clone(result), timestamp: now(),
|
|
152
|
+
...((migrationNovelId || ['novel.create','novel.import'].includes(action)) ? {novelId: ['novel.create','novel.import'].includes(action) ? result.id : migrationNovelId} : {}) };
|
|
153
|
+
if (JSON.stringify(state) !== before) await this.save(state);
|
|
154
|
+
return clone(result);
|
|
155
|
+
} finally { await release(); }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
execute(state, action, a, actor) {
|
|
159
|
+
if (action === 'novel.list') return Object.values(state.novels).filter(n => a.includeArchived || !n.archived).map(n => ({ id: n.id, title: n.title, revision: n.revision, archived: n.archived, ...(n.archivedAt ? {archivedAt:n.archivedAt} : {}), updatedAt: n.updatedAt }));
|
|
160
|
+
if (action === 'novel.create') {
|
|
161
|
+
const n = record({ title: text(a.title, 'title'), description: a.description ?? '', archived: false, volumes: {}, chapters: {}, nodes: {}, edges: {}, plan: null });
|
|
162
|
+
state.novels[n.id] = n; return { id: n.id, title: n.title, revision: n.revision };
|
|
163
|
+
}
|
|
164
|
+
if (action === 'novel.import') return this.importNovel(state, a);
|
|
165
|
+
if (action === 'binding.get') {
|
|
166
|
+
const binding = state.bindings[hash(text(actor.sessionId || a.sessionId, 'sessionId'))];
|
|
167
|
+
return binding && own(state.novels, binding.novelId) && !state.novels[binding.novelId].archived ? binding : null;
|
|
168
|
+
}
|
|
169
|
+
if (action === 'binding.set') {
|
|
170
|
+
const sessionId = text(actor.sessionId || a.sessionId, 'sessionId');
|
|
171
|
+
const n = get(state.novels, a.novelId, '小说');
|
|
172
|
+
if (n.archived) fail('NOVEL_RECYCLED', '作品已在回收站,请先恢复作品');
|
|
173
|
+
if (a.chapterId) { const ch = get(n.chapters, a.chapterId, '章节'); if (ch.deleted) fail('DELETED', '章节已删除'); }
|
|
174
|
+
const previous = state.bindings[hash(sessionId)];
|
|
175
|
+
const sameNovel = previous?.novelId === n.id;
|
|
176
|
+
const stage = a.stage ?? (sameNovel ? previous.stage : null) ?? 'discuss';
|
|
177
|
+
if (!['discuss', 'plan', 'write', 'revise', 'memory'].includes(stage)) fail('INVALID_INPUT', 'stage 必须是 discuss、plan、write、revise 或 memory');
|
|
178
|
+
const binding = { sessionId, novelId: n.id, chapterId: a.chapterId === undefined && sameNovel ? previous.chapterId : a.chapterId || null,
|
|
179
|
+
stage, goal: a.goal === undefined ? (sameNovel ? previous.goal || '' : '') : text(a.goal, 'goal', true), updatedAt: now() };
|
|
180
|
+
state.bindings[hash(sessionId)] = binding; return binding;
|
|
181
|
+
}
|
|
182
|
+
const binding = actor.sessionId && state.bindings[hash(actor.sessionId)];
|
|
183
|
+
const novelId = a.novelId || binding?.novelId;
|
|
184
|
+
if (!novelId) fail('NOVEL_REQUIRED', '请先绑定小说或提供 novelId');
|
|
185
|
+
if (actor.kind !== 'human' && binding && binding.novelId !== novelId) fail('NOVEL_MISMATCH', '当前会话已绑定另一本小说');
|
|
186
|
+
const n = get(state.novels, novelId, '小说');
|
|
187
|
+
if (n.archived && !(actor.kind === 'human' && ['novel.get','novel.export',...novelLifecycleActions].includes(action))) fail('NOVEL_RECYCLED', '作品已在回收站,请先恢复作品');
|
|
188
|
+
if (novelLifecycleActions.includes(action)) {
|
|
189
|
+
cas(n, a);
|
|
190
|
+
applyNovelLifecycle(state, n, action, a);
|
|
191
|
+
if (action === 'novel.purge') return {id: n.id, purged: true};
|
|
192
|
+
touch(n); return {id: n.id, title: n.title, revision: n.revision, archived: n.archived, ...(n.archivedAt ? {archivedAt:n.archivedAt} : {})};
|
|
193
|
+
}
|
|
194
|
+
if (action === 'tavern.preview' || action === 'tavern.import') {
|
|
195
|
+
if (actor.kind !== 'human') fail('HUMAN_REQUIRED', '酒馆文件导入需要作者操作');
|
|
196
|
+
const preview = previewTavern(a);
|
|
197
|
+
if (action === 'tavern.preview') {
|
|
198
|
+
const { raw, ...result } = preview;
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
if (a.confirm !== true) fail('CONFIRM_REQUIRED', '请先预览并确认导入');
|
|
202
|
+
if (a.fingerprint !== preview.fingerprint) fail('CONFLICT', '文件与预览不一致,请重新预览');
|
|
203
|
+
const selected = a.selected ?? preview.nodes.map((_, index) => index);
|
|
204
|
+
if (!Array.isArray(selected) || !selected.length || selected.some(i => !Number.isInteger(i) || !preview.nodes[i])) fail('INVALID_INPUT', '请选择有效的导入条目');
|
|
205
|
+
const imported = [], skipped = [];
|
|
206
|
+
for (const index of new Set(selected)) {
|
|
207
|
+
const sourceIndex=preview.nodes[index].sourceIndex??index;
|
|
208
|
+
const id = `tavern-${preview.fingerprint}-${sourceIndex}`;
|
|
209
|
+
if (own(n.nodes, id)) { skipped.push(id); continue; }
|
|
210
|
+
n.nodes[id] = record({ ...preview.nodes[index], id, deleted:false,
|
|
211
|
+
tavern: { fingerprint:preview.fingerprint, format:preview.format, index:sourceIndex } });
|
|
212
|
+
imported.push(id);
|
|
213
|
+
}
|
|
214
|
+
if (imported.length) {
|
|
215
|
+
n.tavernSources ??= {};
|
|
216
|
+
n.tavernSources[preview.fingerprint] = { format:preview.format, raw:preview.raw };
|
|
217
|
+
touch(n);
|
|
218
|
+
}
|
|
219
|
+
return { imported:imported.length, skipped:skipped.length, nodeIds:imported };
|
|
220
|
+
}
|
|
221
|
+
if (action.startsWith('planning.')) { const previous=n.planning?.sequence; const result=planningAction(n,action,a,actor); if(previous!==n.planning.sequence)touch(n); return result; }
|
|
222
|
+
if (action.startsWith('plan.')) fail('MIGRATED_ACTION', '旧规划接口已迁移,请使用 cuigengji_plan 的 planning.list/get/apply');
|
|
223
|
+
if (action === 'novel.get') return { ...n, tavernSources: undefined, planning: undefined, plan: undefined, volumes: Object.values(n.volumes), chapters: Object.values(n.chapters).map(meta), nodes: Object.values(n.nodes).map(({ content, ...v }) => v), edges: Object.values(n.edges).map(({ content, ...v }) => v) };
|
|
224
|
+
if (action === 'novel.handoff') return createHandoff(n, binding, a);
|
|
225
|
+
if (action === 'novel.export') { ensurePlanning(n); return { format: 'cuigengji', schemaVersion: 2, exportedAt: now(), novel: clone(n) }; }
|
|
226
|
+
if (action === 'novel.update') {
|
|
227
|
+
cas(n, a);
|
|
228
|
+
if (a.title !== undefined) n.title = text(a.title, 'title');
|
|
229
|
+
if (a.description !== undefined) n.description = text(a.description, 'description', true);
|
|
230
|
+
touch(n); return { id: n.id, title: n.title, revision: n.revision, archived: n.archived };
|
|
231
|
+
}
|
|
232
|
+
if (action === 'volume.list') return Object.values(n.volumes).filter(v => a.includeDeleted || !v.deleted).sort((x, y) => x.order - y.order);
|
|
233
|
+
if (action === 'volume.create') {
|
|
234
|
+
const v = record({ title: text(a.title, 'title'), order: ordering(a.order ?? Object.keys(n.volumes).length), deleted: false });
|
|
235
|
+
n.volumes[v.id] = v; touch(n); return v;
|
|
236
|
+
}
|
|
237
|
+
if (action === 'volume.update' || action === 'volume.delete') {
|
|
238
|
+
const v = get(n.volumes, a.volumeId, '卷'); cas(v, a);
|
|
239
|
+
if (action === 'volume.delete') {
|
|
240
|
+
if (a.confirm !== true) fail('CONFIRM_REQUIRED', '删除需要 confirm:true');
|
|
241
|
+
const chapters = Object.values(n.chapters).filter(c => c.volumeId === v.id && !c.deleted);
|
|
242
|
+
if (chapters.length && !['detach', 'delete'].includes(a.chapterPolicy)) fail('CHAPTER_POLICY_REQUIRED', '非空卷必须指定 chapterPolicy: detach 或 delete');
|
|
243
|
+
for (const c of chapters) {
|
|
244
|
+
if (a.chapterPolicy === 'delete') { c.deleted = true; stale(n, c.id); } else c.volumeId = null;
|
|
245
|
+
touch(c); version(c, actor, '卷删除');
|
|
246
|
+
}
|
|
247
|
+
v.deleted = true;
|
|
248
|
+
} else {
|
|
249
|
+
if (a.title !== undefined) v.title = text(a.title, 'title');
|
|
250
|
+
if (a.order !== undefined) v.order = ordering(a.order);
|
|
251
|
+
}
|
|
252
|
+
touch(v); touch(n); return v;
|
|
253
|
+
}
|
|
254
|
+
if (action === 'chapter.list') return Object.values(n.chapters).filter(c => (a.includeDeleted || !c.deleted) && (a.volumeId === undefined || c.volumeId === a.volumeId)).sort((x, y) => x.order - y.order).map(meta);
|
|
255
|
+
if (action === 'chapter.search') {
|
|
256
|
+
const query = text(a.query, 'query');
|
|
257
|
+
const limit = a.limit ?? 30;
|
|
258
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail('INVALID_INPUT', 'limit 必须在 1 到 100 之间');
|
|
259
|
+
const matches = [];
|
|
260
|
+
for (const c of Object.values(n.chapters)) {
|
|
261
|
+
if (c.deleted || (a.chapterId && a.chapterId !== c.id)) continue;
|
|
262
|
+
let start = c.content.indexOf(query);
|
|
263
|
+
while (start >= 0 && matches.length < limit) {
|
|
264
|
+
matches.push({ chapterId: c.id, title: c.title, revision: c.revision, start, excerpt: c.content.slice(Math.max(0, start - 80), start + query.length + 120) });
|
|
265
|
+
start = c.content.indexOf(query, start + query.length);
|
|
266
|
+
}
|
|
267
|
+
if (matches.length >= limit) break;
|
|
268
|
+
}
|
|
269
|
+
return matches;
|
|
270
|
+
}
|
|
271
271
|
if (action === 'chapter.create') {
|
|
272
|
-
if (a.volumeId && get(n.volumes, a.volumeId, '卷').deleted) fail('DELETED', '卷已删除');
|
|
273
|
-
const c = record({ title: text(a.title, 'title'), volumeId: a.volumeId || null, order: ordering(a.order ?? Object.keys(n.chapters).length), content: text(a.content ?? '', 'content', true), deleted: false, versions: [] });
|
|
272
|
+
if (a.volumeId && get(n.volumes, a.volumeId, '卷').deleted) fail('DELETED', '卷已删除');
|
|
273
|
+
const c = record({ title: text(a.title, 'title'), volumeId: a.volumeId || null, order: ordering(a.order ?? Object.keys(n.chapters).length), content: text(a.content ?? '', 'content', true), deleted: false, versions: [] });
|
|
274
274
|
version(c, actor, a.reason); n.chapters[c.id] = c; touch(n); return meta(c);
|
|
275
275
|
}
|
|
276
276
|
if (action === 'chapter.move') {
|
|
@@ -288,44 +288,44 @@ export class Store extends ProjectStore {
|
|
|
288
288
|
}
|
|
289
289
|
return {changed: move.changed, chapters: move.chapters.map(chapter => meta(n.chapters[chapter.id]))};
|
|
290
290
|
}
|
|
291
|
-
if (action.startsWith('chapter.')) {
|
|
292
|
-
const c = get(n.chapters, a.chapterId, '章节');
|
|
293
|
-
if (action === 'chapter.get') {
|
|
294
|
-
const start = a.start ?? 0, maxChars = a.maxChars ?? 12000;
|
|
295
|
-
if (!Number.isSafeInteger(start) || start < 0 || !Number.isSafeInteger(maxChars) || maxChars < 1 || maxChars > 200000) fail('INVALID_INPUT', '读取窗口无效');
|
|
296
|
-
return { ...meta(c), content: c.content.slice(start, start + maxChars), start, nextStart: start + maxChars < c.content.length ? start + maxChars : null };
|
|
297
|
-
}
|
|
298
|
-
if (action === 'chapter.history') return c.versions.map(({ content, ...v }) => ({ ...v, ...(a.includeContent ? { content } : {}) }));
|
|
299
|
-
if (!['chapter.update', 'chapter.delete', 'chapter.restore'].includes(action)) fail('UNKNOWN_ACTION', action);
|
|
300
|
-
cas(c, a);
|
|
301
|
-
if (action === 'chapter.delete') {
|
|
302
|
-
if (a.confirm !== true) fail('CONFIRM_REQUIRED', '删除需要 confirm:true'); c.deleted = true;
|
|
303
|
-
} else if (action === 'chapter.restore') {
|
|
304
|
-
const v = c.versions.find(v => v.revision === a.targetRevision);
|
|
305
|
-
if (!v) fail('NOT_FOUND', '历史版本不存在');
|
|
306
|
-
Object.assign(c, { content: v.content, title: v.title, order: v.order, volumeId: v.volumeId && !n.volumes[v.volumeId]?.deleted ? v.volumeId : null, deleted: false });
|
|
307
|
-
} else {
|
|
308
|
-
if (c.deleted) fail('DELETED', '请先恢复章节');
|
|
309
|
-
if ([a.content, a.patch, a.append].filter(v => v !== undefined).length > 1) fail('INVALID_INPUT', 'content、patch、append 只能选一个');
|
|
310
|
-
if (a.content !== undefined) c.content = text(a.content, 'content', true);
|
|
311
|
-
if (a.append !== undefined) c.content += text(a.append, 'append', true);
|
|
312
|
-
if (a.patch !== undefined) {
|
|
313
|
-
const old = text(a.patch.oldText, 'patch.oldText'); const replacement = text(a.patch.newText, 'patch.newText', true);
|
|
314
|
-
const offset = c.content.indexOf(old);
|
|
315
|
-
if (offset === -1 || c.content.indexOf(old, offset + 1) !== -1) fail('PATCH_CONFLICT', '待替换原文不存在或匹配不唯一');
|
|
316
|
-
c.content = c.content.slice(0, offset) + replacement + c.content.slice(offset + old.length);
|
|
317
|
-
}
|
|
318
|
-
if (a.title !== undefined) c.title = text(a.title, 'title');
|
|
319
|
-
if (a.order !== undefined) c.order = ordering(a.order);
|
|
320
|
-
if (a.volumeId !== undefined) { if (a.volumeId && get(n.volumes, a.volumeId, '卷').deleted) fail('DELETED', '卷已删除'); c.volumeId = a.volumeId || null; }
|
|
321
|
-
}
|
|
322
|
-
touch(c); version(c, actor, a.reason); stale(n, c.id); touch(n); return meta(c);
|
|
323
|
-
}
|
|
324
|
-
if (action === 'graph.list') {
|
|
325
|
-
const q = (a.query || '').toLocaleLowerCase();
|
|
326
|
-
return Object.values(n.nodes).filter(v => (a.includeDeleted || !v.deleted) && (!a.type || v.type === a.type) && (a.groupId === undefined || (v.groupId ?? null) === a.groupId) && (!q || [v.name, v.summary, v.content, ...v.aliases].join(' ').toLocaleLowerCase().includes(q))).map(({ content, ...v }) => v);
|
|
327
|
-
}
|
|
328
|
-
if (action === 'graph.groups' || action.startsWith('graph.group.') || action === 'graph.move') return memoryGroupAction(n, action, a);
|
|
291
|
+
if (action.startsWith('chapter.')) {
|
|
292
|
+
const c = get(n.chapters, a.chapterId, '章节');
|
|
293
|
+
if (action === 'chapter.get') {
|
|
294
|
+
const start = a.start ?? 0, maxChars = a.maxChars ?? 12000;
|
|
295
|
+
if (!Number.isSafeInteger(start) || start < 0 || !Number.isSafeInteger(maxChars) || maxChars < 1 || maxChars > 200000) fail('INVALID_INPUT', '读取窗口无效');
|
|
296
|
+
return { ...meta(c), content: c.content.slice(start, start + maxChars), start, nextStart: start + maxChars < c.content.length ? start + maxChars : null };
|
|
297
|
+
}
|
|
298
|
+
if (action === 'chapter.history') return c.versions.map(({ content, ...v }) => ({ ...v, ...(a.includeContent ? { content } : {}) }));
|
|
299
|
+
if (!['chapter.update', 'chapter.delete', 'chapter.restore'].includes(action)) fail('UNKNOWN_ACTION', action);
|
|
300
|
+
cas(c, a);
|
|
301
|
+
if (action === 'chapter.delete') {
|
|
302
|
+
if (a.confirm !== true) fail('CONFIRM_REQUIRED', '删除需要 confirm:true'); c.deleted = true;
|
|
303
|
+
} else if (action === 'chapter.restore') {
|
|
304
|
+
const v = c.versions.find(v => v.revision === a.targetRevision);
|
|
305
|
+
if (!v) fail('NOT_FOUND', '历史版本不存在');
|
|
306
|
+
Object.assign(c, { content: v.content, title: v.title, order: v.order, volumeId: v.volumeId && !n.volumes[v.volumeId]?.deleted ? v.volumeId : null, deleted: false });
|
|
307
|
+
} else {
|
|
308
|
+
if (c.deleted) fail('DELETED', '请先恢复章节');
|
|
309
|
+
if ([a.content, a.patch, a.append].filter(v => v !== undefined).length > 1) fail('INVALID_INPUT', 'content、patch、append 只能选一个');
|
|
310
|
+
if (a.content !== undefined) c.content = text(a.content, 'content', true);
|
|
311
|
+
if (a.append !== undefined) c.content += text(a.append, 'append', true);
|
|
312
|
+
if (a.patch !== undefined) {
|
|
313
|
+
const old = text(a.patch.oldText, 'patch.oldText'); const replacement = text(a.patch.newText, 'patch.newText', true);
|
|
314
|
+
const offset = c.content.indexOf(old);
|
|
315
|
+
if (offset === -1 || c.content.indexOf(old, offset + 1) !== -1) fail('PATCH_CONFLICT', '待替换原文不存在或匹配不唯一');
|
|
316
|
+
c.content = c.content.slice(0, offset) + replacement + c.content.slice(offset + old.length);
|
|
317
|
+
}
|
|
318
|
+
if (a.title !== undefined) c.title = text(a.title, 'title');
|
|
319
|
+
if (a.order !== undefined) c.order = ordering(a.order);
|
|
320
|
+
if (a.volumeId !== undefined) { if (a.volumeId && get(n.volumes, a.volumeId, '卷').deleted) fail('DELETED', '卷已删除'); c.volumeId = a.volumeId || null; }
|
|
321
|
+
}
|
|
322
|
+
touch(c); version(c, actor, a.reason); stale(n, c.id); touch(n); return meta(c);
|
|
323
|
+
}
|
|
324
|
+
if (action === 'graph.list') {
|
|
325
|
+
const q = (a.query || '').toLocaleLowerCase();
|
|
326
|
+
return Object.values(n.nodes).filter(v => (a.includeDeleted || !v.deleted) && (!a.type || v.type === a.type) && (a.groupId === undefined || (v.groupId ?? null) === a.groupId) && (!q || [v.name, v.summary, v.content, ...v.aliases].join(' ').toLocaleLowerCase().includes(q))).map(({ content, ...v }) => v);
|
|
327
|
+
}
|
|
328
|
+
if (action === 'graph.groups' || action.startsWith('graph.group.') || action === 'graph.move') return memoryGroupAction(n, action, a);
|
|
329
329
|
if (action === 'graph.get') return get(n.nodes, a.nodeId, '节点');
|
|
330
330
|
if (action === 'graph.layout') {
|
|
331
331
|
if (!Array.isArray(a.positions) || !a.positions.length || a.positions.length > 200) fail('INVALID_INPUT', '一次布局需要 1–200 个坐标');
|
|
@@ -349,27 +349,27 @@ export class Store extends ProjectStore {
|
|
|
349
349
|
const edge = this.execute(state, 'edge.create', {novelId:n.id,from:a.side==='in'?node.id:source.id,to:a.side==='in'?source.id:node.id,name:'',content:''}, actor);
|
|
350
350
|
return {node,edge};
|
|
351
351
|
}
|
|
352
|
-
if (action === 'graph.create') {
|
|
353
|
-
if (!kinds.has(a.type)) fail('INVALID_INPUT', '节点类型无效');
|
|
354
|
-
const v = record({ type: a.type, groupId: groupId(n, a.groupId), ...memoryFields(a, n, true), ...nodePosition(a), deleted: false });
|
|
355
|
-
this.checkMemorySources(n, v);
|
|
356
|
-
n.nodes[v.id] = v; touch(n); return v;
|
|
357
|
-
}
|
|
358
|
-
if (action === 'graph.update' || action === 'graph.delete') {
|
|
359
|
-
const v = get(n.nodes, a.nodeId, '节点'); cas(v, a);
|
|
360
|
-
if (action === 'graph.delete') {
|
|
361
|
-
if (a.confirm !== true) fail('CONFIRM_REQUIRED', '删除需要 confirm:true');
|
|
362
|
-
v.deleted = true;
|
|
363
|
-
for (const e of Object.values(n.edges)) if (!e.deleted && (e.from === v.id || e.to === v.id)) { e.deleted = true; touch(e); }
|
|
364
|
-
} else {
|
|
365
|
-
if (v.deleted) fail('DELETED', '节点已删除');
|
|
366
|
-
if (a.type !== undefined) { if (!kinds.has(a.type)) fail('INVALID_INPUT', '节点类型无效'); v.type = a.type; }
|
|
367
|
-
if (a.groupId !== undefined) v.groupId = groupId(n, a.groupId);
|
|
368
|
-
Object.assign(v, memoryFields(a, n), nodePosition(a));
|
|
369
|
-
this.checkMemorySources(n, v);
|
|
370
|
-
}
|
|
371
|
-
touch(v); touch(n); return v;
|
|
372
|
-
}
|
|
352
|
+
if (action === 'graph.create') {
|
|
353
|
+
if (!kinds.has(a.type)) fail('INVALID_INPUT', '节点类型无效');
|
|
354
|
+
const v = record({ type: a.type, groupId: groupId(n, a.groupId), ...memoryFields(a, n, true), ...nodePosition(a), deleted: false });
|
|
355
|
+
this.checkMemorySources(n, v);
|
|
356
|
+
n.nodes[v.id] = v; touch(n); return v;
|
|
357
|
+
}
|
|
358
|
+
if (action === 'graph.update' || action === 'graph.delete') {
|
|
359
|
+
const v = get(n.nodes, a.nodeId, '节点'); cas(v, a);
|
|
360
|
+
if (action === 'graph.delete') {
|
|
361
|
+
if (a.confirm !== true) fail('CONFIRM_REQUIRED', '删除需要 confirm:true');
|
|
362
|
+
v.deleted = true;
|
|
363
|
+
for (const e of Object.values(n.edges)) if (!e.deleted && (e.from === v.id || e.to === v.id)) { e.deleted = true; touch(e); }
|
|
364
|
+
} else {
|
|
365
|
+
if (v.deleted) fail('DELETED', '节点已删除');
|
|
366
|
+
if (a.type !== undefined) { if (!kinds.has(a.type)) fail('INVALID_INPUT', '节点类型无效'); v.type = a.type; }
|
|
367
|
+
if (a.groupId !== undefined) v.groupId = groupId(n, a.groupId);
|
|
368
|
+
Object.assign(v, memoryFields(a, n), nodePosition(a));
|
|
369
|
+
this.checkMemorySources(n, v);
|
|
370
|
+
}
|
|
371
|
+
touch(v); touch(n); return v;
|
|
372
|
+
}
|
|
373
373
|
if (action === 'graph.duplicate') {
|
|
374
374
|
if (!Array.isArray(a.members) || !a.members.length || a.members.length > 200 || new Set(a.members.map(v=>v.id)).size!==a.members.length) fail('INVALID_INPUT', '请选择 1–200 个不同的节点');
|
|
375
375
|
const sources=a.members.map(item=>{const node=get(n.nodes,item.id,'节点');cas(node,item);if(node.deleted)fail('DELETED','节点已删除');return {node,position:item.position===undefined?node.position:nodePosition(item).position};});
|
|
@@ -390,116 +390,116 @@ export class Store extends ProjectStore {
|
|
|
390
390
|
return a.edges.map(item=>this.execute(state,'edge.delete',{novelId:n.id,edgeId:item.id,expectedRevision:item.expectedRevision,confirm:true},actor));
|
|
391
391
|
}
|
|
392
392
|
if (action === 'edge.get') return get(n.edges, a.edgeId, '关系');
|
|
393
|
-
if (action === 'edge.list') return Object.values(n.edges).filter(e => (a.includeDeleted || !e.deleted) && (!a.nodeId || e.from === a.nodeId || e.to === a.nodeId));
|
|
394
|
-
if (action === 'edge.create' || action === 'edge.update' || action === 'edge.delete') {
|
|
395
|
-
let e;
|
|
396
|
-
if (action === 'edge.create') {
|
|
397
|
-
for (const id of [a.from, a.to]) if (get(n.nodes, id, '关系端点').deleted) fail('DELETED', '关系端点已删除');
|
|
398
|
-
e = record({ from: a.from, to: a.to, ...memoryFields({ ...a, name: a.name ?? a.relation ?? '' }, n, true, true), deleted: false }); n.edges[e.id] = e;
|
|
399
|
-
} else {
|
|
400
|
-
e = get(n.edges, a.edgeId, '关系'); cas(e, a);
|
|
401
|
-
if (action === 'edge.delete') { if (a.confirm !== true) fail('CONFIRM_REQUIRED', '删除需要 confirm:true'); e.deleted = true; }
|
|
402
|
-
else {
|
|
403
|
-
if (e.deleted) fail('DELETED', '关系已删除');
|
|
404
|
-
for (const field of ['from', 'to']) if (a[field] !== undefined) { if (get(n.nodes, a[field], '关系端点').deleted) fail('DELETED', '关系端点已删除'); e[field] = a[field]; }
|
|
405
|
-
Object.assign(e, memoryFields(a, n, false, true));
|
|
406
|
-
}
|
|
407
|
-
touch(e);
|
|
408
|
-
}
|
|
409
|
-
this.checkMemorySources(n, e);
|
|
410
|
-
touch(n); return e;
|
|
411
|
-
}
|
|
412
|
-
if (action === 'preset.get') return n.preset || null;
|
|
413
|
-
if (action === 'preset.read') return {
|
|
414
|
-
enabled: Boolean(n.preset?.enabled),
|
|
415
|
-
name: n.preset?.name || '',
|
|
416
|
-
revision: n.preset?.revision || 0,
|
|
417
|
-
content: compilePreset(n.preset),
|
|
418
|
-
};
|
|
419
|
-
if (action === 'preset.preview' || action === 'preset.set') {
|
|
420
|
-
if (actor.kind !== 'human') fail('HUMAN_REQUIRED', '预设只能由作者配置');
|
|
421
|
-
if (action === 'preset.preview') return importPreset(a.input, a.orderId);
|
|
422
|
-
if (n.preset) cas(n.preset,a);
|
|
423
|
-
else if (a.expectedRevision !== 0) fail('CONFLICT','预设版本不匹配,请重新读取');
|
|
424
|
-
const value=validatePreset(a.preset);
|
|
425
|
-
n.preset=record({name:value.name,enabled:value.enabled,blocks:clone(value.blocks),raw:clone(value.raw ?? null),warnings:clone(value.warnings ?? []),...(value.importFormat?{importFormat:value.importFormat}:{}),revision:(n.preset?.revision||0)+1});
|
|
426
|
-
touch(n);return n.preset;
|
|
427
|
-
}
|
|
428
|
-
if (action === 'context.get') return {
|
|
429
|
-
...this.context(n, { ...a, chapterId: a.chapterId || (a.includeChapters === true ? binding?.chapterId : null) }),
|
|
430
|
-
task: binding ? { stage: binding.stage, goal: binding.goal, chapterId: binding.chapterId } : null,
|
|
431
|
-
};
|
|
432
|
-
fail('UNKNOWN_ACTION', `未知操作:${action}`);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
context(n, a) {
|
|
436
|
-
if (a.chapterId) get(n.chapters, a.chapterId, '章节');
|
|
437
|
-
const current = a.chapterId ? n.chapters[a.chapterId] : null;
|
|
438
|
-
const { items, omitted } = a.includeChapters === true || a.chapterId
|
|
439
|
-
? selectReference(n, a.chapterId)
|
|
440
|
-
: { items: [], omitted: [] };
|
|
441
|
-
const add = (kind, entity, content) => {
|
|
442
|
-
if (!content) return;
|
|
443
|
-
items.push({kind,id:entity.id,revision:entity.revision,title:entity.name,content,truncated:false});
|
|
444
|
-
};
|
|
445
|
-
const nodeIds = a.nodeIds ?? [];
|
|
446
|
-
if (!Array.isArray(nodeIds)) fail('INVALID_INPUT', 'nodeIds 必须是数组');
|
|
447
|
-
for (const id of nodeIds) {
|
|
448
|
-
const node = get(n.nodes, id, '节点');
|
|
449
|
-
if (this.memoryVisible(n, node, current, a.povNodeId)) add('memory', node, node.content || node.summary);
|
|
450
|
-
}
|
|
451
|
-
return { novelId: n.id, title: n.title, usedChars: items.reduce((sum,item)=>sum+item.content.length,0), items, omitted,
|
|
452
|
-
staleMemory: Object.values(n.nodes).filter(v => !v.deleted && v.status === 'stale').slice(0, 100).map(v => ({ id: v.id, name: v.name.slice(0, 200), sources: v.sources.slice(0, 10) })) };
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
memoryVisible(novel, node, current, povNodeId) {
|
|
456
|
-
if (node.deleted || node.status !== 'active' || node.factType === 'plan') return false;
|
|
457
|
-
if (current && node.sources.some(s => novel.chapters[s.chapterId]?.order > current.order)) return false;
|
|
458
|
-
if (povNodeId && node.knownBy.length && !node.knownBy.includes(povNodeId)) return false;
|
|
459
|
-
return true;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
checkMemorySources(novel, entity) {
|
|
463
|
-
if (entity.status !== 'active') return;
|
|
464
|
-
if (entity.sources.some(s => novel.chapters[s.chapterId]?.deleted || novel.chapters[s.chapterId]?.revision !== s.revision)
|
|
465
|
-
|| entity.dependsOn.some(id => novel.nodes[id]?.status === 'stale' || novel.nodes[id]?.deleted)) {
|
|
466
|
-
fail('STALE_SOURCE', '来源正文或依赖记忆已变化;请核对后更新来源版本再标记为有效');
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
importNovel(state, a) {
|
|
471
|
-
const backup = a.backup;
|
|
472
|
-
if (!backup || backup.format !== 'cuigengji' || ![1,2].includes(backup.schemaVersion) || !backup.novel) fail('INVALID_BACKUP', '不是支持的 cuigengji 备份');
|
|
473
|
-
const n = clone(backup.novel);
|
|
474
|
-
text(n.id, 'novel.id'); text(n.title, 'novel.title');
|
|
475
|
-
if (!Number.isSafeInteger(n.revision) || n.revision < 1 || typeof n.archived !== 'boolean') fail('INVALID_BACKUP', '小说版本或归档状态无效');
|
|
476
|
-
if (n.archivedAt !== undefined && (typeof n.archivedAt !== 'string' || !Number.isFinite(Date.parse(n.archivedAt)))) fail('INVALID_BACKUP', '作品回收时间无效');
|
|
477
|
-
for (const field of ['volumes', 'chapters', 'nodes', 'edges']) if (!n[field] || typeof n[field] !== 'object' || Array.isArray(n[field])) fail('INVALID_BACKUP', `${field}无效`);
|
|
478
|
-
for (const collection of [n.volumes, n.chapters, n.nodes, n.edges]) for (const [id, entity] of Object.entries(collection)) {
|
|
479
|
-
if (id !== entity.id || !Number.isSafeInteger(entity.revision) || entity.revision < 1) fail('INVALID_BACKUP', '对象 ID 或版本无效');
|
|
480
|
-
}
|
|
481
|
-
for (const c of Object.values(n.chapters)) {
|
|
482
|
-
if (typeof c.content !== 'string' || c.contentHash !== hash(c.content) || !Array.isArray(c.versions) || !c.versions.length || (c.volumeId && !own(n.volumes, c.volumeId))) fail('INVALID_BACKUP', '章节或卷关系无效');
|
|
483
|
-
let previousRevision = 0;
|
|
484
|
-
for (const v of c.versions) {
|
|
485
|
-
if (!Number.isSafeInteger(v.revision) || v.revision <= previousRevision || typeof v.content !== 'string' || v.contentHash !== hash(v.content)) fail('INVALID_BACKUP', '历史正文版本或哈希不匹配');
|
|
486
|
-
previousRevision = v.revision;
|
|
487
|
-
}
|
|
488
|
-
const latest = c.versions.at(-1);
|
|
489
|
-
if (latest.revision !== c.revision || latest.contentHash !== c.contentHash) fail('INVALID_BACKUP', '最新历史版本与正文不一致');
|
|
490
|
-
}
|
|
491
|
-
for (const node of Object.values(n.nodes)) { if (!kinds.has(node.type)) fail('INVALID_BACKUP', '节点类型无效'); Object.assign(node, memoryFields(node, n, true), nodePosition(node)); }
|
|
492
|
-
for (const edge of Object.values(n.edges)) { if (!own(n.nodes, edge.from) || !own(n.nodes, edge.to)) fail('INVALID_BACKUP', '关系端点不存在'); Object.assign(edge, memoryFields(edge, n, true, true)); }
|
|
493
|
-
if (n.plan && (typeof n.plan.content !== 'string' || !Number.isSafeInteger(n.plan.revision) || n.plan.revision < 1 || typeof n.plan.approved !== 'boolean')) fail('INVALID_BACKUP', '规划格式无效');
|
|
494
|
-
validateMemoryGroups(n);
|
|
495
|
-
if (n.planning) validatePlanning(n.planning,n);
|
|
496
|
-
if (n.preset) { validatePreset(n.preset); if (!Number.isSafeInteger(n.preset.revision) || n.preset.revision < 1) fail('INVALID_BACKUP','预设版本无效'); }
|
|
497
|
-
if (own(state.novels, n.id)) {
|
|
393
|
+
if (action === 'edge.list') return Object.values(n.edges).filter(e => (a.includeDeleted || !e.deleted) && (!a.nodeId || e.from === a.nodeId || e.to === a.nodeId));
|
|
394
|
+
if (action === 'edge.create' || action === 'edge.update' || action === 'edge.delete') {
|
|
395
|
+
let e;
|
|
396
|
+
if (action === 'edge.create') {
|
|
397
|
+
for (const id of [a.from, a.to]) if (get(n.nodes, id, '关系端点').deleted) fail('DELETED', '关系端点已删除');
|
|
398
|
+
e = record({ from: a.from, to: a.to, ...memoryFields({ ...a, name: a.name ?? a.relation ?? '' }, n, true, true), deleted: false }); n.edges[e.id] = e;
|
|
399
|
+
} else {
|
|
400
|
+
e = get(n.edges, a.edgeId, '关系'); cas(e, a);
|
|
401
|
+
if (action === 'edge.delete') { if (a.confirm !== true) fail('CONFIRM_REQUIRED', '删除需要 confirm:true'); e.deleted = true; }
|
|
402
|
+
else {
|
|
403
|
+
if (e.deleted) fail('DELETED', '关系已删除');
|
|
404
|
+
for (const field of ['from', 'to']) if (a[field] !== undefined) { if (get(n.nodes, a[field], '关系端点').deleted) fail('DELETED', '关系端点已删除'); e[field] = a[field]; }
|
|
405
|
+
Object.assign(e, memoryFields(a, n, false, true));
|
|
406
|
+
}
|
|
407
|
+
touch(e);
|
|
408
|
+
}
|
|
409
|
+
this.checkMemorySources(n, e);
|
|
410
|
+
touch(n); return e;
|
|
411
|
+
}
|
|
412
|
+
if (action === 'preset.get') return n.preset || null;
|
|
413
|
+
if (action === 'preset.read') return {
|
|
414
|
+
enabled: Boolean(n.preset?.enabled),
|
|
415
|
+
name: n.preset?.name || '',
|
|
416
|
+
revision: n.preset?.revision || 0,
|
|
417
|
+
content: compilePreset(n.preset),
|
|
418
|
+
};
|
|
419
|
+
if (action === 'preset.preview' || action === 'preset.set') {
|
|
420
|
+
if (actor.kind !== 'human') fail('HUMAN_REQUIRED', '预设只能由作者配置');
|
|
421
|
+
if (action === 'preset.preview') return importPreset(a.input, a.orderId);
|
|
422
|
+
if (n.preset) cas(n.preset,a);
|
|
423
|
+
else if (a.expectedRevision !== 0) fail('CONFLICT','预设版本不匹配,请重新读取');
|
|
424
|
+
const value=validatePreset(a.preset);
|
|
425
|
+
n.preset=record({name:value.name,enabled:value.enabled,blocks:clone(value.blocks),raw:clone(value.raw ?? null),warnings:clone(value.warnings ?? []),...(value.importFormat?{importFormat:value.importFormat}:{}),revision:(n.preset?.revision||0)+1});
|
|
426
|
+
touch(n);return n.preset;
|
|
427
|
+
}
|
|
428
|
+
if (action === 'context.get') return {
|
|
429
|
+
...this.context(n, { ...a, chapterId: a.chapterId || (a.includeChapters === true ? binding?.chapterId : null) }),
|
|
430
|
+
task: binding ? { stage: binding.stage, goal: binding.goal, chapterId: binding.chapterId } : null,
|
|
431
|
+
};
|
|
432
|
+
fail('UNKNOWN_ACTION', `未知操作:${action}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
context(n, a) {
|
|
436
|
+
if (a.chapterId) get(n.chapters, a.chapterId, '章节');
|
|
437
|
+
const current = a.chapterId ? n.chapters[a.chapterId] : null;
|
|
438
|
+
const { items, omitted } = a.includeChapters === true || a.chapterId
|
|
439
|
+
? selectReference(n, a.chapterId)
|
|
440
|
+
: { items: [], omitted: [] };
|
|
441
|
+
const add = (kind, entity, content) => {
|
|
442
|
+
if (!content) return;
|
|
443
|
+
items.push({kind,id:entity.id,revision:entity.revision,title:entity.name,content,truncated:false});
|
|
444
|
+
};
|
|
445
|
+
const nodeIds = a.nodeIds ?? [];
|
|
446
|
+
if (!Array.isArray(nodeIds)) fail('INVALID_INPUT', 'nodeIds 必须是数组');
|
|
447
|
+
for (const id of nodeIds) {
|
|
448
|
+
const node = get(n.nodes, id, '节点');
|
|
449
|
+
if (this.memoryVisible(n, node, current, a.povNodeId)) add('memory', node, node.content || node.summary);
|
|
450
|
+
}
|
|
451
|
+
return { novelId: n.id, title: n.title, usedChars: items.reduce((sum,item)=>sum+item.content.length,0), items, omitted,
|
|
452
|
+
staleMemory: Object.values(n.nodes).filter(v => !v.deleted && v.status === 'stale').slice(0, 100).map(v => ({ id: v.id, name: v.name.slice(0, 200), sources: v.sources.slice(0, 10) })) };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
memoryVisible(novel, node, current, povNodeId) {
|
|
456
|
+
if (node.deleted || node.status !== 'active' || node.factType === 'plan') return false;
|
|
457
|
+
if (current && node.sources.some(s => novel.chapters[s.chapterId]?.order > current.order)) return false;
|
|
458
|
+
if (povNodeId && node.knownBy.length && !node.knownBy.includes(povNodeId)) return false;
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
checkMemorySources(novel, entity) {
|
|
463
|
+
if (entity.status !== 'active') return;
|
|
464
|
+
if (entity.sources.some(s => novel.chapters[s.chapterId]?.deleted || novel.chapters[s.chapterId]?.revision !== s.revision)
|
|
465
|
+
|| entity.dependsOn.some(id => novel.nodes[id]?.status === 'stale' || novel.nodes[id]?.deleted)) {
|
|
466
|
+
fail('STALE_SOURCE', '来源正文或依赖记忆已变化;请核对后更新来源版本再标记为有效');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
importNovel(state, a) {
|
|
471
|
+
const backup = a.backup;
|
|
472
|
+
if (!backup || backup.format !== 'cuigengji' || ![1,2].includes(backup.schemaVersion) || !backup.novel) fail('INVALID_BACKUP', '不是支持的 cuigengji 备份');
|
|
473
|
+
const n = clone(backup.novel);
|
|
474
|
+
text(n.id, 'novel.id'); text(n.title, 'novel.title');
|
|
475
|
+
if (!Number.isSafeInteger(n.revision) || n.revision < 1 || typeof n.archived !== 'boolean') fail('INVALID_BACKUP', '小说版本或归档状态无效');
|
|
476
|
+
if (n.archivedAt !== undefined && (typeof n.archivedAt !== 'string' || !Number.isFinite(Date.parse(n.archivedAt)))) fail('INVALID_BACKUP', '作品回收时间无效');
|
|
477
|
+
for (const field of ['volumes', 'chapters', 'nodes', 'edges']) if (!n[field] || typeof n[field] !== 'object' || Array.isArray(n[field])) fail('INVALID_BACKUP', `${field}无效`);
|
|
478
|
+
for (const collection of [n.volumes, n.chapters, n.nodes, n.edges]) for (const [id, entity] of Object.entries(collection)) {
|
|
479
|
+
if (id !== entity.id || !Number.isSafeInteger(entity.revision) || entity.revision < 1) fail('INVALID_BACKUP', '对象 ID 或版本无效');
|
|
480
|
+
}
|
|
481
|
+
for (const c of Object.values(n.chapters)) {
|
|
482
|
+
if (typeof c.content !== 'string' || c.contentHash !== hash(c.content) || !Array.isArray(c.versions) || !c.versions.length || (c.volumeId && !own(n.volumes, c.volumeId))) fail('INVALID_BACKUP', '章节或卷关系无效');
|
|
483
|
+
let previousRevision = 0;
|
|
484
|
+
for (const v of c.versions) {
|
|
485
|
+
if (!Number.isSafeInteger(v.revision) || v.revision <= previousRevision || typeof v.content !== 'string' || v.contentHash !== hash(v.content)) fail('INVALID_BACKUP', '历史正文版本或哈希不匹配');
|
|
486
|
+
previousRevision = v.revision;
|
|
487
|
+
}
|
|
488
|
+
const latest = c.versions.at(-1);
|
|
489
|
+
if (latest.revision !== c.revision || latest.contentHash !== c.contentHash) fail('INVALID_BACKUP', '最新历史版本与正文不一致');
|
|
490
|
+
}
|
|
491
|
+
for (const node of Object.values(n.nodes)) { if (!kinds.has(node.type)) fail('INVALID_BACKUP', '节点类型无效'); Object.assign(node, memoryFields(node, n, true), nodePosition(node)); }
|
|
492
|
+
for (const edge of Object.values(n.edges)) { if (!own(n.nodes, edge.from) || !own(n.nodes, edge.to)) fail('INVALID_BACKUP', '关系端点不存在'); Object.assign(edge, memoryFields(edge, n, true, true)); }
|
|
493
|
+
if (n.plan && (typeof n.plan.content !== 'string' || !Number.isSafeInteger(n.plan.revision) || n.plan.revision < 1 || typeof n.plan.approved !== 'boolean')) fail('INVALID_BACKUP', '规划格式无效');
|
|
494
|
+
validateMemoryGroups(n);
|
|
495
|
+
if (n.planning) validatePlanning(n.planning,n);
|
|
496
|
+
if (n.preset) { validatePreset(n.preset); if (!Number.isSafeInteger(n.preset.revision) || n.preset.revision < 1) fail('INVALID_BACKUP','预设版本无效'); }
|
|
497
|
+
if (own(state.novels, n.id)) {
|
|
498
498
|
if (isDeepStrictEqual(state.novels[n.id], n)) return { id: n.id, imported: false, reason: 'identical' };
|
|
499
|
-
fail('IMPORT_CONFLICT', '同 ID 小说已存在且内容不同;不会覆盖现有小说');
|
|
500
|
-
}
|
|
501
|
-
state.novels[n.id] = n; return { id: n.id, imported: true };
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
export default Store;
|
|
499
|
+
fail('IMPORT_CONFLICT', '同 ID 小说已存在且内容不同;不会覆盖现有小说');
|
|
500
|
+
}
|
|
501
|
+
state.novels[n.id] = n; return { id: n.id, imported: true };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export default Store;
|