@waterwx/dsh-novel-forge 1.3.3 → 1.4.1

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.
@@ -144,12 +144,14 @@ export const zh = {
144
144
  'settings.model': '模型',
145
145
  'settings.modelCustom': '自定义…',
146
146
  'settings.modelCustomPlaceholder': '输入任意模型 id',
147
- 'settings.reasoningEffort': '思考强度 (reasoningEffort)',
147
+ 'settings.reasoningEffort': '写作类任务思考强度 (reasoningEffort)',
148
+ 'settings.reasoningHint': '章节生成/润色/重写/审稿等写作任务使用;关掉更省更快。',
149
+ 'settings.analysisReasoning': '分析类任务思考强度(提炼/拆书/反推大纲)',
150
+ 'settings.analysisReasoningHint': '角色/场景/道藏提炼、拆书、反推大纲等一次性分析任务默认 low,保证质量,不受上面写作档位影响。',
148
151
  'settings.reasoning.off': 'off · 关闭思考(推荐日常写文)',
149
152
  'settings.reasoning.low': 'low · 低思考',
150
153
  'settings.reasoning.high': 'high · 高思考',
151
154
  'settings.reasoning.max': 'max · 最高思考(质检/推演)',
152
- 'settings.reasoningHint': 'off 最快最省;high/max 更慢更贵但推理更强',
153
155
  'settings.theme': '主题',
154
156
  'settings.themeLiquid': 'iOS 液态玻璃(绿)',
155
157
  'settings.themeClassic': '经典毛玻璃(蓝)',
@@ -295,7 +297,9 @@ export const en: Record<string, string> = {
295
297
  'settings.model': 'Model',
296
298
  'settings.modelCustom': 'Custom…',
297
299
  'settings.modelCustomPlaceholder': 'Type any model id',
298
- 'settings.reasoningEffort': 'Reasoning effort',
300
+ 'settings.reasoningEffort': 'Writing-task reasoning effort',
301
+ 'settings.analysisReasoning': 'Analysis-task reasoning (extract / breakdown / reverse outline)',
302
+ 'settings.analysisReasoningHint': 'One-off analysis tasks (roles/scenes/bible extract, breakdown, reverse outline) default to low; not affected by the writing-level switch.',
299
303
  'settings.reasoning.off': 'off · no thinking (recommended)',
300
304
  'settings.reasoning.low': 'low',
301
305
  'settings.reasoning.high': 'high',
@@ -0,0 +1,293 @@
1
+ /**
2
+ * 导入小说弹窗:两种模式 ——
3
+ * A) 已有项目目录(含 novel-project.json)→ 登记/激活书架(粘贴服务器本地路径);
4
+ * B) txt/md 全本 → 点击选择文件,浏览器读取内容 → 服务器拆章预览 → 确认后导入。
5
+ * Mode B 也保留「服务器本地路径」高级选项(大文件或服务器端已有文件时用)。
6
+ */
7
+ import { useRef, useState } from 'react'
8
+ import type { NovelApi } from '../api.ts'
9
+ import type { BookImportTextPreviewResponse } from '../../protocol.ts'
10
+ import css from './panel.module.css'
11
+
12
+ type ImportMode = 'dir' | 'text'
13
+
14
+ /** 导入结果(成功态展示)。 */
15
+ interface ImportResult {
16
+ kind: 'dir' | 'text'
17
+ bookName: string
18
+ existed?: boolean
19
+ chapters?: number
20
+ skipped?: string[]
21
+ }
22
+
23
+ export function ImportModal({
24
+ api,
25
+ onClose,
26
+ onImported,
27
+ }: {
28
+ api: NovelApi
29
+ onClose: () => void
30
+ /** 导入成功并已激活该书后回调(刷新书架)。 */
31
+ onImported: () => void | Promise<void>
32
+ }) {
33
+ const [mode, setMode] = useState<ImportMode>('dir')
34
+ // Mode A
35
+ const [dir, setDir] = useState('')
36
+ // Mode B:文件选择 + 预览
37
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
38
+ const [fileName, setFileName] = useState('')
39
+ const [fileText, setFileText] = useState<string | null>(null)
40
+ const [previewing, setPreviewing] = useState(false)
41
+ const [preview, setPreview] = useState<BookImportTextPreviewResponse | null>(null)
42
+ // Mode B:服务器本地路径(高级)
43
+ const [showPath, setShowPath] = useState(false)
44
+ const [filePath, setFilePath] = useState('')
45
+ // 通用
46
+ const [outDir, setOutDir] = useState('')
47
+ const [busy, setBusy] = useState(false)
48
+ const [error, setError] = useState('')
49
+ const [result, setResult] = useState<ImportResult | null>(null)
50
+
51
+ /** 选择文件 → 读取内容 → 调服务器预览拆章。 */
52
+ const onPickFile = async (file: File | undefined) => {
53
+ if (file === undefined) return
54
+ setError('')
55
+ setResult(null)
56
+ setPreview(null)
57
+ setFileName(file.name)
58
+ setPreviewing(true)
59
+ try {
60
+ const text = await file.text()
61
+ setFileText(text)
62
+ const p = await api.bookImportTextPreview(text, file.name)
63
+ setPreview(p)
64
+ } catch (err) {
65
+ setError((err as Error).message)
66
+ } finally {
67
+ setPreviewing(false)
68
+ }
69
+ }
70
+
71
+ const runImport = async () => {
72
+ setError('')
73
+ setResult(null)
74
+ if (mode === 'dir') {
75
+ const d = dir.trim()
76
+ if (d === '') { setError('请输入项目目录(含 novel-project.json 的文件夹)'); return }
77
+ setBusy(true)
78
+ try {
79
+ const r = await api.bookImportDir(d)
80
+ setResult({ kind: 'dir', bookName: r.book.bookName, existed: r.existed })
81
+ await onImported()
82
+ } catch (err) {
83
+ setError((err as Error).message)
84
+ } finally {
85
+ setBusy(false)
86
+ }
87
+ return
88
+ }
89
+ // Mode B:优先上传内容;未选文件时退回服务器路径
90
+ if (fileText !== null && fileText.length > 0) {
91
+ setBusy(true)
92
+ try {
93
+ const o = outDir.trim()
94
+ const r = await api.bookImportTextContent(fileText, fileName, o !== '' ? o : undefined)
95
+ setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
96
+ await onImported()
97
+ } catch (err) {
98
+ setError((err as Error).message)
99
+ } finally {
100
+ setBusy(false)
101
+ }
102
+ return
103
+ }
104
+ const f = filePath.trim()
105
+ if (f === '') { setError('请先选择文件,或输入服务器本地文件路径'); return }
106
+ setBusy(true)
107
+ try {
108
+ const o = outDir.trim()
109
+ const r = await api.bookImportText(f, o !== '' ? o : undefined)
110
+ setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
111
+ await onImported()
112
+ } catch (err) {
113
+ setError((err as Error).message)
114
+ } finally {
115
+ setBusy(false)
116
+ }
117
+ }
118
+
119
+ const switchMode = (m: ImportMode) => { setMode(m); setError(''); setResult(null); setPreview(null) }
120
+
121
+ return (
122
+ <div className={css.importModalOverlay} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
123
+ <div className={css.importModal}>
124
+ <div className={css.importModalHead}>
125
+ <span className={css.panelTitle} style={{ margin: 0 }}>📥 导入小说</span>
126
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={onClose} title="关闭">✕</button>
127
+ </div>
128
+
129
+ <div className={css.importModalTabs} role="tablist">
130
+ <button
131
+ type="button"
132
+ role="tab"
133
+ aria-selected={mode === 'dir'}
134
+ className={`${css.button} ${mode === 'dir' ? css.buttonPrimary : ''}`}
135
+ onClick={() => { switchMode('dir') }}
136
+ >
137
+ 📂 A · 已有项目目录
138
+ </button>
139
+ <button
140
+ type="button"
141
+ role="tab"
142
+ aria-selected={mode === 'text'}
143
+ className={`${css.button} ${mode === 'text' ? css.buttonPrimary : ''}`}
144
+ onClick={() => { switchMode('text') }}
145
+ >
146
+ 📄 B · txt/md 全本
147
+ </button>
148
+ </div>
149
+
150
+ <div className={css.importModalBody}>
151
+ {mode === 'dir' ? (
152
+ <>
153
+ <label className={css.importField}>
154
+ <span>项目目录(绝对路径)</span>
155
+ <input
156
+ className={css.input}
157
+ type="text"
158
+ placeholder="例如 D:\novels\我的小说"
159
+ value={dir}
160
+ onChange={e => { setDir(e.target.value) }}
161
+ />
162
+ </label>
163
+ <div className={css.importHint}>
164
+ 目录需含 novel-project.json。已在书架中的目录会直接切换激活,不会重复登记。
165
+ </div>
166
+ </>
167
+ ) : (
168
+ <>
169
+ {/* 文件选择 + 预览 */}
170
+ <input
171
+ ref={fileInputRef}
172
+ type="file"
173
+ accept=".txt,.md,text/plain,text/markdown"
174
+ style={{ display: 'none' }}
175
+ onChange={e => { void onPickFile(e.target.files?.[0]); e.target.value = '' }}
176
+ />
177
+ <div className={css.importField}>
178
+ <span>全本文本文件(txt / md)</span>
179
+ <button
180
+ type="button"
181
+ className={`${css.button} ${css.buttonPrimary}`}
182
+ onClick={() => { fileInputRef.current?.click() }}
183
+ >
184
+ 📄 选择文件
185
+ </button>
186
+ </div>
187
+ {fileName !== '' && (
188
+ <div className={css.importFileInfo}>
189
+ <span>已选择:{fileName}</span>
190
+ {previewing && <span className={css.meta}>正在拆章预览…</span>}
191
+ </div>
192
+ )}
193
+ {preview !== null && (
194
+ <div className={css.importPreview}>
195
+ <span>📖 《{preview.bookName}》 · 识别到 {preview.chapters.length} 章</span>
196
+ {preview.chapters.length > 0 && (
197
+ <ul className={css.importPreviewList}>
198
+ {preview.chapters.slice(0, 5).map(c => (
199
+ <li key={c.no}>第{c.no}章 {c.title}({c.chars} 字)</li>
200
+ ))}
201
+ {preview.chapters.length > 5 && <li className={css.meta}>… 共 {preview.chapters.length} 章</li>}
202
+ </ul>
203
+ )}
204
+ {preview.skipped.length > 0 && (
205
+ <span className={css.meta}>
206
+ 跳过 {preview.skipped.length} 个过短章节:{preview.skipped.slice(0, 5).join('、')}
207
+ {preview.skipped.length > 5 ? ' 等' : ''}
208
+ </span>
209
+ )}
210
+ </div>
211
+ )}
212
+
213
+ {/* 输出目录 + 高级路径选项 */}
214
+ <label className={css.importField}>
215
+ <span>输出目录(可选,默认 ~/.dsh/novels/书名)</span>
216
+ <input
217
+ className={css.input}
218
+ type="text"
219
+ placeholder="留空使用默认目录"
220
+ value={outDir}
221
+ onChange={e => { setOutDir(e.target.value) }}
222
+ />
223
+ </label>
224
+ <button
225
+ type="button"
226
+ className={`${css.button} ${css.buttonSmall}`}
227
+ onClick={() => { setShowPath(!showPath) }}
228
+ >
229
+ {showPath ? '▾ 收起高级选项' : '▸ 高级:使用服务器本地文件路径'}
230
+ </button>
231
+ {showPath && (
232
+ <label className={css.importField}>
233
+ <span>服务器本地文件绝对路径(大文件或服务器端已有文件时用)</span>
234
+ <input
235
+ className={css.input}
236
+ type="text"
237
+ placeholder="例如 D:\novels\全本.txt"
238
+ value={filePath}
239
+ onChange={e => { setFilePath(e.target.value) }}
240
+ />
241
+ </label>
242
+ )}
243
+ <div className={css.importHint}>
244
+ 服务器按「第X章/第X回/第X节/第X卷」、中文数字章节(一、二、三…)、序章/楔子/尾声、英文 Chapter N 拆章;正文过短(&lt;50 字)的章节会跳过并列出。
245
+ </div>
246
+ </>
247
+ )}
248
+
249
+ {error !== '' && <div className={css.importError}>{error}</div>}
250
+
251
+ {result !== null && (
252
+ <div className={css.importResult}>
253
+ {result.kind === 'dir' ? (
254
+ <>
255
+ <span>✅ 已{result.existed === true ? '重新激活' : '登记'}《{result.bookName}》</span>
256
+ <span className={css.meta}>
257
+ {result.existed === true
258
+ ? '该目录已在书架中,现已切换为当前书;工作台数据按该书目录加载。'
259
+ : '已加入书架并设为当前书,进入工作台即可看到全部模块。'
260
+ }
261
+ </span>
262
+ </>
263
+ ) : (
264
+ <>
265
+ <span>✅ 《{result.bookName}》导入完成:{result.chapters} 章</span>
266
+ {(result.skipped ?? []).length > 0 && (
267
+ <span className={css.meta}>
268
+ 跳过 {result.skipped!.length} 个过短章节:{result.skipped!.slice(0, 8).join('、')}
269
+ {result.skipped!.length > 8 ? ` 等 ${result.skipped!.length} 个` : ''}
270
+ </span>
271
+ )}
272
+ <span className={css.meta}>章节已按「written」登记,进入工作台后逐章审稿/补设定即可。</span>
273
+ </>
274
+ )}
275
+ </div>
276
+ )}
277
+ </div>
278
+
279
+ <div className={css.importModalActions}>
280
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={onClose}>关闭</button>
281
+ <button
282
+ type="button"
283
+ className={`${css.button} ${css.buttonPrimary}`}
284
+ disabled={busy}
285
+ onClick={runImport}
286
+ >
287
+ {busy ? '导入中…' : '开始导入'}
288
+ </button>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ )
293
+ }