@waterwx/dsh-novel-forge 0.1.0 → 1.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/README.md +67 -25
- package/cordis.patch.yml +1 -1
- package/lib/client.js +4563 -1093
- package/lib/client.js.map +1 -1
- package/lib/index.js +1138 -92
- package/lib/index.js.map +1 -1
- package/lib/types/assets.d.ts +2 -2
- package/lib/types/assistant.d.ts +2 -0
- package/lib/types/bookshelf.d.ts +2 -0
- package/lib/types/client/api.d.ts +61 -2
- package/lib/types/client/locales.d.ts +24 -24
- package/lib/types/client/panel/AssetsTab.d.ts +6 -1
- package/lib/types/client/panel/CreateBookView.d.ts +9 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/ShelfView.d.ts +11 -0
- package/lib/types/client/panel/WorldTab.d.ts +10 -0
- package/lib/types/engine.d.ts +67 -6
- package/lib/types/index.d.ts +1 -1
- package/lib/types/protocol.d.ts +177 -0
- package/package.json +1 -13
- package/src/assets.ts +9 -7
- package/src/assistant.ts +152 -32
- package/src/bookshelf.ts +14 -0
- package/src/client/api.ts +80 -3
- package/src/client/locales.ts +24 -24
- package/src/client/panel/AssetsTab.tsx +139 -41
- package/src/client/panel/AssistantTab.tsx +136 -23
- package/src/client/panel/BookshelfBar.tsx +62 -8
- package/src/client/panel/CreateBookView.tsx +189 -0
- package/src/client/panel/NovelPanel.tsx +1834 -201
- package/src/client/panel/ShelfView.tsx +112 -0
- package/src/client/panel/WorldTab.tsx +202 -0
- package/src/client/panel/panel.module.css +1210 -59
- package/src/engine.ts +618 -29
- package/src/index.ts +1 -1
- package/src/protocol.ts +189 -1
- package/src/routes.ts +511 -14
package/src/client/locales.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
/** zh dictionary. */
|
|
6
6
|
export const zh = {
|
|
7
7
|
'entry.label': '小说工坊',
|
|
8
|
-
'entry.tooltip': 'AI
|
|
8
|
+
'entry.tooltip': 'AI 编译小说工作台:总纲 → 道藏 → 卷计划 → 章节计划 → 逐章生成+审稿',
|
|
9
9
|
'panel.title': '小说工坊',
|
|
10
10
|
'common.close': '关闭',
|
|
11
11
|
'common.loading': '加载中…',
|
|
@@ -15,36 +15,36 @@ export const zh = {
|
|
|
15
15
|
'common.generating': '生成中…',
|
|
16
16
|
'common.chars': '字',
|
|
17
17
|
'tab.workflow': '工作流',
|
|
18
|
-
'tab.overview': '
|
|
18
|
+
'tab.overview': '总纲',
|
|
19
19
|
'tab.plan': '章节',
|
|
20
|
-
'tab.bible': '
|
|
21
|
-
'tab.foreshadow': '
|
|
20
|
+
'tab.bible': '道藏',
|
|
21
|
+
'tab.foreshadow': '暗线',
|
|
22
22
|
'tab.assistant': 'AI 助手',
|
|
23
23
|
'tab.settings': '设置',
|
|
24
24
|
'workflow.title': '创作工作流',
|
|
25
|
-
'workflow.step1': '①
|
|
26
|
-
'workflow.step2': '②
|
|
25
|
+
'workflow.step1': '① 加载总纲',
|
|
26
|
+
'workflow.step2': '② 提炼道藏',
|
|
27
27
|
'workflow.step3': '③ 规划卷',
|
|
28
28
|
'workflow.step4': '④ 生成章节计划',
|
|
29
29
|
'workflow.step5': '⑤ 逐章写作 + AI 审稿',
|
|
30
30
|
'workflow.step6': '⑥ 润色 / 导出',
|
|
31
|
-
'workflow.loadOutline': '
|
|
32
|
-
'workflow.genBible': '
|
|
31
|
+
'workflow.loadOutline': '读取总纲',
|
|
32
|
+
'workflow.genBible': '提炼道藏',
|
|
33
33
|
'workflow.genVolumes': '生成卷计划',
|
|
34
34
|
'workflow.genPlan': '生成章节计划',
|
|
35
35
|
'workflow.done': '已完成',
|
|
36
36
|
'workflow.todo': '待办',
|
|
37
|
-
'workflow.bibleDone': '
|
|
37
|
+
'workflow.bibleDone': '道藏已生成({n} 条规则 / {c} 个人物 / {r} 条红线)',
|
|
38
38
|
'workflow.volumesDone': '卷计划已生成({n} 卷)',
|
|
39
39
|
'workflow.planDone': '章节计划已生成({n} 章)',
|
|
40
|
-
'workflow.progress': '
|
|
41
|
-
'overview.loadDocx': '从 docx
|
|
42
|
-
'overview.loadDocxDefault': '
|
|
40
|
+
'workflow.progress': '进度:总纲 ✓ · 道藏 {bible} · 卷 {volumes} · 计划 {plan} · 已完成 {done}/{total} 章',
|
|
41
|
+
'overview.loadDocx': '从 docx 读取总纲',
|
|
42
|
+
'overview.loadDocxDefault': '读取默认总纲',
|
|
43
43
|
'overview.loadingOutline': '正在解析 docx…',
|
|
44
|
-
'overview.outlineHint': '
|
|
45
|
-
'overview.outlineChars': '
|
|
46
|
-
'overview.saveOutline': '
|
|
47
|
-
'overview.saved': '
|
|
44
|
+
'overview.outlineHint': '总纲文本(可编辑)',
|
|
45
|
+
'overview.outlineChars': '总纲字数',
|
|
46
|
+
'overview.saveOutline': '保存总纲',
|
|
47
|
+
'overview.saved': '总纲已保存',
|
|
48
48
|
'overview.bookName': '书名',
|
|
49
49
|
'overview.loadCustom': '指定 docx 路径',
|
|
50
50
|
'overview.loadCustomHint': '绝对路径,留空使用默认',
|
|
@@ -79,17 +79,17 @@ export const zh = {
|
|
|
79
79
|
'plan.summary': '章节摘要',
|
|
80
80
|
'plan.volumes': '卷',
|
|
81
81
|
'plan.noVolume': '未分卷',
|
|
82
|
-
'bible.title': '
|
|
83
|
-
'bible.gen': 'AI
|
|
82
|
+
'bible.title': '道藏',
|
|
83
|
+
'bible.gen': 'AI 提炼道藏',
|
|
84
84
|
'bible.genre': '题材基调',
|
|
85
85
|
'bible.worldRules': '世界规则',
|
|
86
|
-
'bible.characters': '
|
|
86
|
+
'bible.characters': '人物志',
|
|
87
87
|
'bible.redLines': '写作红线',
|
|
88
88
|
'bible.style': '风格要求',
|
|
89
|
-
'bible.none': '
|
|
90
|
-
'foreshadow.title': '
|
|
91
|
-
'foreshadow.suggest': 'AI
|
|
92
|
-
'foreshadow.none': '
|
|
89
|
+
'bible.none': '尚未生成道藏。生成后写作会严格遵守人设与金手指规则,审稿也会按红线检查。',
|
|
90
|
+
'foreshadow.title': '暗线管理',
|
|
91
|
+
'foreshadow.suggest': 'AI 建议暗线',
|
|
92
|
+
'foreshadow.none': '暂无暗线',
|
|
93
93
|
'foreshadow.status': '状态',
|
|
94
94
|
'foreshadow.planned': '计划中',
|
|
95
95
|
'foreshadow.planted': '已埋设',
|
|
@@ -123,7 +123,7 @@ export const zh = {
|
|
|
123
123
|
'progress.rewriting': '正在修订第 {no} 章…',
|
|
124
124
|
'progress.polishing': '正在润色第 {no} 章…',
|
|
125
125
|
'progress.empty': '生成/审稿进度将显示在这里',
|
|
126
|
-
'assistant.hint': '和 AI
|
|
126
|
+
'assistant.hint': '和 AI 编辑讨论剧情、人设、暗线;达成一致后可让它直接修改总纲、道藏、章节内容。',
|
|
127
127
|
'assistant.placeholder': '例如:我想让第 2 章结尾加一个悬念——墟境里传来爷爷的声音…',
|
|
128
128
|
'assistant.send': '发送',
|
|
129
129
|
'assistant.toolStart': '⚙ 执行操作:{name}…',
|
|
@@ -11,6 +11,8 @@ import css from './panel.module.css'
|
|
|
11
11
|
/** Props. */
|
|
12
12
|
export interface AssetsTabProps {
|
|
13
13
|
api: NovelApi
|
|
14
|
+
/** 初始子页(左侧导航直达对应资产分类)。 */
|
|
15
|
+
initialTab?: AssetSubTab
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/** 渲染题材树(带勾选当前题材)。 */
|
|
@@ -21,7 +23,14 @@ function GenreTree({ node, selected, onSelect }: { node: GenreNode; selected: st
|
|
|
21
23
|
<input type="radio" name="genre" checked={selected === node.name} onChange={() => { onSelect(node) }} />
|
|
22
24
|
<span>
|
|
23
25
|
<b>{node.name}</b>
|
|
24
|
-
{node.description !== '' &&
|
|
26
|
+
{node.description !== '' && (
|
|
27
|
+
<span
|
|
28
|
+
className={`${css.meta} ${css.genreDesc}`}
|
|
29
|
+
title={node.description}
|
|
30
|
+
>
|
|
31
|
+
— {node.description}
|
|
32
|
+
</span>
|
|
33
|
+
)}
|
|
25
34
|
</span>
|
|
26
35
|
</label>
|
|
27
36
|
{node.children.length > 0 && (
|
|
@@ -42,14 +51,14 @@ type AssetSubTab = 'genre' | 'progression' | 'templates' | 'rules' | 'style'
|
|
|
42
51
|
const SUB_TABS: ReadonlyArray<{ id: AssetSubTab; label: string }> = [
|
|
43
52
|
{ id: 'genre', label: '题材基底' },
|
|
44
53
|
{ id: 'progression', label: '推进模式' },
|
|
45
|
-
{ id: 'templates', label: '
|
|
46
|
-
{ id: 'rules', label: '
|
|
47
|
-
{ id: 'style', label: '
|
|
54
|
+
{ id: 'templates', label: '笔法帖' },
|
|
55
|
+
{ id: 'rules', label: '文戒' },
|
|
56
|
+
{ id: 'style', label: '心法' },
|
|
48
57
|
]
|
|
49
58
|
|
|
50
59
|
/** 写作资产页签。 */
|
|
51
|
-
export function AssetsTab({ api }: AssetsTabProps) {
|
|
52
|
-
const [assetTab, setAssetTab] = useState<AssetSubTab>(
|
|
60
|
+
export function AssetsTab({ api, initialTab = 'genre' }: AssetsTabProps) {
|
|
61
|
+
const [assetTab, setAssetTab] = useState<AssetSubTab>(initialTab)
|
|
53
62
|
const [data, setData] = useState<AssetsResponse | null>(null)
|
|
54
63
|
const [busy, setBusy] = useState(false)
|
|
55
64
|
const [error, setError] = useState('')
|
|
@@ -58,6 +67,8 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
58
67
|
const [styleName, setStyleName] = useState('')
|
|
59
68
|
const [newRule, setNewRule] = useState('')
|
|
60
69
|
const [newProgression, setNewProgression] = useState('')
|
|
70
|
+
/** 正在行内编辑的自定义反 AI 规则(下标 + 草稿字段)。 */
|
|
71
|
+
const [editingRule, setEditingRule] = useState<{ index: number; name: string; avoid: string; fix: string } | null>(null)
|
|
61
72
|
const loadId = useRef(0)
|
|
62
73
|
|
|
63
74
|
/** Load assets (or reset from a new call). */
|
|
@@ -135,6 +146,39 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
135
146
|
setNewRule('')
|
|
136
147
|
}
|
|
137
148
|
|
|
149
|
+
/** 保存行内编辑的自定义规则。 */
|
|
150
|
+
const handleSaveRuleEdit = async (): Promise<void> => {
|
|
151
|
+
if (editingRule === null) return
|
|
152
|
+
const rules = [...(data?.projectAssets.antiAiRules ?? [])]
|
|
153
|
+
if (editingRule.index < 0 || editingRule.index >= rules.length) return
|
|
154
|
+
const name = editingRule.name.trim() || rules[editingRule.index]!.name
|
|
155
|
+
if (editingRule.avoid.trim() === '' && editingRule.fix.trim() === '') return
|
|
156
|
+
rules[editingRule.index] = { name, avoid: editingRule.avoid.trim(), fix: editingRule.fix.trim() }
|
|
157
|
+
await patch({ antiAiRules: rules })
|
|
158
|
+
setEditingRule(null)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 删除一条自定义规则。 */
|
|
162
|
+
const handleRemoveRule = async (index: number): Promise<void> => {
|
|
163
|
+
const rules = [...(data?.projectAssets.antiAiRules ?? [])]
|
|
164
|
+
rules.splice(index, 1)
|
|
165
|
+
setEditingRule(null)
|
|
166
|
+
await patch({ antiAiRules: rules })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 把内置规则复制为自定义副本(同名覆盖生效),并打开行内编辑。 */
|
|
170
|
+
const handleOverrideBuiltin = (rule: AntiAiRule): void => {
|
|
171
|
+
const rules = data?.projectAssets.antiAiRules ?? []
|
|
172
|
+
const existing = rules.findIndex(r => r.name === rule.name)
|
|
173
|
+
if (existing >= 0) {
|
|
174
|
+
setEditingRule({ index: existing, name: rules[existing]!.name, avoid: rules[existing]!.avoid, fix: rules[existing]!.fix ?? '' })
|
|
175
|
+
} else {
|
|
176
|
+
const next = [...rules, { ...rule, fix: rule.fix ?? '' }]
|
|
177
|
+
setEditingRule({ index: next.length - 1, name: rule.name, avoid: rule.avoid, fix: rule.fix ?? '' })
|
|
178
|
+
void patch({ antiAiRules: next })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
138
182
|
/** 设置题材。 */
|
|
139
183
|
const handleSelectGenre = (node: GenreNode): void => {
|
|
140
184
|
void patch({ genre: node })
|
|
@@ -165,21 +209,30 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
165
209
|
{error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
|
|
166
210
|
{notice !== '' && <div className={css.card}><span style={{ color: 'var(--nf-success)' }}>{notice}</span></div>}
|
|
167
211
|
|
|
168
|
-
{/*
|
|
169
|
-
<div className={css.
|
|
170
|
-
{
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
212
|
+
{/* 资产状态总览(参照 AI-Novel-Writing-Assistant 状态网格) */}
|
|
213
|
+
<div className={css.assetGrid}>
|
|
214
|
+
<div className={css.assetStat}>
|
|
215
|
+
<span className={css.assetStatLabel}>当前题材</span>
|
|
216
|
+
<span className={css.assetStatValue}>{assets.genre?.name ?? '未设置'}</span>
|
|
217
|
+
{assets.genre !== undefined && <span className={css.assetStatDetail} title={assets.genre.description}>{assets.genre.description}</span>}
|
|
218
|
+
</div>
|
|
219
|
+
<div className={css.assetStat}>
|
|
220
|
+
<span className={css.assetStatLabel}>主推进模式</span>
|
|
221
|
+
<span className={css.assetStatValue}>{assets.primaryProgression?.name ?? '未设置'}</span>
|
|
222
|
+
{assets.primaryProgression !== undefined && <span className={css.assetStatDetail} title={assets.primaryProgression.driver}>{assets.primaryProgression.driver}</span>}
|
|
223
|
+
</div>
|
|
224
|
+
<div className={css.assetStat}>
|
|
225
|
+
<span className={css.assetStatLabel}>已绑定写法</span>
|
|
226
|
+
<span className={css.assetStatValue}>{assets.styleAssets?.length ?? 0} 套</span>
|
|
227
|
+
<span className={css.assetStatDetail} title={(assets.styleAssets ?? []).map(s => s.name).join('、')}>
|
|
228
|
+
{(assets.styleAssets ?? []).map(s => s.name).join('、') || '未绑定(可在「笔法帖」一键选用)'}
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
<div className={css.assetStat}>
|
|
232
|
+
<span className={css.assetStatLabel}>文戒</span>
|
|
233
|
+
<span className={css.assetStatValue}>{builtinRules.length} 内置 + {(assets.antiAiRules ?? []).length} 自定义</span>
|
|
234
|
+
<span className={css.assetStatDetail}>全部生效于生成与审稿提示词</span>
|
|
235
|
+
</div>
|
|
183
236
|
</div>
|
|
184
237
|
|
|
185
238
|
{/* 题材基底库 */}
|
|
@@ -192,7 +245,8 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
192
245
|
<b>当前题材:{assets.genre.name}</b> — {assets.genre.description}
|
|
193
246
|
</div>
|
|
194
247
|
)}
|
|
195
|
-
|
|
248
|
+
{/* 题材基底库:自然展开,不套内部滚动条(描述两行截断,悬停看全文) */}
|
|
249
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
196
250
|
{genreLibrary.map(root => <GenreTree key={root.name} node={root} selected={assets.genre?.name ?? ''} onSelect={handleSelectGenre} />)}
|
|
197
251
|
</div>
|
|
198
252
|
</div>
|
|
@@ -229,10 +283,10 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
229
283
|
</div>
|
|
230
284
|
)}
|
|
231
285
|
|
|
232
|
-
{/*
|
|
286
|
+
{/* 笔法帖(一键绑定) */}
|
|
233
287
|
{assetTab === 'templates' && (
|
|
234
288
|
<div className={css.card}>
|
|
235
|
-
<span className={css.cardTitle}
|
|
289
|
+
<span className={css.cardTitle}>笔法帖</span>
|
|
236
290
|
<span className={css.meta}>从内置 8 套叙事风格模板中一键选用(来自 AI-Novel-Writing-Assistant 写法引擎),无需样本文本;绑定后生成与润色都遵循该风格。</span>
|
|
237
291
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 420, overflowY: 'auto' }}>
|
|
238
292
|
{data.styleTemplates.map(template => {
|
|
@@ -269,24 +323,68 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
269
323
|
</div>
|
|
270
324
|
)}
|
|
271
325
|
|
|
272
|
-
{/*
|
|
326
|
+
{/* 文戒 */}
|
|
273
327
|
{assetTab === 'rules' && (
|
|
274
328
|
<div className={css.card}>
|
|
275
|
-
<span className={css.cardTitle}
|
|
276
|
-
<span className={css.meta}>写作时必须遵守的表达边界(内置全局 +
|
|
329
|
+
<span className={css.cardTitle}>文戒</span>
|
|
330
|
+
<span className={css.meta}>写作时必须遵守的表达边界(内置全局 + 项目自定义),生成与审稿都会检查。内置规则可用「覆盖编辑」复制为自定义版本调整。</span>
|
|
277
331
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 280, overflowY: 'auto' }}>
|
|
278
|
-
{builtinRules.map(rule =>
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
<div
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
332
|
+
{builtinRules.map(rule => {
|
|
333
|
+
const overridden = customRules.some(r => r.name === rule.name)
|
|
334
|
+
return (
|
|
335
|
+
<div key={rule.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
|
|
336
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
|
337
|
+
<span>
|
|
338
|
+
<b>{rule.name}</b>{' '}
|
|
339
|
+
<span className={css.badge} style={{ borderColor: overridden ? 'var(--nf-accent)' : 'var(--nf-text-3)', color: overridden ? 'var(--nf-accent)' : 'var(--nf-text-3)' }}>
|
|
340
|
+
{overridden ? '自定义覆盖中' : '内置'}
|
|
341
|
+
</span>
|
|
342
|
+
</span>
|
|
343
|
+
<button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { handleOverrideBuiltin(rule) }}>
|
|
344
|
+
{overridden ? '✎ 编辑覆盖' : '+ 覆盖编辑'}
|
|
345
|
+
</button>
|
|
346
|
+
</div>
|
|
347
|
+
<div className={css.meta}>避免:{rule.avoid}</div>
|
|
348
|
+
<div className={css.meta}>修正:{rule.fix}</div>
|
|
349
|
+
</div>
|
|
350
|
+
)
|
|
351
|
+
})}
|
|
352
|
+
{customRules.map((rule, index) => (
|
|
353
|
+
<div key={`${rule.name}-${index}`} style={{ border: '1px solid var(--nf-accent)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
|
|
354
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
|
355
|
+
<span>
|
|
356
|
+
<b>{rule.name}</b>{' '}
|
|
357
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>自定义</span>
|
|
358
|
+
</span>
|
|
359
|
+
<span style={{ display: 'flex', gap: 6 }}>
|
|
360
|
+
<button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { setEditingRule({ index, name: rule.name, avoid: rule.avoid, fix: rule.fix ?? '' }) }}>
|
|
361
|
+
编辑
|
|
362
|
+
</button>
|
|
363
|
+
<button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRemoveRule(index) }}>
|
|
364
|
+
删除
|
|
365
|
+
</button>
|
|
366
|
+
</span>
|
|
367
|
+
</div>
|
|
368
|
+
{editingRule !== null && editingRule.index === index ? (
|
|
369
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
|
|
370
|
+
<input className={css.input} placeholder="规则名" value={editingRule.name} onChange={e => { setEditingRule({ ...editingRule, name: e.target.value }) }} />
|
|
371
|
+
<input className={css.input} placeholder="避免(要杜绝的表达)" value={editingRule.avoid} onChange={e => { setEditingRule({ ...editingRule, avoid: e.target.value }) }} />
|
|
372
|
+
<input className={css.input} placeholder="修正(改写方向,可留空)" value={editingRule.fix} onChange={e => { setEditingRule({ ...editingRule, fix: e.target.value }) }} />
|
|
373
|
+
<div className={css.row}>
|
|
374
|
+
<button className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || (editingRule.avoid.trim() === '' && editingRule.fix.trim() === '')} onClick={() => { void handleSaveRuleEdit() }}>
|
|
375
|
+
保存
|
|
376
|
+
</button>
|
|
377
|
+
<button className={`${css.button} ${css.buttonSmall}`} onClick={() => { setEditingRule(null) }}>
|
|
378
|
+
取消
|
|
379
|
+
</button>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
) : (
|
|
383
|
+
<>
|
|
384
|
+
<div className={css.meta}>避免:{rule.avoid}</div>
|
|
385
|
+
{rule.fix !== '' && <div className={css.meta}>修正:{rule.fix}</div>}
|
|
386
|
+
</>
|
|
387
|
+
)}
|
|
290
388
|
</div>
|
|
291
389
|
))}
|
|
292
390
|
</div>
|
|
@@ -303,10 +401,10 @@ export function AssetsTab({ api }: AssetsTabProps) {
|
|
|
303
401
|
</div>
|
|
304
402
|
)}
|
|
305
403
|
|
|
306
|
-
{/*
|
|
404
|
+
{/* 心法(自定义写法引擎) */}
|
|
307
405
|
{assetTab === 'style' && (
|
|
308
406
|
<div className={css.card}>
|
|
309
|
-
<span className={css.cardTitle}
|
|
407
|
+
<span className={css.cardTitle}>心法</span>
|
|
310
408
|
<span className={css.meta}>粘贴一段你喜欢的样本文本,AI 提取叙事风格规则并绑定到本书,后续章节保持同一味道。</span>
|
|
311
409
|
{assets.styleAssets.map(style => (
|
|
312
410
|
<div key={style.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AI
|
|
3
|
-
* 大纲 / 设定圣经 /
|
|
2
|
+
* AI 助手(编辑老师):与 AI 编辑对话讨论剧情,助手通过动作指令直接修改
|
|
3
|
+
* 大纲 / 设定圣经 / 章节。流式渲染回复;工具调用显示为步骤卡片(中文名 +
|
|
4
|
+
* 状态 + 耗时),顶部状态条展示当前在做什么;支持清空对话记录。
|
|
4
5
|
*/
|
|
5
6
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
6
7
|
import type { NovelApi } from '../api.ts'
|
|
@@ -8,13 +9,35 @@ import { tt } from './helpers.ts'
|
|
|
8
9
|
import type { AssistantFrame, AssistantMessage } from '../../protocol.ts'
|
|
9
10
|
import css from './panel.module.css'
|
|
10
11
|
|
|
12
|
+
/** 工具中文名 + 图标映射(作者看得懂)。 */
|
|
13
|
+
const TOOL_LABELS: Record<string, { icon: string; label: string }> = {
|
|
14
|
+
book_overview: { icon: '📖', label: '读取全书上下文' },
|
|
15
|
+
impact_analysis: { icon: '🔗', label: '影响分析' },
|
|
16
|
+
outline_text: { icon: '📄', label: '读取大纲' },
|
|
17
|
+
outline_replace: { icon: '📝', label: '修改大纲' },
|
|
18
|
+
bible_set_rule: { icon: '📖', label: '修改设定规则' },
|
|
19
|
+
bible_set_redline: { icon: '🚫', label: '修改写作红线' },
|
|
20
|
+
chapter_text: { icon: '📄', label: '读取章节' },
|
|
21
|
+
chapter_rewrite: { icon: '✏️', label: '修订章节' },
|
|
22
|
+
chapter_generate: { icon: '✨', label: '生成章节' },
|
|
23
|
+
chapter_review: { icon: '🔍', label: 'AI 审稿' },
|
|
24
|
+
foreshadow_add: { icon: '🪤', label: '新增伏笔' },
|
|
25
|
+
foreshadow_update: { icon: '🪤', label: '更新伏笔' },
|
|
26
|
+
export_txt: { icon: '📦', label: '导出 TXT' },
|
|
27
|
+
assets_status: { icon: '🎨', label: '查看写作资产' },
|
|
28
|
+
assets_set_genre: { icon: '🏷️', label: '设置题材' },
|
|
29
|
+
assets_set_progression: { icon: '📈', label: '设置推进模式' },
|
|
30
|
+
assets_add_rule: { icon: '🚫', label: '新增反AI规则' },
|
|
31
|
+
error: { icon: '⚠️', label: '出错了' },
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
/** One chat bubble (either side). */
|
|
12
35
|
interface ChatLine {
|
|
13
36
|
id: number
|
|
14
37
|
role: 'user' | 'assistant'
|
|
15
38
|
text: string
|
|
16
|
-
/** Tool
|
|
17
|
-
tools: Array<{ name: string; status: 'start' | 'done' | 'error'; detail?: string }>
|
|
39
|
+
/** Tool steps interleaved with the assistant reply. */
|
|
40
|
+
tools: Array<{ name: string; status: 'start' | 'done' | 'error'; detail?: string; startedAt?: number; elapsedMs?: number }>
|
|
18
41
|
/** Live output while a tool runs (generated text streamed into the bubble). */
|
|
19
42
|
live?: string
|
|
20
43
|
}
|
|
@@ -30,9 +53,25 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
30
53
|
const [input, setInput] = useState('')
|
|
31
54
|
const [busy, setBusy] = useState(false)
|
|
32
55
|
const [error, setError] = useState('')
|
|
56
|
+
const [notice, setNotice] = useState('')
|
|
57
|
+
/** 思考耗时(秒,busy 时每秒刷新)。 */
|
|
58
|
+
const [thinkSeconds, setThinkSeconds] = useState(0)
|
|
33
59
|
const idRef = useRef(0)
|
|
34
60
|
const scrollRef = useRef<HTMLDivElement | null>(null)
|
|
35
61
|
|
|
62
|
+
/** busy 计时:显示"已思考 Xs",让作者知道在干活。 */
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!busy) {
|
|
65
|
+
setThinkSeconds(0)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
const started = Date.now()
|
|
69
|
+
const timer = window.setInterval(() => {
|
|
70
|
+
setThinkSeconds(Math.floor((Date.now() - started) / 1000))
|
|
71
|
+
}, 1000)
|
|
72
|
+
return () => { window.clearInterval(timer) }
|
|
73
|
+
}, [busy])
|
|
74
|
+
|
|
36
75
|
/** Append a bubble (or extend the current assistant bubble). */
|
|
37
76
|
const pushLine = useCallback((line: Omit<ChatLine, 'id'>) => {
|
|
38
77
|
setLines(prev => {
|
|
@@ -45,14 +84,37 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
45
84
|
})
|
|
46
85
|
}, [])
|
|
47
86
|
|
|
48
|
-
/** Push a tool event
|
|
87
|
+
/** Push a tool event: start 创建步骤,done/error 更新同名步骤(含耗时)。 */
|
|
49
88
|
const pushTool = useCallback((tool: ChatLine['tools'][number]) => {
|
|
50
89
|
setLines(prev => {
|
|
51
90
|
const last = prev[prev.length - 1]
|
|
52
91
|
if (last === undefined || last.role !== 'assistant') {
|
|
53
|
-
return [...prev, {
|
|
92
|
+
return [...prev, {
|
|
93
|
+
id: idRef.current++,
|
|
94
|
+
role: 'assistant',
|
|
95
|
+
text: '',
|
|
96
|
+
tools: [{ ...tool, startedAt: tool.status === 'start' ? Date.now() : undefined }],
|
|
97
|
+
}]
|
|
54
98
|
}
|
|
55
|
-
|
|
99
|
+
const tools = [...last.tools]
|
|
100
|
+
if (tool.status === 'start') {
|
|
101
|
+
tools.push({ ...tool, startedAt: Date.now() })
|
|
102
|
+
} else {
|
|
103
|
+
// 找到同名未完成的步骤,更新状态与耗时。
|
|
104
|
+
for (let i = tools.length - 1; i >= 0; i--) {
|
|
105
|
+
const step = tools[i]!
|
|
106
|
+
if (step.name === tool.name && step.status === 'start') {
|
|
107
|
+
tools[i] = {
|
|
108
|
+
...step,
|
|
109
|
+
status: tool.status,
|
|
110
|
+
detail: tool.detail,
|
|
111
|
+
elapsedMs: Date.now() - (step.startedAt ?? Date.now()),
|
|
112
|
+
}
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...prev.slice(0, -1), { ...last, tools, live: undefined }]
|
|
56
118
|
})
|
|
57
119
|
}, [])
|
|
58
120
|
|
|
@@ -65,6 +127,19 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
65
127
|
})
|
|
66
128
|
}, [])
|
|
67
129
|
|
|
130
|
+
/** 当前进行中的工具(状态条显示)。 */
|
|
131
|
+
const activeTool = (() => {
|
|
132
|
+
if (!busy) return null
|
|
133
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
134
|
+
const line = lines[i]!
|
|
135
|
+
if (line.role !== 'assistant') continue
|
|
136
|
+
for (let j = line.tools.length - 1; j >= 0; j--) {
|
|
137
|
+
if (line.tools[j]!.status === 'start') return line.tools[j]!
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
})()
|
|
142
|
+
|
|
68
143
|
/** Load persisted history on mount. */
|
|
69
144
|
useEffect(() => {
|
|
70
145
|
let cancelled = false
|
|
@@ -78,11 +153,6 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
78
153
|
restored.push({ id: idRef.current++, role: 'user', text: entry.content, tools: [] })
|
|
79
154
|
} else if (entry.role === 'assistant') {
|
|
80
155
|
restored.push({ id: idRef.current++, role: 'assistant', text: entry.content, tools: [] })
|
|
81
|
-
} else if (entry.role === 'tool') {
|
|
82
|
-
const last = restored[restored.length - 1]
|
|
83
|
-
if (last !== undefined && last.role === 'assistant') {
|
|
84
|
-
last.tools.push({ name: entry.tool ?? 'tool', status: 'done', detail: entry.content.slice(0, 120) })
|
|
85
|
-
}
|
|
86
156
|
}
|
|
87
157
|
}
|
|
88
158
|
setLines(restored)
|
|
@@ -98,12 +168,29 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
98
168
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
|
99
169
|
}, [lines])
|
|
100
170
|
|
|
171
|
+
/** 清空对话记录(服务端删除历史 + 本地清空)。 */
|
|
172
|
+
const handleClear = async (): Promise<void> => {
|
|
173
|
+
if (!window.confirm('清空全部聊天记录?此操作不可恢复(不影响大纲/设定/章节)。')) return
|
|
174
|
+
setBusy(true)
|
|
175
|
+
setError('')
|
|
176
|
+
try {
|
|
177
|
+
await api.assistantClear()
|
|
178
|
+
setLines([])
|
|
179
|
+
setNotice('对话已清空,编辑老师会重新了解项目')
|
|
180
|
+
} catch (err) {
|
|
181
|
+
setError((err as Error).message)
|
|
182
|
+
} finally {
|
|
183
|
+
setBusy(false)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
101
187
|
/** Send one message. */
|
|
102
188
|
const handleSend = async (): Promise<void> => {
|
|
103
189
|
const message = input.trim()
|
|
104
190
|
if (message === '' || busy) return
|
|
105
191
|
setInput('')
|
|
106
192
|
setError('')
|
|
193
|
+
setNotice('')
|
|
107
194
|
pushLine({ role: 'user', text: message, tools: [] })
|
|
108
195
|
// Start an empty assistant bubble.
|
|
109
196
|
setLines(prev => [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [] }])
|
|
@@ -133,9 +220,21 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
133
220
|
|
|
134
221
|
return (
|
|
135
222
|
<div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
136
|
-
<
|
|
223
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
224
|
+
<span className={css.cardTitle}>{tt('tab.assistant')}</span>
|
|
225
|
+
<button type="button" className={css.iconButton} title="清空聊天记录" aria-label="清空聊天记录" onClick={() => { void handleClear() }}>
|
|
226
|
+
🗑️
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
137
229
|
<span className={css.meta}>{tt('assistant.hint')}</span>
|
|
230
|
+
{notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 12 }}>{notice}</span>}
|
|
138
231
|
{error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>{tt('common.error')}: {error}</span>}
|
|
232
|
+
{/* 状态条:编辑老师正在做什么 */}
|
|
233
|
+
<div className={`${css.assistantStatus} ${busy ? css.assistantStatusBusy : ''}`}>
|
|
234
|
+
{busy
|
|
235
|
+
? `🤖 编辑老师 · ${activeTool !== null ? `正在「${TOOL_LABELS[activeTool.name]?.label ?? activeTool.name}」` : `正在思考…(已 ${thinkSeconds}s)`}`
|
|
236
|
+
: '💬 编辑老师 · 等你开口'}
|
|
237
|
+
</div>
|
|
139
238
|
<div
|
|
140
239
|
ref={scrollRef}
|
|
141
240
|
className={css.chatScroll}
|
|
@@ -148,17 +247,31 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
148
247
|
{line.live !== undefined && line.live !== '' && (
|
|
149
248
|
<div className={css.toolLive}>{line.live}</div>
|
|
150
249
|
)}
|
|
250
|
+
{/* 步骤卡片 */}
|
|
151
251
|
{line.tools.length > 0 && (
|
|
152
|
-
<div
|
|
153
|
-
{line.tools.map((tool, i) =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
252
|
+
<div className={css.toolSteps}>
|
|
253
|
+
{line.tools.map((tool, i) => {
|
|
254
|
+
const meta = TOOL_LABELS[tool.name] ?? { icon: '⚙️', label: tool.name }
|
|
255
|
+
return (
|
|
256
|
+
<div
|
|
257
|
+
key={i}
|
|
258
|
+
className={`${css.toolStep} ${tool.status === 'error' ? css.toolStepError : tool.status === 'start' ? css.toolStepActive : ''}`}
|
|
259
|
+
>
|
|
260
|
+
<span className={css.toolStepIcon}>{meta.icon}</span>
|
|
261
|
+
<span className={css.toolStepName}>{meta.label}</span>
|
|
262
|
+
<span className={css.toolStepStatus}>
|
|
263
|
+
{tool.status === 'start'
|
|
264
|
+
? '⏳ 进行中'
|
|
265
|
+
: tool.status === 'done'
|
|
266
|
+
? `✓ ${tool.elapsedMs !== undefined ? `${(tool.elapsedMs / 1000).toFixed(1)}s` : ''}`
|
|
267
|
+
: '✗ 失败'}
|
|
268
|
+
</span>
|
|
269
|
+
{tool.status === 'error' && tool.detail !== undefined && (
|
|
270
|
+
<span className={css.toolStepDetail}>{tool.detail}</span>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
)
|
|
274
|
+
})}
|
|
162
275
|
</div>
|
|
163
276
|
)}
|
|
164
277
|
</div>
|