@waterwx/dsh-novel-forge 0.1.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.
- package/LICENSE +202 -0
- package/README.md +180 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +3924 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3120 -0
- package/lib/index.js.map +1 -0
- package/lib/types/assets.d.ts +35 -0
- package/lib/types/assistant.d.ts +43 -0
- package/lib/types/bookshelf.d.ts +35 -0
- package/lib/types/client/api.d.ts +68 -0
- package/lib/types/client/docx.d.ts +15 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/locales.d.ts +139 -0
- package/lib/types/client/mount.d.ts +9 -0
- package/lib/types/client/panel/AssetsTab.d.ts +7 -0
- package/lib/types/client/panel/AssistantTab.d.ts +7 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +11 -0
- package/lib/types/client/panel/NovelPanel.d.ts +13 -0
- package/lib/types/client/panel/controller.d.ts +19 -0
- package/lib/types/client/panel/helpers.d.ts +8 -0
- package/lib/types/client/sidebar-entry.d.ts +13 -0
- package/lib/types/docx.d.ts +19 -0
- package/lib/types/engine.d.ts +95 -0
- package/lib/types/index.d.ts +55 -0
- package/lib/types/protocol.d.ts +521 -0
- package/lib/types/routes.d.ts +29 -0
- package/package.json +105 -0
- package/src/assets.ts +518 -0
- package/src/assistant.ts +547 -0
- package/src/bookshelf.ts +137 -0
- package/src/client/api.ts +254 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/docx.ts +69 -0
- package/src/client/index.ts +34 -0
- package/src/client/locales.ts +271 -0
- package/src/client/mount.tsx +97 -0
- package/src/client/panel/AssetsTab.tsx +341 -0
- package/src/client/panel/AssistantTab.tsx +188 -0
- package/src/client/panel/BookshelfBar.tsx +116 -0
- package/src/client/panel/NovelPanel.tsx +990 -0
- package/src/client/panel/controller.ts +45 -0
- package/src/client/panel/helpers.ts +17 -0
- package/src/client/panel/panel.module.css +894 -0
- package/src/client/sidebar-entry.ts +122 -0
- package/src/docx.ts +83 -0
- package/src/engine.ts +1019 -0
- package/src/index.ts +184 -0
- package/src/protocol.ts +539 -0
- package/src/routes.ts +955 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side API client for the /api/dsh-novel-forge route family. Plain
|
|
3
|
+
* fetch, same origin; generation/rewrite/polish ride NDJSON streams read
|
|
4
|
+
* incrementally.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
NOVEL_API,
|
|
9
|
+
type AssetsPatch,
|
|
10
|
+
type AssetsResponse,
|
|
11
|
+
type BibleResponse,
|
|
12
|
+
type ChapterResponse,
|
|
13
|
+
type ConfigPatch,
|
|
14
|
+
type ExportResponse,
|
|
15
|
+
type ForeshadowRequest,
|
|
16
|
+
type ForeshadowResponse,
|
|
17
|
+
type JobFrame,
|
|
18
|
+
type LoadOutlineResponse,
|
|
19
|
+
type NovelConfig,
|
|
20
|
+
type PlanResponse,
|
|
21
|
+
type ReviewReport,
|
|
22
|
+
type StatusResponse,
|
|
23
|
+
type StyleEngineRequest,
|
|
24
|
+
type StyleAsset,
|
|
25
|
+
type VolumesResponse,
|
|
26
|
+
} from '../protocol.ts'
|
|
27
|
+
|
|
28
|
+
/** Error carrying the route's JSON error message. */
|
|
29
|
+
export class NovelApiError extends Error {
|
|
30
|
+
constructor(message: string) {
|
|
31
|
+
super(message)
|
|
32
|
+
this.name = 'NovelApiError'
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parse a JSON response or throw a NovelApiError. */
|
|
37
|
+
async function readJson<T>(response: Response): Promise<T> {
|
|
38
|
+
let body: unknown
|
|
39
|
+
try {
|
|
40
|
+
body = await response.json()
|
|
41
|
+
} catch {
|
|
42
|
+
throw new NovelApiError(`HTTP ${response.status}: invalid JSON response`)
|
|
43
|
+
}
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
const message = typeof body === 'object' && body !== null && typeof (body as { error?: unknown }).error === 'string'
|
|
46
|
+
? (body as { error: string }).error
|
|
47
|
+
: `HTTP ${response.status}`
|
|
48
|
+
throw new NovelApiError(message)
|
|
49
|
+
}
|
|
50
|
+
return body as T
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** POST JSON, return parsed JSON. */
|
|
54
|
+
async function postJson<T>(path: string, payload: unknown): Promise<T> {
|
|
55
|
+
const response = await fetch(path, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'content-type': 'application/json' },
|
|
58
|
+
body: JSON.stringify(payload),
|
|
59
|
+
})
|
|
60
|
+
return readJson<T>(response)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The browser half's only data entry point. */
|
|
64
|
+
export class NovelApi {
|
|
65
|
+
async status(): Promise<StatusResponse> {
|
|
66
|
+
const response = await fetch(NOVEL_API.status)
|
|
67
|
+
return readJson<StatusResponse>(response)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async loadOutline(path?: string, text?: string): Promise<LoadOutlineResponse> {
|
|
71
|
+
return postJson<LoadOutlineResponse>(NOVEL_API.loadOutline, { path, text })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async saveOutline(text: string): Promise<{ ok: boolean; bookName: string }> {
|
|
75
|
+
return postJson<{ ok: boolean; bookName: string }>(NOVEL_API.saveOutline, { text })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async plan(outline?: string, chapterCount?: number, volume?: number): Promise<PlanResponse> {
|
|
79
|
+
return postJson<PlanResponse>(NOVEL_API.plan, { outline, chapterCount, volume })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async volumes(outline?: string): Promise<VolumesResponse> {
|
|
83
|
+
return postJson<VolumesResponse>(NOVEL_API.volumes, { outline })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async bible(outline?: string): Promise<BibleResponse> {
|
|
87
|
+
return postJson<BibleResponse>(NOVEL_API.bible, { outline })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async review(chapterNo: number): Promise<{ report: ReviewReport }> {
|
|
91
|
+
return postJson<{ report: ReviewReport }>(NOVEL_API.review, { chapterNo })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async summarize(chapterNo: number): Promise<{ summary: string }> {
|
|
95
|
+
return postJson<{ summary: string }>(NOVEL_API.summary, { chapterNo })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async foreshadow(req: ForeshadowRequest): Promise<ForeshadowResponse> {
|
|
99
|
+
return postJson<ForeshadowResponse>(NOVEL_API.foreshadow, req)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async exportBook(format: 'txt' | 'md'): Promise<ExportResponse> {
|
|
103
|
+
return postJson<ExportResponse>(NOVEL_API.exportBook, { format })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async chapter(no: number): Promise<ChapterResponse> {
|
|
107
|
+
const response = await fetch(`${NOVEL_API.chapter}?no=${no}`)
|
|
108
|
+
return readJson<ChapterResponse>(response)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async patchConfig(patch: ConfigPatch): Promise<{ config: NovelConfig }> {
|
|
112
|
+
return postJson<{ config: NovelConfig }>(NOVEL_API.config, patch)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async openFolder(): Promise<void> {
|
|
116
|
+
await fetch(NOVEL_API.openFolder, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 书架快照。 */
|
|
120
|
+
async bookshelf(): Promise<import('../protocol.ts').BookshelfSnapshot> {
|
|
121
|
+
const response = await fetch(NOVEL_API.bookshelf)
|
|
122
|
+
return readJson<import('../protocol.ts').BookshelfSnapshot>(response)
|
|
123
|
+
}
|
|
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 })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 切换当前书。 */
|
|
131
|
+
async bookActivate(id: string): Promise<import('../protocol.ts').BookshelfSnapshot> {
|
|
132
|
+
return postJson<import('../protocol.ts').BookshelfSnapshot>('/api/dsh-novel-forge/bookshelf/activate', { id })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 移除书架条目。 */
|
|
136
|
+
async bookRemove(id: string): Promise<import('../protocol.ts').BookshelfSnapshot> {
|
|
137
|
+
return postJson<import('../protocol.ts').BookshelfSnapshot>('/api/dsh-novel-forge/bookshelf/remove', { id })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Get project writing assets + built-in libraries. */
|
|
141
|
+
async assets(): Promise<AssetsResponse> {
|
|
142
|
+
const response = await fetch(NOVEL_API.assets)
|
|
143
|
+
return readJson<AssetsResponse>(response)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Patch project writing assets. */
|
|
147
|
+
async patchAssets(patch: AssetsPatch): Promise<AssetsResponse> {
|
|
148
|
+
return postJson<AssetsResponse>(NOVEL_API.assets, patch)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Extract a style asset from sample text. */
|
|
152
|
+
async styleEngine(req: StyleEngineRequest): Promise<{ styleAsset: StyleAsset }> {
|
|
153
|
+
return postJson<{ styleAsset: StyleAsset }>(NOVEL_API.styleEngine, req)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Consume an NDJSON job stream (generate / rewrite / polish).
|
|
158
|
+
* @param path - the route to POST to.
|
|
159
|
+
* @param payload - the JSON body.
|
|
160
|
+
* @param onFrame - receives every frame as it lands.
|
|
161
|
+
*/
|
|
162
|
+
private async streamJob(path: string, payload: unknown, onFrame: (frame: JobFrame) => void): Promise<void> {
|
|
163
|
+
const response = await fetch(path, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'content-type': 'application/json' },
|
|
166
|
+
body: JSON.stringify(payload),
|
|
167
|
+
})
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
await readJson<{ error?: string }>(response)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (response.body === null) throw new NovelApiError('job: no response body')
|
|
173
|
+
const reader = response.body.getReader()
|
|
174
|
+
const decoder = new TextDecoder()
|
|
175
|
+
let buffer = ''
|
|
176
|
+
for (;;) {
|
|
177
|
+
const { done, value } = await reader.read()
|
|
178
|
+
if (done) break
|
|
179
|
+
buffer += decoder.decode(value, { stream: true })
|
|
180
|
+
const lines = buffer.split('\n')
|
|
181
|
+
buffer = lines.pop() ?? ''
|
|
182
|
+
for (const line of lines) {
|
|
183
|
+
if (line.trim() === '') continue
|
|
184
|
+
let frame: JobFrame
|
|
185
|
+
try {
|
|
186
|
+
frame = JSON.parse(line) as JobFrame
|
|
187
|
+
} catch {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
onFrame(frame)
|
|
191
|
+
if (frame.type === 'error') {
|
|
192
|
+
throw new NovelApiError(frame.message)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Generate one chapter. */
|
|
199
|
+
async generate(chapterNo: number, skipReview: boolean, onFrame: (frame: JobFrame) => void): Promise<void> {
|
|
200
|
+
await this.streamJob(NOVEL_API.generate, { chapterNo, skipReview }, onFrame)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Rewrite one chapter (whole-chapter, or local when `target` is given). */
|
|
204
|
+
async rewrite(chapterNo: number, instructions: string, target: string, onFrame: (frame: JobFrame) => void): Promise<void> {
|
|
205
|
+
await this.streamJob(NOVEL_API.rewrite, { chapterNo, instructions, target }, onFrame)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Polish (de-AI-ify) one chapter. */
|
|
209
|
+
async polish(chapterNo: number, onFrame: (frame: JobFrame) => void): Promise<void> {
|
|
210
|
+
await this.streamJob(NOVEL_API.polish, { chapterNo }, onFrame)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Run one assistant turn (NDJSON stream). */
|
|
214
|
+
async assistant(message: string, onFrame: (frame: import('../protocol.ts').AssistantFrame) => void): Promise<void> {
|
|
215
|
+
const response = await fetch(NOVEL_API.assistant, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: { 'content-type': 'application/json' },
|
|
218
|
+
body: JSON.stringify({ message }),
|
|
219
|
+
})
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
await readJson<{ error?: string }>(response)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
if (response.body === null) throw new NovelApiError('assistant: no response body')
|
|
225
|
+
const reader = response.body.getReader()
|
|
226
|
+
const decoder = new TextDecoder()
|
|
227
|
+
let buffer = ''
|
|
228
|
+
for (;;) {
|
|
229
|
+
const { done, value } = await reader.read()
|
|
230
|
+
if (done) break
|
|
231
|
+
buffer += decoder.decode(value, { stream: true })
|
|
232
|
+
const lines = buffer.split('\n')
|
|
233
|
+
buffer = lines.pop() ?? ''
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
if (line.trim() === '') continue
|
|
236
|
+
let frame: import('../protocol.ts').AssistantFrame
|
|
237
|
+
try {
|
|
238
|
+
frame = JSON.parse(line) as import('../protocol.ts').AssistantFrame
|
|
239
|
+
} catch {
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
onFrame(frame)
|
|
243
|
+
if (frame.type === 'error') throw new NovelApiError(frame.message)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Load the persisted assistant conversation. */
|
|
249
|
+
async assistantHistory(): Promise<import('../protocol.ts').AssistantMessage[]> {
|
|
250
|
+
const response = await fetch(NOVEL_API.assistantHistory)
|
|
251
|
+
const body = await readJson<{ messages: import('../protocol.ts').AssistantMessage[] }>(response)
|
|
252
|
+
return body.messages
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side docx outline extraction: a .docx is a zip whose
|
|
3
|
+
* word/document.xml holds the body text in <w:t> runs inside <w:p> paragraphs.
|
|
4
|
+
* Uses fflate (inlined into the client bundle) so the user can pick or drag a
|
|
5
|
+
* docx without any server upload.
|
|
6
|
+
*
|
|
7
|
+
* Import from 'fflate/browser' (not 'fflate'): the default entry resolves to
|
|
8
|
+
* the Node build (esm/index.mjs), which calls module.createRequire() for the
|
|
9
|
+
* optional worker_threads path — inlining that into the browser bundle leaves
|
|
10
|
+
* a bare require("module") the client-modules table cannot answer.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { unzipSync, strFromU8 } from 'fflate/browser'
|
|
14
|
+
|
|
15
|
+
/** Decode the handful of XML entities docx bodies actually use. */
|
|
16
|
+
function decodeEntities(text: string): string {
|
|
17
|
+
return text
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, "'")
|
|
22
|
+
.replace(/&/g, '&')
|
|
23
|
+
.replace(/ /g, ' ')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Extract plain text from a docx buffer: one line per <w:p> paragraph. */
|
|
27
|
+
export function extractDocxTextFromBuffer(buffer: ArrayBuffer | Uint8Array): string {
|
|
28
|
+
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
|
|
29
|
+
let files: ReturnType<typeof unzipSync>
|
|
30
|
+
try {
|
|
31
|
+
files = unzipSync(bytes)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`不是有效的 docx(zip 解压失败):${(error as Error).message}`)
|
|
34
|
+
}
|
|
35
|
+
const document = files['word/document.xml']
|
|
36
|
+
if (document === undefined) {
|
|
37
|
+
throw new Error('不是有效的 docx(缺少 word/document.xml)')
|
|
38
|
+
}
|
|
39
|
+
const xml = strFromU8(document)
|
|
40
|
+
|
|
41
|
+
const paragraphs: string[] = []
|
|
42
|
+
const parts = xml.split(/<w:p\b[^>]*>/)
|
|
43
|
+
for (let i = 1; i < parts.length; i++) {
|
|
44
|
+
const segment = parts[i]!
|
|
45
|
+
const runs: string[] = []
|
|
46
|
+
const runRe = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\b[^>]*\/>|<w:br\b[^>]*\/>/g
|
|
47
|
+
for (const match of segment.matchAll(runRe)) {
|
|
48
|
+
if (match[0].startsWith('<w:tab')) {
|
|
49
|
+
runs.push('\t')
|
|
50
|
+
} else if (match[0].startsWith('<w:br')) {
|
|
51
|
+
runs.push('\n')
|
|
52
|
+
} else {
|
|
53
|
+
runs.push(decodeEntities(match[1] ?? ''))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
paragraphs.push(runs.join('').replace(/\u00a0/g, ' ').trimEnd())
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const text = paragraphs.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
60
|
+
if (text.length === 0) {
|
|
61
|
+
throw new Error('docx 中没有可提取的文本')
|
|
62
|
+
}
|
|
63
|
+
return text
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Read a File as ArrayBuffer. */
|
|
67
|
+
export function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
|
68
|
+
return file.arrayBuffer()
|
|
69
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-half entry for the dsh-novel-forge plugin — runs inside the dsh web
|
|
3
|
+
* GUI. Registers the sidebar entry row and the workbench panel. DOM mounting
|
|
4
|
+
* problems are logged, never thrown — the web shell fails the whole boot when
|
|
5
|
+
* a plugin apply throws.
|
|
6
|
+
*/
|
|
7
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
8
|
+
import { NovelApi } from './api.ts'
|
|
9
|
+
import { mountPanel } from './mount.tsx'
|
|
10
|
+
import { PanelController } from './panel/controller.ts'
|
|
11
|
+
import { mountSidebarEntry } from './sidebar-entry.ts'
|
|
12
|
+
|
|
13
|
+
/** Required services (fiber inject waiting). */
|
|
14
|
+
export const inject = ['slots', 'locale']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mount the novel-forge workbench.
|
|
18
|
+
* @param ctx - client root context.
|
|
19
|
+
*/
|
|
20
|
+
export function apply(ctx: ClientContext): void {
|
|
21
|
+
const controller = new PanelController()
|
|
22
|
+
const api = new NovelApi()
|
|
23
|
+
const disposers: Array<() => void> = []
|
|
24
|
+
try {
|
|
25
|
+
disposers.push(mountSidebarEntry(controller))
|
|
26
|
+
disposers.push(mountPanel(controller, api))
|
|
27
|
+
} catch (error) {
|
|
28
|
+
// DOM failures degrade the panel, never the GUI.
|
|
29
|
+
console.warn('[dsh-novel-forge] mount failed:', error)
|
|
30
|
+
}
|
|
31
|
+
ctx.effect(() => () => {
|
|
32
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
33
|
+
}, 'dsh-novel-forge: ui mounts')
|
|
34
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-novel-forge — locale dictionaries (zh / en).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** zh dictionary. */
|
|
6
|
+
export const zh = {
|
|
7
|
+
'entry.label': '小说工坊',
|
|
8
|
+
'entry.tooltip': 'AI 编译小说工作台:大纲 → 设定圣经 → 卷计划 → 章节计划 → 逐章生成+审稿',
|
|
9
|
+
'panel.title': '小说工坊',
|
|
10
|
+
'common.close': '关闭',
|
|
11
|
+
'common.loading': '加载中…',
|
|
12
|
+
'common.save': '保存',
|
|
13
|
+
'common.error': '错误',
|
|
14
|
+
'common.success': '成功',
|
|
15
|
+
'common.generating': '生成中…',
|
|
16
|
+
'common.chars': '字',
|
|
17
|
+
'tab.workflow': '工作流',
|
|
18
|
+
'tab.overview': '大纲',
|
|
19
|
+
'tab.plan': '章节',
|
|
20
|
+
'tab.bible': '设定库',
|
|
21
|
+
'tab.foreshadow': '伏笔',
|
|
22
|
+
'tab.assistant': 'AI 助手',
|
|
23
|
+
'tab.settings': '设置',
|
|
24
|
+
'workflow.title': '创作工作流',
|
|
25
|
+
'workflow.step1': '① 加载大纲',
|
|
26
|
+
'workflow.step2': '② 提炼设定圣经',
|
|
27
|
+
'workflow.step3': '③ 规划卷',
|
|
28
|
+
'workflow.step4': '④ 生成章节计划',
|
|
29
|
+
'workflow.step5': '⑤ 逐章写作 + AI 审稿',
|
|
30
|
+
'workflow.step6': '⑥ 润色 / 导出',
|
|
31
|
+
'workflow.loadOutline': '读取大纲',
|
|
32
|
+
'workflow.genBible': '提炼设定圣经',
|
|
33
|
+
'workflow.genVolumes': '生成卷计划',
|
|
34
|
+
'workflow.genPlan': '生成章节计划',
|
|
35
|
+
'workflow.done': '已完成',
|
|
36
|
+
'workflow.todo': '待办',
|
|
37
|
+
'workflow.bibleDone': '设定圣经已生成({n} 条规则 / {c} 个角色 / {r} 条红线)',
|
|
38
|
+
'workflow.volumesDone': '卷计划已生成({n} 卷)',
|
|
39
|
+
'workflow.planDone': '章节计划已生成({n} 章)',
|
|
40
|
+
'workflow.progress': '进度:大纲 ✓ · 设定 {bible} · 卷 {volumes} · 计划 {plan} · 已完成 {done}/{total} 章',
|
|
41
|
+
'overview.loadDocx': '从 docx 读取大纲',
|
|
42
|
+
'overview.loadDocxDefault': '读取默认大纲',
|
|
43
|
+
'overview.loadingOutline': '正在解析 docx…',
|
|
44
|
+
'overview.outlineHint': '大纲文本(可编辑)',
|
|
45
|
+
'overview.outlineChars': '大纲字数',
|
|
46
|
+
'overview.saveOutline': '保存大纲',
|
|
47
|
+
'overview.saved': '大纲已保存',
|
|
48
|
+
'overview.bookName': '书名',
|
|
49
|
+
'overview.loadCustom': '指定 docx 路径',
|
|
50
|
+
'overview.loadCustomHint': '绝对路径,留空使用默认',
|
|
51
|
+
'plan.generate': '生成章节计划',
|
|
52
|
+
'plan.generateHint': '章节数量',
|
|
53
|
+
'plan.count': '章',
|
|
54
|
+
'plan.empty': '暂无章节计划,请先生成',
|
|
55
|
+
'plan.chapter': '章',
|
|
56
|
+
'plan.pending': '待生成',
|
|
57
|
+
'plan.generating': '生成中',
|
|
58
|
+
'plan.written': '待审稿',
|
|
59
|
+
'plan.reviewing': '审稿中',
|
|
60
|
+
'plan.approved': '已通过',
|
|
61
|
+
'plan.rejected': '待修订',
|
|
62
|
+
'plan.error': '失败',
|
|
63
|
+
'plan.write': '生成本章',
|
|
64
|
+
'plan.rewrite': '修订',
|
|
65
|
+
'plan.review': '审稿',
|
|
66
|
+
'plan.polish': '去AI味',
|
|
67
|
+
'plan.writeAll': '批量生成全部',
|
|
68
|
+
'plan.writeAllPending': '批量生成剩余',
|
|
69
|
+
'plan.generated': '已生成',
|
|
70
|
+
'plan.progress': '进度',
|
|
71
|
+
'plan.beats': '剧情要点',
|
|
72
|
+
'plan.reviewReport': '审稿报告',
|
|
73
|
+
'plan.reviewScore': '评分',
|
|
74
|
+
'plan.reviewVerdict': '总评',
|
|
75
|
+
'plan.reviewIssues': '问题清单',
|
|
76
|
+
'plan.reviewPass': '通过',
|
|
77
|
+
'plan.reviewFail': '未通过',
|
|
78
|
+
'plan.approve': '手动通过',
|
|
79
|
+
'plan.summary': '章节摘要',
|
|
80
|
+
'plan.volumes': '卷',
|
|
81
|
+
'plan.noVolume': '未分卷',
|
|
82
|
+
'bible.title': '设定圣经',
|
|
83
|
+
'bible.gen': 'AI 提炼设定圣经',
|
|
84
|
+
'bible.genre': '题材基调',
|
|
85
|
+
'bible.worldRules': '世界规则',
|
|
86
|
+
'bible.characters': '角色卡',
|
|
87
|
+
'bible.redLines': '写作红线',
|
|
88
|
+
'bible.style': '风格要求',
|
|
89
|
+
'bible.none': '尚未生成设定圣经。生成后写作会严格遵守人设与金手指规则,审稿也会按红线检查。',
|
|
90
|
+
'foreshadow.title': '伏笔管理',
|
|
91
|
+
'foreshadow.suggest': 'AI 建议伏笔',
|
|
92
|
+
'foreshadow.none': '暂无伏笔',
|
|
93
|
+
'foreshadow.status': '状态',
|
|
94
|
+
'foreshadow.planned': '计划中',
|
|
95
|
+
'foreshadow.planted': '已埋设',
|
|
96
|
+
'foreshadow.progressing': '推进中',
|
|
97
|
+
'foreshadow.resolved': '已回收',
|
|
98
|
+
'foreshadow.abandoned': '已放弃',
|
|
99
|
+
'foreshadow.target': '预计回收',
|
|
100
|
+
'foreshadow.plantedAt': '埋设于',
|
|
101
|
+
'foreshadow.setPlanted': '标记已埋设',
|
|
102
|
+
'foreshadow.setResolved': '标记已回收',
|
|
103
|
+
'settings.title': '设置',
|
|
104
|
+
'settings.outlinePath': '默认大纲路径',
|
|
105
|
+
'settings.outputDir': '输出目录',
|
|
106
|
+
'settings.provider': '模型提供商',
|
|
107
|
+
'settings.model': '模型',
|
|
108
|
+
'settings.chapterChars': '每章目标字数',
|
|
109
|
+
'settings.maxTokens': '单章最大输出 tokens',
|
|
110
|
+
'settings.reviewPassScore': '审稿通过分数(0-100)',
|
|
111
|
+
'settings.autoReview': '生成后自动审稿',
|
|
112
|
+
'settings.save': '保存设置',
|
|
113
|
+
'settings.saved': '设置已保存',
|
|
114
|
+
'settings.openFolder': '打开输出文件夹',
|
|
115
|
+
'settings.export': '导出',
|
|
116
|
+
'settings.exportTxt': '导出 TXT',
|
|
117
|
+
'settings.exportMd': '导出 Markdown',
|
|
118
|
+
'settings.exported': '已导出:{file}({chars} 字,{chapters} 章)',
|
|
119
|
+
'progress.generating': '正在生成第 {no} 章《{title}》…',
|
|
120
|
+
'progress.done': '第 {no} 章完成({chars} 字)→ {file}',
|
|
121
|
+
'progress.reviewed': '第 {no} 章审稿:{score} 分 — {verdict}',
|
|
122
|
+
'progress.error': '第 {no} 章失败:{message}',
|
|
123
|
+
'progress.rewriting': '正在修订第 {no} 章…',
|
|
124
|
+
'progress.polishing': '正在润色第 {no} 章…',
|
|
125
|
+
'progress.empty': '生成/审稿进度将显示在这里',
|
|
126
|
+
'assistant.hint': '和 AI 编辑讨论剧情、人设、伏笔;达成一致后可让它直接修改大纲、设定圣经、章节内容。',
|
|
127
|
+
'assistant.placeholder': '例如:我想让第 2 章结尾加一个悬念——墟境里传来爷爷的声音…',
|
|
128
|
+
'assistant.send': '发送',
|
|
129
|
+
'assistant.toolStart': '⚙ 执行操作:{name}…',
|
|
130
|
+
'assistant.toolDone': '✓ {name} 完成:{detail}',
|
|
131
|
+
'assistant.toolError': '✗ {name} 失败:{detail}',
|
|
132
|
+
'assistant.empty': '还没有对话。和 AI 编辑聊聊剧情吧。',
|
|
133
|
+
'status.projectNone': '输出目录中还没有项目。请先加载大纲。',
|
|
134
|
+
'status.files': '已生成文件',
|
|
135
|
+
'api.error': '请求失败',
|
|
136
|
+
} as const
|
|
137
|
+
|
|
138
|
+
/** en dictionary (fallback). */
|
|
139
|
+
export const en: Record<string, string> = {
|
|
140
|
+
'entry.label': 'Novel Forge',
|
|
141
|
+
'entry.tooltip': 'AI novel workbench: outline → bible → volumes → plan → write + review',
|
|
142
|
+
'panel.title': 'Novel Forge',
|
|
143
|
+
'common.close': 'Close',
|
|
144
|
+
'common.loading': 'Loading…',
|
|
145
|
+
'common.save': 'Save',
|
|
146
|
+
'common.error': 'Error',
|
|
147
|
+
'common.success': 'Success',
|
|
148
|
+
'common.generating': 'Generating…',
|
|
149
|
+
'common.chars': ' chars',
|
|
150
|
+
'tab.workflow': 'Workflow',
|
|
151
|
+
'tab.overview': 'Outline',
|
|
152
|
+
'tab.plan': 'Chapters',
|
|
153
|
+
'tab.bible': 'Bible',
|
|
154
|
+
'tab.foreshadow': 'Foreshadow',
|
|
155
|
+
'tab.settings': 'Settings',
|
|
156
|
+
'workflow.title': 'Writing workflow',
|
|
157
|
+
'workflow.step1': '① Load outline',
|
|
158
|
+
'workflow.step2': '② Extract story bible',
|
|
159
|
+
'workflow.step3': '③ Plan volumes',
|
|
160
|
+
'workflow.step4': '④ Plan chapters',
|
|
161
|
+
'workflow.step5': '⑤ Write + AI review',
|
|
162
|
+
'workflow.step6': '⑥ Polish / export',
|
|
163
|
+
'workflow.loadOutline': 'Load outline',
|
|
164
|
+
'workflow.genBible': 'Extract bible',
|
|
165
|
+
'workflow.genVolumes': 'Plan volumes',
|
|
166
|
+
'workflow.genPlan': 'Plan chapters',
|
|
167
|
+
'workflow.done': 'done',
|
|
168
|
+
'workflow.todo': 'todo',
|
|
169
|
+
'workflow.bibleDone': 'Bible ready ({n} rules / {c} characters / {r} red lines)',
|
|
170
|
+
'workflow.volumesDone': 'Volumes ready ({n})',
|
|
171
|
+
'workflow.planDone': 'Plan ready ({n} chapters)',
|
|
172
|
+
'workflow.progress': 'Outline ✓ · bible {bible} · volumes {volumes} · plan {plan} · {done}/{total} chapters',
|
|
173
|
+
'overview.loadDocx': 'Load outline from docx',
|
|
174
|
+
'overview.loadDocxDefault': 'Load default outline',
|
|
175
|
+
'overview.loadingOutline': 'Parsing docx…',
|
|
176
|
+
'overview.outlineHint': 'Outline text (editable)',
|
|
177
|
+
'overview.outlineChars': 'Outline length',
|
|
178
|
+
'overview.saveOutline': 'Save outline',
|
|
179
|
+
'overview.saved': 'Outline saved',
|
|
180
|
+
'overview.bookName': 'Book',
|
|
181
|
+
'overview.loadCustom': 'Custom docx path',
|
|
182
|
+
'overview.loadCustomHint': 'Absolute path; empty = default',
|
|
183
|
+
'plan.generate': 'Plan chapters',
|
|
184
|
+
'plan.generateHint': 'Chapter count',
|
|
185
|
+
'plan.count': ' chapters',
|
|
186
|
+
'plan.empty': 'No plan yet — generate one first',
|
|
187
|
+
'plan.chapter': 'Ch.',
|
|
188
|
+
'plan.pending': 'pending',
|
|
189
|
+
'plan.generating': 'writing',
|
|
190
|
+
'plan.written': 'to review',
|
|
191
|
+
'plan.reviewing': 'reviewing',
|
|
192
|
+
'plan.approved': 'approved',
|
|
193
|
+
'plan.rejected': 'to revise',
|
|
194
|
+
'plan.error': 'failed',
|
|
195
|
+
'plan.write': 'Write',
|
|
196
|
+
'plan.rewrite': 'Revise',
|
|
197
|
+
'plan.review': 'Review',
|
|
198
|
+
'plan.polish': 'De-AI',
|
|
199
|
+
'plan.writeAll': 'Write all',
|
|
200
|
+
'plan.writeAllPending': 'Write remaining',
|
|
201
|
+
'plan.generated': 'generated',
|
|
202
|
+
'plan.progress': 'progress',
|
|
203
|
+
'plan.beats': 'Beats',
|
|
204
|
+
'plan.reviewReport': 'Review report',
|
|
205
|
+
'plan.reviewScore': 'Score',
|
|
206
|
+
'plan.reviewVerdict': 'Verdict',
|
|
207
|
+
'plan.reviewIssues': 'Issues',
|
|
208
|
+
'plan.reviewPass': 'Passed',
|
|
209
|
+
'plan.reviewFail': 'Failed',
|
|
210
|
+
'plan.approve': 'Approve',
|
|
211
|
+
'plan.summary': 'Summary',
|
|
212
|
+
'plan.volumes': 'Volumes',
|
|
213
|
+
'plan.noVolume': 'No volume',
|
|
214
|
+
'bible.title': 'Story bible',
|
|
215
|
+
'bible.gen': 'Extract bible with AI',
|
|
216
|
+
'bible.genre': 'Genre',
|
|
217
|
+
'bible.worldRules': 'World rules',
|
|
218
|
+
'bible.characters': 'Characters',
|
|
219
|
+
'bible.redLines': 'Red lines',
|
|
220
|
+
'bible.style': 'Style',
|
|
221
|
+
'bible.none': 'No bible yet. Generation and review follow it strictly once extracted.',
|
|
222
|
+
'foreshadow.title': 'Foreshadowing',
|
|
223
|
+
'foreshadow.suggest': 'Suggest with AI',
|
|
224
|
+
'foreshadow.none': 'No foreshadows',
|
|
225
|
+
'foreshadow.status': 'Status',
|
|
226
|
+
'foreshadow.planned': 'planned',
|
|
227
|
+
'foreshadow.planted': 'planted',
|
|
228
|
+
'foreshadow.progressing': 'progressing',
|
|
229
|
+
'foreshadow.resolved': 'resolved',
|
|
230
|
+
'foreshadow.abandoned': 'abandoned',
|
|
231
|
+
'foreshadow.target': 'target',
|
|
232
|
+
'foreshadow.plantedAt': 'planted at',
|
|
233
|
+
'foreshadow.setPlanted': 'Mark planted',
|
|
234
|
+
'foreshadow.setResolved': 'Mark resolved',
|
|
235
|
+
'settings.title': 'Settings',
|
|
236
|
+
'settings.outlinePath': 'Default outline path',
|
|
237
|
+
'settings.outputDir': 'Output directory',
|
|
238
|
+
'settings.provider': 'Provider',
|
|
239
|
+
'settings.model': 'Model',
|
|
240
|
+
'settings.chapterChars': 'Chars per chapter',
|
|
241
|
+
'settings.maxTokens': 'Max output tokens',
|
|
242
|
+
'settings.reviewPassScore': 'Review pass score (0-100)',
|
|
243
|
+
'settings.autoReview': 'Auto-review after writing',
|
|
244
|
+
'settings.save': 'Save settings',
|
|
245
|
+
'settings.saved': 'Settings saved',
|
|
246
|
+
'settings.openFolder': 'Open output folder',
|
|
247
|
+
'settings.export': 'Export',
|
|
248
|
+
'settings.exportTxt': 'Export TXT',
|
|
249
|
+
'settings.exportMd': 'Export Markdown',
|
|
250
|
+
'settings.exported': 'Exported: {file} ({chars} chars, {chapters} chapters)',
|
|
251
|
+
'progress.generating': 'Writing chapter {no} “{title}”…',
|
|
252
|
+
'progress.done': 'Chapter {no} done ({chars} chars) → {file}',
|
|
253
|
+
'progress.reviewed': 'Chapter {no} review: {score} — {verdict}',
|
|
254
|
+
'progress.error': 'Chapter {no} failed: {message}',
|
|
255
|
+
'progress.rewriting': 'Revising chapter {no}…',
|
|
256
|
+
'progress.polishing': 'Polishing chapter {no}…',
|
|
257
|
+
'progress.empty': 'Generation/review progress appears here',
|
|
258
|
+
'assistant.hint': 'Discuss plot, characters, foreshadowing with the AI editor; once agreed, let it edit the outline, bible, or chapters directly.',
|
|
259
|
+
'assistant.placeholder': 'e.g. Add a hook at the end of chapter 2…',
|
|
260
|
+
'assistant.send': 'Send',
|
|
261
|
+
'assistant.toolStart': '⚙ Running {name}…',
|
|
262
|
+
'assistant.toolDone': '✓ {name} done: {detail}',
|
|
263
|
+
'assistant.toolError': '✗ {name} failed: {detail}',
|
|
264
|
+
'assistant.empty': 'No conversation yet. Chat with the AI editor.',
|
|
265
|
+
'status.projectNone': 'No project in the output directory yet. Load an outline first.',
|
|
266
|
+
'status.files': 'Generated files',
|
|
267
|
+
'api.error': 'Request failed',
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Keys type for the zh dictionary. */
|
|
271
|
+
export type NovelKey = keyof typeof zh
|