@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.
@@ -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' | 'assets' | 'foreshadow' | 'assistant' | 'settings';
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). */
@@ -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: 'done';
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: 'done';
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
  /**
@@ -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": "0.1.0",
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
- // Summarize + re-review so the assistant can report quality.
243
- yield '(正在生成章节摘要…)'
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 !== '' && <span className={css.meta}> — {node.description}</span>}
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>('genre')
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
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 340, overflowY: 'auto' }}>
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}>写作时必须遵守的表达边界(内置全局 + 项目自定义),生成与审稿都会检查。</span>
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
- <div key={rule.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
280
- <b>{rule.name}</b> <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)' }}>内置</span>
281
- <div className={css.meta}>避免:{rule.avoid}</div>
282
- <div className={css.meta}>修正:{rule.fix}</div>
283
- </div>
284
- ))}
285
- {customRules.map(rule => (
286
- <div key={rule.name} style={{ border: '1px solid var(--nf-accent)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
287
- <b>{rule.name}</b> <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>自定义</span>
288
- <div className={css.meta}>避免:{rule.avoid}</div>
289
- {rule.fix !== '' && <div className={css.meta}>修正:{rule.fix}</div>}
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>