@waterwx/dsh-novel-forge 0.1.0 → 1.0.0
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/README.md +51 -25
- package/cordis.patch.yml +1 -1
- package/lib/client.js +3456 -1316
- package/lib/client.js.map +1 -1
- package/lib/index.js +436 -37
- package/lib/index.js.map +1 -1
- package/lib/types/bookshelf.d.ts +2 -0
- package/lib/types/client/api.d.ts +32 -2
- package/lib/types/client/panel/AssetsTab.d.ts +6 -1
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/engine.d.ts +25 -6
- package/lib/types/protocol.d.ts +82 -0
- package/package.json +1 -13
- package/src/assistant.ts +24 -3
- package/src/bookshelf.ts +11 -0
- package/src/client/api.ts +38 -3
- package/src/client/panel/AssetsTab.tsx +132 -17
- package/src/client/panel/BookshelfBar.tsx +62 -8
- package/src/client/panel/NovelPanel.tsx +1341 -172
- package/src/client/panel/panel.module.css +863 -59
- package/src/engine.ts +235 -21
- package/src/protocol.ts +84 -0
- package/src/routes.ts +225 -8
package/lib/types/bookshelf.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export declare function activeBook(store: BookshelfStore): BookEntry | undefined
|
|
|
17
17
|
export declare function bookshelfSnapshot(store: BookshelfStore): BookshelfSnapshot;
|
|
18
18
|
/** 新建一本书(自动成为当前书)。 */
|
|
19
19
|
export declare function createBook(bookName: string, outputDir: string): BookEntry;
|
|
20
|
+
/** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
|
|
21
|
+
export declare function renameBook(id: string, bookName: string): boolean;
|
|
20
22
|
/**
|
|
21
23
|
* 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
|
|
22
24
|
* 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
|
|
@@ -34,8 +34,28 @@ export declare class NovelApi {
|
|
|
34
34
|
openFolder(): Promise<void>;
|
|
35
35
|
/** 书架快照。 */
|
|
36
36
|
bookshelf(): Promise<import('../protocol.ts').BookshelfSnapshot>;
|
|
37
|
-
/**
|
|
38
|
-
bookCreate(bookName: string, outputDir?: string): Promise<import('../protocol.ts').BookshelfSnapshot>;
|
|
37
|
+
/** 新建书并激活(开书向导:可携带大纲文本,创建即建项目)。 */
|
|
38
|
+
bookCreate(bookName: string, outputDir?: string, outline?: string): Promise<import('../protocol.ts').BookshelfSnapshot>;
|
|
39
|
+
/** 重置项目(清空进度;可携带新大纲)。 */
|
|
40
|
+
reset(outline?: string): Promise<{
|
|
41
|
+
ok: boolean;
|
|
42
|
+
bookName: string;
|
|
43
|
+
}>;
|
|
44
|
+
/** 全书一致性质检。 */
|
|
45
|
+
audit(): Promise<import('../protocol.ts').AuditResponse>;
|
|
46
|
+
/** 角色卡刷新(基于事实库聚合)。 */
|
|
47
|
+
charactersRefresh(): Promise<{
|
|
48
|
+
cards: import('../protocol.ts').RoleStatusCard[];
|
|
49
|
+
}>;
|
|
50
|
+
/** 事实库回填:对历史已生成章节批量抽取事实。 */
|
|
51
|
+
factsBackfill(): Promise<{
|
|
52
|
+
ok: boolean;
|
|
53
|
+
filled: number;
|
|
54
|
+
}>;
|
|
55
|
+
/** 设定圣经局部修补。 */
|
|
56
|
+
biblePatch(patch: import('../protocol.ts').BiblePatchRequest): Promise<{
|
|
57
|
+
bible: import('../protocol.ts').StoryBible;
|
|
58
|
+
}>;
|
|
39
59
|
/** 切换当前书。 */
|
|
40
60
|
bookActivate(id: string): Promise<import('../protocol.ts').BookshelfSnapshot>;
|
|
41
61
|
/** 移除书架条目。 */
|
|
@@ -61,6 +81,16 @@ export declare class NovelApi {
|
|
|
61
81
|
rewrite(chapterNo: number, instructions: string, target: string, onFrame: (frame: JobFrame) => void): Promise<void>;
|
|
62
82
|
/** Polish (de-AI-ify) one chapter. */
|
|
63
83
|
polish(chapterNo: number, onFrame: (frame: JobFrame) => void): Promise<void>;
|
|
84
|
+
/** 采纳待确认草稿(润色/重写产物),覆盖正文文件。 */
|
|
85
|
+
draftApply(chapterNo: number): Promise<{
|
|
86
|
+
ok: boolean;
|
|
87
|
+
chars: number;
|
|
88
|
+
file: string;
|
|
89
|
+
}>;
|
|
90
|
+
/** 放弃待确认草稿,保留原稿。 */
|
|
91
|
+
draftDiscard(chapterNo: number): Promise<{
|
|
92
|
+
ok: boolean;
|
|
93
|
+
}>;
|
|
64
94
|
/** Run one assistant turn (NDJSON stream). */
|
|
65
95
|
assistant(message: string, onFrame: (frame: import('../protocol.ts').AssistantFrame) => void): Promise<void>;
|
|
66
96
|
/** Load the persisted assistant conversation. */
|
|
@@ -2,6 +2,11 @@ import type { NovelApi } from '../api.ts';
|
|
|
2
2
|
/** Props. */
|
|
3
3
|
export interface AssetsTabProps {
|
|
4
4
|
api: NovelApi;
|
|
5
|
+
/** 初始子页(左侧导航直达对应资产分类)。 */
|
|
6
|
+
initialTab?: AssetSubTab;
|
|
5
7
|
}
|
|
8
|
+
/** 写作资产子页签。 */
|
|
9
|
+
type AssetSubTab = 'genre' | 'progression' | 'templates' | 'rules' | 'style';
|
|
6
10
|
/** 写作资产页签。 */
|
|
7
|
-
export declare function AssetsTab({ api }: AssetsTabProps): import("react").JSX.Element;
|
|
11
|
+
export declare function AssetsTab({ api, initialTab }: AssetsTabProps): import("react").JSX.Element;
|
|
12
|
+
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { NovelApi } from '../api.ts';
|
|
2
2
|
import type { PanelController } from './controller.ts';
|
|
3
3
|
/** The panel's tab identifiers. */
|
|
4
|
-
export type NovelTab = 'workflow' | 'overview' | 'plan' | 'bible' | '
|
|
4
|
+
export type NovelTab = 'workflow' | 'overview' | 'plan' | 'bible' | 'foreshadow' | 'assistant' | 'settings' | 'characters' | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle';
|
|
5
5
|
/** Panel shell props. */
|
|
6
6
|
export interface NovelPanelProps {
|
|
7
7
|
/** The panel state owner (open/close/toggle). */
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* web-server dependencies), so routes stay thin and logic is testable.
|
|
7
7
|
*/
|
|
8
8
|
import type { Context } from '@deepseek-ai/cordis';
|
|
9
|
-
import type { ChapterPlan, Foreshadow, NovelConfig, ProjectState, ReviewReport, StoryBible, Volume } from './protocol.ts';
|
|
9
|
+
import type { AuditIssue, ChapterPlan, Foreshadow, NovelConfig, ProjectState, ReviewReport, RoleStatusCard, StoryBible, Volume } from './protocol.ts';
|
|
10
10
|
/** Project state file name inside the output dir. */
|
|
11
11
|
export declare const PROJECT_FILE = "novel-project.json";
|
|
12
12
|
/** Chapter output file name, e.g. 第001章_开篇.md */
|
|
@@ -47,20 +47,21 @@ export declare function rewriteChapterStream(ctx: Context, config: NovelConfig,
|
|
|
47
47
|
frame: 'delta';
|
|
48
48
|
text: string;
|
|
49
49
|
} | {
|
|
50
|
-
frame: '
|
|
51
|
-
file: string;
|
|
50
|
+
frame: 'drafted';
|
|
52
51
|
chars: number;
|
|
52
|
+
draft: string;
|
|
53
53
|
}, void, unknown>;
|
|
54
|
-
/** Stream a chapter polish (de-AI-ify).
|
|
54
|
+
/** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
|
|
55
|
+
* in `chapter.pendingDraft` and is only applied on draft/apply. */
|
|
55
56
|
export declare function polishChapterStream(ctx: Context, config: NovelConfig, project: ProjectState, outputDir: string, chapterNo: number): AsyncGenerator<{
|
|
56
57
|
frame: 'start';
|
|
57
58
|
} | {
|
|
58
59
|
frame: 'delta';
|
|
59
60
|
text: string;
|
|
60
61
|
} | {
|
|
61
|
-
frame: '
|
|
62
|
-
file: string;
|
|
62
|
+
frame: 'drafted';
|
|
63
63
|
chars: number;
|
|
64
|
+
draft: string;
|
|
64
65
|
}, void, unknown>;
|
|
65
66
|
/** Generate one chapter (streaming). Yields progress frames; persists when done. */
|
|
66
67
|
export declare function generateChapterStream(ctx: Context, config: NovelConfig, project: ProjectState, outputDir: string, chapterNo: number): AsyncGenerator<{
|
|
@@ -75,6 +76,24 @@ export declare function generateChapterStream(ctx: Context, config: NovelConfig,
|
|
|
75
76
|
}, void, unknown>;
|
|
76
77
|
/** Generate a chapter summary (narrative memory). */
|
|
77
78
|
export declare function summarizeChapter(ctx: Context, config: NovelConfig, project: ProjectState, outputDir: string, chapterNo: number): Promise<string>;
|
|
79
|
+
/**
|
|
80
|
+
* 抽取本章「已确立事实」追加到事实库/时间线(最多 300 条,最新优先)。
|
|
81
|
+
* 事实注入后续章节生成提示词,保证人物状态/境界/资源/关系长期一致。
|
|
82
|
+
* @returns 新增事实条数(失败返回 0,调用方 best-effort)。
|
|
83
|
+
*/
|
|
84
|
+
export declare function extractFacts(ctx: Context, config: NovelConfig, project: ProjectState, outputDir: string, chapterNo: number): Promise<number>;
|
|
85
|
+
/** 全书一致性质检:LLM 扫描已生成章节 + 设定 + 事实库,输出矛盾清单。 */
|
|
86
|
+
export declare function auditBook(ctx: Context, config: NovelConfig, project: ProjectState, outputDir: string): Promise<AuditIssue[]>;
|
|
87
|
+
/**
|
|
88
|
+
* 事实库回填:对历史已生成章节批量抽取事实(无事实记录的旧章节)。
|
|
89
|
+
* @returns 回填的章节数。
|
|
90
|
+
*/
|
|
91
|
+
export declare function backfillFacts(ctx: Context, config: NovelConfig, project: ProjectState, outputDir: string): Promise<number>;
|
|
92
|
+
/**
|
|
93
|
+
* 角色卡刷新:出场统计由服务端从正文精确计算(角色名出现过的章节数、
|
|
94
|
+
* 最近出现章节),LLM 只负责聚合「当前状态」一句话。
|
|
95
|
+
*/
|
|
96
|
+
export declare function refreshCharacters(ctx: Context, config: NovelConfig, project: ProjectState, outputDir: string): Promise<RoleStatusCard[]>;
|
|
78
97
|
/** Suggest foreshadows from the outline + plan. */
|
|
79
98
|
export declare function suggestForeshadows(ctx: Context, config: NovelConfig, project: ProjectState): Promise<Foreshadow[]>;
|
|
80
99
|
/**
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -18,6 +18,10 @@ export declare const NOVEL_API: {
|
|
|
18
18
|
readonly review: "/api/dsh-novel-forge/review";
|
|
19
19
|
readonly rewrite: "/api/dsh-novel-forge/rewrite";
|
|
20
20
|
readonly polish: "/api/dsh-novel-forge/polish";
|
|
21
|
+
/** 采纳待确认草稿(润色/重写产物)覆盖正文文件。 */
|
|
22
|
+
readonly draftApply: "/api/dsh-novel-forge/draft/apply";
|
|
23
|
+
/** 放弃待确认草稿,保留原稿。 */
|
|
24
|
+
readonly draftDiscard: "/api/dsh-novel-forge/draft/discard";
|
|
21
25
|
readonly summary: "/api/dsh-novel-forge/summary";
|
|
22
26
|
readonly foreshadow: "/api/dsh-novel-forge/foreshadow";
|
|
23
27
|
readonly exportBook: "/api/dsh-novel-forge/export";
|
|
@@ -25,6 +29,16 @@ export declare const NOVEL_API: {
|
|
|
25
29
|
readonly assistant: "/api/dsh-novel-forge/assistant";
|
|
26
30
|
readonly assistantHistory: "/api/dsh-novel-forge/assistant-history";
|
|
27
31
|
readonly bookshelf: "/api/dsh-novel-forge/bookshelf";
|
|
32
|
+
/** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
|
|
33
|
+
readonly reset: "/api/dsh-novel-forge/reset";
|
|
34
|
+
/** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
|
|
35
|
+
readonly audit: "/api/dsh-novel-forge/audit";
|
|
36
|
+
/** 角色卡刷新:基于事实库与各章摘要聚合角色当前状态。 */
|
|
37
|
+
readonly charactersRefresh: "/api/dsh-novel-forge/characters/refresh";
|
|
38
|
+
/** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
|
|
39
|
+
readonly factsBackfill: "/api/dsh-novel-forge/facts/backfill";
|
|
40
|
+
/** 设定圣经局部修补(如世界观规则编辑)。 */
|
|
41
|
+
readonly biblePatch: "/api/dsh-novel-forge/bible/patch";
|
|
28
42
|
readonly config: "/api/dsh-novel-forge/config";
|
|
29
43
|
readonly openFolder: "/api/dsh-novel-forge/open-folder";
|
|
30
44
|
};
|
|
@@ -55,6 +69,13 @@ export interface BookshelfSnapshot {
|
|
|
55
69
|
export interface BookCreateRequest {
|
|
56
70
|
bookName: string;
|
|
57
71
|
outputDir?: string;
|
|
72
|
+
/** 开书向导:创建时直接导入的大纲文本(提供则立即建立项目)。 */
|
|
73
|
+
outline?: string;
|
|
74
|
+
}
|
|
75
|
+
/** POST /reset 请求:重置项目(可选更新大纲)。 */
|
|
76
|
+
export interface ResetRequest {
|
|
77
|
+
/** 新大纲文本;提供则替换 outline,否则保留原大纲。 */
|
|
78
|
+
outline?: string;
|
|
58
79
|
}
|
|
59
80
|
/** POST /bookshelf/activate 请求:切换当前书。 */
|
|
60
81
|
export interface BookActivateRequest {
|
|
@@ -90,6 +111,11 @@ export interface ChapterPlan {
|
|
|
90
111
|
summary?: string;
|
|
91
112
|
/** Latest review report (present once reviewed). */
|
|
92
113
|
review?: ReviewReport;
|
|
114
|
+
/**
|
|
115
|
+
* 待确认草稿:润色(去AI味)或整章重写的产物正文。生成时先存这里,
|
|
116
|
+
* 用户看过对比后点「采纳」才覆盖正文文件;点「放弃」则丢弃。刷新页面不丢失。
|
|
117
|
+
*/
|
|
118
|
+
pendingDraft?: string;
|
|
93
119
|
}
|
|
94
120
|
/** One review finding. */
|
|
95
121
|
export interface ReviewIssue {
|
|
@@ -167,6 +193,49 @@ export interface Foreshadow {
|
|
|
167
193
|
/** Resolution note when resolved. */
|
|
168
194
|
resolvedNote?: string;
|
|
169
195
|
}
|
|
196
|
+
/** 一条已确立的叙事事实(事实库/时间线,注入后续章节生成)。 */
|
|
197
|
+
export interface ChapterFact {
|
|
198
|
+
/** 来源章节号。 */
|
|
199
|
+
chapterNo: number;
|
|
200
|
+
/** 事实文本(人物状态/境界资源/关系变化/伏笔落地等)。 */
|
|
201
|
+
text: string;
|
|
202
|
+
}
|
|
203
|
+
/** 一条全书质检发现的问题(一致性矛盾,定位到章)。 */
|
|
204
|
+
export interface AuditIssue {
|
|
205
|
+
/** 问题所在章节号(无法定位时 0)。 */
|
|
206
|
+
chapterNo: number;
|
|
207
|
+
severity: 'high' | 'medium' | 'low';
|
|
208
|
+
/** 矛盾描述。 */
|
|
209
|
+
item: string;
|
|
210
|
+
/** 修改建议。 */
|
|
211
|
+
suggestion: string;
|
|
212
|
+
}
|
|
213
|
+
/** POST /audit 响应。 */
|
|
214
|
+
export interface AuditResponse {
|
|
215
|
+
issues: AuditIssue[];
|
|
216
|
+
/** 参与质检的章节数。 */
|
|
217
|
+
auditedChapters: number;
|
|
218
|
+
/** 质检时间。 */
|
|
219
|
+
auditedAt: string;
|
|
220
|
+
}
|
|
221
|
+
/** 角色卡:角色当前状态(从事实库聚合)。 */
|
|
222
|
+
export interface RoleStatusCard {
|
|
223
|
+
name: string;
|
|
224
|
+
/** protagonist / supporting / antagonist / other。 */
|
|
225
|
+
role: string;
|
|
226
|
+
/** 当前状态一句话(境界/资源/伤势/心境)。 */
|
|
227
|
+
status: string;
|
|
228
|
+
/** 最近出场章节。 */
|
|
229
|
+
lastChapter: number;
|
|
230
|
+
/** 出场次数。 */
|
|
231
|
+
appearances: number;
|
|
232
|
+
}
|
|
233
|
+
/** POST /bible/patch 请求:局部修补设定圣经。 */
|
|
234
|
+
export interface BiblePatchRequest {
|
|
235
|
+
worldRules?: string[];
|
|
236
|
+
redLines?: string[];
|
|
237
|
+
style?: string[];
|
|
238
|
+
}
|
|
170
239
|
/** The persisted project: outline + bible + plan + progress. */
|
|
171
240
|
export interface ProjectState {
|
|
172
241
|
/** Book title (first non-empty line of the outline, usually). */
|
|
@@ -185,6 +254,8 @@ export interface ProjectState {
|
|
|
185
254
|
foreshadows: Foreshadow[];
|
|
186
255
|
/** 写作资产(题材基底/推进模式/反AI规则/写法资产)。 */
|
|
187
256
|
assets?: ProjectAssets;
|
|
257
|
+
/** 事实库/时间线:每章生成后抽取,注入后续章节保持一致性。 */
|
|
258
|
+
facts?: ChapterFact[];
|
|
188
259
|
/** ISO timestamps. */
|
|
189
260
|
createdAt: string;
|
|
190
261
|
updatedAt: string;
|
|
@@ -292,6 +363,13 @@ export type JobFrame = {
|
|
|
292
363
|
no: number;
|
|
293
364
|
file: string;
|
|
294
365
|
chars: number;
|
|
366
|
+
}
|
|
367
|
+
/** 润色/重写完成,产物作为待确认草稿(尚未覆盖正文)。 */
|
|
368
|
+
| {
|
|
369
|
+
type: 'drafted';
|
|
370
|
+
no: number;
|
|
371
|
+
chars: number;
|
|
372
|
+
draft: string;
|
|
295
373
|
} | {
|
|
296
374
|
type: 'error';
|
|
297
375
|
no: number;
|
|
@@ -316,6 +394,10 @@ export interface RewriteRequest {
|
|
|
316
394
|
export interface PolishRequest {
|
|
317
395
|
chapterNo: number;
|
|
318
396
|
}
|
|
397
|
+
/** POST /draft/apply | /draft/discard request: 采纳或放弃待确认草稿。 */
|
|
398
|
+
export interface DraftDecisionRequest {
|
|
399
|
+
chapterNo: number;
|
|
400
|
+
}
|
|
319
401
|
/** POST /summary request: (re)generate a chapter summary. */
|
|
320
402
|
export interface SummaryRequest {
|
|
321
403
|
chapterNo: number;
|
package/package.json
CHANGED
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waterwx/dsh-novel-forge",
|
|
3
3
|
"description": "AI 编译小说工作台 for the dsh web GUI: import a novel outline (docx/text), plan chapters with LLM, generate chapter by chapter (3000-4000 chars each), and save every chapter as Markdown into your chosen output folder.",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "1.0.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
8
8
|
},
|
|
9
|
-
"publishConfig": {
|
|
10
|
-
"access": "public",
|
|
11
|
-
"registry": "https://registry.npmjs.org/"
|
|
12
|
-
},
|
|
13
|
-
"repository": {
|
|
14
|
-
"type": "git",
|
|
15
|
-
"url": "git+https://github.com/watersxya/dsh-novel-forge.git"
|
|
16
|
-
},
|
|
17
|
-
"bugs": {
|
|
18
|
-
"url": "https://github.com/watersxya/dsh-novel-forge/issues"
|
|
19
|
-
},
|
|
20
|
-
"homepage": "https://github.com/watersxya/dsh-novel-forge",
|
|
21
9
|
"main": "lib/index.js",
|
|
22
10
|
"types": "lib/types/index.d.ts",
|
|
23
11
|
"exports": {
|
package/src/assistant.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { emptyProjectAssets } from './assets.ts'
|
|
|
22
22
|
import {
|
|
23
23
|
chapterFileName,
|
|
24
24
|
exportBook,
|
|
25
|
+
extractFacts,
|
|
25
26
|
generateChapterStream,
|
|
26
27
|
readChapterFile,
|
|
27
28
|
reviewChapter,
|
|
@@ -173,7 +174,7 @@ export async function* executeAction(
|
|
|
173
174
|
const num = (value: unknown): number | undefined => typeof value === 'number' ? value : undefined
|
|
174
175
|
|
|
175
176
|
/** Forward live text deltas from a streaming chapter job (text only). */
|
|
176
|
-
const forward = async function* (stream: AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number }, void, unknown>): AsyncGenerator<string, void, unknown> {
|
|
177
|
+
const forward = async function* (stream: AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number } | { frame: 'drafted'; chars: number; draft: string }, void, unknown>): AsyncGenerator<string, void, unknown> {
|
|
177
178
|
for await (const step of stream) {
|
|
178
179
|
if (step.frame === 'delta') yield step.text
|
|
179
180
|
}
|
|
@@ -239,8 +240,25 @@ export async function* executeAction(
|
|
|
239
240
|
for await (const chunk of forward(rewriteChapterStream(ctx, config, project, outputDir, no, instructions, target === '' ? undefined : target))) {
|
|
240
241
|
yield chunk
|
|
241
242
|
}
|
|
242
|
-
//
|
|
243
|
-
|
|
243
|
+
// Draft mode: the assistant acts on the user's explicit instruction, so
|
|
244
|
+
// apply the draft immediately — summarize/review must see the new body.
|
|
245
|
+
const chapter = project.chapters.find(c => c.no === no)
|
|
246
|
+
const draft = chapter?.pendingDraft
|
|
247
|
+
if (chapter === undefined || draft === undefined || draft === '') {
|
|
248
|
+
throw new Error(`章节 ${no} 修订后没有产出草稿`)
|
|
249
|
+
}
|
|
250
|
+
const fileName = chapterFileName(chapter)
|
|
251
|
+
mkdirSync(outputDir, { recursive: true })
|
|
252
|
+
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
|
|
253
|
+
chapter.pendingDraft = undefined
|
|
254
|
+
chapter.status = 'written'
|
|
255
|
+
chapter.chars = draft.length
|
|
256
|
+
chapter.file = fileName
|
|
257
|
+
chapter.review = undefined
|
|
258
|
+
chapter.error = undefined
|
|
259
|
+
project.updatedAt = new Date().toISOString()
|
|
260
|
+
saveProject(outputDir, project)
|
|
261
|
+
yield '(已采纳修订稿,正在生成章节摘要…)'
|
|
244
262
|
try {
|
|
245
263
|
await summarizeChapter(ctx, config, project, outputDir, no)
|
|
246
264
|
} catch { /* summary is best-effort */ }
|
|
@@ -258,6 +276,9 @@ export async function* executeAction(
|
|
|
258
276
|
try {
|
|
259
277
|
await summarizeChapter(ctx, config, project, outputDir, no)
|
|
260
278
|
} catch { /* best-effort */ }
|
|
279
|
+
try {
|
|
280
|
+
await extractFacts(ctx, config, project, outputDir, no)
|
|
281
|
+
} catch { /* best-effort */ }
|
|
261
282
|
yield '(正在 AI 审稿…)'
|
|
262
283
|
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
263
284
|
return `章节 ${no} 已生成(${project.chapters.find(c => c.no === no)?.chars ?? '?'} 字)。审稿:${report.score} 分 — ${report.verdict}`
|
package/src/bookshelf.ts
CHANGED
|
@@ -80,6 +80,17 @@ export function createBook(bookName: string, outputDir: string): BookEntry {
|
|
|
80
80
|
return book
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
|
|
84
|
+
export function renameBook(id: string, bookName: string): boolean {
|
|
85
|
+
const store = loadBookshelf()
|
|
86
|
+
const book = store.books.find(b => b.id === id)
|
|
87
|
+
if (book === undefined) return false
|
|
88
|
+
book.bookName = bookName
|
|
89
|
+
book.updatedAt = new Date().toISOString()
|
|
90
|
+
saveBookshelf(store)
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
83
94
|
/**
|
|
84
95
|
* 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
|
|
85
96
|
* 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
|
package/src/client/api.ts
CHANGED
|
@@ -122,9 +122,34 @@ export class NovelApi {
|
|
|
122
122
|
return readJson<import('../protocol.ts').BookshelfSnapshot>(response)
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/**
|
|
126
|
-
async bookCreate(bookName: string, outputDir?: string): Promise<import('../protocol.ts').BookshelfSnapshot> {
|
|
127
|
-
return postJson<import('../protocol.ts').BookshelfSnapshot>(NOVEL_API.bookshelf, { bookName, outputDir })
|
|
125
|
+
/** 新建书并激活(开书向导:可携带大纲文本,创建即建项目)。 */
|
|
126
|
+
async bookCreate(bookName: string, outputDir?: string, outline?: string): Promise<import('../protocol.ts').BookshelfSnapshot> {
|
|
127
|
+
return postJson<import('../protocol.ts').BookshelfSnapshot>(NOVEL_API.bookshelf, { bookName, outputDir, outline })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 重置项目(清空进度;可携带新大纲)。 */
|
|
131
|
+
async reset(outline?: string): Promise<{ ok: boolean; bookName: string }> {
|
|
132
|
+
return postJson<{ ok: boolean; bookName: string }>(NOVEL_API.reset, { outline })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 全书一致性质检。 */
|
|
136
|
+
async audit(): Promise<import('../protocol.ts').AuditResponse> {
|
|
137
|
+
return postJson<import('../protocol.ts').AuditResponse>(NOVEL_API.audit, {})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 角色卡刷新(基于事实库聚合)。 */
|
|
141
|
+
async charactersRefresh(): Promise<{ cards: import('../protocol.ts').RoleStatusCard[] }> {
|
|
142
|
+
return postJson<{ cards: import('../protocol.ts').RoleStatusCard[] }>(NOVEL_API.charactersRefresh, {})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 事实库回填:对历史已生成章节批量抽取事实。 */
|
|
146
|
+
async factsBackfill(): Promise<{ ok: boolean; filled: number }> {
|
|
147
|
+
return postJson<{ ok: boolean; filled: number }>(NOVEL_API.factsBackfill, {})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 设定圣经局部修补。 */
|
|
151
|
+
async biblePatch(patch: import('../protocol.ts').BiblePatchRequest): Promise<{ bible: import('../protocol.ts').StoryBible }> {
|
|
152
|
+
return postJson<{ bible: import('../protocol.ts').StoryBible }>(NOVEL_API.biblePatch, patch)
|
|
128
153
|
}
|
|
129
154
|
|
|
130
155
|
/** 切换当前书。 */
|
|
@@ -210,6 +235,16 @@ export class NovelApi {
|
|
|
210
235
|
await this.streamJob(NOVEL_API.polish, { chapterNo }, onFrame)
|
|
211
236
|
}
|
|
212
237
|
|
|
238
|
+
/** 采纳待确认草稿(润色/重写产物),覆盖正文文件。 */
|
|
239
|
+
async draftApply(chapterNo: number): Promise<{ ok: boolean; chars: number; file: string }> {
|
|
240
|
+
return postJson<{ ok: boolean; chars: number; file: string }>(NOVEL_API.draftApply, { chapterNo })
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 放弃待确认草稿,保留原稿。 */
|
|
244
|
+
async draftDiscard(chapterNo: number): Promise<{ ok: boolean }> {
|
|
245
|
+
return postJson<{ ok: boolean }>(NOVEL_API.draftDiscard, { chapterNo })
|
|
246
|
+
}
|
|
247
|
+
|
|
213
248
|
/** Run one assistant turn (NDJSON stream). */
|
|
214
249
|
async assistant(message: string, onFrame: (frame: import('../protocol.ts').AssistantFrame) => void): Promise<void> {
|
|
215
250
|
const response = await fetch(NOVEL_API.assistant, {
|
|
@@ -11,6 +11,8 @@ import css from './panel.module.css'
|
|
|
11
11
|
/** Props. */
|
|
12
12
|
export interface AssetsTabProps {
|
|
13
13
|
api: NovelApi
|
|
14
|
+
/** 初始子页(左侧导航直达对应资产分类)。 */
|
|
15
|
+
initialTab?: AssetSubTab
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/** 渲染题材树(带勾选当前题材)。 */
|
|
@@ -21,7 +23,14 @@ function GenreTree({ node, selected, onSelect }: { node: GenreNode; selected: st
|
|
|
21
23
|
<input type="radio" name="genre" checked={selected === node.name} onChange={() => { onSelect(node) }} />
|
|
22
24
|
<span>
|
|
23
25
|
<b>{node.name}</b>
|
|
24
|
-
{node.description !== '' &&
|
|
26
|
+
{node.description !== '' && (
|
|
27
|
+
<span
|
|
28
|
+
className={`${css.meta} ${css.genreDesc}`}
|
|
29
|
+
title={node.description}
|
|
30
|
+
>
|
|
31
|
+
— {node.description}
|
|
32
|
+
</span>
|
|
33
|
+
)}
|
|
25
34
|
</span>
|
|
26
35
|
</label>
|
|
27
36
|
{node.children.length > 0 && (
|
|
@@ -48,8 +57,8 @@ const SUB_TABS: ReadonlyArray<{ id: AssetSubTab; label: string }> = [
|
|
|
48
57
|
]
|
|
49
58
|
|
|
50
59
|
/** 写作资产页签。 */
|
|
51
|
-
export function AssetsTab({ api }: AssetsTabProps) {
|
|
52
|
-
const [assetTab, setAssetTab] = useState<AssetSubTab>(
|
|
60
|
+
export function AssetsTab({ api, initialTab = 'genre' }: AssetsTabProps) {
|
|
61
|
+
const [assetTab, setAssetTab] = useState<AssetSubTab>(initialTab)
|
|
53
62
|
const [data, setData] = useState<AssetsResponse | null>(null)
|
|
54
63
|
const [busy, setBusy] = useState(false)
|
|
55
64
|
const [error, setError] = useState('')
|
|
@@ -58,6 +67,8 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
58
67
|
const [styleName, setStyleName] = useState('')
|
|
59
68
|
const [newRule, setNewRule] = useState('')
|
|
60
69
|
const [newProgression, setNewProgression] = useState('')
|
|
70
|
+
/** 正在行内编辑的自定义反 AI 规则(下标 + 草稿字段)。 */
|
|
71
|
+
const [editingRule, setEditingRule] = useState<{ index: number; name: string; avoid: string; fix: string } | null>(null)
|
|
61
72
|
const loadId = useRef(0)
|
|
62
73
|
|
|
63
74
|
/** Load assets (or reset from a new call). */
|
|
@@ -135,6 +146,39 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
135
146
|
setNewRule('')
|
|
136
147
|
}
|
|
137
148
|
|
|
149
|
+
/** 保存行内编辑的自定义规则。 */
|
|
150
|
+
const handleSaveRuleEdit = async (): Promise<void> => {
|
|
151
|
+
if (editingRule === null) return
|
|
152
|
+
const rules = [...(data?.projectAssets.antiAiRules ?? [])]
|
|
153
|
+
if (editingRule.index < 0 || editingRule.index >= rules.length) return
|
|
154
|
+
const name = editingRule.name.trim() || rules[editingRule.index]!.name
|
|
155
|
+
if (editingRule.avoid.trim() === '' && editingRule.fix.trim() === '') return
|
|
156
|
+
rules[editingRule.index] = { name, avoid: editingRule.avoid.trim(), fix: editingRule.fix.trim() }
|
|
157
|
+
await patch({ antiAiRules: rules })
|
|
158
|
+
setEditingRule(null)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 删除一条自定义规则。 */
|
|
162
|
+
const handleRemoveRule = async (index: number): Promise<void> => {
|
|
163
|
+
const rules = [...(data?.projectAssets.antiAiRules ?? [])]
|
|
164
|
+
rules.splice(index, 1)
|
|
165
|
+
setEditingRule(null)
|
|
166
|
+
await patch({ antiAiRules: rules })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 把内置规则复制为自定义副本(同名覆盖生效),并打开行内编辑。 */
|
|
170
|
+
const handleOverrideBuiltin = (rule: AntiAiRule): void => {
|
|
171
|
+
const rules = data?.projectAssets.antiAiRules ?? []
|
|
172
|
+
const existing = rules.findIndex(r => r.name === rule.name)
|
|
173
|
+
if (existing >= 0) {
|
|
174
|
+
setEditingRule({ index: existing, name: rules[existing]!.name, avoid: rules[existing]!.avoid, fix: rules[existing]!.fix ?? '' })
|
|
175
|
+
} else {
|
|
176
|
+
const next = [...rules, { ...rule, fix: rule.fix ?? '' }]
|
|
177
|
+
setEditingRule({ index: next.length - 1, name: rule.name, avoid: rule.avoid, fix: rule.fix ?? '' })
|
|
178
|
+
void patch({ antiAiRules: next })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
138
182
|
/** 设置题材。 */
|
|
139
183
|
const handleSelectGenre = (node: GenreNode): void => {
|
|
140
184
|
void patch({ genre: node })
|
|
@@ -165,6 +209,32 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
165
209
|
{error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
|
|
166
210
|
{notice !== '' && <div className={css.card}><span style={{ color: 'var(--nf-success)' }}>{notice}</span></div>}
|
|
167
211
|
|
|
212
|
+
{/* 资产状态总览(参照 AI-Novel-Writing-Assistant 状态网格) */}
|
|
213
|
+
<div className={css.assetGrid}>
|
|
214
|
+
<div className={css.assetStat}>
|
|
215
|
+
<span className={css.assetStatLabel}>当前题材</span>
|
|
216
|
+
<span className={css.assetStatValue}>{assets.genre?.name ?? '未设置'}</span>
|
|
217
|
+
{assets.genre !== undefined && <span className={css.assetStatDetail} title={assets.genre.description}>{assets.genre.description}</span>}
|
|
218
|
+
</div>
|
|
219
|
+
<div className={css.assetStat}>
|
|
220
|
+
<span className={css.assetStatLabel}>主推进模式</span>
|
|
221
|
+
<span className={css.assetStatValue}>{assets.primaryProgression?.name ?? '未设置'}</span>
|
|
222
|
+
{assets.primaryProgression !== undefined && <span className={css.assetStatDetail} title={assets.primaryProgression.driver}>{assets.primaryProgression.driver}</span>}
|
|
223
|
+
</div>
|
|
224
|
+
<div className={css.assetStat}>
|
|
225
|
+
<span className={css.assetStatLabel}>已绑定写法</span>
|
|
226
|
+
<span className={css.assetStatValue}>{assets.styleAssets?.length ?? 0} 套</span>
|
|
227
|
+
<span className={css.assetStatDetail} title={(assets.styleAssets ?? []).map(s => s.name).join('、')}>
|
|
228
|
+
{(assets.styleAssets ?? []).map(s => s.name).join('、') || '未绑定(可在「预置写法」一键选用)'}
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
<div className={css.assetStat}>
|
|
232
|
+
<span className={css.assetStatLabel}>反 AI 规则</span>
|
|
233
|
+
<span className={css.assetStatValue}>{builtinRules.length} 内置 + {(assets.antiAiRules ?? []).length} 自定义</span>
|
|
234
|
+
<span className={css.assetStatDetail}>全部生效于生成与审稿提示词</span>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
168
238
|
{/* 子页签栏 */}
|
|
169
239
|
<div className={css.tabBar} role="tablist" style={{ padding: '0 0 8px', borderBottom: '1px solid var(--nf-border)' }}>
|
|
170
240
|
{SUB_TABS.map(tab => (
|
|
@@ -192,7 +262,8 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
192
262
|
<b>当前题材:{assets.genre.name}</b> — {assets.genre.description}
|
|
193
263
|
</div>
|
|
194
264
|
)}
|
|
195
|
-
|
|
265
|
+
{/* 题材基底库:自然展开,不套内部滚动条(描述两行截断,悬停看全文) */}
|
|
266
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
196
267
|
{genreLibrary.map(root => <GenreTree key={root.name} node={root} selected={assets.genre?.name ?? ''} onSelect={handleSelectGenre} />)}
|
|
197
268
|
</div>
|
|
198
269
|
</div>
|
|
@@ -273,20 +344,64 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
273
344
|
{assetTab === 'rules' && (
|
|
274
345
|
<div className={css.card}>
|
|
275
346
|
<span className={css.cardTitle}>反 AI 规则</span>
|
|
276
|
-
<span className={css.meta}>写作时必须遵守的表达边界(内置全局 +
|
|
347
|
+
<span className={css.meta}>写作时必须遵守的表达边界(内置全局 + 项目自定义),生成与审稿都会检查。内置规则可用「覆盖编辑」复制为自定义版本调整。</span>
|
|
277
348
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 280, overflowY: 'auto' }}>
|
|
278
|
-
{builtinRules.map(rule =>
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
<div
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
349
|
+
{builtinRules.map(rule => {
|
|
350
|
+
const overridden = customRules.some(r => r.name === rule.name)
|
|
351
|
+
return (
|
|
352
|
+
<div key={rule.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
|
|
353
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
|
354
|
+
<span>
|
|
355
|
+
<b>{rule.name}</b>{' '}
|
|
356
|
+
<span className={css.badge} style={{ borderColor: overridden ? 'var(--nf-accent)' : 'var(--nf-text-3)', color: overridden ? 'var(--nf-accent)' : 'var(--nf-text-3)' }}>
|
|
357
|
+
{overridden ? '自定义覆盖中' : '内置'}
|
|
358
|
+
</span>
|
|
359
|
+
</span>
|
|
360
|
+
<button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { handleOverrideBuiltin(rule) }}>
|
|
361
|
+
{overridden ? '✎ 编辑覆盖' : '+ 覆盖编辑'}
|
|
362
|
+
</button>
|
|
363
|
+
</div>
|
|
364
|
+
<div className={css.meta}>避免:{rule.avoid}</div>
|
|
365
|
+
<div className={css.meta}>修正:{rule.fix}</div>
|
|
366
|
+
</div>
|
|
367
|
+
)
|
|
368
|
+
})}
|
|
369
|
+
{customRules.map((rule, index) => (
|
|
370
|
+
<div key={`${rule.name}-${index}`} style={{ border: '1px solid var(--nf-accent)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
|
|
371
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
|
372
|
+
<span>
|
|
373
|
+
<b>{rule.name}</b>{' '}
|
|
374
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>自定义</span>
|
|
375
|
+
</span>
|
|
376
|
+
<span style={{ display: 'flex', gap: 6 }}>
|
|
377
|
+
<button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { setEditingRule({ index, name: rule.name, avoid: rule.avoid, fix: rule.fix ?? '' }) }}>
|
|
378
|
+
编辑
|
|
379
|
+
</button>
|
|
380
|
+
<button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRemoveRule(index) }}>
|
|
381
|
+
删除
|
|
382
|
+
</button>
|
|
383
|
+
</span>
|
|
384
|
+
</div>
|
|
385
|
+
{editingRule !== null && editingRule.index === index ? (
|
|
386
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
|
|
387
|
+
<input className={css.input} placeholder="规则名" value={editingRule.name} onChange={e => { setEditingRule({ ...editingRule, name: e.target.value }) }} />
|
|
388
|
+
<input className={css.input} placeholder="避免(要杜绝的表达)" value={editingRule.avoid} onChange={e => { setEditingRule({ ...editingRule, avoid: e.target.value }) }} />
|
|
389
|
+
<input className={css.input} placeholder="修正(改写方向,可留空)" value={editingRule.fix} onChange={e => { setEditingRule({ ...editingRule, fix: e.target.value }) }} />
|
|
390
|
+
<div className={css.row}>
|
|
391
|
+
<button className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || (editingRule.avoid.trim() === '' && editingRule.fix.trim() === '')} onClick={() => { void handleSaveRuleEdit() }}>
|
|
392
|
+
保存
|
|
393
|
+
</button>
|
|
394
|
+
<button className={`${css.button} ${css.buttonSmall}`} onClick={() => { setEditingRule(null) }}>
|
|
395
|
+
取消
|
|
396
|
+
</button>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
) : (
|
|
400
|
+
<>
|
|
401
|
+
<div className={css.meta}>避免:{rule.avoid}</div>
|
|
402
|
+
{rule.fix !== '' && <div className={css.meta}>修正:{rule.fix}</div>}
|
|
403
|
+
</>
|
|
404
|
+
)}
|
|
290
405
|
</div>
|
|
291
406
|
))}
|
|
292
407
|
</div>
|