@waterwx/dsh-novel-forge 1.3.1 → 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.
Files changed (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +24 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. package/src/comic-presets.ts +0 -67
@@ -1,458 +1,448 @@
1
- /**
2
- * 写作资产页签:题材基底库 / 推进模式库 / 反 AI 规则 / 写法引擎。
3
- * 学习自 AI-Novel-Writing-Assistant 的四大资产模块,注入到生成与审稿提示词中。
4
- */
5
- import { useCallback, useEffect, useRef, useState } from 'react'
6
- import type { NovelApi } from '../api.ts'
7
- import { tt } from './helpers.ts'
8
- import type { AntiAiRule, AssetsResponse, GenreNode, ProgressionMode, StyleAsset, StyleTemplate } from '../../protocol.ts'
9
- import css from './panel.module.css'
10
-
11
- /** Props. */
12
- export interface AssetsTabProps {
13
- api: NovelApi
14
- /** 初始子页(左侧导航直达对应资产分类)。 */
15
- initialTab?: AssetSubTab
16
- }
17
-
18
- /** 渲染题材树(带勾选当前题材)。 */
19
- function GenreTree({ node, selected, onSelect }: { node: GenreNode; selected: string; onSelect: (node: GenreNode) => void }) {
20
- return (
21
- <div>
22
- <label style={{ display: 'flex', alignItems: 'flex-start', gap: 6, cursor: 'pointer' }}>
23
- <input type="radio" name="genre" checked={selected === node.name} onChange={() => { onSelect(node) }} />
24
- <span>
25
- <b>{node.name}</b>
26
- {node.description !== '' && (
27
- <span
28
- className={`${css.meta} ${css.genreDesc}`}
29
- title={node.description}
30
- >
31
- — {node.description}
32
- </span>
33
- )}
34
- </span>
35
- </label>
36
- {node.children.length > 0 && (
37
- <div style={{ marginLeft: 22, display: 'flex', flexDirection: 'column', gap: 4 }}>
38
- {node.children.map(child => (
39
- <GenreTree key={child.name} node={child} selected={selected} onSelect={onSelect} />
40
- ))}
41
- </div>
42
- )}
43
- </div>
44
- )
45
- }
46
-
47
- /** 写作资产子页签。 */
48
- type AssetSubTab = 'genre' | 'progression' | 'templates' | 'rules' | 'style'
49
-
50
- /** 子页签定义。 */
51
- const SUB_TABS: ReadonlyArray<{ id: AssetSubTab; label: string }> = [
52
- { id: 'genre', label: '题材基底' },
53
- { id: 'progression', label: '推进模式' },
54
- { id: 'templates', label: '笔法帖' },
55
- { id: 'rules', label: '文戒' },
56
- { id: 'style', label: '心法' },
57
- ]
58
-
59
- /** 写作资产页签。 */
60
- export function AssetsTab({ api, initialTab = 'genre' }: AssetsTabProps) {
61
- const [assetTab, setAssetTab] = useState<AssetSubTab>(initialTab)
62
- const [data, setData] = useState<AssetsResponse | null>(null)
63
- const [busy, setBusy] = useState(false)
64
- const [error, setError] = useState('')
65
- const [notice, setNotice] = useState('')
66
- const [sampleText, setSampleText] = useState('')
67
- const [styleName, setStyleName] = useState('')
68
- const [newRule, setNewRule] = useState('')
69
- const [newProgression, setNewProgression] = useState('')
70
- /** 正在行内编辑的自定义反 AI 规则(下标 + 草稿字段)。 */
71
- const [editingRule, setEditingRule] = useState<{ index: number; name: string; avoid: string; fix: string } | null>(null)
72
- const loadId = useRef(0)
73
-
74
- /** Load assets (or reset from a new call). */
75
- const refresh = useCallback(async () => {
76
- try {
77
- const result = await api.assets()
78
- setData(result)
79
- } catch (err) {
80
- setError((err as Error).message)
81
- }
82
- }, [api])
83
-
84
- useEffect(() => {
85
- void refresh()
86
- }, [refresh])
87
-
88
- /** Patch assets and refresh. */
89
- const patch = async (patch: Parameters<NovelApi['patchAssets']>[0]): Promise<void> => {
90
- setBusy(true)
91
- setError('')
92
- try {
93
- const result = await api.patchAssets(patch)
94
- setData(result)
95
- setNotice('已保存')
96
- } catch (err) {
97
- setError((err as Error).message)
98
- } finally {
99
- setBusy(false)
100
- }
101
- }
102
-
103
- /** 提取写法资产。 */
104
- const handleExtractStyle = async (): Promise<void> => {
105
- if (sampleText.trim().length < 50) {
106
- setError(tt('settings.exported') === '' ? '样本文本过短' : '样本文本过短(<50 字符)')
107
- return
108
- }
109
- setBusy(true)
110
- setError('')
111
- try {
112
- const result = await api.styleEngine({ sampleText, name: styleName })
113
- setData(prev => prev === null ? prev : {
114
- ...prev,
115
- projectAssets: {
116
- ...prev.projectAssets,
117
- styleAssets: [...(prev.projectAssets.styleAssets ?? []), result.styleAsset],
118
- updatedAt: new Date().toISOString(),
119
- },
120
- })
121
- setNotice(`写法资产「${result.styleAsset.name}」已提取并绑定`)
122
- setSampleText('')
123
- setStyleName('')
124
- } catch (err) {
125
- setError((err as Error).message)
126
- } finally {
127
- setBusy(false)
128
- }
129
- }
130
-
131
- /** 添加自定义反 AI 规则(一行 "名称:要避免的" 简单格式由用户填写 JSON)。 */
132
- const handleAddRule = async (): Promise<void> => {
133
- const text = newRule.trim()
134
- if (text === '') return
135
- let rule: AntiAiRule
136
- try {
137
- const parsed = JSON.parse(text) as AntiAiRule
138
- rule = { name: parsed.name ?? '自定义规则', avoid: parsed.avoid ?? '', fix: parsed.fix ?? '' }
139
- } catch {
140
- // Fallback: treat as "避免什么" text.
141
- rule = { name: `自定义规则 ${(data?.projectAssets.antiAiRules ?? []).length + 1}`, avoid: text, fix: '' }
142
- }
143
- if (rule.avoid === '' && rule.fix === '') return
144
- const next = [...(data?.projectAssets.antiAiRules ?? []), rule]
145
- await patch({ antiAiRules: next })
146
- setNewRule('')
147
- }
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
-
182
- /** 设置题材。 */
183
- const handleSelectGenre = (node: GenreNode): void => {
184
- void patch({ genre: node })
185
- }
186
-
187
- /** 添加推进模式(从内置库选择)。 */
188
- const handleAddProgression = async (mode: ProgressionMode): Promise<void> => {
189
- const current = data?.projectAssets
190
- const isPrimary = (data?.projectAssets.primaryProgression ?? undefined) === undefined
191
- if (isPrimary) {
192
- await patch({ primaryProgression: { ...mode, primary: true } })
193
- } else {
194
- await patch({ auxiliaryProgressions: [...(current?.auxiliaryProgressions ?? []), { ...mode, primary: false }] })
195
- }
196
- }
197
-
198
- if (data === null) {
199
- return <div className={css.card}><span className={css.meta}>{tt('common.loading')}</span></div>
200
- }
201
-
202
- const assets = data.projectAssets
203
- const builtinRules = data.antiAiLibrary
204
- const customRules = assets.antiAiRules ?? []
205
- const genreLibrary = data.genreLibrary
206
-
207
- return (
208
- <div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, minHeight: 0 }}>
209
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
210
- <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🧰 创作资产</span>
211
- <span className={css.meta}>题材基底 / 推进模式 / 笔法帖 / 文戒 / 心法</span>
212
- </div>
213
- {error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
214
- {notice !== '' && <div className={css.card}><span style={{ color: 'var(--nf-success)' }}>{notice}</span></div>}
215
-
216
- {/* 资产状态总览(参照 AI-Novel-Writing-Assistant 状态网格) */}
217
- <div className={css.assetGrid}>
218
- <div className={css.assetStat}>
219
- <span className={css.assetStatLabel}>当前题材</span>
220
- <span className={css.assetStatValue}>{assets.genre?.name ?? '未设置'}</span>
221
- {assets.genre !== undefined && <span className={css.assetStatDetail} title={assets.genre.description}>{assets.genre.description}</span>}
222
- </div>
223
- <div className={css.assetStat}>
224
- <span className={css.assetStatLabel}>主推进模式</span>
225
- <span className={css.assetStatValue}>{assets.primaryProgression?.name ?? '未设置'}</span>
226
- {assets.primaryProgression !== undefined && <span className={css.assetStatDetail} title={assets.primaryProgression.driver}>{assets.primaryProgression.driver}</span>}
227
- </div>
228
- <div className={css.assetStat}>
229
- <span className={css.assetStatLabel}>已绑定写法</span>
230
- <span className={css.assetStatValue}>{assets.styleAssets?.length ?? 0} 套</span>
231
- <span className={css.assetStatDetail} title={(assets.styleAssets ?? []).map(s => s.name).join('、')}>
232
- {(assets.styleAssets ?? []).map(s => s.name).join('、') || '未绑定(可在「笔法帖」一键选用)'}
233
- </span>
234
- </div>
235
- <div className={css.assetStat}>
236
- <span className={css.assetStatLabel}>文戒</span>
237
- <span className={css.assetStatValue}>{builtinRules.length} 内置 + {(assets.antiAiRules ?? []).length} 自定义</span>
238
- <span className={css.assetStatDetail}>全部生效于生成与审稿提示词</span>
239
- </div>
240
- </div>
241
-
242
- {/* 子页签:题材基底 / 推进模式 / 笔法帖 / 文戒 / 心法 */}
243
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
244
- {SUB_TABS.map(t => (
245
- <button
246
- key={t.id}
247
- type="button"
248
- className={`${css.button} ${assetTab === t.id ? css.buttonPrimary : ''}`}
249
- style={{ fontSize: 14, flex: 1 }}
250
- onClick={() => { setAssetTab(t.id) }}
251
- >
252
- {t.label}
253
- </button>
254
- ))}
255
- </div>
256
-
257
- {/* 题材基底库 */}
258
- {assetTab === 'genre' && (
259
- <div className={css.card}>
260
- <span className={css.cardTitle}>题材基底库</span>
261
- <span className={css.meta}>这本书属于哪个阅读市场?题材定位会注入章节生成与审稿提示词。</span>
262
- {assets.genre !== undefined && (
263
- <div style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
264
- <b>当前题材:{assets.genre.name}</b> — {assets.genre.description}
265
- </div>
266
- )}
267
- {/* 题材基底库:自然展开,不套内部滚动条(描述两行截断,悬停看全文) */}
268
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
269
- {genreLibrary.map(root => <GenreTree key={root.name} node={root} selected={assets.genre?.name ?? ''} onSelect={handleSelectGenre} />)}
270
- </div>
271
- </div>
272
- )}
273
-
274
- {/* 推进模式库 */}
275
- {assetTab === 'progression' && (
276
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
277
- <span className={css.cardTitle}>推进模式库</span>
278
- <span className={css.meta}>读者为什么继续看下一章?主模式 + 辅助模式注入卷规划与章节生成。</span>
279
- {assets.primaryProgression !== undefined && (
280
- <div style={{ border: '1px solid var(--nf-accent)', borderRadius: 6, padding: '6px 10px', fontSize: 12, color: 'var(--nf-accent)' }}>
281
- <b>主推进:{assets.primaryProgression.name}</b> — {assets.primaryProgression.driver}
282
- </div>
283
- )}
284
- {assets.auxiliaryProgressions.map(mode => (
285
- <div key={mode.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
286
- <b>{mode.name}</b> — {mode.driver}
287
- </div>
288
- ))}
289
- <span className={css.meta}>从内置推进模式库选择添加(第一个设为主推进):</span>
290
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1, minHeight: 0, overflowY: 'auto' }}>
291
- {data.progressionLibrary.map(mode => {
292
- const alreadyPrimary = assets.primaryProgression?.name === mode.name
293
- const alreadyAux = assets.auxiliaryProgressions.some(m => m.name === mode.name)
294
- if (alreadyPrimary || alreadyAux) return null
295
- return (
296
- <button key={mode.name} className={css.button} disabled={busy} onClick={() => { void handleAddProgression(mode) }}>
297
- + {assets.primaryProgression === undefined ? `主推进:` : '辅助:'}{mode.name} — {mode.driver.slice(0, 40)}…
298
- </button>
299
- )
300
- })}
301
- </div>
302
- </div>
303
- )}
304
-
305
- {/* 笔法帖(一键绑定) */}
306
- {assetTab === 'templates' && (
307
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
308
- <span className={css.cardTitle}>笔法帖</span>
309
- <span className={css.meta}>从内置 8 套叙事风格模板中一键选用(来自 AI-Novel-Writing-Assistant 写法引擎),无需样本文本;绑定后生成与润色都遵循该风格。</span>
310
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minHeight: 0, overflowY: 'auto' }}>
311
- {data.styleTemplates.map(template => {
312
- const bound = assets.styleAssets.some(s => s.name === template.name)
313
- return (
314
- <div key={template.key} style={{ border: `1px solid ${bound ? 'var(--nf-accent)' : 'var(--nf-border)'}`, borderRadius: 6, padding: '8px 10px', fontSize: 12 }}>
315
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
316
- <span><b>{template.name}</b> <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)' }}>{template.category}</span></span>
317
- <button
318
- className={`${css.button} ${css.buttonSmall} ${bound ? '' : css.buttonPrimary}`}
319
- disabled={busy || bound}
320
- onClick={() => {
321
- const styleAsset: StyleAsset = {
322
- name: template.name,
323
- proseRules: [...template.proseRules, ...template.rhythmRules.map(r => `节奏:${r}`)],
324
- dialogueRules: template.dialogueRules,
325
- descriptionRules: template.languageRules,
326
- boundaries: [`模板「${template.name}」适用题材:${template.applicableGenres.join('、')}`, '不要违背模板的叙事单元结构与节奏约束'],
327
- createdAt: new Date().toISOString(),
328
- }
329
- void patch({ styleAssets: [...(data.projectAssets.styleAssets ?? []), styleAsset] })
330
- }}
331
- >
332
- {bound ? '✓ 已绑定' : '+ 绑定'}
333
- </button>
334
- </div>
335
- <div className={css.meta}>{template.description}</div>
336
- <div className={css.meta}>叙述:{template.proseRules.slice(0, 2).join(';')}</div>
337
- <div className={css.meta}>台词:{template.dialogueRules.slice(0, 1).join(';')}</div>
338
- </div>
339
- )
340
- })}
341
- </div>
342
- </div>
343
- )}
344
-
345
- {/* 文戒 */}
346
- {assetTab === 'rules' && (
347
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
348
- <span className={css.cardTitle}>文戒</span>
349
- <span className={css.meta}>写作时必须遵守的表达边界(内置全局 + 项目自定义),生成与审稿都会检查。内置规则可用「覆盖编辑」复制为自定义版本调整。</span>
350
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1, minHeight: 0, overflowY: 'auto' }}>
351
- {builtinRules.map(rule => {
352
- const overridden = customRules.some(r => r.name === rule.name)
353
- return (
354
- <div key={rule.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
355
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
356
- <span>
357
- <b>{rule.name}</b>{' '}
358
- <span className={css.badge} style={{ borderColor: overridden ? 'var(--nf-accent)' : 'var(--nf-text-3)', color: overridden ? 'var(--nf-accent)' : 'var(--nf-text-3)' }}>
359
- {overridden ? '自定义覆盖中' : '内置'}
360
- </span>
361
- </span>
362
- <button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { handleOverrideBuiltin(rule) }}>
363
- {overridden ? '✎ 编辑覆盖' : '+ 覆盖编辑'}
364
- </button>
365
- </div>
366
- <div className={css.meta}>避免:{rule.avoid}</div>
367
- <div className={css.meta}>修正:{rule.fix}</div>
368
- </div>
369
- )
370
- })}
371
- {customRules.map((rule, index) => (
372
- <div key={`${rule.name}-${index}`} style={{ border: '1px solid var(--nf-accent)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
373
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
374
- <span>
375
- <b>{rule.name}</b>{' '}
376
- <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>自定义</span>
377
- </span>
378
- <span style={{ display: 'flex', gap: 6 }}>
379
- <button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { setEditingRule({ index, name: rule.name, avoid: rule.avoid, fix: rule.fix ?? '' }) }}>
380
- 编辑
381
- </button>
382
- <button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRemoveRule(index) }}>
383
- 删除
384
- </button>
385
- </span>
386
- </div>
387
- {editingRule !== null && editingRule.index === index ? (
388
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
389
- <input className={css.input} placeholder="规则名" value={editingRule.name} onChange={e => { setEditingRule({ ...editingRule, name: e.target.value }) }} />
390
- <input className={css.input} placeholder="避免(要杜绝的表达)" value={editingRule.avoid} onChange={e => { setEditingRule({ ...editingRule, avoid: e.target.value }) }} />
391
- <input className={css.input} placeholder="修正(改写方向,可留空)" value={editingRule.fix} onChange={e => { setEditingRule({ ...editingRule, fix: e.target.value }) }} />
392
- <div className={css.row}>
393
- <button className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || (editingRule.avoid.trim() === '' && editingRule.fix.trim() === '')} onClick={() => { void handleSaveRuleEdit() }}>
394
- 保存
395
- </button>
396
- <button className={`${css.button} ${css.buttonSmall}`} onClick={() => { setEditingRule(null) }}>
397
- 取消
398
- </button>
399
- </div>
400
- </div>
401
- ) : (
402
- <>
403
- <div className={css.meta}>避免:{rule.avoid}</div>
404
- {rule.fix !== '' && <div className={css.meta}>修正:{rule.fix}</div>}
405
- </>
406
- )}
407
- </div>
408
- ))}
409
- </div>
410
- <div className={css.row}>
411
- <input
412
- className={css.input}
413
- style={{ flex: 1 }}
414
- placeholder='新增规则(格式:{"name":"规则名","avoid":"要避免的","fix":"修正方向"};或直接填要避免的问题)'
415
- value={newRule}
416
- onChange={e => { setNewRule(e.target.value) }}
417
- />
418
- <button className={`${css.button} ${css.buttonPrimary}`} disabled={busy || newRule.trim() === ''} onClick={() => { void handleAddRule() }}>+ 添加</button>
419
- </div>
420
- </div>
421
- )}
422
-
423
- {/* 心法(自定义写法引擎) */}
424
- {assetTab === 'style' && (
425
- <div className={css.card}>
426
- <span className={css.cardTitle}>心法</span>
427
- <span className={css.meta}>粘贴一段你喜欢的样本文本,AI 提取叙事风格规则并绑定到本书,后续章节保持同一味道。</span>
428
- {assets.styleAssets.map(style => (
429
- <div key={style.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
430
- <b>{style.name}</b>
431
- <div className={css.meta}>叙述:{style.proseRules.slice(0, 3).join(';')}</div>
432
- {style.dialogueRules.length > 0 && <div className={css.meta}>台词:{style.dialogueRules.slice(0, 2).join(';')}</div>}
433
- </div>
434
- ))}
435
- <textarea
436
- className={css.textarea}
437
- style={{ minHeight: 90 }}
438
- placeholder="粘贴样本文本(一段能代表目标风格的文字,50 字以上)…"
439
- value={sampleText}
440
- onChange={e => { setSampleText(e.target.value) }}
441
- />
442
- <div className={css.row}>
443
- <input
444
- className={css.input}
445
- style={{ flex: 1 }}
446
- placeholder="写法资产名(可选)"
447
- value={styleName}
448
- onChange={e => { setStyleName(e.target.value) }}
449
- />
450
- <button className={`${css.button} ${css.buttonPrimary}`} disabled={busy || sampleText.trim().length < 50} onClick={() => { void handleExtractStyle() }}>
451
- 提取并绑定
452
- </button>
453
- </div>
454
- </div>
455
- )}
456
- </div>
457
- )
458
- }
1
+ /**
2
+ * 写作资产页签:题材基底库 / 推进模式库 / 反 AI 规则 / 写法引擎。
3
+ * 四大资产模块(题材 / 推进模式 / 反 AI 规则 / 叙事风格),注入到生成与审稿提示词中。
4
+ */
5
+ import { useCallback, useEffect, useState } from 'react'
6
+ import type { NovelApi } from '../api.ts'
7
+ import { tt } from './helpers.ts'
8
+ import type { AntiAiRule, AssetsResponse, GenreNode, ProgressionMode, StyleAsset } from '../../protocol.ts'
9
+ import css from './panel.module.css'
10
+
11
+ /** Props. */
12
+ export interface AssetsTabProps {
13
+ api: NovelApi
14
+ /** 初始子页(左侧导航直达对应资产分类)。 */
15
+ initialTab?: AssetSubTab
16
+ }
17
+
18
+ /** 渲染题材树(带勾选当前题材)。 */
19
+ function GenreTree({ node, selected, onSelect }: { node: GenreNode; selected: string; onSelect: (node: GenreNode) => void }) {
20
+ return (
21
+ <div>
22
+ <label style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--nf-space-6)', cursor: 'pointer' }}>
23
+ <input type="radio" name="genre" checked={selected === node.name} onChange={() => { onSelect(node) }} />
24
+ <span>
25
+ <b>{node.name}</b>
26
+ {node.description !== '' && (
27
+ <span
28
+ className={`${css.meta} ${css.genreDesc}`}
29
+ title={node.description}
30
+ >
31
+ — {node.description}
32
+ </span>
33
+ )}
34
+ </span>
35
+ </label>
36
+ {node.children.length > 0 && (
37
+ <div style={{ marginLeft: 'var(--nf-space-24)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)' }}>
38
+ {node.children.map(child => (
39
+ <GenreTree key={child.name} node={child} selected={selected} onSelect={onSelect} />
40
+ ))}
41
+ </div>
42
+ )}
43
+ </div>
44
+ )
45
+ }
46
+
47
+ /** 写作资产子页签。 */
48
+ type AssetSubTab = 'genre' | 'progression' | 'templates' | 'rules' | 'style'
49
+
50
+ /** 子页签定义。 */
51
+ const SUB_TABS: ReadonlyArray<{ id: AssetSubTab; label: string }> = [
52
+ { id: 'genre', label: '题材基底' },
53
+ { id: 'progression', label: '推进模式' },
54
+ { id: 'templates', label: '笔法帖' },
55
+ { id: 'rules', label: '文戒' },
56
+ { id: 'style', label: '心法' },
57
+ ]
58
+
59
+ /** 写作资产页签。 */
60
+ export function AssetsTab({ api, initialTab = 'genre' }: AssetsTabProps) {
61
+ const [assetTab, setAssetTab] = useState<AssetSubTab>(initialTab)
62
+ const [data, setData] = useState<AssetsResponse | null>(null)
63
+ const [busy, setBusy] = useState(false)
64
+ const [error, setError] = useState('')
65
+ const [notice, setNotice] = useState('')
66
+ const [sampleText, setSampleText] = useState('')
67
+ const [styleName, setStyleName] = useState('')
68
+ const [newRule, setNewRule] = useState('')
69
+ /** 正在行内编辑的自定义反 AI 规则(下标 + 草稿字段)。 */
70
+ const [editingRule, setEditingRule] = useState<{ index: number; name: string; avoid: string; fix: string } | null>(null)
71
+
72
+ /** Load assets (or reset from a new call). */
73
+ const refresh = useCallback(async () => {
74
+ try {
75
+ const result = await api.assets()
76
+ setData(result)
77
+ } catch (err) {
78
+ setError((err as Error).message)
79
+ }
80
+ }, [api])
81
+
82
+ useEffect(() => {
83
+ void refresh()
84
+ }, [refresh])
85
+
86
+ /** Patch assets and refresh. */
87
+ const patch = async (patch: Parameters<NovelApi['patchAssets']>[0]): Promise<void> => {
88
+ setBusy(true)
89
+ setError('')
90
+ try {
91
+ const result = await api.patchAssets(patch)
92
+ setData(result)
93
+ setNotice('已保存')
94
+ } catch (err) {
95
+ setError((err as Error).message)
96
+ } finally {
97
+ setBusy(false)
98
+ }
99
+ }
100
+
101
+ /** 提取写法资产。 */
102
+ const handleExtractStyle = async (): Promise<void> => {
103
+ if (sampleText.trim().length < 50) {
104
+ setError(tt('settings.exported') === '' ? '样本文本过短' : '样本文本过短(<50 字符)')
105
+ return
106
+ }
107
+ setBusy(true)
108
+ setError('')
109
+ try {
110
+ const result = await api.styleEngine({ sampleText, name: styleName })
111
+ setData(prev => prev === null ? prev : {
112
+ ...prev,
113
+ projectAssets: {
114
+ ...prev.projectAssets,
115
+ styleAssets: [...(prev.projectAssets.styleAssets ?? []), result.styleAsset],
116
+ updatedAt: new Date().toISOString(),
117
+ },
118
+ })
119
+ setNotice(`写法资产「${result.styleAsset.name}」已提取并绑定`)
120
+ setSampleText('')
121
+ setStyleName('')
122
+ } catch (err) {
123
+ setError((err as Error).message)
124
+ } finally {
125
+ setBusy(false)
126
+ }
127
+ }
128
+
129
+ /** 添加自定义反 AI 规则(一行 "名称:要避免的" 简单格式由用户填写 JSON)。 */
130
+ const handleAddRule = async (): Promise<void> => {
131
+ const text = newRule.trim()
132
+ if (text === '') return
133
+ let rule: AntiAiRule
134
+ try {
135
+ const parsed = JSON.parse(text) as AntiAiRule
136
+ rule = { name: parsed.name ?? '自定义规则', avoid: parsed.avoid ?? '', fix: parsed.fix ?? '' }
137
+ } catch {
138
+ // Fallback: treat as "避免什么" text.
139
+ rule = { name: `自定义规则 ${(data?.projectAssets.antiAiRules ?? []).length + 1}`, avoid: text, fix: '' }
140
+ }
141
+ if (rule.avoid === '' && rule.fix === '') return
142
+ const next = [...(data?.projectAssets.antiAiRules ?? []), rule]
143
+ await patch({ antiAiRules: next })
144
+ setNewRule('')
145
+ }
146
+
147
+ /** 保存行内编辑的自定义规则。 */
148
+ const handleSaveRuleEdit = async (): Promise<void> => {
149
+ if (editingRule === null) return
150
+ const rules = [...(data?.projectAssets.antiAiRules ?? [])]
151
+ if (editingRule.index < 0 || editingRule.index >= rules.length) return
152
+ const name = editingRule.name.trim() || rules[editingRule.index]!.name
153
+ if (editingRule.avoid.trim() === '' && editingRule.fix.trim() === '') return
154
+ rules[editingRule.index] = { name, avoid: editingRule.avoid.trim(), fix: editingRule.fix.trim() }
155
+ await patch({ antiAiRules: rules })
156
+ setEditingRule(null)
157
+ }
158
+
159
+ /** 删除一条自定义规则。 */
160
+ const handleRemoveRule = async (index: number): Promise<void> => {
161
+ const rules = [...(data?.projectAssets.antiAiRules ?? [])]
162
+ rules.splice(index, 1)
163
+ setEditingRule(null)
164
+ await patch({ antiAiRules: rules })
165
+ }
166
+
167
+ /** 把内置规则复制为自定义副本(同名覆盖生效),并打开行内编辑。 */
168
+ const handleOverrideBuiltin = (rule: AntiAiRule): void => {
169
+ const rules = data?.projectAssets.antiAiRules ?? []
170
+ const existing = rules.findIndex(r => r.name === rule.name)
171
+ if (existing >= 0) {
172
+ setEditingRule({ index: existing, name: rules[existing]!.name, avoid: rules[existing]!.avoid, fix: rules[existing]!.fix ?? '' })
173
+ } else {
174
+ const next = [...rules, { ...rule, fix: rule.fix ?? '' }]
175
+ setEditingRule({ index: next.length - 1, name: rule.name, avoid: rule.avoid, fix: rule.fix ?? '' })
176
+ void patch({ antiAiRules: next })
177
+ }
178
+ }
179
+
180
+ /** 设置题材。 */
181
+ const handleSelectGenre = (node: GenreNode): void => {
182
+ void patch({ genre: node })
183
+ }
184
+
185
+ /** 添加推进模式(从内置库选择)。 */
186
+ const handleAddProgression = async (mode: ProgressionMode): Promise<void> => {
187
+ const current = data?.projectAssets
188
+ const isPrimary = (data?.projectAssets.primaryProgression ?? undefined) === undefined
189
+ if (isPrimary) {
190
+ await patch({ primaryProgression: { ...mode, primary: true } })
191
+ } else {
192
+ await patch({ auxiliaryProgressions: [...(current?.auxiliaryProgressions ?? []), { ...mode, primary: false }] })
193
+ }
194
+ }
195
+
196
+ if (data === null) {
197
+ return <div className={css.card}><span className={css.meta}>{tt('common.loading')}</span></div>
198
+ }
199
+
200
+ const assets = data.projectAssets
201
+ const builtinRules = data.antiAiLibrary
202
+ const customRules = assets.antiAiRules ?? []
203
+ const genreLibrary = data.genreLibrary
204
+
205
+ return (
206
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-12)', flex: 1, minHeight: 0 }}>
207
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
208
+ <div className={css.titleBlock}>
209
+ <span className={css.eyebrow}>Writing Assets</span>
210
+ <span className={css.cardTitleLg}> 创作资产</span>
211
+ </div>
212
+ <span className={css.meta}>题材基底 / 推进模式 / 笔法帖 / 文戒 / 心法</span>
213
+ </div>
214
+ {error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
215
+ {notice !== '' && <div className={css.card}><span style={{ color: 'var(--nf-success)' }}>{notice}</span></div>}
216
+
217
+ {/* T2 主从:左 rail 分类 + 状态摘要,右内容区。 */}
218
+ <div className={css.t2Master} style={{ flex: 1, minHeight: 0 }}>
219
+ <aside className={css.t2Rail} aria-label="资产分类">
220
+ <div className={css.t2RailHead}>分类</div>
221
+ {SUB_TABS.map(t => (
222
+ <button
223
+ key={t.id}
224
+ type="button"
225
+ role="tab"
226
+ aria-selected={assetTab === t.id}
227
+ className={css.t2RailBtn}
228
+ data-active={assetTab === t.id ? '' : undefined}
229
+ onClick={() => { setAssetTab(t.id) }}
230
+ >
231
+ <span aria-hidden>{'·'}</span>
232
+ <span>{t.label}</span>
233
+ </button>
234
+ ))}
235
+ <div className={css.t2RailSep} />
236
+ <div className={css.t2RailMeta}>
237
+ <div>题材:{assets.genre?.name ?? '未设置'}</div>
238
+ <div>主推进:{assets.primaryProgression?.name ?? '未设置'}</div>
239
+ <div>已绑写法:{assets.styleAssets?.length ?? 0} 套</div>
240
+ <div>文戒:{builtinRules.length} 内置 + {(assets.antiAiRules ?? []).length} 自定义</div>
241
+ </div>
242
+ </aside>
243
+ <div className={css.t2Content}>
244
+
245
+ {/* 题材基底库 */}
246
+ {assetTab === 'genre' && (
247
+ <div className={css.card}>
248
+ <span className={css.cardTitle}>题材基底库</span>
249
+ <span className={css.meta}>这本书属于哪个阅读市场?题材定位会注入章节生成与审稿提示词。</span>
250
+ {assets.genre !== undefined && (
251
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-6)', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}>
252
+ <b>当前题材:{assets.genre.name}</b> — {assets.genre.description}
253
+ </div>
254
+ )}
255
+ {/* 题材基底库:自然展开,不套内部滚动条(描述两行截断,悬停看全文) */}
256
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)' }}>
257
+ {genreLibrary.map(root => <GenreTree key={root.name} node={root} selected={assets.genre?.name ?? ''} onSelect={handleSelectGenre} />)}
258
+ </div>
259
+ </div>
260
+ )}
261
+
262
+ {/* 推进模式库 */}
263
+ {assetTab === 'progression' && (
264
+ <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
265
+ <span className={css.cardTitle}>推进模式库</span>
266
+ <span className={css.meta}>读者为什么继续看下一章?主模式 + 辅助模式注入卷规划与章节生成。</span>
267
+ {assets.primaryProgression !== undefined && (
268
+ <div style={{ border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-6)', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)', color: 'var(--nf-accent)' }}>
269
+ <b>主推进:{assets.primaryProgression.name}</b> — {assets.primaryProgression.driver}
270
+ </div>
271
+ )}
272
+ {assets.auxiliaryProgressions.map(mode => (
273
+ <div key={mode.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-6)', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}>
274
+ <b>{mode.name}</b> — {mode.driver}
275
+ </div>
276
+ ))}
277
+ <span className={css.meta}>从内置推进模式库选择添加(第一个设为主推进):</span>
278
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', flex: 1, minHeight: 0, overflowY: 'auto' }}>
279
+ {data.progressionLibrary.map(mode => {
280
+ const alreadyPrimary = assets.primaryProgression?.name === mode.name
281
+ const alreadyAux = assets.auxiliaryProgressions.some(m => m.name === mode.name)
282
+ if (alreadyPrimary || alreadyAux) return null
283
+ return (
284
+ <button key={mode.name} className={css.button} disabled={busy} onClick={() => { void handleAddProgression(mode) }}>
285
+ + {assets.primaryProgression === undefined ? `主推进:` : '辅助:'}{mode.name} — {mode.driver.slice(0, 40)}…
286
+ </button>
287
+ )
288
+ })}
289
+ </div>
290
+ </div>
291
+ )}
292
+
293
+ {/* 笔法帖(一键绑定) */}
294
+ {assetTab === 'templates' && (
295
+ <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
296
+ <span className={css.cardTitle}>笔法帖</span>
297
+ <span className={css.meta}>从内置 8 套叙事风格模板中一键选用,无需样本文本;绑定后生成与润色都遵循该风格。</span>
298
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', flex: 1, minHeight: 0, overflowY: 'auto' }}>
299
+ {data.styleTemplates.map(template => {
300
+ const bound = assets.styleAssets.some(s => s.name === template.name)
301
+ return (
302
+ <div key={template.key} style={{ border: `1px solid ${bound ? 'var(--nf-accent)' : 'var(--nf-border)'}`, borderRadius: 'var(--nf-radius-6)', padding: 'var(--nf-space-8) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}>
303
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--nf-space-8)' }}>
304
+ <span><b>{template.name}</b> <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)' }}>{template.category}</span></span>
305
+ <button
306
+ className={`${css.button} ${css.buttonSmall} ${bound ? '' : css.buttonPrimary}`}
307
+ disabled={busy || bound}
308
+ onClick={() => {
309
+ const styleAsset: StyleAsset = {
310
+ name: template.name,
311
+ proseRules: [...template.proseRules, ...template.rhythmRules.map(r => `节奏:${r}`)],
312
+ dialogueRules: template.dialogueRules,
313
+ descriptionRules: template.languageRules,
314
+ boundaries: [`模板「${template.name}」适用题材:${template.applicableGenres.join('、')}`, '不要违背模板的叙事单元结构与节奏约束'],
315
+ createdAt: new Date().toISOString(),
316
+ }
317
+ void patch({ styleAssets: [...(data.projectAssets.styleAssets ?? []), styleAsset] })
318
+ }}
319
+ >
320
+ {bound ? '✓ 已绑定' : '+ 绑定'}
321
+ </button>
322
+ </div>
323
+ <div className={css.meta}>{template.description}</div>
324
+ <div className={css.meta}>叙述:{template.proseRules.slice(0, 2).join(';')}</div>
325
+ <div className={css.meta}>台词:{template.dialogueRules.slice(0, 1).join(';')}</div>
326
+ </div>
327
+ )
328
+ })}
329
+ </div>
330
+ </div>
331
+ )}
332
+
333
+ {/* 文戒 */}
334
+ {assetTab === 'rules' && (
335
+ <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
336
+ <span className={css.cardTitle}>文戒</span>
337
+ <span className={css.meta}>写作时必须遵守的表达边界(内置全局 + 项目自定义),生成与审稿都会检查。内置规则可用「覆盖编辑」复制为自定义版本调整。</span>
338
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', flex: 1, minHeight: 0, overflowY: 'auto' }}>
339
+ {builtinRules.map(rule => {
340
+ const overridden = customRules.some(r => r.name === rule.name)
341
+ return (
342
+ <div key={rule.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-6)', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}>
343
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--nf-space-8)' }}>
344
+ <span>
345
+ <b>{rule.name}</b>{' '}
346
+ <span className={css.badge} style={{ borderColor: overridden ? 'var(--nf-accent)' : 'var(--nf-text-3)', color: overridden ? 'var(--nf-accent)' : 'var(--nf-text-3)' }}>
347
+ {overridden ? '自定义覆盖中' : '内置'}
348
+ </span>
349
+ </span>
350
+ <button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { handleOverrideBuiltin(rule) }}>
351
+ {overridden ? '✎ 编辑覆盖' : '+ 覆盖编辑'}
352
+ </button>
353
+ </div>
354
+ <div className={css.meta}>避免:{rule.avoid}</div>
355
+ <div className={css.meta}>修正:{rule.fix}</div>
356
+ </div>
357
+ )
358
+ })}
359
+ {customRules.map((rule, index) => (
360
+ <div key={`${rule.name}-${index}`} style={{ border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-6)', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}>
361
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--nf-space-8)' }}>
362
+ <span>
363
+ <b>{rule.name}</b>{' '}
364
+ <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>自定义</span>
365
+ </span>
366
+ <span style={{ display: 'flex', gap: 'var(--nf-space-6)' }}>
367
+ <button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { setEditingRule({ index, name: rule.name, avoid: rule.avoid, fix: rule.fix ?? '' }) }}>
368
+ 编辑
369
+ </button>
370
+ <button className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRemoveRule(index) }}>
371
+ 删除
372
+ </button>
373
+ </span>
374
+ </div>
375
+ {editingRule !== null && editingRule.index === index ? (
376
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', marginTop: 'var(--nf-space-6)' }}>
377
+ <input className={css.input} placeholder="规则名" value={editingRule.name} onChange={e => { setEditingRule({ ...editingRule, name: e.target.value }) }} />
378
+ <input className={css.input} placeholder="避免(要杜绝的表达)" value={editingRule.avoid} onChange={e => { setEditingRule({ ...editingRule, avoid: e.target.value }) }} />
379
+ <input className={css.input} placeholder="修正(改写方向,可留空)" value={editingRule.fix} onChange={e => { setEditingRule({ ...editingRule, fix: e.target.value }) }} />
380
+ <div className={css.row}>
381
+ <button className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || (editingRule.avoid.trim() === '' && editingRule.fix.trim() === '')} onClick={() => { void handleSaveRuleEdit() }}>
382
+ 保存
383
+ </button>
384
+ <button className={`${css.button} ${css.buttonSmall}`} onClick={() => { setEditingRule(null) }}>
385
+ 取消
386
+ </button>
387
+ </div>
388
+ </div>
389
+ ) : (
390
+ <>
391
+ <div className={css.meta}>避免:{rule.avoid}</div>
392
+ {rule.fix !== '' && <div className={css.meta}>修正:{rule.fix}</div>}
393
+ </>
394
+ )}
395
+ </div>
396
+ ))}
397
+ </div>
398
+ <div className={css.row}>
399
+ <input
400
+ className={css.input}
401
+ style={{ flex: 1 }}
402
+ placeholder='新增规则(格式:{"name":"规则名","avoid":"要避免的","fix":"修正方向"};或直接填要避免的问题)'
403
+ value={newRule}
404
+ onChange={e => { setNewRule(e.target.value) }}
405
+ />
406
+ <button className={`${css.button} ${css.buttonPrimary}`} disabled={busy || newRule.trim() === ''} onClick={() => { void handleAddRule() }}>+ 添加</button>
407
+ </div>
408
+ </div>
409
+ )}
410
+
411
+ {/* 心法(自定义写法引擎) */}
412
+ {assetTab === 'style' && (
413
+ <div className={css.card}>
414
+ <span className={css.cardTitle}>心法</span>
415
+ <span className={css.meta}>粘贴一段你喜欢的样本文本,AI 提取叙事风格规则并绑定到本书,后续章节保持同一味道。</span>
416
+ {assets.styleAssets.map(style => (
417
+ <div key={style.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-6)', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}>
418
+ <b>{style.name}</b>
419
+ <div className={css.meta}>叙述:{style.proseRules.slice(0, 3).join(';')}</div>
420
+ {style.dialogueRules.length > 0 && <div className={css.meta}>台词:{style.dialogueRules.slice(0, 2).join(';')}</div>}
421
+ </div>
422
+ ))}
423
+ <textarea
424
+ className={css.textarea}
425
+ style={{ minHeight: 90 }}
426
+ placeholder="粘贴样本文本(一段能代表目标风格的文字,50 字以上)…"
427
+ value={sampleText}
428
+ onChange={e => { setSampleText(e.target.value) }}
429
+ />
430
+ <div className={css.row}>
431
+ <input
432
+ className={css.input}
433
+ style={{ flex: 1 }}
434
+ placeholder="写法资产名(可选)"
435
+ value={styleName}
436
+ onChange={e => { setStyleName(e.target.value) }}
437
+ />
438
+ <button className={`${css.button} ${css.buttonPrimary}`} disabled={busy || sampleText.trim().length < 50} onClick={() => { void handleExtractStyle() }}>
439
+ 提取并绑定
440
+ </button>
441
+ </div>
442
+ </div>
443
+ )}
444
+ </div>
445
+ </div>
446
+ </div>
447
+ )
448
+ }