@waterwx/dsh-novel-forge 1.1.7 → 1.1.9

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/src/client/api.ts CHANGED
@@ -75,6 +75,26 @@ export class NovelApi {
75
75
  return postJson<{ ok: boolean; bookName: string }>(NOVEL_API.saveOutline, { text })
76
76
  }
77
77
 
78
+ /** 开书想法 → AI 大纲:生成 count 个方案(换批时传 exclude 避开已暂留方向)。 */
79
+ async outlineSuggest(idea: string, count?: number, exclude?: string[]): Promise<import('../protocol.ts').OutlineSuggestResponse> {
80
+ return postJson<import('../protocol.ts').OutlineSuggestResponse>(NOVEL_API.outlineSuggest, { idea, count, exclude })
81
+ }
82
+
83
+ /** 拆书分析:对已写章节做结构/人物/文风/卖点体检。 */
84
+ async breakdown(scope?: string, preset?: 'quick' | 'standard', budgetTokens?: number): Promise<import('../protocol.ts').BreakdownResponse> {
85
+ return postJson<import('../protocol.ts').BreakdownResponse>(NOVEL_API.breakdown, { scope, preset, budgetTokens })
86
+ }
87
+
88
+ /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表。 */
89
+ async storyboard(chapterNo: number, genre?: string, platform?: string, tool?: string): Promise<import('../protocol.ts').StoryboardResponse> {
90
+ return postJson<import('../protocol.ts').StoryboardResponse>(NOVEL_API.storyboard, { chapterNo, genre, platform, tool })
91
+ }
92
+
93
+ /** 漫剧分集计划:读一卷 → 按故事弧线分集。 */
94
+ async storyboardPlan(volumeNo: number, platform?: string, maxEpisodes?: number): Promise<import('../protocol.ts').StoryboardPlanResponse> {
95
+ return postJson<import('../protocol.ts').StoryboardPlanResponse>(NOVEL_API.storyboardPlan, { volumeNo, platform, maxEpisodes })
96
+ }
97
+
78
98
  async plan(outline?: string, chapterCount?: number, volume?: number): Promise<PlanResponse> {
79
99
  return postJson<PlanResponse>(NOVEL_API.plan, { outline, chapterCount, volume })
80
100
  }
@@ -108,9 +128,9 @@ export class NovelApi {
108
128
  return readJson<ChapterResponse>(response)
109
129
  }
110
130
 
111
- /** 审查手动编辑的正文(不落盘)。 */
112
- async chapterCheck(no: number, text: string): Promise<{ report: ReviewReport }> {
113
- return postJson<{ report: ReviewReport }>(NOVEL_API.chapterCheck, { chapterNo: no, text })
131
+ /** 审查手动编辑的正文(不落盘)。previousReport 传入时走「验证模式」(核对原意见解决 + 只挑新增 high)。 */
132
+ async chapterCheck(no: number, text: string, previousReport?: ReviewReport): Promise<{ report: ReviewReport }> {
133
+ return postJson<{ report: ReviewReport }>(NOVEL_API.chapterCheck, { chapterNo: no, text, previousReport })
114
134
  }
115
135
 
116
136
  /** 保存手动编辑的正文(自动备份 .bak;带报告则沿用落盘,否则保存后自动审稿)。 */
@@ -234,6 +254,29 @@ export class NovelApi {
234
254
  return postJson<import('../protocol.ts').BookshelfSnapshot>('/api/dsh-novel-forge/bookshelf/remove', { id })
235
255
  }
236
256
 
257
+ /** 生产单:启动批量生产(区间或新增 N 章;计划不足自动补)。 */
258
+ async runStart(req: import('../protocol.ts').RunStartRequest): Promise<import('../protocol.ts').RunState> {
259
+ return postJson<import('../protocol.ts').RunState>(NOVEL_API.runStart, req)
260
+ }
261
+
262
+ /** 生产单控制:pause / resume / stop。 */
263
+ async runControl(action: 'pause' | 'resume' | 'stop'): Promise<import('../protocol.ts').RunState | null> {
264
+ const response = await fetch(NOVEL_API.runControl, {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({ action }),
268
+ })
269
+ if (response.status === 400) return null
270
+ return readJson<import('../protocol.ts').RunState>(response)
271
+ }
272
+
273
+ /** 生产单状态(无生产单返回 null)。 */
274
+ async runStatus(): Promise<import('../protocol.ts').RunState | null> {
275
+ const response = await fetch(NOVEL_API.runStatus)
276
+ if (response.status === 404) return null
277
+ return readJson<import('../protocol.ts').RunState | null>(response)
278
+ }
279
+
237
280
  /** Get project writing assets + built-in libraries. */
238
281
  async assets(): Promise<AssetsResponse> {
239
282
  const response = await fetch(NOVEL_API.assets)
@@ -307,9 +350,10 @@ export class NovelApi {
307
350
  await this.streamJob(NOVEL_API.polish, { chapterNo }, onFrame)
308
351
  }
309
352
 
310
- /** 采纳待确认草稿(润色/重写产物),覆盖正文文件。 */
311
- async draftApply(chapterNo: number): Promise<{ ok: boolean; chars: number; file: string }> {
312
- return postJson<{ ok: boolean; chars: number; file: string }>(NOVEL_API.draftApply, { chapterNo })
353
+ /** 采纳待确认草稿(润色/重写产物),覆盖正文文件。返回采纳后的新正文(markdown)。
354
+ * 可携带审查报告(沿用结论定状态:通过 → approved)。 */
355
+ async draftApply(chapterNo: number, report?: import('../protocol.ts').ReviewReport): Promise<{ ok: boolean; chars: number; file: string; markdown: string }> {
356
+ return postJson<{ ok: boolean; chars: number; file: string; markdown: string }>(NOVEL_API.draftApply, { chapterNo, report })
313
357
  }
314
358
 
315
359
  /** 放弃待确认草稿,保留原稿。 */
@@ -155,6 +155,8 @@ export const zh = {
155
155
  'settings.autoReview': '生成后自动审稿',
156
156
  'settings.autoAuthorReview': '生成后自动作者复盘',
157
157
  'settings.autoAuthorReviewHint': '复盘钩子兑现/结尾钩子/剧情线推进/连续性/节奏趋势,每章约 2000 token',
158
+ 'settings.autoReviewAfterRevise': '修订后自动审查',
159
+ 'settings.autoReviewAfterReviseHint': '按意见修订/润色产出草稿后自动附带一次 AI 审查,直接显示新稿评分与剩余问题(每章约 2-3k token,可关)',
158
160
  'settings.save': '保存设置',
159
161
  'settings.saved': '设置已保存',
160
162
  'settings.openFolder': '打开输出文件夹',
@@ -296,6 +298,8 @@ export const en: Record<string, string> = {
296
298
  'settings.autoReview': 'Auto-review after writing',
297
299
  'settings.autoAuthorReview': 'Auto author review after writing',
298
300
  'settings.autoAuthorReviewHint': 'Reviews hook payoff / ending hook / plotline progress / continuity / pacing trend (~2000 tokens per chapter)',
301
+ 'settings.autoReviewAfterRevise': 'Auto review after revise',
302
+ 'settings.autoReviewAfterReviseHint': 'Automatically runs one AI review on the produced draft after revise/polish, showing the new score and remaining issues (~2-3k tokens, can be disabled)',
299
303
  'settings.save': 'Save settings',
300
304
  'settings.saved': 'Settings saved',
301
305
  'settings.openFolder': 'Open output folder',
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * 开书向导:独立页面视图 —— 书名 + 大纲(选择 docx / 拖拽 / 粘贴),
3
3
  * 实时书名识别与题材提示,开书即建项目并进入工作台。
4
+ * 另含「想法 → AI 大纲」:输入一句话想法生成 2-3 个方案,可暂留换批,选中后回填大纲框。
4
5
  */
5
6
  import { useRef, useState } from 'react'
6
7
  import type { NovelApi } from '../api.ts'
8
+ import type { OutlineCandidate } from '../../protocol.ts'
7
9
  import { extractDocxTextFromBuffer } from '../docx.ts'
8
10
  import css from './panel.module.css'
9
11
 
@@ -51,6 +53,15 @@ export function CreateBookView({
51
53
  const [error, setError] = useState('')
52
54
  const outlineFileRef = useRef<HTMLInputElement | null>(null)
53
55
 
56
+ // ---- 想法 → AI 大纲状态 ----
57
+ const [ideaOpen, setIdeaOpen] = useState(false)
58
+ const [idea, setIdea] = useState('')
59
+ const [suggesting, setSuggesting] = useState(false)
60
+ const [candidates, setCandidates] = useState<OutlineCandidate[]>([])
61
+ const [pinned, setPinned] = useState<string[]>([])
62
+ /** 回填后提示(如「已填入方案A,可继续修改」)。 */
63
+ const [fillNotice, setFillNotice] = useState('')
64
+
54
65
  const autoName = inferBookNamePreview(outlineText)
55
66
  const effectiveName = name.trim() !== '' ? name.trim() : autoName
56
67
  const genre = guessGenre(outlineText)
@@ -73,6 +84,47 @@ export function CreateBookView({
73
84
  }
74
85
  }
75
86
 
87
+ /** 生成/换批:只补未暂留的空槽;exclude 传已暂留方案的卖点方向。 */
88
+ const handleSuggest = async (): Promise<void> => {
89
+ if (idea.trim().length < 10) {
90
+ setError('想法太短(<10 字):至少写一句完整想法,如「男主穿越修仙界靠做菜无敌」')
91
+ return
92
+ }
93
+ setSuggesting(true)
94
+ setError('')
95
+ try {
96
+ const exclude = candidates
97
+ .filter(c => pinned.includes(c.id))
98
+ .map(c => `${c.bookName}:${c.sellingPoint}${c.genre !== '' ? `(${c.genre})` : ''}`)
99
+ const count = Math.max(1, 3 - pinned.length)
100
+ const result = await api.outlineSuggest(idea.trim(), count, exclude)
101
+ // 换批合并:已暂留的原地保留,新生成的补充进空槽。
102
+ setCandidates(prev => {
103
+ const kept = prev.filter(c => pinned.includes(c.id))
104
+ return [...kept, ...result.candidates].slice(0, 3)
105
+ })
106
+ setFillNotice('')
107
+ } catch (err) {
108
+ setError((err as Error).message)
109
+ } finally {
110
+ setSuggesting(false)
111
+ }
112
+ }
113
+
114
+ /** 暂留/取消暂留。 */
115
+ const togglePin = (id: string): void => {
116
+ setPinned(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
117
+ }
118
+
119
+ /** 选中方案:回填大纲框 + 书名,滚动到上方。 */
120
+ const handlePick = (candidate: OutlineCandidate): void => {
121
+ setOutlineText(candidate.outline)
122
+ setName(candidate.bookName)
123
+ setFillNotice(`已填入方案《${candidate.bookName}》(${candidate.genre}),可继续修改后开书`)
124
+ setIdeaOpen(false)
125
+ window.scrollTo({ top: 0, behavior: 'smooth' })
126
+ }
127
+
76
128
  const handleCreate = async (): Promise<void> => {
77
129
  if (effectiveName === '') {
78
130
  setError('请填写书名,或提供大纲自动识别')
@@ -114,6 +166,12 @@ export function CreateBookView({
114
166
  </div>
115
167
  )}
116
168
 
169
+ {fillNotice !== '' && (
170
+ <div className={css.card} style={{ borderColor: 'var(--nf-success)', padding: '8px 12px' }}>
171
+ <span style={{ color: 'var(--nf-success)', fontSize: 12 }}>✅ {fillNotice}</span>
172
+ </div>
173
+ )}
174
+
117
175
  <div className={css.field}>
118
176
  <label className={css.fieldLabel}>书名</label>
119
177
  <input
@@ -171,6 +229,107 @@ export function CreateBookView({
171
229
  </div>
172
230
  )}
173
231
 
232
+ {/* 想法 → AI 大纲(可折叠) */}
233
+ <div className={css.ideaCard}>
234
+ <button
235
+ type="button"
236
+ className={css.ideaToggle}
237
+ onClick={() => { setIdeaOpen(v => !v) }}
238
+ aria-expanded={ideaOpen}
239
+ >
240
+ ✨ {ideaOpen ? '▾' : '▸'} 没有大纲?用一句话想法让 AI 生成
241
+ </button>
242
+ {ideaOpen && (
243
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
244
+ <textarea
245
+ className={css.textarea}
246
+ style={{ minHeight: 60 }}
247
+ placeholder="例如:现代外卖员被雷劈穿越到修仙世界,靠祖传古玉捡漏发育,苟着苟着成了大佬…(≥50 字)"
248
+ value={idea}
249
+ onChange={e => { setIdea(e.target.value) }}
250
+ spellCheck={false}
251
+ />
252
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
253
+ <button
254
+ type="button"
255
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
256
+ disabled={suggesting || idea.trim().length < 10}
257
+ onClick={() => { void handleSuggest() }}
258
+ title="生成 3 个方向不同的大纲方案供选择(约消耗 6-8k token)"
259
+ >
260
+ {suggesting ? '⏳ 生成中…' : candidates.length === 0 ? '✨ 生成大纲方案' : `↻ 换一批(${Math.max(1, 3 - pinned.length)} 个)`}
261
+ </button>
262
+ {candidates.length > 0 && (
263
+ <span className={css.meta}>
264
+ 已暂留 {pinned.length}/3 · 换批保留已暂留,只补新方案
265
+ </span>
266
+ )}
267
+ </div>
268
+
269
+ {/* 方案卡 */}
270
+ {candidates.length > 0 && (
271
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
272
+ {candidates.map(candidate => {
273
+ const isPinned = pinned.includes(candidate.id)
274
+ return (
275
+ <div
276
+ key={candidate.id}
277
+ className={css.ideaCandidate}
278
+ style={isPinned ? { borderColor: 'var(--nf-accent)', boxShadow: '0 0 0 2px var(--nf-accent-soft)' } : undefined}
279
+ >
280
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', gap: 6 }}>
281
+ <span style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
282
+ <b>《{candidate.bookName}》</b>
283
+ {candidate.genre !== '' && <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{candidate.genre}</span>}
284
+ {isPinned && <span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)' }}>★ 已暂留</span>}
285
+ </span>
286
+ <span style={{ display: 'flex', gap: 6 }}>
287
+ <button
288
+ type="button"
289
+ className={`${css.button} ${css.buttonSmall} ${isPinned ? '' : css.buttonPrimary}`}
290
+ onClick={() => { togglePin(candidate.id) }}
291
+ title={isPinned ? '取消暂留:下次换批会覆盖此槽位' : '暂留此方案:换批时保留,继续对比新方案'}
292
+ >
293
+ {isPinned ? '★ 取消暂留' : '☆ 暂留'}
294
+ </button>
295
+ <button
296
+ type="button"
297
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
298
+ onClick={() => { handlePick(candidate) }}
299
+ title="将这份大纲填入上方大纲框(可继续修改后开书)"
300
+ >
301
+ 选这个
302
+ </button>
303
+ </span>
304
+ </div>
305
+ {candidate.sellingPoint !== '' && (
306
+ <div className={css.meta}><b>卖点:</b>{candidate.sellingPoint}</div>
307
+ )}
308
+ <div
309
+ className={css.meta}
310
+ style={{
311
+ display: '-webkit-box',
312
+ WebkitLineClamp: 4,
313
+ WebkitBoxOrient: 'vertical',
314
+ overflow: 'hidden',
315
+ whiteSpace: 'pre-wrap',
316
+ }}
317
+ title={candidate.outline}
318
+ >
319
+ {candidate.outline}
320
+ </div>
321
+ </div>
322
+ )
323
+ })}
324
+ {pinned.length >= 3 && (
325
+ <span className={css.meta}>已暂留全部方案——直接挑一个「选这个」即可;换批需先取消某个暂留。</span>
326
+ )}
327
+ </div>
328
+ )}
329
+ </div>
330
+ )}
331
+ </div>
332
+
174
333
  <button
175
334
  type="button"
176
335
  className={`${css.button} ${css.buttonPrimary}`}