dsh-cuigengji-plugin 0.4.3 → 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 +7 -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 +28 -12
  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 +15 -11
  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 +18 -14
  22. package/package.json +18 -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,165 +1,165 @@
1
- import { persistDraft, restoreDraft } from '../../shared/drafts.js';
2
-
3
- export interface Chapter {
4
- id: string; revision: number; contentHash: string; deleted: boolean;
5
- title: string; content: string; volumeId: string | null; order: number;
6
- [key: string]: any;
7
- }
8
- const fields = ['content','title','volumeId','order'] as const;
1
+ import { persistDraft, restoreDraft } from '../../shared/drafts.js';
2
+
3
+ export interface Chapter {
4
+ id: string; revision: number; contentHash: string; deleted: boolean;
5
+ title: string; content: string; volumeId: string | null; order: number;
6
+ [key: string]: any;
7
+ }
8
+ const fields = ['content','title','volumeId','order'] as const;
9
9
  const same = (a: Chapter, b: Chapter) => fields.every(key => a[key] === b[key]);
10
10
  /** Autosave never re-sends untouched placement fields from an older draft. */
11
11
  export function chapterChanges(value: Chapter, base: Chapter): Partial<Chapter> {
12
12
  return Object.fromEntries(fields.filter(key => value[key] !== base[key]).map(key => [key, value[key]]));
13
13
  }
14
- function edit(base: string, text: string) {
15
- let start=0,end=base.length,tail=text.length;
16
- while(start<end&&start<tail&&base[start]===text[start]) start++;
17
- while(end>start&&tail>start&&base[end-1]===text[tail-1]) {end--;tail--;}
18
- return {start,end,text:text.slice(start,tail)};
19
- }
20
- /** Conservative three-way merge: overlapping edits always remain for human review. */
21
- export function mergeText(base: string, local: string, remote: string): string | null {
22
- if(local===remote||remote===base)return local;
23
- if(local===base)return remote;
24
- const a=edit(base,local),b=edit(base,remote);
25
- if(!(a.end<b.start||b.end<a.start))return null;
26
- let result=base;
27
- for(const patch of [a,b].sort((x,y)=>y.start-x.start))result=result.slice(0,patch.start)+patch.text+result.slice(patch.end);
28
- return result;
29
- }
30
- export function mergeChapter(base: Chapter, local: Chapter, remote: Chapter): Chapter | null {
31
- if(remote.deleted&&!same(base,local))return null;
32
- const value={...remote};
33
- for(const key of fields){
34
- if(key==='content'){
35
- const merged=mergeText(base.content,local.content,remote.content);
36
- if(merged===null)return null;value.content=merged;
37
- }else if(local[key]===base[key])value[key]=remote[key] as never;
38
- else if(remote[key]===base[key]||remote[key]===local[key])value[key]=local[key] as never;
39
- else return null;
40
- }
41
- return value;
42
- }
43
- type Phase='saved'|'waiting'|'saving'|'error'|'conflict';
44
- type State={base:Chapter;value:Chapter;phase:Phase;error:string;cacheError:string;notice:string;remote:Chapter|null};
45
- type Storage=Pick<globalThis.Storage,'getItem'|'setItem'|'removeItem'>;
46
- type Options={key:string;initial:Chapter;storage:Storage;read:()=>Promise<Chapter>;write:(value:Chapter,base:Chapter,requestId:string)=>Promise<Partial<Chapter>>;onSaved:()=>void;delay?:number};
47
-
48
- /** One serialized save queue per chapter. It survives tab switches and keeps drafts durable. */
49
- export class ChapterSync {
50
- state: State;
51
- private listeners=new Set<()=>void>();
52
- private timer:ReturnType<typeof setTimeout>|undefined;
53
- private saving=false;
54
- private reading=false;
55
- private composing=false;
56
- private touched=0;
57
- private pendingRemote:Chapter|null=null;
58
- private pending:{value:Chapter;base:Chapter;requestId:string}|null=null;
59
- private retries=0;
60
- options:Options;
61
- constructor(options:Options){
62
- this.options=options;
63
- const restored=restoreDraft(options.storage,options.key,options.initial);
64
- this.state={...restored,phase:same(restored.base,restored.value)?'saved':'waiting',error:'',cacheError:'',notice:'',remote:null};
65
- }
66
- get dirty(){return !same(this.state.base,this.state.value);}
67
- snapshot=()=>this.state;
68
- subscribe=(listener:()=>void)=>{this.listeners.add(listener);return()=>{this.listeners.delete(listener);};};
69
- private publish(patch:Partial<State>){
70
- this.state={...this.state,...patch};
71
- try{persistDraft(this.options.storage,this.options.key,{base:this.state.base,value:this.state.value});this.state.cacheError='';}
72
- catch{this.state.cacheError='本地草稿缓存失败,请保持此页面打开并重试保存。';}
73
- this.listeners.forEach(fn=>fn());
74
- }
75
- private schedule(delay=this.options.delay??1500){
76
- clearTimeout(this.timer);
77
- if(!this.composing&&this.state.phase!=='conflict')this.timer=setTimeout(()=>void this.flush(),delay);
78
- }
79
- change=(next:Chapter|((old:Chapter)=>Chapter))=>{
80
- this.touched=Date.now();this.retries=0;
81
- this.publish({value:typeof next==='function'?next(this.state.value):next,notice:'',phase:this.state.remote?'conflict':this.saving?'saving':'waiting'});
82
- this.schedule();
83
- };
84
- composition=(active:boolean)=>{this.composing=active;if(active)clearTimeout(this.timer);else this.schedule();};
85
- accept=(value:Chapter)=>{
86
- if(this.saving)return;
87
- this.pending=null;this.pendingRemote=null;this.retries=0;
88
- this.publish({base:value,value,remote:null,phase:'saved',error:'',notice:''});
89
- };
90
- rebase=(remote:Chapter)=>{
91
- if(this.saving||remote.deleted)return;
92
- this.pending=null;
93
- const value={...remote,...Object.fromEntries(fields.map(key=>[key,this.state.value[key]]))};
94
- this.publish({base:remote,value,remote:null,phase:'waiting',error:'',notice:''});this.schedule();
95
- };
14
+ function edit(base: string, text: string) {
15
+ let start=0,end=base.length,tail=text.length;
16
+ while(start<end&&start<tail&&base[start]===text[start]) start++;
17
+ while(end>start&&tail>start&&base[end-1]===text[tail-1]) {end--;tail--;}
18
+ return {start,end,text:text.slice(start,tail)};
19
+ }
20
+ /** Conservative three-way merge: overlapping edits always remain for human review. */
21
+ export function mergeText(base: string, local: string, remote: string): string | null {
22
+ if(local===remote||remote===base)return local;
23
+ if(local===base)return remote;
24
+ const a=edit(base,local),b=edit(base,remote);
25
+ if(!(a.end<b.start||b.end<a.start))return null;
26
+ let result=base;
27
+ for(const patch of [a,b].sort((x,y)=>y.start-x.start))result=result.slice(0,patch.start)+patch.text+result.slice(patch.end);
28
+ return result;
29
+ }
30
+ export function mergeChapter(base: Chapter, local: Chapter, remote: Chapter): Chapter | null {
31
+ if(remote.deleted&&!same(base,local))return null;
32
+ const value={...remote};
33
+ for(const key of fields){
34
+ if(key==='content'){
35
+ const merged=mergeText(base.content,local.content,remote.content);
36
+ if(merged===null)return null;value.content=merged;
37
+ }else if(local[key]===base[key])value[key]=remote[key] as never;
38
+ else if(remote[key]===base[key]||remote[key]===local[key])value[key]=local[key] as never;
39
+ else return null;
40
+ }
41
+ return value;
42
+ }
43
+ type Phase='saved'|'waiting'|'saving'|'error'|'conflict';
44
+ type State={base:Chapter;value:Chapter;phase:Phase;error:string;cacheError:string;notice:string;remote:Chapter|null};
45
+ type Storage=Pick<globalThis.Storage,'getItem'|'setItem'|'removeItem'>;
46
+ type Options={key:string;initial:Chapter;storage:Storage;read:()=>Promise<Chapter>;write:(value:Chapter,base:Chapter,requestId:string)=>Promise<Partial<Chapter>>;onSaved:()=>void;delay?:number};
47
+
48
+ /** One serialized save queue per chapter. It survives tab switches and keeps drafts durable. */
49
+ export class ChapterSync {
50
+ state: State;
51
+ private listeners=new Set<()=>void>();
52
+ private timer:ReturnType<typeof setTimeout>|undefined;
53
+ private saving=false;
54
+ private reading=false;
55
+ private composing=false;
56
+ private touched=0;
57
+ private pendingRemote:Chapter|null=null;
58
+ private pending:{value:Chapter;base:Chapter;requestId:string}|null=null;
59
+ private retries=0;
60
+ options:Options;
61
+ constructor(options:Options){
62
+ this.options=options;
63
+ const restored=restoreDraft(options.storage,options.key,options.initial);
64
+ this.state={...restored,phase:same(restored.base,restored.value)?'saved':'waiting',error:'',cacheError:'',notice:'',remote:null};
65
+ }
66
+ get dirty(){return !same(this.state.base,this.state.value);}
67
+ snapshot=()=>this.state;
68
+ subscribe=(listener:()=>void)=>{this.listeners.add(listener);return()=>{this.listeners.delete(listener);};};
69
+ private publish(patch:Partial<State>){
70
+ this.state={...this.state,...patch};
71
+ try{persistDraft(this.options.storage,this.options.key,{base:this.state.base,value:this.state.value});this.state.cacheError='';}
72
+ catch{this.state.cacheError='本地草稿缓存失败,请保持此页面打开并重试保存。';}
73
+ this.listeners.forEach(fn=>fn());
74
+ }
75
+ private schedule(delay=this.options.delay??1500){
76
+ clearTimeout(this.timer);
77
+ if(!this.composing&&this.state.phase!=='conflict')this.timer=setTimeout(()=>void this.flush(),delay);
78
+ }
79
+ change=(next:Chapter|((old:Chapter)=>Chapter))=>{
80
+ this.touched=Date.now();this.retries=0;
81
+ this.publish({value:typeof next==='function'?next(this.state.value):next,notice:'',phase:this.state.remote?'conflict':this.saving?'saving':'waiting'});
82
+ this.schedule();
83
+ };
84
+ composition=(active:boolean)=>{this.composing=active;if(active)clearTimeout(this.timer);else this.schedule();};
85
+ accept=(value:Chapter)=>{
86
+ if(this.saving)return;
87
+ this.pending=null;this.pendingRemote=null;this.retries=0;
88
+ this.publish({base:value,value,remote:null,phase:'saved',error:'',notice:''});
89
+ };
90
+ rebase=(remote:Chapter)=>{
91
+ if(this.saving||remote.deleted)return;
92
+ this.pending=null;
93
+ const value={...remote,...Object.fromEntries(fields.map(key=>[key,this.state.value[key]]))};
94
+ this.publish({base:remote,value,remote:null,phase:'waiting',error:'',notice:''});this.schedule();
95
+ };
96
96
  private reconcile(remote:Chapter){
97
97
  if(remote.revision<=this.state.base.revision)return;
98
98
  // A write may fail after the next IME composition has already started.
99
99
  // Keep that remote version pending until the final composed input is present.
100
100
  if(this.composing){this.pendingRemote=remote;this.publish({phase:'waiting',error:''});return;}
101
- const value=mergeChapter(this.state.base,this.state.value,remote);
102
- if(!value){this.publish({remote,phase:'conflict',error:'',notice:''});return;}
103
- const dirty=!same(value,remote);
104
- this.publish({base:remote,value,remote:null,phase:dirty?'waiting':'saved',error:'',notice:dirty?'已合并双方不同位置的修改':'已同步最新正文'});
105
- if(dirty)this.schedule();
106
- }
107
- refresh=async()=>{
108
- if(this.reading||this.saving||this.pending)return;
109
- this.reading=true;
110
- try{
111
- const remote=await this.options.read();
112
- if(this.saving||this.pending)return;
113
- if(this.composing||Date.now()-this.touched<(this.options.delay??1500)){this.pendingRemote=remote;this.schedule();}
114
- else this.reconcile(remote);
115
- }catch{/* The directory reports read failures; retain the complete local draft. */}
116
- finally{this.reading=false;}
117
- };
118
- private flight:Promise<void>|null=null;
119
- flush=():Promise<void>=>{
120
- if(this.flight)return this.flight;
121
- this.flight=this.performFlush().finally(()=>{this.flight=null;});
122
- return this.flight;
123
- };
124
- /** A destructive UI action must wait for the submitted snapshot and later typing. */
125
- prepareRemoval=async()=>{
126
- for(let attempt=0;attempt<4;attempt++){
127
- if(this.composing)throw new Error('请先完成正在输入的文字。');
128
- await this.flush();
129
- if(this.state.remote)throw new Error('正文存在未解决的版本冲突,草稿已保留。请先合并。');
130
- if(this.state.phase==='error')throw new Error(this.state.error||'正文尚未保存,草稿已保留。');
131
- if(!this.dirty&&!this.pending)return this.state.base;
132
- }
133
- throw new Error('正文尚未保存完成,请稍后重试。');
134
- };
135
- private performFlush=async()=>{
136
- clearTimeout(this.timer);
137
- if(this.saving||this.composing||this.state.remote)return;
138
- if(this.pendingRemote){const remote=this.pendingRemote;this.pendingRemote=null;this.reconcile(remote);if(this.state.remote)return;}
139
- if(!this.dirty&&!this.pending){this.publish({phase:'saved'});return;}
140
- if(this.state.base.deleted||!this.state.value.title.trim())return;
141
- this.saving=true;
142
- const request=this.pending??{value:{...this.state.value},base:this.state.base,requestId:crypto.randomUUID()};
143
- this.pending=request;this.publish({phase:'saving',error:''});
144
- try{
145
- const result=await this.options.write(request.value,request.base,request.requestId);
146
- const base={...request.value,...result} as Chapter;
147
- // Preserve keystrokes entered while the submitted snapshot was in flight.
148
- const value={...base,...Object.fromEntries(fields.map(key=>[key,this.state.value[key]]))};
149
- this.pending=null;this.retries=0;
150
- this.publish({base,value,phase:same(base,value)?'saved':'waiting',error:'',notice:''});
151
- this.options.onSaved();
152
- }catch(error:any){
153
- if(error?.code==='CONFLICT'||/版本已变化/.test(error?.message||'')){
154
- this.pending=null;
155
- try{this.reconcile(await this.options.read());}
156
- catch{this.publish({phase:'error',error:'无法读取最新正文,草稿已保留。请重试。'});}
157
- }else{this.publish({phase:'error',error:'自动保存失败,草稿已保留。请重试。'});this.retries++;}
158
- }finally{
159
- this.saving=false;
160
- if(this.state.phase==='waiting')this.schedule();
161
- else if(this.state.phase==='error'&&this.retries>0&&this.retries<=2)this.schedule(2000*this.retries);
162
- }
163
- };
164
- dispose(){clearTimeout(this.timer);this.listeners.clear();}
165
- }
101
+ const value=mergeChapter(this.state.base,this.state.value,remote);
102
+ if(!value){this.publish({remote,phase:'conflict',error:'',notice:''});return;}
103
+ const dirty=!same(value,remote);
104
+ this.publish({base:remote,value,remote:null,phase:dirty?'waiting':'saved',error:'',notice:dirty?'已合并双方不同位置的修改':'已同步最新正文'});
105
+ if(dirty)this.schedule();
106
+ }
107
+ refresh=async()=>{
108
+ if(this.reading||this.saving||this.pending)return;
109
+ this.reading=true;
110
+ try{
111
+ const remote=await this.options.read();
112
+ if(this.saving||this.pending)return;
113
+ if(this.composing||Date.now()-this.touched<(this.options.delay??1500)){this.pendingRemote=remote;this.schedule();}
114
+ else this.reconcile(remote);
115
+ }catch{/* The directory reports read failures; retain the complete local draft. */}
116
+ finally{this.reading=false;}
117
+ };
118
+ private flight:Promise<void>|null=null;
119
+ flush=():Promise<void>=>{
120
+ if(this.flight)return this.flight;
121
+ this.flight=this.performFlush().finally(()=>{this.flight=null;});
122
+ return this.flight;
123
+ };
124
+ /** A destructive UI action must wait for the submitted snapshot and later typing. */
125
+ prepareRemoval=async()=>{
126
+ for(let attempt=0;attempt<4;attempt++){
127
+ if(this.composing)throw new Error('请先完成正在输入的文字。');
128
+ await this.flush();
129
+ if(this.state.remote)throw new Error('正文存在未解决的版本冲突,草稿已保留。请先合并。');
130
+ if(this.state.phase==='error')throw new Error(this.state.error||'正文尚未保存,草稿已保留。');
131
+ if(!this.dirty&&!this.pending)return this.state.base;
132
+ }
133
+ throw new Error('正文尚未保存完成,请稍后重试。');
134
+ };
135
+ private performFlush=async()=>{
136
+ clearTimeout(this.timer);
137
+ if(this.saving||this.composing||this.state.remote)return;
138
+ if(this.pendingRemote){const remote=this.pendingRemote;this.pendingRemote=null;this.reconcile(remote);if(this.state.remote)return;}
139
+ if(!this.dirty&&!this.pending){this.publish({phase:'saved'});return;}
140
+ if(this.state.base.deleted||!this.state.value.title.trim())return;
141
+ this.saving=true;
142
+ const request=this.pending??{value:{...this.state.value},base:this.state.base,requestId:crypto.randomUUID()};
143
+ this.pending=request;this.publish({phase:'saving',error:''});
144
+ try{
145
+ const result=await this.options.write(request.value,request.base,request.requestId);
146
+ const base={...request.value,...result} as Chapter;
147
+ // Preserve keystrokes entered while the submitted snapshot was in flight.
148
+ const value={...base,...Object.fromEntries(fields.map(key=>[key,this.state.value[key]]))};
149
+ this.pending=null;this.retries=0;
150
+ this.publish({base,value,phase:same(base,value)?'saved':'waiting',error:'',notice:''});
151
+ this.options.onSaved();
152
+ }catch(error:any){
153
+ if(error?.code==='CONFLICT'||/版本已变化/.test(error?.message||'')){
154
+ this.pending=null;
155
+ try{this.reconcile(await this.options.read());}
156
+ catch{this.publish({phase:'error',error:'无法读取最新正文,草稿已保留。请重试。'});}
157
+ }else{this.publish({phase:'error',error:'自动保存失败,草稿已保留。请重试。'});this.retries++;}
158
+ }finally{
159
+ this.saving=false;
160
+ if(this.state.phase==='waiting')this.schedule();
161
+ else if(this.state.phase==='error'&&this.retries>0&&this.retries<=2)this.schedule(2000*this.retries);
162
+ }
163
+ };
164
+ dispose(){clearTimeout(this.timer);this.listeners.clear();}
165
+ }
@@ -1,39 +1,39 @@
1
- import {useContext,useEffect,useMemo,useSyncExternalStore} from 'react';
2
- import {SessionScope} from '../../shared/state.js';
3
- import {draftKey} from '../../shared/drafts.js';
4
- import {ChapterSync,type Chapter} from './sync.ts';
5
-
6
- const sessions=new Map<string,ChapterSync>();
7
- /** Also hydrate cached drafts for chapters no longer open in the editor. */
8
- export async function prepareChapterRemoval({scope,novelId,chapterId,read,write,onSaved}: {
9
- scope:string;novelId:string;chapterId:string;read:()=>Promise<Chapter>;
10
- write:(value:Chapter,base:Chapter,requestId:string)=>Promise<Partial<Chapter>>;onSaved:()=>void;
11
- }) {
12
- const key=draftKey(scope,`${novelId}:chapter:${chapterId}`);
13
- let controller=sessions.get(key);
14
- if(!controller){controller=new ChapterSync({key,initial:await read(),storage:localStorage,read,write,onSaved});sessions.set(key,controller);}
15
- controller.options={...controller.options,read,write,onSaved};
16
- return {chapter:await controller.prepareRemoval(),accept:controller.accept};
17
- }
18
- export function useChapterSync({novelId,chapterId,initial,latest,read,write,onSaved}: {
19
- novelId:string;chapterId:string;initial:Chapter;latest?:{revision:number};
20
- read:()=>Promise<Chapter>;write:(value:Chapter,base:Chapter,requestId:string)=>Promise<Partial<Chapter>>;onSaved:()=>void;
21
- }){
22
- const scope=useContext(SessionScope),key=draftKey(scope,`${novelId}:chapter:${chapterId}`);
23
- const controller=useMemo(()=>{
24
- let existing=sessions.get(key);
25
- if(!existing){existing=new ChapterSync({key,initial,storage:localStorage,read,write,onSaved});sessions.set(key,existing);}
26
- return existing;
27
- },[key]);
28
- controller.options={...controller.options,read,write,onSaved};
29
- const state=useSyncExternalStore(controller.subscribe,controller.snapshot,controller.snapshot);
30
- useEffect(()=>{void controller.refresh();void controller.flush();return()=>{void controller.flush();};},[controller]);
31
- useEffect(()=>{if(latest&&latest.revision>controller.state.base.revision)void controller.refresh();},[controller,latest?.revision,state.base.revision]);
32
- useEffect(()=>{
33
- const online=()=>{void controller.flush();void controller.refresh();};
34
- const leave=(event:BeforeUnloadEvent)=>{if(controller.dirty){void controller.flush();event.preventDefault();event.returnValue='';}};
35
- window.addEventListener('online',online);window.addEventListener('beforeunload',leave);
36
- return()=>{window.removeEventListener('online',online);window.removeEventListener('beforeunload',leave);};
37
- },[controller]);
38
- return {...state,dirty:controller.dirty,change:controller.change,accept:controller.accept,rebase:controller.rebase,save:controller.flush,composition:controller.composition};
39
- }
1
+ import {useContext,useEffect,useMemo,useSyncExternalStore} from 'react';
2
+ import {SessionScope} from '../../shared/state.js';
3
+ import {draftKey} from '../../shared/drafts.js';
4
+ import {ChapterSync,type Chapter} from './sync.ts';
5
+
6
+ const sessions=new Map<string,ChapterSync>();
7
+ /** Also hydrate cached drafts for chapters no longer open in the editor. */
8
+ export async function prepareChapterRemoval({scope,novelId,chapterId,read,write,onSaved}: {
9
+ scope:string;novelId:string;chapterId:string;read:()=>Promise<Chapter>;
10
+ write:(value:Chapter,base:Chapter,requestId:string)=>Promise<Partial<Chapter>>;onSaved:()=>void;
11
+ }) {
12
+ const key=draftKey(scope,`${novelId}:chapter:${chapterId}`);
13
+ let controller=sessions.get(key);
14
+ if(!controller){controller=new ChapterSync({key,initial:await read(),storage:localStorage,read,write,onSaved});sessions.set(key,controller);}
15
+ controller.options={...controller.options,read,write,onSaved};
16
+ return {chapter:await controller.prepareRemoval(),accept:controller.accept};
17
+ }
18
+ export function useChapterSync({novelId,chapterId,initial,latest,read,write,onSaved}: {
19
+ novelId:string;chapterId:string;initial:Chapter;latest?:{revision:number};
20
+ read:()=>Promise<Chapter>;write:(value:Chapter,base:Chapter,requestId:string)=>Promise<Partial<Chapter>>;onSaved:()=>void;
21
+ }){
22
+ const scope=useContext(SessionScope),key=draftKey(scope,`${novelId}:chapter:${chapterId}`);
23
+ const controller=useMemo(()=>{
24
+ let existing=sessions.get(key);
25
+ if(!existing){existing=new ChapterSync({key,initial,storage:localStorage,read,write,onSaved});sessions.set(key,existing);}
26
+ return existing;
27
+ },[key]);
28
+ controller.options={...controller.options,read,write,onSaved};
29
+ const state=useSyncExternalStore(controller.subscribe,controller.snapshot,controller.snapshot);
30
+ useEffect(()=>{void controller.refresh();void controller.flush();return()=>{void controller.flush();};},[controller]);
31
+ useEffect(()=>{if(latest&&latest.revision>controller.state.base.revision)void controller.refresh();},[controller,latest?.revision,state.base.revision]);
32
+ useEffect(()=>{
33
+ const online=()=>{void controller.flush();void controller.refresh();};
34
+ const leave=(event:BeforeUnloadEvent)=>{if(controller.dirty){void controller.flush();event.preventDefault();event.returnValue='';}};
35
+ window.addEventListener('online',online);window.addEventListener('beforeunload',leave);
36
+ return()=>{window.removeEventListener('online',online);window.removeEventListener('beforeunload',leave);};
37
+ },[controller]);
38
+ return {...state,dirty:controller.dirty,change:controller.change,accept:controller.accept,rebase:controller.rebase,save:controller.flush,composition:controller.composition};
39
+ }
@@ -1,22 +1,22 @@
1
- import React from 'react';
2
- import { useResource } from '../../shared/state.js';
3
- import { ResourceState, ReadingText } from '../../shared/ui.jsx';
4
- import { renderHandoff } from '../../../application/queries/handoff-text.js';
5
- export function Context({call,novelId,tick,onNavigate}) {
6
- const resource=useResource(()=>call('novel.handoff',{novelId}),[call,novelId,tick]);
7
- const value=resource.value;
8
- const current=value?.chapters.find(chapter=>chapter.id===value.binding?.chapterId);
9
- return <><div className="row"><h2 className="grow">项目接手信息</h2><button disabled={resource.loading} onClick={resource.retry}>刷新</button></div>
10
- <p className="muted">AI 接手时只收到项目索引,正文和资料由它按任务读取。</p>
11
- <ResourceState resource={resource}>{value&&<>
12
- <h3>{value.novel.title}</h3>
13
- <p>参考章节:{current?.title||(value.currentChapterStatus==='unavailable'?'原章节已不可用,请重新选择':'未指定')}</p>
14
- <p>当前任务:{value.binding?.goal||'以当前会话中的要求为准'}</p>
15
- <p className="muted">共 {value.chapterCount} 章 · 预设{value.available.preset?'已启用':'未启用'} · {value.available.planning} 条规划 · {value.available.memory} 条人物与世界资料</p>
16
- <p>取材顺序:正文 → 预设 → 规划 → 相关人物与世界资料。</p>
17
- <div className="row"><button onClick={()=>onNavigate('chapters')}>查看正文</button><button onClick={()=>onNavigate('preset')}>写作预设</button><button onClick={()=>onNavigate('plan')}>查看规划</button><button onClick={()=>onNavigate('memory')}>人物与世界</button></div>
18
- <details className="card"><summary>查看完整接手说明</summary><ReadingText text={renderHandoff(value)}/></details>
19
- <p className="muted">这里展示当前项目的接手说明,不代表 AI 已经读过正文,也不是上一轮读取记录。</p>
20
- </>}</ResourceState>
21
- </>;
22
- }
1
+ import React from 'react';
2
+ import { useResource } from '../../shared/state.js';
3
+ import { ResourceState, ReadingText } from '../../shared/ui.jsx';
4
+ import { renderHandoff } from '../../../application/queries/handoff-text.js';
5
+ export function Context({call,novelId,tick,onNavigate}) {
6
+ const resource=useResource(()=>call('novel.handoff',{novelId}),[call,novelId,tick]);
7
+ const value=resource.value;
8
+ const current=value?.chapters.find(chapter=>chapter.id===value.binding?.chapterId);
9
+ return <><div className="row"><h2 className="grow">项目接手信息</h2><button disabled={resource.loading} onClick={resource.retry}>刷新</button></div>
10
+ <p className="muted">AI 接手时只收到项目索引,正文和资料由它按任务读取。</p>
11
+ <ResourceState resource={resource}>{value&&<>
12
+ <h3>{value.novel.title}</h3>
13
+ <p>参考章节:{current?.title||(value.currentChapterStatus==='unavailable'?'原章节已不可用,请重新选择':'未指定')}</p>
14
+ <p>当前任务:{value.binding?.goal||'以当前会话中的要求为准'}</p>
15
+ <p className="muted">共 {value.chapterCount} 章 · 预设{value.available.preset?'已启用':'未启用'} · {value.available.planning} 条规划 · {value.available.memory} 条人物与世界资料</p>
16
+ <p>取材顺序:正文 → 预设 → 规划 → 相关人物与世界资料。</p>
17
+ <div className="row"><button onClick={()=>onNavigate('chapters')}>查看正文</button><button onClick={()=>onNavigate('preset')}>写作预设</button><button onClick={()=>onNavigate('plan')}>查看规划</button><button onClick={()=>onNavigate('memory')}>人物与世界</button></div>
18
+ <details className="card"><summary>查看完整接手说明</summary><ReadingText text={renderHandoff(value)}/></details>
19
+ <p className="muted">这里展示当前项目的接手说明,不代表 AI 已经读过正文,也不是上一轮读取记录。</p>
20
+ </>}</ResourceState>
21
+ </>;
22
+ }