@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.
- package/README.md +16 -0
- package/lib/client.js +2056 -1832
- package/lib/client.js.map +1 -1
- package/lib/index.js +44 -13
- package/lib/index.js.map +1 -1
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/engine.d.ts +1 -1
- package/package.json +2 -2
- package/src/client/locales.ts +4 -0
- package/src/client/panel/NovelPanel.tsx +159 -19
- package/src/client/panel/panel.module.css +66 -5
- package/src/engine.ts +72 -10
- package/src/routes.ts +1 -1
|
@@ -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': "设置已保存";
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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(
|
|
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,
|
|
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
|
-
<
|
|
1618
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
1690
|
-
|
|
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}
|
|
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:
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
502
|
-
if (
|
|
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)
|