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.
Files changed (67) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1 -1
  3. package/cordis.patch.yml +3 -3
  4. package/docs/CHANGELOG.md +15 -0
  5. package/docs/agent-installation.md +19 -12
  6. package/docs/architecture.md +74 -74
  7. package/docs/desktop-migration-20260930.md +26 -26
  8. package/docs/development.md +7 -3
  9. package/docs/getting-started.md +17 -7
  10. package/docs/layout-progress.md +31 -31
  11. package/docs/market-listing.md +5 -3
  12. package/docs/npm-publishing.md +35 -11
  13. package/docs/package-name-migration.md +3 -3
  14. package/docs/planning-board.md +44 -44
  15. package/docs/presets.md +24 -24
  16. package/docs/release-process.md +45 -10
  17. package/docs/tavern-compatibility-audit-20261001.md +109 -109
  18. package/docs/test-report.md +139 -129
  19. package/docs/upgrade.md +3 -1
  20. package/docs/usage-review.md +13 -13
  21. package/lib/client.js +19 -14
  22. package/package.json +19 -14
  23. package/skills/cuigengji-maintenance/SKILL.md +2 -2
  24. package/src/adapters/dsh/prompt.js +36 -36
  25. package/src/adapters/dsh/rpc.js +96 -96
  26. package/src/adapters/dsh/skills.js +17 -17
  27. package/src/adapters/mcp/actions.js +6 -6
  28. package/src/adapters/mcp/client.js +172 -172
  29. package/src/adapters/mcp/server.js +40 -40
  30. package/src/application/bindings/session.js +72 -72
  31. package/src/application/queries/context.js +15 -15
  32. package/src/application/store.js +412 -412
  33. package/src/client/features/chapters/clipboard.ts +27 -27
  34. package/src/client/features/chapters/history.ts +11 -11
  35. package/src/client/features/chapters/index.jsx +118 -118
  36. package/src/client/features/chapters/sync.ts +155 -155
  37. package/src/client/features/chapters/useChapterSync.ts +39 -39
  38. package/src/client/features/context/index.jsx +22 -22
  39. package/src/client/features/memory/index.jsx +73 -73
  40. package/src/client/features/planning/Decorations.tsx +1 -1
  41. package/src/client/features/planning/Pages.tsx +11 -11
  42. package/src/client/features/planning/data.js +16 -16
  43. package/src/client/index.tsx +130 -130
  44. package/src/client/launcher.tsx +78 -78
  45. package/src/client/shared/creation.tsx +34 -34
  46. package/src/client/shared/detail-page.tsx +61 -61
  47. package/src/client/shared/drafts.js +17 -17
  48. package/src/client/shared/evidence.jsx +11 -11
  49. package/src/client/shared/groups.tsx +34 -34
  50. package/src/client/shared/preferences.tsx +104 -104
  51. package/src/client/shared/select-style.ts +25 -25
  52. package/src/client/shared/state.js +95 -95
  53. package/src/client/shared/ui.jsx +19 -19
  54. package/src/client/shared/workspace.tsx +11 -11
  55. package/src/client/style.ts +274 -274
  56. package/src/client/visual.ts +190 -190
  57. package/src/contracts/actions.js +19 -19
  58. package/src/contracts/planning.ts +52 -52
  59. package/src/domain/manuscript.ts +4 -4
  60. package/src/domain/planning/index.ts +113 -113
  61. package/src/domain/planning/model.ts +59 -59
  62. package/src/domain/preset/index.js +89 -89
  63. package/src/index.js +91 -91
  64. package/src/infrastructure/migrations/legacy.js +96 -96
  65. package/src/infrastructure/migrations/tavern.js +78 -78
  66. package/src/infrastructure/persistence/atomic-store.ts +80 -80
  67. package/src/infrastructure/persistence/project-layout.ts +1 -1
@@ -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;