@waterwx/dsh-novel-forge 1.1.0 → 1.1.2

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.
@@ -107,6 +107,8 @@ export declare const zh: {
107
107
  readonly 'settings.chapterChars': "每章目标字数";
108
108
  readonly 'settings.maxTokens': "单章最大输出 tokens";
109
109
  readonly 'settings.reviewPassScore': "审稿通过分数(0-100)";
110
+ readonly 'settings.editorFontSize': "编辑器字号(仅编辑页显示)";
111
+ readonly 'settings.editorFontSizeHint': "存于浏览器本地,不影响其他设备";
110
112
  readonly 'settings.autoReview': "生成后自动审稿";
111
113
  readonly 'settings.save': "保存设置";
112
114
  readonly 'settings.saved': "设置已保存";
@@ -32,7 +32,7 @@ export declare function planVolumes(ctx: Context, config: NovelConfig, outline:
32
32
  /**
33
33
  * Plan chapters from an outline (optionally for one volume).
34
34
  */
35
- export declare function planChapters(ctx: Context, config: NovelConfig, project: ProjectState, chapterCount: number, volumeNo?: number): Promise<ChapterPlan[]>;
35
+ export declare function planChapters(ctx: Context, config: NovelConfig, project: ProjectState, chapterCount: number, volumeNo?: number, outputDir?: string): Promise<ChapterPlan[]>;
36
36
  /** Run the AI review on one chapter. */
37
37
  export declare function reviewChapter(ctx: Context, config: NovelConfig, project: ProjectState, outputDir: string, chapterNo: number): Promise<ReviewReport>;
38
38
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
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": "1.1.0",
4
+ "version": "1.1.2",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -90,4 +90,4 @@
90
90
  "typecheck": "tsc --noEmit",
91
91
  "test": "vitest run"
92
92
  }
93
- }
93
+ }
@@ -108,6 +108,8 @@ export const zh = {
108
108
  'settings.chapterChars': '每章目标字数',
109
109
  'settings.maxTokens': '单章最大输出 tokens',
110
110
  'settings.reviewPassScore': '审稿通过分数(0-100)',
111
+ 'settings.editorFontSize': '编辑器字号(仅编辑页显示)',
112
+ 'settings.editorFontSizeHint': '存于浏览器本地,不影响其他设备',
111
113
  'settings.autoReview': '生成后自动审稿',
112
114
  'settings.save': '保存设置',
113
115
  'settings.saved': '设置已保存',
@@ -240,6 +242,8 @@ export const en: Record<string, string> = {
240
242
  'settings.chapterChars': 'Chars per chapter',
241
243
  'settings.maxTokens': 'Max output tokens',
242
244
  'settings.reviewPassScore': 'Review pass score (0-100)',
245
+ 'settings.editorFontSize': 'Editor font size (workspace only)',
246
+ 'settings.editorFontSizeHint': 'Stored in browser localStorage; display-only',
243
247
  'settings.autoReview': 'Auto-review after writing',
244
248
  'settings.save': 'Save settings',
245
249
  'settings.saved': 'Settings saved',
@@ -92,6 +92,12 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
92
92
  /** Settings tab — pinned to the bottom of the nav rail. */
93
93
  const SETTINGS_TAB: { id: NovelTab; label: string; icon: string } = { id: 'settings', label: tt('tab.settings'), icon: '⚙️' }
94
94
 
95
+ /** 构建时注入的插件版本(tsdown define 替换为字符串字面量)。 */
96
+ declare const __NOVEL_FORGE_VERSION__: string | undefined
97
+ const PLUGIN_VERSION: string = typeof __NOVEL_FORGE_VERSION__ !== 'undefined' ? __NOVEL_FORGE_VERSION__ : '0.0.0'
98
+ /** GitHub 仓库地址(关于区块点击跳转)。 */
99
+ const REPO_URL = 'https://github.com/watersxya/dsh-novel-forge'
100
+
95
101
  /** Whether any chapter is being generated right now. */
96
102
  function anyGenerating(chapters: ChapterPlan[] | undefined): boolean {
97
103
  return (chapters ?? []).some(c => c.status === 'generating' || c.status === 'reviewing')
@@ -213,7 +219,7 @@ function renderBeats(beats: string): ReactElement {
213
219
  }
214
220
 
215
221
  /** The diff list of a draft-vs-original comparison (scrollable). */
216
- function DiffList({ original, draft }: { original: string; draft: string }): ReactElement { const rows = useMemo(() => paragraphDiff(original, draft), [original, draft])
222
+ function DiffList({ original, draft, fontSize }: { original: string; draft: string; fontSize?: number }): ReactElement { const rows = useMemo(() => paragraphDiff(original, draft), [original, draft])
217
223
  const changed = rows.filter(r => r.kind === 'change').length
218
224
  const added = rows.filter(r => r.kind === 'add').length
219
225
  const removed = rows.filter(r => r.kind === 'del').length
@@ -236,7 +242,7 @@ function DiffList({ original, draft }: { original: string; draft: string }): Rea
236
242
  只看改动
237
243
  </label>
238
244
  </div>
239
- <div className={css.diffList}>
245
+ <div className={css.diffList} style={fontSize !== undefined ? { fontSize } : undefined}>
240
246
  {shown.map((row, idx) => {
241
247
  if (row.kind === 'same') {
242
248
  return (
@@ -327,6 +333,20 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
327
333
  const wsEditorRef = useRef<HTMLTextAreaElement | null>(null)
328
334
  /** 工作区:手动编辑后的 AI 审查结果(不落盘)。 */
329
335
  const [wsCheckReport, setWsCheckReport] = useState<ReviewReport | null>(null)
336
+ /** 手动审查结果中作者勾选要修复的问题(issue 下标)。 */
337
+ const [wsChecked, setWsChecked] = useState<number[]>([])
338
+ /** 编辑页字号(localStorage 记忆,仅影响显示)。 */
339
+ const [editorFontSize, setEditorFontSize] = useState<number>(() => {
340
+ try {
341
+ const v = Number(window.localStorage.getItem('dsh-novel-forge.editor.fontSize'))
342
+ return v >= 12 && v <= 24 ? v : 14
343
+ } catch { return 14 }
344
+ })
345
+ const changeEditorFontSize = (next: number): void => {
346
+ const v = Math.min(24, Math.max(12, next))
347
+ setEditorFontSize(v)
348
+ try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
349
+ }
330
350
  /** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
331
351
  const [draftNo, setDraftNo] = useState<number | null>(null)
332
352
  /** 大纲页「更新大纲」编辑区是否展开。 */
@@ -347,6 +367,20 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
347
367
  const coverFileRef = useRef<HTMLInputElement | null>(null)
348
368
  /** 章节列表按卷折叠(存已折叠的卷号)。 */
349
369
  const [collapsedVolumes, setCollapsedVolumes] = useState<number[]>([])
370
+ /** npm 最新版本(更新检测;null = 未检测/检测失败)。 */
371
+ const [npmLatest, setNpmLatest] = useState<string | null>(null)
372
+
373
+ /** 后台检测 npm 最新版本(失败静默,不打扰)。 */
374
+ useEffect(() => {
375
+ let cancelled = false
376
+ void fetch('https://registry.npmjs.org/@waterwx%2Fdsh-novel-forge')
377
+ .then(response => response.json() as Promise<{ 'dist-tags'?: { latest?: string } }>)
378
+ .then(data => {
379
+ if (!cancelled && data['dist-tags']?.latest !== undefined) setNpmLatest(data['dist-tags'].latest)
380
+ })
381
+ .catch(() => { /* best-effort */ })
382
+ return () => { cancelled = true }
383
+ }, [])
350
384
  /** AI 助手悬浮窗:是否打开。 */
351
385
  const [assistantOpen, setAssistantOpen] = useState(false)
352
386
  /** 悬浮窗位置(相对面板,localStorage 记忆)。 */
@@ -756,6 +790,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
756
790
  try {
757
791
  const result = await api.chapterCheck(workspace.no, workspace.original)
758
792
  setWsCheckReport(result.report)
793
+ // 默认勾选 high 问题(无 high 则勾选全部 medium),作者可自行增删。
794
+ const highIdx = result.report.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'high').map(x => x.i)
795
+ const mediumIdx = result.report.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'medium').map(x => x.i)
796
+ setWsChecked(highIdx.length > 0 ? highIdx : mediumIdx)
759
797
  pushProgress(`审查完成:${result.report.score} 分 — ${result.report.verdict}`, result.report.passed ? 'done' : 'error')
760
798
  } catch (err) {
761
799
  setError((err as Error).message)
@@ -781,6 +819,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
781
819
  setWorkspace(null)
782
820
  setDraftNo(null)
783
821
  setWsCheckReport(null)
822
+ setWsChecked([])
784
823
  if (report !== undefined) {
785
824
  pushProgress(`已保存并审稿:${report.score} 分 — ${report.verdict}(${report.passed ? '通过' : '未通过'})`, report.passed ? 'done' : 'error')
786
825
  } else {
@@ -879,14 +918,23 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
879
918
  setError('')
880
919
  try {
881
920
  const result = await api.plan(outlineText || undefined, planCount)
921
+ let freshCount = 0
882
922
  setProject(prev => {
883
923
  const base = prev ?? {
884
924
  bookName: '', outline: outlineText, chapters: [] as ChapterPlan[],
885
925
  foreshadows: [] as Foreshadow[], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
886
926
  }
887
- return { ...base, chapters: [...base.chapters, ...result.chapters], updatedAt: new Date().toISOString() }
927
+ // 追加时按标题去重,避免重复生成计划导致剧情「重头再来」。
928
+ const existingTitles = new Set(base.chapters.map(c => c.title))
929
+ const fresh = result.chapters.filter(c => !existingTitles.has(c.title))
930
+ freshCount = fresh.length
931
+ if (fresh.length === 0) return base
932
+ return { ...base, chapters: [...base.chapters, ...fresh], updatedAt: new Date().toISOString() }
888
933
  })
889
- pushProgress(tt('workflow.planDone', { n: result.chapters.length }), 'done')
934
+ pushProgress(tt('workflow.planDone', { n: freshCount }), 'done')
935
+ if (freshCount < result.chapters.length) {
936
+ pushProgress(`已跳过 ${result.chapters.length - freshCount} 个与已有章节同名的重复章节`, 'error')
937
+ }
890
938
  } catch (err) {
891
939
  setError((err as Error).message)
892
940
  pushProgress(`生成章节计划失败:${(err as Error).message}`, 'error')
@@ -918,7 +966,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
918
966
  draft: chapter.pendingDraft !== undefined && chapter.pendingDraft !== '' ? chapter.pendingDraft : null,
919
967
  })
920
968
  setWsSelected('')
921
- setWsShowDiff(false)
969
+ setWsShowDiff(true)
970
+ setWsCheckReport(null)
971
+ setWsChecked([])
922
972
  } catch { /* best-effort */ }
923
973
  }, [api])
924
974
 
@@ -951,14 +1001,15 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
951
1001
  }
952
1002
 
953
1003
  /** 工作区:按指令修订(whole=true 整章,false 仅修订选中片段)。 */
954
- const handleWsRewrite = async (whole: boolean): Promise<void> => {
1004
+ const handleWsRewrite = async (whole: boolean, overrideInstruction?: string): Promise<void> => {
955
1005
  if (workspace === null) return
956
1006
  const target = whole ? '' : wsSelected
1007
+ const instruction = overrideInstruction ?? workspace.instruction
957
1008
  setBusy(true)
958
1009
  setBusyLabel(`${tt('plan.rewrite')} 第${workspace.no}章`)
959
1010
  setError('')
960
1011
  try {
961
- await api.rewrite(workspace.no, workspace.instruction, target, frame => { applyJobFrame(frame, n => tt('progress.rewriting', { no: n })) })
1012
+ await api.rewrite(workspace.no, instruction, target, frame => { applyJobFrame(frame, n => tt('progress.rewriting', { no: n })) })
962
1013
  } catch (err) {
963
1014
  setError((err as Error).message)
964
1015
  pushProgress(`第 ${workspace.no} 章修订失败:${(err as Error).message}`, 'error')
@@ -969,6 +1020,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
969
1020
  }
970
1021
  }
971
1022
 
1023
+ /** 工作区:按审查报告中勾选的问题一键修订(预填指令 + 立即整章修订到草稿)。 */
1024
+ const handleWsReviseByReport = async (): Promise<void> => {
1025
+ if (workspace === null || wsCheckReport === null) return
1026
+ const picked = wsChecked
1027
+ .map(i => wsCheckReport.issues[i])
1028
+ .filter((it): it is ReviewReport['issues'][number] => it !== undefined)
1029
+ .slice(0, 5)
1030
+ if (picked.length === 0) return
1031
+ const instruction = '按审稿意见修订(优先处理):\n' + picked.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
1032
+ setWorkspace({ ...workspace, instruction })
1033
+ await handleWsRewrite(true, instruction)
1034
+ }
1035
+
972
1036
  /** 采纳草稿:覆盖正文文件并刷新。 */
973
1037
  const handleDraftApply = async (no: number): Promise<void> => {
974
1038
  setBusy(true)
@@ -1575,6 +1639,32 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1575
1639
  <span className={css.navTabIcon}>{SETTINGS_TAB.icon}</span>
1576
1640
  {!navCollapsed && <span className={css.navTabLabel}>{SETTINGS_TAB.label}</span>}
1577
1641
  </button>
1642
+ {/* 关于:版本 + GitHub + 更新检测 */}
1643
+ <div className={css.navAbout}>
1644
+ <button
1645
+ type="button"
1646
+ className={css.navAboutRow}
1647
+ title="打开 GitHub 仓库"
1648
+ onClick={() => { window.open(REPO_URL, '_blank', 'noopener') }}
1649
+ >
1650
+ <span>ℹ️ v{PLUGIN_VERSION}</span>
1651
+ <span className={css.meta}>GitHub ↗</span>
1652
+ </button>
1653
+ {npmLatest !== null && npmLatest !== PLUGIN_VERSION && (
1654
+ <button
1655
+ type="button"
1656
+ className={css.navAboutUpdate}
1657
+ title="查看更新方法"
1658
+ onClick={() => {
1659
+ window.alert(
1660
+ `检测到新版本 v${npmLatest}(当前 v${PLUGIN_VERSION})\n\n更新方式:\n\n【npm 安装】\ncd ~/.dsh/profiles/web && pnpm add @waterwx/dsh-novel-forge@latest\n然后重启 dsh web\n\n【GitHub 安装】\ndsh plugin --profile web add github:watersxya/dsh-novel-forge\n\n【本地开发】\n拉取最新代码 → pnpm install && pnpm build → 重启 dsh web`,
1661
+ )
1662
+ }}
1663
+ >
1664
+ 📦 有新版本 v{npmLatest}
1665
+ </button>
1666
+ )}
1667
+ </div>
1578
1668
  </nav>
1579
1669
  <div className={css.panelContent}>
1580
1670
  {error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
@@ -1610,12 +1700,21 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1610
1700
  </div>
1611
1701
  )}
1612
1702
 
1613
- {/* 润色/修订工作区:左栏原文(可选中局部目标)+ 右栏指令/预览/应用 */}
1703
+ {/* 章节编辑页(独占整页:点「编辑」进入,返回后回到原页面) */}
1614
1704
  {workspace !== null && (
1615
1705
  <div className={css.card} style={{ borderColor: 'var(--nf-accent)' }}>
1616
- <div className={css.busyRow}>
1617
- <span className={css.cardTitle}>第 {workspace.no} 章《{workspace.title}》润色/修订</span>
1618
- <button type="button" className={css.iconButton} title="关闭工作区" aria-label="关闭工作区" onClick={() => { setWorkspace(null) }}>×</button>
1706
+ <div className={css.busyRow} style={{ flexWrap: 'wrap', gap: 8 }}>
1707
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorkspace(null) }} title="返回章节列表(草稿不丢失)">
1708
+ ← 返回
1709
+ </button>
1710
+ <span className={css.cardTitle}>第 {workspace.no} 章《{workspace.title}》</span>
1711
+ <span className={css.meta}>{workspace.original.length} 字</span>
1712
+ <span style={{ display: 'flex', gap: 4, alignItems: 'center', marginLeft: 'auto' }}>
1713
+ <button type="button" className={css.iconButton} title="减小字号" aria-label="减小字号" onClick={() => { changeEditorFontSize(editorFontSize - 1) }}>A−</button>
1714
+ <span className={css.meta}>{editorFontSize}px</span>
1715
+ <button type="button" className={css.iconButton} title="增大字号" aria-label="增大字号" onClick={() => { changeEditorFontSize(editorFontSize + 1) }}>A+</button>
1716
+ <button type="button" className={css.iconButton} title="关闭工作区" aria-label="关闭工作区" onClick={() => { setWorkspace(null) }}>×</button>
1717
+ </span>
1619
1718
  </div>
1620
1719
  <div className={css.wsColumns}>
1621
1720
  <div className={css.wsColumn}>
@@ -1625,6 +1724,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1625
1724
  <textarea
1626
1725
  ref={wsEditorRef}
1627
1726
  className={`${css.textarea} ${css.wsEditor}`}
1727
+ style={{ fontSize: editorFontSize }}
1628
1728
  value={workspace.original}
1629
1729
  onChange={e => { setWorkspace({ ...workspace, original: e.target.value }) }}
1630
1730
  onMouseUp={captureWsSelection}
@@ -1651,7 +1751,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1651
1751
  <div className={css.meta}>未选中内容时仅支持整章润色/修订。</div>
1652
1752
  )}
1653
1753
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
1654
- {workspace.instruction.startsWith('按审稿意见修订') && (
1754
+ {workspace.instruction.includes('按审稿意见修订') && (
1655
1755
  <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWsRewrite(true) }} title="AI 按已预填的审稿意见自动修订整章(无需自己找问题)">
1656
1756
  🔧 按意见修订
1657
1757
  </button>
@@ -1683,16 +1783,40 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1683
1783
  </div>
1684
1784
  <div className={css.meta}><b>总评:</b>{wsCheckReport.verdict}</div>
1685
1785
  {wsCheckReport.issues.length > 0 && (
1686
- <ul style={{ margin: 0, paddingLeft: 16, fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
1786
+ <ul style={{ margin: 0, paddingLeft: 16, display: 'flex', flexDirection: 'column', gap: 3, fontSize: editorFontSize - 1, maxHeight: '45vh', overflowY: 'auto' }}>
1687
1787
  {wsCheckReport.issues.map((issue, i) => (
1688
- <li key={i} style={{ color: severityColor(issue.severity) }}>
1689
- [{issue.severity}] {issue.item}
1690
- {issue.suggestion !== '' && <span style={{ color: 'var(--nf-text-2)' }}> → {issue.suggestion}</span>}
1788
+ <li key={i} style={{ color: severityColor(issue.severity), display: 'flex', gap: 6, alignItems: 'flex-start' }}>
1789
+ <input
1790
+ type="checkbox"
1791
+ style={{ marginTop: 1 }}
1792
+ checked={wsChecked.includes(i)}
1793
+ onChange={e => {
1794
+ setWsChecked(prev => e.target.checked ? [...prev, i] : prev.filter(x => x !== i))
1795
+ }}
1796
+ title="勾选后可由「修复所选问题」一起修订"
1797
+ />
1798
+ <span>
1799
+ [{issue.severity}] {issue.item}
1800
+ {issue.suggestion !== '' && <span style={{ color: 'var(--nf-text-2)' }}> → {issue.suggestion}</span>}
1801
+ </span>
1691
1802
  </li>
1692
1803
  ))}
1693
1804
  </ul>
1694
1805
  )}
1695
- <span className={css.meta}>审查只读不落盘;改完点「💾 保存为正文」才写入文件(原稿自动备份 .bak)。</span>
1806
+ <span className={css.meta}>审查只读不落盘;勾选要修的问题,点下方按钮一键修订;改完点「💾 保存为正文」才写入文件(原稿自动备份 .bak)。</span>
1807
+ {wsCheckReport.issues.length > 0 && wsChecked.length > 0 && (
1808
+ <div style={{ marginTop: 8 }}>
1809
+ <button
1810
+ type="button"
1811
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
1812
+ disabled={busy}
1813
+ onClick={() => { void handleWsReviseByReport() }}
1814
+ title="自动按勾选的问题修订整章,产出草稿预览(不落盘),对比后应用或放弃"
1815
+ >
1816
+ 🔧 修复所选问题({wsChecked.length})
1817
+ </button>
1818
+ </div>
1819
+ )}
1696
1820
  </div>
1697
1821
  )}
1698
1822
  {workspace.draft !== null && (
@@ -1712,8 +1836,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1712
1836
  </span>
1713
1837
  </div>
1714
1838
  {wsShowDiff
1715
- ? <DiffList original={workspace.original} draft={workspace.draft} />
1716
- : <pre className={css.wsPreviewText}>{workspace.draft}</pre>}
1839
+ ? <DiffList original={workspace.original} draft={workspace.draft} fontSize={editorFontSize} />
1840
+ : <pre className={css.wsPreviewText} style={{ fontSize: editorFontSize }}>{workspace.draft}</pre>}
1717
1841
  </div>
1718
1842
  )}
1719
1843
  </div>
@@ -1721,6 +1845,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1721
1845
  </div>
1722
1846
  )}
1723
1847
 
1848
+ {/* 编辑页打开时独占整页:隐藏其余所有页面内容 */}
1849
+ {workspace === null && (<>
1724
1850
  {activeTab === 'workflow' && (
1725
1851
  <>
1726
1852
  {/* ⭐ 主行动大卡片 */}
@@ -2533,6 +2659,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2533
2659
  <label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
2534
2660
  <input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
2535
2661
  </div>
2662
+ <div className={css.field} style={{ flex: 1 }}>
2663
+ <label className={css.fieldLabel}>{tt('settings.editorFontSize')}</label>
2664
+ <select
2665
+ className={css.input}
2666
+ value={editorFontSize}
2667
+ onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
2668
+ >
2669
+ {[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
2670
+ <option key={v} value={v}>{v}px</option>
2671
+ ))}
2672
+ </select>
2673
+ <span className={css.meta}>{tt('settings.editorFontSizeHint')}</span>
2674
+ </div>
2536
2675
  <div className={css.field} style={{ flex: 1 }}>
2537
2676
  <label className={css.fieldLabel}>{tt('settings.autoReview')}</label>
2538
2677
  <select
@@ -2584,6 +2723,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2584
2723
  )}
2585
2724
  </div>
2586
2725
  )}
2726
+ </>)}
2587
2727
  </div>
2588
2728
  </div>
2589
2729
  </>
@@ -295,6 +295,67 @@ html[data-dsh-novelforge-active]:not([data-dsh-taskboard-active]):not([data-dsh-
295
295
  min-height: 8px;
296
296
  }
297
297
 
298
+ /* 导航底部:关于(版本 + GitHub + 更新) */
299
+ .navAbout {
300
+ flex: none;
301
+ display: flex;
302
+ flex-direction: column;
303
+ gap: 4px;
304
+ padding: 8px 6px 4px;
305
+ border-top: 1px solid var(--nf-border);
306
+ margin-top: 4px;
307
+ }
308
+
309
+ .navAboutRow {
310
+ display: flex;
311
+ align-items: center;
312
+ justify-content: space-between;
313
+ gap: 4px;
314
+ border: none;
315
+ background: transparent;
316
+ cursor: pointer;
317
+ font-size: 11px;
318
+ color: var(--nf-text-3);
319
+ padding: 4px 6px;
320
+ border-radius: 8px;
321
+ transition: color 0.15s ease, background 0.15s ease;
322
+ text-align: left;
323
+ white-space: nowrap;
324
+ }
325
+
326
+ .navAboutRow:hover {
327
+ color: var(--nf-text);
328
+ background: var(--nf-hover);
329
+ }
330
+
331
+ .navAboutUpdate {
332
+ border: none;
333
+ background: var(--nf-accent-soft);
334
+ color: var(--nf-accent);
335
+ cursor: pointer;
336
+ font-size: 10.5px;
337
+ font-weight: 600;
338
+ padding: 4px 6px;
339
+ border-radius: 8px;
340
+ text-align: left;
341
+ white-space: nowrap;
342
+ }
343
+
344
+ .navAboutUpdate:hover {
345
+ color: var(--nf-accent-hover);
346
+ }
347
+
348
+ .panelNavCollapsed .navAbout {
349
+ padding: 6px 2px 2px;
350
+ align-items: center;
351
+ }
352
+
353
+ .panelNavCollapsed .navAboutRow {
354
+ justify-content: center;
355
+ padding: 4px 0;
356
+ width: 100%;
357
+ }
358
+
298
359
  .navTabBadge {
299
360
  margin-left: auto;
300
361
  flex: none;
@@ -1009,7 +1070,7 @@ html[data-dsh-novelforge-active]:not([data-dsh-taskboard-active]):not([data-dsh-
1009
1070
  }
1010
1071
 
1011
1072
  .diffList {
1012
- max-height: 460px;
1073
+ max-height: 70vh;
1013
1074
  overflow-y: auto;
1014
1075
  border: 1px solid var(--nf-border);
1015
1076
  border-radius: 8px;
@@ -1132,10 +1193,10 @@ html[data-dsh-novelforge-active]:not([data-dsh-taskboard-active]):not([data-dsh-
1132
1193
  color: var(--nf-success);
1133
1194
  }
1134
1195
 
1135
- /* Revision workspace: left original editor + right instruction/preview. */
1196
+ /* Revision workspace: left original editor + right instruction/preview, 1:1. */
1136
1197
  .wsColumns {
1137
1198
  display: grid;
1138
- grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
1199
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
1139
1200
  gap: 14px;
1140
1201
  }
1141
1202
 
@@ -1148,7 +1209,7 @@ html[data-dsh-novelforge-active]:not([data-dsh-taskboard-active]):not([data-dsh-
1148
1209
 
1149
1210
  .wsEditor {
1150
1211
  flex: 1;
1151
- min-height: 340px;
1212
+ min-height: 50vh;
1152
1213
  resize: vertical;
1153
1214
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1154
1215
  font-size: 13px;
@@ -1184,7 +1245,7 @@ html[data-dsh-novelforge-active]:not([data-dsh-taskboard-active]):not([data-dsh-
1184
1245
 
1185
1246
  .wsPreviewText {
1186
1247
  margin: 0;
1187
- max-height: 320px;
1248
+ max-height: 55vh;
1188
1249
  overflow: auto;
1189
1250
  font-size: 13px;
1190
1251
  line-height: 1.7;
package/src/engine.ts CHANGED
@@ -255,7 +255,17 @@ function parseJson<T>(text: string, wantArray: boolean): T {
255
255
  for (const candidate of candidates) {
256
256
  for (const attempt of [candidate, repair(candidate)]) {
257
257
  try {
258
- return JSON.parse(attempt) as T
258
+ const value = JSON.parse(attempt) as unknown
259
+ if (!wantArray || Array.isArray(value)) return value as T
260
+ // wantArray but the model wrapped the list in an object, e.g.
261
+ // {"chapters": [...]} — extract the first array-valued key.
262
+ if (typeof value === 'object' && value !== null) {
263
+ for (const key of Object.keys(value as Record<string, unknown>)) {
264
+ const inner = (value as Record<string, unknown>)[key]
265
+ if (Array.isArray(inner)) return inner as T
266
+ }
267
+ }
268
+ // Not an array — keep trying the remaining candidates.
259
269
  } catch {
260
270
  // try the next candidate
261
271
  }
@@ -475,31 +485,83 @@ export async function planChapters(
475
485
  project: ProjectState,
476
486
  chapterCount: number,
477
487
  volumeNo?: number,
488
+ outputDir?: string,
478
489
  ): Promise<ChapterPlan[]> {
479
490
  const volume = project.volumes?.find(v => v.no === volumeNo)
491
+ const existing = project.chapters
492
+ const startNo = existing.length === 0 ? 1 : Math.max(...existing.map(c => c.no)) + 1
493
+ const continuation = existing.length > 0
494
+ const latestFacts = continuation && Array.isArray(project.facts)
495
+ ? project.facts.slice(-15).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 150)}`).join('\n')
496
+ : ''
497
+ // 上一章(已写章节中章号最大者)结尾原文,作为续写剧情起点。
498
+ let prevTail = ''
499
+ if (continuation) {
500
+ const written = existing.filter(c => c.status !== 'pending')
501
+ const last = written[written.length - 1]
502
+ if (last !== undefined && last.file !== undefined && outputDir !== undefined) {
503
+ try {
504
+ const raw = readFileSync(join(outputDir, last.file), 'utf8')
505
+ prevTail = raw.replace(/^#.*$/m, '').trim().slice(-600)
506
+ } catch { /* 文件缺失时忽略,仅依赖编年录 */ }
507
+ }
508
+ }
509
+ // 续写模式下大纲截断到「关键剧情桥段」之前:只保留设定(人设/金手指/规则/战力),
510
+ // 去掉分卷主线步骤模板,避免模型照抄已完成的剧情再走一遍。
511
+ const outlineBlock = continuation
512
+ ? (() => {
513
+ const cut = project.outline.indexOf('七、关键剧情桥段')
514
+ if (cut > 1500) return project.outline.slice(0, cut).trimEnd() + '\n(大纲后续剧情桥段与分卷细节从略;续写请以「上一章结尾原文」与「最新剧情状态」为剧情起点)'
515
+ return project.outline.slice(0, 3000) + '\n…(大纲过长已节选)'
516
+ })()
517
+ : project.outline
480
518
  const user = [
481
519
  '请为下面这部小说规划章节。',
482
520
  volume !== undefined
483
521
  ? `本次只规划第 ${volume.no} 卷《${volume.title}》的章节:\n${volume.summary}`
484
- : '请规划全书开篇章节。',
485
- `大纲如下:\n${project.outline}`,
522
+ : continuation
523
+ ? `本书已有 ${existing.length} 章已规划/已写作(见下方「已有章节」)。请规划**后续**章节:从第 ${startNo} 章开始。`
524
+ : '请规划全书开篇章节。',
525
+ continuation
526
+ ? '【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。以下情节均已在已有章节中发生过,后续章节**绝对不得再次出现**:穿越、暴雨送餐、滴血认主/古玉认主、首次进入墟境、用废铁淬炼首件法器、绝境肉身入鼎洗炼(该机缘已用尽)、杀死白袍弟子与灰衣随从、藏尸水沟。'
527
+ : '',
528
+ prevTail !== ''
529
+ ? `【上一章(第 ${startNo - 1} 章)结尾原文】第 ${startNo} 章必须紧接此状态继续,从新的事件写起,不得回顾重述:\n${prevTail}`
530
+ : '',
531
+ latestFacts !== ''
532
+ ? `【最新剧情状态(本书编年录,第 ${startNo - 1} 章结尾的事实)】规划续写时必须以此为起点,时间线、人物状态与地点衔接一致:\n${latestFacts}`
533
+ : '',
534
+ continuation
535
+ ? '已有章节:\n'
536
+ + existing.map(c => {
537
+ const sm = c.summary !== undefined && c.summary !== '' ? `(${c.summary.slice(0, 120)})` : ''
538
+ return `第${c.no}章《${c.title}》${sm}`
539
+ }).join('\n')
540
+ : '',
541
+ `全书大纲(设定参考,续写剧情不得与设定冲突):\n${outlineBlock}`,
486
542
  '',
487
543
  `请规划 ${chapterCount} 章。输出 JSON 数组(不要输出其他文字):`,
488
544
  ].join('\n')
489
- const text = await complete(ctx, config, { system: planSystemPrompt(project.volumes), user, temperature: 0.7, maxTokens: Math.max(config.maxTokens, 20000) })
545
+ const system = planSystemPrompt(project.volumes) + (continuation
546
+ ? '\n重要:本次是**续写规划**——已有章节的剧情不得重写或重复,新章节标题不得与已有章节标题相同,新章节的剧情必须从上一章结尾自然接续(人物状态、时间线、地点衔接一致)。'
547
+ : '')
548
+ const text = await complete(ctx, config, { system, user, temperature: 0.7, maxTokens: Math.max(config.maxTokens, 40000) })
490
549
  const parsed = parseJsonArray<Record<string, unknown>>(text)
491
550
  const chapters: ChapterPlan[] = []
492
- const existing = new Set(project.chapters.map(c => c.no))
493
- const startNo = project.chapters.length + 1
494
- for (let i = 0; i < Math.min(parsed.length, chapterCount); i++) {
495
- const item = parsed[i]
551
+ const existingNos = new Set(existing.map(c => c.no))
552
+ const existingTitles = new Set(existing.map(c => c.title))
553
+ let cursor = startNo
554
+ for (const item of parsed) {
555
+ if (chapters.length >= chapterCount) break
496
556
  if (typeof item !== 'object' || item === null) continue
497
557
  const entry = item as Record<string, unknown>
498
558
  const title = typeof entry.title === 'string' ? entry.title.trim().slice(0, 30) : ''
499
559
  const beats = typeof entry.beats === 'string' ? entry.beats.trim() : ''
500
560
  if (title === '' && beats === '') continue
501
- const no = startNo + i
502
- if (existing.has(no)) continue
561
+ // 续写模式下,标题与已有章节重复的一律丢弃(模型可能复述旧章节)。
562
+ if (title !== '' && existingTitles.has(title)) continue
563
+ while (existingNos.has(cursor)) cursor++
564
+ const no = cursor++
503
565
  chapters.push({
504
566
  no,
505
567
  volume: volumeOf(no, project.volumes),
package/src/routes.ts CHANGED
@@ -363,7 +363,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
363
363
  }
364
364
  try {
365
365
  const next = project ?? createProject(outline)
366
- const chapters: ChapterPlan[] = await planChapters(ctx, config, next, count, body?.volume)
366
+ const chapters: ChapterPlan[] = await planChapters(ctx, config, next, count, body?.volume, config.outputDir)
367
367
  next.chapters.push(...chapters)
368
368
  next.updatedAt = new Date().toISOString()
369
369
  saveProject(config.outputDir, next)