@waterwx/dsh-novel-forge 1.3.1-alpha → 1.3.2-alpha
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 +3 -2
- package/lib/client.js +653 -458
- package/lib/index.js +24 -1
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/run-status.d.ts +2 -0
- package/lib/types/protocol.d.ts +6 -0
- package/lib/types/run.d.ts +12 -0
- package/package.json +1 -1
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/panel/RunPanel.tsx +236 -223
- package/src/client/panel/SettingsView.tsx +354 -340
- package/src/client/panel/panel.module.css +5766 -5763
- package/src/client/run-status.ts +171 -143
- package/src/protocol.ts +6 -0
- package/src/run.ts +37 -1
package/src/client/run-status.ts
CHANGED
|
@@ -1,143 +1,171 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 运行状态芯片:固定在页面顶部的「小说工坊生成状态」小徽标。
|
|
3
|
-
* 轮询 /run/status,实时显示 生成中/暂停/完成/停止/出错 + 当前章号;
|
|
4
|
-
* 点击可打开/关闭小说工坊面板。独立 DOM 注入,不触碰主面板逻辑,挂载失败静默降级。
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import type {
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
chip.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
chip.addEventListener('
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
chip.addEventListener('
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 运行状态芯片:固定在页面顶部的「小说工坊生成状态」小徽标。
|
|
3
|
+
* 轮询 /run/status,实时显示 生成中/暂停/完成/停止/出错 + 当前章号;
|
|
4
|
+
* 点击可打开/关闭小说工坊面板。独立 DOM 注入,不触碰主面板逻辑,挂载失败静默降级。
|
|
5
|
+
* 显示偏好(关 / 仅生成中暂停时 / 总是)存在 localStorage,见 chip-prefs.ts:
|
|
6
|
+
* 书架页「设置 · 全局 → 外观与主题」里有对应下拉,选择即时生效(跨标签页也同步)。
|
|
7
|
+
*
|
|
8
|
+
* P0 重写说明(原实现的三个问题):
|
|
9
|
+
* 1. 整块背景染状态色 + 固定白字 → 浅色主题下「白字配亮底」对比度崩坏。
|
|
10
|
+
* 现在改为「玻璃底 + 主题前景色文字 + 左侧状态色点」,状态只由色点承载,
|
|
11
|
+
* 文字永远跟随主题,任何深浅色下都可读。
|
|
12
|
+
* 2. 样式写死在 JS 的 inline style 里,切主题纹丝不动。
|
|
13
|
+
* 现在改用 CSS Module class,配色走 --nf-chip-* 令牌,
|
|
14
|
+
* 由 body[data-ds-dark-theme] 跟随宿主深浅色。
|
|
15
|
+
* 3. role="button" 却没有 tabindex、没有键盘处理,键盘用户完全用不了。
|
|
16
|
+
* 现在补 tabindex + Enter/Space 激活 + aria-label/live。
|
|
17
|
+
*/
|
|
18
|
+
import type { NovelApi } from './api.ts'
|
|
19
|
+
import type { PanelController } from './panel/controller.ts'
|
|
20
|
+
import type { RunState } from '../protocol.ts'
|
|
21
|
+
import css from './panel/panel.module.css'
|
|
22
|
+
import { isRunChipVisible, readRunChipMode, subscribeRunChipMode, type RunChipMode } from './chip-prefs.ts'
|
|
23
|
+
|
|
24
|
+
const REFRESH_MS = 4000
|
|
25
|
+
|
|
26
|
+
/** 状态文案。状态语义由左侧色点承载,这里不再放 emoji(避免与色点重复表意)。 */
|
|
27
|
+
function label(s: RunState): string {
|
|
28
|
+
const no = s.currentNo
|
|
29
|
+
// 带书名的原因:状态是"全局单实例 + 恢复时会扫书架",不带书名根本不知道是谁的批次。
|
|
30
|
+
const book = typeof s.bookName === 'string' && s.bookName.trim() !== '' ? `《${s.bookName.trim()}》` : ''
|
|
31
|
+
const head = book === '' ? '小说工坊' : book
|
|
32
|
+
switch (s.status) {
|
|
33
|
+
case 'running': return `${head} 生成中 · 第${no}章`
|
|
34
|
+
case 'paused': return `${head} 已暂停 · 第${no}章`
|
|
35
|
+
case 'done': return `${head} 批完成 ${s.startNo}–${s.endNo}`
|
|
36
|
+
case 'stopped': return `${head} 已停止 · 第${no}章`
|
|
37
|
+
case 'error': return `${head} 出错 · 第${no}章`
|
|
38
|
+
default: return book === '' ? '小说工坊' : book
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 生成一个固定顶部的状态芯片并开始轮询。返回 dispose。 */
|
|
43
|
+
export function mountRunStatus(controller: PanelController, api: NovelApi): () => void {
|
|
44
|
+
const chip = document.createElement('div')
|
|
45
|
+
chip.className = css.runChip
|
|
46
|
+
chip.setAttribute('role', 'button')
|
|
47
|
+
// P0: role="button" 必须可聚焦、可键盘激活,否则对键盘/读屏用户等同不存在。
|
|
48
|
+
chip.setAttribute('tabindex', '0')
|
|
49
|
+
chip.setAttribute('title', '小说工坊生成状态(点击打开工坊)')
|
|
50
|
+
// 状态是异步轮询出来的,用 aria-live 让读屏用户也能感知变化。
|
|
51
|
+
chip.setAttribute('aria-live', 'polite')
|
|
52
|
+
|
|
53
|
+
const dot = document.createElement('span')
|
|
54
|
+
dot.className = css.runChipDot
|
|
55
|
+
// 纯装饰,颜色信息已由文字表达,不要让读屏重复播报。
|
|
56
|
+
dot.setAttribute('aria-hidden', 'true')
|
|
57
|
+
|
|
58
|
+
const text = document.createElement('span')
|
|
59
|
+
text.className = css.runChipText
|
|
60
|
+
|
|
61
|
+
chip.append(dot, text)
|
|
62
|
+
|
|
63
|
+
const toggle = (): void => { controller.toggle() }
|
|
64
|
+
|
|
65
|
+
// 自由拖拽 + 位置记忆(与 AI 进度浮窗一致)
|
|
66
|
+
const POS_KEY = 'dsh-novel-forge.runChip.pos'
|
|
67
|
+
let dragging = false, moved = false, sx = 0, sy = 0, ox = 0, oy = 0
|
|
68
|
+
const readPos = (): { x: number; y: number } | null => {
|
|
69
|
+
try { const p = window.localStorage.getItem(POS_KEY); return p === null ? null : JSON.parse(p) as { x: number; y: number } } catch { return null }
|
|
70
|
+
}
|
|
71
|
+
const writePos = (x: number, y: number): void => {
|
|
72
|
+
try { window.localStorage.setItem(POS_KEY, JSON.stringify({ x, y })) } catch { /* ignore */ }
|
|
73
|
+
}
|
|
74
|
+
const clampX = (x: number): number => Math.max(0, Math.min(Math.max(0, window.innerWidth - 40), x))
|
|
75
|
+
const clampY = (y: number): number => Math.max(0, Math.min(Math.max(0, window.innerHeight - 40), y))
|
|
76
|
+
const applyPos = (x: number, y: number): void => {
|
|
77
|
+
chip.style.left = clampX(x) + 'px'
|
|
78
|
+
chip.style.top = clampY(y) + 'px'
|
|
79
|
+
chip.style.right = 'auto'
|
|
80
|
+
}
|
|
81
|
+
chip.addEventListener('pointerdown', (e) => {
|
|
82
|
+
if (e.button !== 0) return
|
|
83
|
+
dragging = true; moved = false; sx = e.clientX; sy = e.clientY
|
|
84
|
+
ox = chip.offsetLeft; oy = chip.offsetTop
|
|
85
|
+
try { chip.setPointerCapture(e.pointerId) } catch { /* ignore */ }
|
|
86
|
+
})
|
|
87
|
+
chip.addEventListener('pointermove', (e) => {
|
|
88
|
+
if (!dragging) return
|
|
89
|
+
const dx = e.clientX - sx, dy = e.clientY - sy
|
|
90
|
+
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true
|
|
91
|
+
if (moved) {
|
|
92
|
+
applyPos(ox + dx, oy + dy)
|
|
93
|
+
writePos(chip.offsetLeft, chip.offsetTop)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
const endDrag = (): void => { dragging = false }
|
|
97
|
+
chip.addEventListener('pointerup', endDrag)
|
|
98
|
+
chip.addEventListener('pointercancel', endDrag)
|
|
99
|
+
// 拖拽后松开不触发开合
|
|
100
|
+
chip.addEventListener('click', (e) => {
|
|
101
|
+
if (moved) { e.preventDefault(); e.stopImmediatePropagation(); moved = false; return }
|
|
102
|
+
toggle()
|
|
103
|
+
})
|
|
104
|
+
chip.addEventListener('keydown', (e) => {
|
|
105
|
+
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
|
106
|
+
e.preventDefault()
|
|
107
|
+
toggle()
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
document.body.appendChild(chip)
|
|
112
|
+
|
|
113
|
+
// 恢复上次拖拽位置(若曾移动过)
|
|
114
|
+
const saved = readPos()
|
|
115
|
+
if (saved !== null) applyPos(saved.x, saved.y)
|
|
116
|
+
|
|
117
|
+
let disposed = false
|
|
118
|
+
let timer: number | undefined
|
|
119
|
+
let mode: RunChipMode = readRunChipMode()
|
|
120
|
+
/** 最近一次拿到的生产单状态:切换偏好时据此立刻重算可见性,不必等下一轮询。 */
|
|
121
|
+
let last: RunState | null = null
|
|
122
|
+
|
|
123
|
+
const render = (s: RunState | null): void => {
|
|
124
|
+
last = s
|
|
125
|
+
if (!isRunChipVisible(mode, s)) {
|
|
126
|
+
chip.dataset.visible = 'false'
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
let body = label(s!)
|
|
130
|
+
if (s!.status === 'running' && s!.stats.error > 0) body += ` · ×${s!.stats.error}`
|
|
131
|
+
text.textContent = body
|
|
132
|
+
chip.dataset.status = s!.status
|
|
133
|
+
chip.dataset.visible = 'true'
|
|
134
|
+
// 芯片单行且限宽:书名过长会省略号截断,全名与归属目录放进 title / aria-label。
|
|
135
|
+
const full = `${body}(第 ${s!.startNo}-${s!.endNo} 章)`
|
|
136
|
+
chip.setAttribute('aria-label', full)
|
|
137
|
+
chip.setAttribute('title', full + ' · 点击打开小说工坊')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tick = async (): Promise<void> => {
|
|
141
|
+
if (disposed) return
|
|
142
|
+
try {
|
|
143
|
+
const s = await api.runStatus()
|
|
144
|
+
render(s)
|
|
145
|
+
} catch { /* best-effort */ }
|
|
146
|
+
if (!disposed && mode !== 'off') timer = window.setTimeout(() => { void tick() }, REFRESH_MS)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 偏好变化:立即重算可见性;「关闭」时停掉轮询,重新打开时续上。 */
|
|
150
|
+
const applyMode = (next: RunChipMode): void => {
|
|
151
|
+
const wasOff = mode === 'off'
|
|
152
|
+
mode = next
|
|
153
|
+
render(last)
|
|
154
|
+
if (mode === 'off') {
|
|
155
|
+
if (timer !== undefined) { window.clearTimeout(timer); timer = undefined }
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
if (wasOff && timer === undefined) void tick()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const unsubscribe = subscribeRunChipMode(applyMode)
|
|
162
|
+
|
|
163
|
+
void tick()
|
|
164
|
+
|
|
165
|
+
return () => {
|
|
166
|
+
disposed = true
|
|
167
|
+
unsubscribe()
|
|
168
|
+
if (timer !== undefined) window.clearTimeout(timer)
|
|
169
|
+
chip.remove()
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -1579,6 +1579,12 @@ export interface StatusResponse {
|
|
|
1579
1579
|
/** 生产单状态(生产单 = 区间批量生产执行器:计划补足 + 逐章生成 + 被拒分级处理)。 */
|
|
1580
1580
|
export interface RunState {
|
|
1581
1581
|
runId: string
|
|
1582
|
+
/**
|
|
1583
|
+
* 本批次归属的书名(可选,兼容旧 run-state.json)。
|
|
1584
|
+
* 生产单是全局单实例,而 /run/status 恢复时会扫书架取第一个有 run-state 的目录,
|
|
1585
|
+
* 因此"这是哪本书的批次"必须显式带上,否则芯片/面板只能显示一个无法归属的状态。
|
|
1586
|
+
*/
|
|
1587
|
+
bookName?: string
|
|
1582
1588
|
/** 区间起点(含)。 */
|
|
1583
1589
|
startNo: number
|
|
1584
1590
|
/** 区间终点(含)。 */
|
package/src/run.ts
CHANGED
|
@@ -34,6 +34,30 @@ function runStateFile(outputDir: string): string {
|
|
|
34
34
|
return join(outputDir, 'run-state.json')
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 解析某个输出目录的书名(用于旧 run-state.json 的兜底)。
|
|
39
|
+
* 优先项目文件里的 bookName,其次目录名——两者都没有时返回空串。
|
|
40
|
+
* @param outputDir - 该书输出目录。
|
|
41
|
+
*/
|
|
42
|
+
export function resolveRunBookName(outputDir: string): string {
|
|
43
|
+
const fromProject = loadProject(outputDir)?.bookName
|
|
44
|
+
if (typeof fromProject === 'string' && fromProject.trim() !== '') return fromProject.trim()
|
|
45
|
+
const base = outputDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
|
|
46
|
+
return base.trim()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 一句话描述"正在跑的是哪本书的哪一章"。
|
|
51
|
+
* 面板状态行与 409 报错共用,避免两处各写各的(也便于单测覆盖)。
|
|
52
|
+
* @param state - 运行中的生产单状态。
|
|
53
|
+
*/
|
|
54
|
+
export function describeRunningBatch(state: RunState): string {
|
|
55
|
+
const name = typeof state.bookName === 'string' && state.bookName.trim() !== ''
|
|
56
|
+
? `《${state.bookName.trim()}》`
|
|
57
|
+
: '未标注书目'
|
|
58
|
+
return `${name}第${state.currentNo}章`
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
export class ProductionRunner {
|
|
38
62
|
private state: RunState | null = null
|
|
39
63
|
private working = false
|
|
@@ -60,6 +84,11 @@ export class ProductionRunner {
|
|
|
60
84
|
if (parsed?.runId === undefined) continue
|
|
61
85
|
// 重启恢复:若之前 running,则视为可续跑(loop 未在跑)。
|
|
62
86
|
if (parsed.status === 'running') parsed.status = 'paused'
|
|
87
|
+
// 旧 run-state.json 没有 bookName:用该目录的项目名兜底,再退回目录名,
|
|
88
|
+
// 保证"这是哪本书"永远有答案(否则芯片/面板会显示一个无法归属的状态)。
|
|
89
|
+
if (parsed.bookName === undefined || parsed.bookName === '') {
|
|
90
|
+
parsed.bookName = resolveRunBookName(outputDir)
|
|
91
|
+
}
|
|
63
92
|
this.state = parsed
|
|
64
93
|
this.bookDir = outputDir
|
|
65
94
|
return this.state
|
|
@@ -85,7 +114,13 @@ export class ProductionRunner {
|
|
|
85
114
|
/** 启动/续跑生产单:startNo..endNo 区间,endNo 超出计划时先自动补计划。 */
|
|
86
115
|
async start(startNo: number, endNo: number, runDir?: string): Promise<RunState> {
|
|
87
116
|
const config = this.deps.getConfig()
|
|
88
|
-
if (this.working)
|
|
117
|
+
if (this.working) {
|
|
118
|
+
// 报错要说清"在跑哪本书的哪一章":全局单实例,含糊的提示会让多书用户无从下手。
|
|
119
|
+
const running = this.state
|
|
120
|
+
throw new Error(running === null
|
|
121
|
+
? '生产单正在运行中,请先暂停或停止'
|
|
122
|
+
: `生产单正在运行中(${describeRunningBatch(running)}),请先暂停或停止`)
|
|
123
|
+
}
|
|
89
124
|
const outputDir = runDir ?? config.outputDir
|
|
90
125
|
let project = loadProject(outputDir)
|
|
91
126
|
if (project === undefined) throw new Error('输出目录中没有项目')
|
|
@@ -104,6 +139,7 @@ export class ProductionRunner {
|
|
|
104
139
|
|
|
105
140
|
this.state = {
|
|
106
141
|
runId: `run-${Date.now().toString(36)}`,
|
|
142
|
+
bookName: project.bookName,
|
|
107
143
|
startNo,
|
|
108
144
|
endNo,
|
|
109
145
|
status: 'running',
|