@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.
Files changed (50) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +180 -0
  3. package/cordis.patch.yml +13 -0
  4. package/lib/client.js +3924 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.js +3120 -0
  7. package/lib/index.js.map +1 -0
  8. package/lib/types/assets.d.ts +35 -0
  9. package/lib/types/assistant.d.ts +43 -0
  10. package/lib/types/bookshelf.d.ts +35 -0
  11. package/lib/types/client/api.d.ts +68 -0
  12. package/lib/types/client/docx.d.ts +15 -0
  13. package/lib/types/client/index.d.ts +14 -0
  14. package/lib/types/client/locales.d.ts +139 -0
  15. package/lib/types/client/mount.d.ts +9 -0
  16. package/lib/types/client/panel/AssetsTab.d.ts +7 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +7 -0
  18. package/lib/types/client/panel/BookshelfBar.d.ts +11 -0
  19. package/lib/types/client/panel/NovelPanel.d.ts +13 -0
  20. package/lib/types/client/panel/controller.d.ts +19 -0
  21. package/lib/types/client/panel/helpers.d.ts +8 -0
  22. package/lib/types/client/sidebar-entry.d.ts +13 -0
  23. package/lib/types/docx.d.ts +19 -0
  24. package/lib/types/engine.d.ts +95 -0
  25. package/lib/types/index.d.ts +55 -0
  26. package/lib/types/protocol.d.ts +521 -0
  27. package/lib/types/routes.d.ts +29 -0
  28. package/package.json +105 -0
  29. package/src/assets.ts +518 -0
  30. package/src/assistant.ts +547 -0
  31. package/src/bookshelf.ts +137 -0
  32. package/src/client/api.ts +254 -0
  33. package/src/client/css-modules.d.ts +8 -0
  34. package/src/client/docx.ts +69 -0
  35. package/src/client/index.ts +34 -0
  36. package/src/client/locales.ts +271 -0
  37. package/src/client/mount.tsx +97 -0
  38. package/src/client/panel/AssetsTab.tsx +341 -0
  39. package/src/client/panel/AssistantTab.tsx +188 -0
  40. package/src/client/panel/BookshelfBar.tsx +116 -0
  41. package/src/client/panel/NovelPanel.tsx +990 -0
  42. package/src/client/panel/controller.ts +45 -0
  43. package/src/client/panel/helpers.ts +17 -0
  44. package/src/client/panel/panel.module.css +894 -0
  45. package/src/client/sidebar-entry.ts +122 -0
  46. package/src/docx.ts +83 -0
  47. package/src/engine.ts +1019 -0
  48. package/src/index.ts +184 -0
  49. package/src/protocol.ts +539 -0
  50. package/src/routes.ts +955 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Panel view mounting — mirrors the family plugins: a container appended
3
+ * inside the conversation grid item, hidden while inactive; toggling is a
4
+ * data attribute on <html>, with cross-plugin activation events.
5
+ */
6
+ import { createRoot, type Root } from 'react-dom/client'
7
+ import type { NovelApi } from './api.ts'
8
+ import type { PanelController } from './panel/controller.ts'
9
+ import { NovelPanel } from './panel/NovelPanel.tsx'
10
+ import css from './panel/panel.module.css'
11
+
12
+ /** The injected panel container. */
13
+ export const PANEL_VIEW_SELECTOR = '[data-dsh-novelforge-view]'
14
+
15
+ const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
16
+ const ACTIVE_ATTR = 'data-dsh-novelforge-active'
17
+ /** Sibling panels' activation attributes (evicted when this panel opens). */
18
+ const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
19
+ /** Cross-plugin activation event; detail is the activating panel name. */
20
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
21
+ const PANEL_NAME = 'novelforge'
22
+
23
+ /** Find the center column, or undefined while the frame is not mounted. */
24
+ function conversationColumn(): HTMLElement | undefined {
25
+ return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
26
+ }
27
+
28
+ /**
29
+ * Mount the panel React tree into the center column and bind visibility to
30
+ * the controller.
31
+ */
32
+ export function mountPanel(controller: PanelController, api: NovelApi): () => void {
33
+ let root: Root | undefined
34
+ let container: HTMLDivElement | undefined
35
+
36
+ const ensure = (): void => {
37
+ if (container !== undefined) {
38
+ if (container.isConnected) return
39
+ root?.unmount()
40
+ root = undefined
41
+ container.remove()
42
+ container = undefined
43
+ }
44
+ const column = conversationColumn()
45
+ if (column === undefined) return
46
+ container = document.createElement('div')
47
+ container.dataset.dshNovelforgeView = 'true'
48
+ container.className = css.view
49
+ column.appendChild(container)
50
+ root = createRoot(container)
51
+ root.render(<NovelPanel controller={controller} api={api} />)
52
+ }
53
+
54
+ const waitObserver = new MutationObserver(() => { ensure() })
55
+ waitObserver.observe(document.body, { childList: true, subtree: true })
56
+
57
+ const applyActive = (): void => {
58
+ if (controller.getSnapshot().panelOpen) {
59
+ for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
60
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
61
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
62
+ } else {
63
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
64
+ }
65
+ }
66
+ const onOtherActivate = (event: Event): void => {
67
+ const detail = (event as CustomEvent).detail
68
+ if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
69
+ controller.close()
70
+ }
71
+ }
72
+ // Jump out on sidebar context clicks.
73
+ const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
74
+ const onClickSidebarRow = (event: MouseEvent): void => {
75
+ if (!controller.getSnapshot().panelOpen) return
76
+ const target = event.target as HTMLElement | null
77
+ if (target === null) return
78
+ if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
79
+ }
80
+ document.addEventListener('click', onClickSidebarRow, true)
81
+ document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
82
+ const unsubscribe = controller.subscribe(applyActive)
83
+ applyActive()
84
+ ensure()
85
+
86
+ return () => {
87
+ document.removeEventListener('click', onClickSidebarRow, true)
88
+ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
89
+ waitObserver.disconnect()
90
+ unsubscribe()
91
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
92
+ root?.unmount()
93
+ root = undefined
94
+ container?.remove()
95
+ container = undefined
96
+ }
97
+ }
@@ -0,0 +1,341 @@
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
+
16
+ /** 渲染题材树(带勾选当前题材)。 */
17
+ function GenreTree({ node, selected, onSelect }: { node: GenreNode; selected: string; onSelect: (node: GenreNode) => void }) {
18
+ return (
19
+ <div>
20
+ <label style={{ display: 'flex', alignItems: 'flex-start', gap: 6, cursor: 'pointer' }}>
21
+ <input type="radio" name="genre" checked={selected === node.name} onChange={() => { onSelect(node) }} />
22
+ <span>
23
+ <b>{node.name}</b>
24
+ {node.description !== '' && <span className={css.meta}> — {node.description}</span>}
25
+ </span>
26
+ </label>
27
+ {node.children.length > 0 && (
28
+ <div style={{ marginLeft: 22, display: 'flex', flexDirection: 'column', gap: 4 }}>
29
+ {node.children.map(child => (
30
+ <GenreTree key={child.name} node={child} selected={selected} onSelect={onSelect} />
31
+ ))}
32
+ </div>
33
+ )}
34
+ </div>
35
+ )
36
+ }
37
+
38
+ /** 写作资产子页签。 */
39
+ type AssetSubTab = 'genre' | 'progression' | 'templates' | 'rules' | 'style'
40
+
41
+ /** 子页签定义。 */
42
+ const SUB_TABS: ReadonlyArray<{ id: AssetSubTab; label: string }> = [
43
+ { id: 'genre', label: '题材基底' },
44
+ { id: 'progression', label: '推进模式' },
45
+ { id: 'templates', label: '预置写法' },
46
+ { id: 'rules', label: '反 AI 规则' },
47
+ { id: 'style', label: '自定义写法' },
48
+ ]
49
+
50
+ /** 写作资产页签。 */
51
+ export function AssetsTab({ api }: AssetsTabProps) {
52
+ const [assetTab, setAssetTab] = useState<AssetSubTab>('genre')
53
+ const [data, setData] = useState<AssetsResponse | null>(null)
54
+ const [busy, setBusy] = useState(false)
55
+ const [error, setError] = useState('')
56
+ const [notice, setNotice] = useState('')
57
+ const [sampleText, setSampleText] = useState('')
58
+ const [styleName, setStyleName] = useState('')
59
+ const [newRule, setNewRule] = useState('')
60
+ const [newProgression, setNewProgression] = useState('')
61
+ const loadId = useRef(0)
62
+
63
+ /** Load assets (or reset from a new call). */
64
+ const refresh = useCallback(async () => {
65
+ try {
66
+ const result = await api.assets()
67
+ setData(result)
68
+ } catch (err) {
69
+ setError((err as Error).message)
70
+ }
71
+ }, [api])
72
+
73
+ useEffect(() => {
74
+ void refresh()
75
+ }, [refresh])
76
+
77
+ /** Patch assets and refresh. */
78
+ const patch = async (patch: Parameters<NovelApi['patchAssets']>[0]): Promise<void> => {
79
+ setBusy(true)
80
+ setError('')
81
+ try {
82
+ const result = await api.patchAssets(patch)
83
+ setData(result)
84
+ setNotice('已保存')
85
+ } catch (err) {
86
+ setError((err as Error).message)
87
+ } finally {
88
+ setBusy(false)
89
+ }
90
+ }
91
+
92
+ /** 提取写法资产。 */
93
+ const handleExtractStyle = async (): Promise<void> => {
94
+ if (sampleText.trim().length < 50) {
95
+ setError(tt('settings.exported') === '' ? '样本文本过短' : '样本文本过短(<50 字符)')
96
+ return
97
+ }
98
+ setBusy(true)
99
+ setError('')
100
+ try {
101
+ const result = await api.styleEngine({ sampleText, name: styleName })
102
+ setData(prev => prev === null ? prev : {
103
+ ...prev,
104
+ projectAssets: {
105
+ ...prev.projectAssets,
106
+ styleAssets: [...(prev.projectAssets.styleAssets ?? []), result.styleAsset],
107
+ updatedAt: new Date().toISOString(),
108
+ },
109
+ })
110
+ setNotice(`写法资产「${result.styleAsset.name}」已提取并绑定`)
111
+ setSampleText('')
112
+ setStyleName('')
113
+ } catch (err) {
114
+ setError((err as Error).message)
115
+ } finally {
116
+ setBusy(false)
117
+ }
118
+ }
119
+
120
+ /** 添加自定义反 AI 规则(一行 "名称:要避免的" 简单格式由用户填写 JSON)。 */
121
+ const handleAddRule = async (): Promise<void> => {
122
+ const text = newRule.trim()
123
+ if (text === '') return
124
+ let rule: AntiAiRule
125
+ try {
126
+ const parsed = JSON.parse(text) as AntiAiRule
127
+ rule = { name: parsed.name ?? '自定义规则', avoid: parsed.avoid ?? '', fix: parsed.fix ?? '' }
128
+ } catch {
129
+ // Fallback: treat as "避免什么" text.
130
+ rule = { name: `自定义规则 ${(data?.projectAssets.antiAiRules ?? []).length + 1}`, avoid: text, fix: '' }
131
+ }
132
+ if (rule.avoid === '' && rule.fix === '') return
133
+ const next = [...(data?.projectAssets.antiAiRules ?? []), rule]
134
+ await patch({ antiAiRules: next })
135
+ setNewRule('')
136
+ }
137
+
138
+ /** 设置题材。 */
139
+ const handleSelectGenre = (node: GenreNode): void => {
140
+ void patch({ genre: node })
141
+ }
142
+
143
+ /** 添加推进模式(从内置库选择)。 */
144
+ const handleAddProgression = async (mode: ProgressionMode): Promise<void> => {
145
+ const current = data?.projectAssets
146
+ const isPrimary = (data?.projectAssets.primaryProgression ?? undefined) === undefined
147
+ if (isPrimary) {
148
+ await patch({ primaryProgression: { ...mode, primary: true } })
149
+ } else {
150
+ await patch({ auxiliaryProgressions: [...(current?.auxiliaryProgressions ?? []), { ...mode, primary: false }] })
151
+ }
152
+ }
153
+
154
+ if (data === null) {
155
+ return <div className={css.card}><span className={css.meta}>{tt('common.loading')}</span></div>
156
+ }
157
+
158
+ const assets = data.projectAssets
159
+ const builtinRules = data.antiAiLibrary
160
+ const customRules = assets.antiAiRules ?? []
161
+ const genreLibrary = data.genreLibrary
162
+
163
+ return (
164
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
165
+ {error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
166
+ {notice !== '' && <div className={css.card}><span style={{ color: 'var(--nf-success)' }}>{notice}</span></div>}
167
+
168
+ {/* 子页签栏 */}
169
+ <div className={css.tabBar} role="tablist" style={{ padding: '0 0 8px', borderBottom: '1px solid var(--nf-border)' }}>
170
+ {SUB_TABS.map(tab => (
171
+ <button
172
+ key={tab.id}
173
+ type="button"
174
+ role="tab"
175
+ aria-selected={assetTab === tab.id}
176
+ data-active={assetTab === tab.id ? '' : undefined}
177
+ className={css.tab}
178
+ onClick={() => { setAssetTab(tab.id) }}
179
+ >
180
+ {tab.label}
181
+ </button>
182
+ ))}
183
+ </div>
184
+
185
+ {/* 题材基底库 */}
186
+ {assetTab === 'genre' && (
187
+ <div className={css.card}>
188
+ <span className={css.cardTitle}>题材基底库</span>
189
+ <span className={css.meta}>这本书属于哪个阅读市场?题材定位会注入章节生成与审稿提示词。</span>
190
+ {assets.genre !== undefined && (
191
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
192
+ <b>当前题材:{assets.genre.name}</b> — {assets.genre.description}
193
+ </div>
194
+ )}
195
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 340, overflowY: 'auto' }}>
196
+ {genreLibrary.map(root => <GenreTree key={root.name} node={root} selected={assets.genre?.name ?? ''} onSelect={handleSelectGenre} />)}
197
+ </div>
198
+ </div>
199
+ )}
200
+
201
+ {/* 推进模式库 */}
202
+ {assetTab === 'progression' && (
203
+ <div className={css.card}>
204
+ <span className={css.cardTitle}>推进模式库</span>
205
+ <span className={css.meta}>读者为什么继续看下一章?主模式 + 辅助模式注入卷规划与章节生成。</span>
206
+ {assets.primaryProgression !== undefined && (
207
+ <div style={{ border: '1px solid var(--nf-accent)', borderRadius: 6, padding: '6px 10px', fontSize: 12, color: 'var(--nf-accent)' }}>
208
+ <b>主推进:{assets.primaryProgression.name}</b> — {assets.primaryProgression.driver}
209
+ </div>
210
+ )}
211
+ {assets.auxiliaryProgressions.map(mode => (
212
+ <div key={mode.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
213
+ <b>{mode.name}</b> — {mode.driver}
214
+ </div>
215
+ ))}
216
+ <span className={css.meta}>从内置推进模式库选择添加(第一个设为主推进):</span>
217
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 260, overflowY: 'auto' }}>
218
+ {data.progressionLibrary.map(mode => {
219
+ const alreadyPrimary = assets.primaryProgression?.name === mode.name
220
+ const alreadyAux = assets.auxiliaryProgressions.some(m => m.name === mode.name)
221
+ if (alreadyPrimary || alreadyAux) return null
222
+ return (
223
+ <button key={mode.name} className={css.button} disabled={busy} onClick={() => { void handleAddProgression(mode) }}>
224
+ + {assets.primaryProgression === undefined ? `主推进:` : '辅助:'}{mode.name} — {mode.driver.slice(0, 40)}…
225
+ </button>
226
+ )
227
+ })}
228
+ </div>
229
+ </div>
230
+ )}
231
+
232
+ {/* 预置写法模板(一键绑定) */}
233
+ {assetTab === 'templates' && (
234
+ <div className={css.card}>
235
+ <span className={css.cardTitle}>预置写法模板</span>
236
+ <span className={css.meta}>从内置 8 套叙事风格模板中一键选用(来自 AI-Novel-Writing-Assistant 写法引擎),无需样本文本;绑定后生成与润色都遵循该风格。</span>
237
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 420, overflowY: 'auto' }}>
238
+ {data.styleTemplates.map(template => {
239
+ const bound = assets.styleAssets.some(s => s.name === template.name)
240
+ return (
241
+ <div key={template.key} style={{ border: `1px solid ${bound ? 'var(--nf-accent)' : 'var(--nf-border)'}`, borderRadius: 6, padding: '8px 10px', fontSize: 12 }}>
242
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
243
+ <span><b>{template.name}</b> <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)' }}>{template.category}</span></span>
244
+ <button
245
+ className={`${css.button} ${css.buttonSmall} ${bound ? '' : css.buttonPrimary}`}
246
+ disabled={busy || bound}
247
+ onClick={() => {
248
+ const styleAsset: StyleAsset = {
249
+ name: template.name,
250
+ proseRules: [...template.proseRules, ...template.rhythmRules.map(r => `节奏:${r}`)],
251
+ dialogueRules: template.dialogueRules,
252
+ descriptionRules: template.languageRules,
253
+ boundaries: [`模板「${template.name}」适用题材:${template.applicableGenres.join('、')}`, '不要违背模板的叙事单元结构与节奏约束'],
254
+ createdAt: new Date().toISOString(),
255
+ }
256
+ void patch({ styleAssets: [...(data.projectAssets.styleAssets ?? []), styleAsset] })
257
+ }}
258
+ >
259
+ {bound ? '✓ 已绑定' : '+ 绑定'}
260
+ </button>
261
+ </div>
262
+ <div className={css.meta}>{template.description}</div>
263
+ <div className={css.meta}>叙述:{template.proseRules.slice(0, 2).join(';')}</div>
264
+ <div className={css.meta}>台词:{template.dialogueRules.slice(0, 1).join(';')}</div>
265
+ </div>
266
+ )
267
+ })}
268
+ </div>
269
+ </div>
270
+ )}
271
+
272
+ {/* 反 AI 规则 */}
273
+ {assetTab === 'rules' && (
274
+ <div className={css.card}>
275
+ <span className={css.cardTitle}>反 AI 规则</span>
276
+ <span className={css.meta}>写作时必须遵守的表达边界(内置全局 + 项目自定义),生成与审稿都会检查。</span>
277
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 280, overflowY: 'auto' }}>
278
+ {builtinRules.map(rule => (
279
+ <div key={rule.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
280
+ <b>{rule.name}</b> <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)' }}>内置</span>
281
+ <div className={css.meta}>避免:{rule.avoid}</div>
282
+ <div className={css.meta}>修正:{rule.fix}</div>
283
+ </div>
284
+ ))}
285
+ {customRules.map(rule => (
286
+ <div key={rule.name} style={{ border: '1px solid var(--nf-accent)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
287
+ <b>{rule.name}</b> <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>自定义</span>
288
+ <div className={css.meta}>避免:{rule.avoid}</div>
289
+ {rule.fix !== '' && <div className={css.meta}>修正:{rule.fix}</div>}
290
+ </div>
291
+ ))}
292
+ </div>
293
+ <div className={css.row}>
294
+ <input
295
+ className={css.input}
296
+ style={{ flex: 1 }}
297
+ placeholder='新增规则(格式:{"name":"规则名","avoid":"要避免的","fix":"修正方向"};或直接填要避免的问题)'
298
+ value={newRule}
299
+ onChange={e => { setNewRule(e.target.value) }}
300
+ />
301
+ <button className={`${css.button} ${css.buttonPrimary}`} disabled={busy || newRule.trim() === ''} onClick={() => { void handleAddRule() }}>+ 添加</button>
302
+ </div>
303
+ </div>
304
+ )}
305
+
306
+ {/* 自定义写法引擎 */}
307
+ {assetTab === 'style' && (
308
+ <div className={css.card}>
309
+ <span className={css.cardTitle}>自定义写法引擎</span>
310
+ <span className={css.meta}>粘贴一段你喜欢的样本文本,AI 提取叙事风格规则并绑定到本书,后续章节保持同一味道。</span>
311
+ {assets.styleAssets.map(style => (
312
+ <div key={style.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>
313
+ <b>{style.name}</b>
314
+ <div className={css.meta}>叙述:{style.proseRules.slice(0, 3).join(';')}</div>
315
+ {style.dialogueRules.length > 0 && <div className={css.meta}>台词:{style.dialogueRules.slice(0, 2).join(';')}</div>}
316
+ </div>
317
+ ))}
318
+ <textarea
319
+ className={css.textarea}
320
+ style={{ minHeight: 90 }}
321
+ placeholder="粘贴样本文本(一段能代表目标风格的文字,50 字以上)…"
322
+ value={sampleText}
323
+ onChange={e => { setSampleText(e.target.value) }}
324
+ />
325
+ <div className={css.row}>
326
+ <input
327
+ className={css.input}
328
+ style={{ flex: 1 }}
329
+ placeholder="写法资产名(可选)"
330
+ value={styleName}
331
+ onChange={e => { setStyleName(e.target.value) }}
332
+ />
333
+ <button className={`${css.button} ${css.buttonPrimary}`} disabled={busy || sampleText.trim().length < 50} onClick={() => { void handleExtractStyle() }}>
334
+ 提取并绑定
335
+ </button>
336
+ </div>
337
+ </div>
338
+ )}
339
+ </div>
340
+ )
341
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * AI 助手页签:与 AI 编辑对话讨论剧情,助手可通过动作指令直接修改
3
+ * 大纲 / 设定圣经 / 章节。流式渲染回复,工具调用以事件行展示。
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 { AssistantFrame, AssistantMessage } from '../../protocol.ts'
9
+ import css from './panel.module.css'
10
+
11
+ /** One chat bubble (either side). */
12
+ interface ChatLine {
13
+ id: number
14
+ role: 'user' | 'assistant'
15
+ text: string
16
+ /** Tool events interleaved with the assistant reply. */
17
+ tools: Array<{ name: string; status: 'start' | 'done' | 'error'; detail?: string }>
18
+ /** Live output while a tool runs (generated text streamed into the bubble). */
19
+ live?: string
20
+ }
21
+
22
+ /** Props. */
23
+ export interface AssistantTabProps {
24
+ api: NovelApi
25
+ }
26
+
27
+ /** The assistant conversation tab. */
28
+ export function AssistantTab({ api }: AssistantTabProps) {
29
+ const [lines, setLines] = useState<ChatLine[]>([])
30
+ const [input, setInput] = useState('')
31
+ const [busy, setBusy] = useState(false)
32
+ const [error, setError] = useState('')
33
+ const idRef = useRef(0)
34
+ const scrollRef = useRef<HTMLDivElement | null>(null)
35
+
36
+ /** Append a bubble (or extend the current assistant bubble). */
37
+ const pushLine = useCallback((line: Omit<ChatLine, 'id'>) => {
38
+ setLines(prev => {
39
+ const last = prev[prev.length - 1]
40
+ // Extend the live assistant bubble while streaming.
41
+ if (line.role === 'assistant' && last !== undefined && last.role === 'assistant' && last.tools.length === 0) {
42
+ return [...prev.slice(0, -1), { ...last, text: last.text + line.text }]
43
+ }
44
+ return [...prev, { ...line, id: idRef.current++ }]
45
+ })
46
+ }, [])
47
+
48
+ /** Push a tool event onto the current assistant bubble. */
49
+ const pushTool = useCallback((tool: ChatLine['tools'][number]) => {
50
+ setLines(prev => {
51
+ const last = prev[prev.length - 1]
52
+ if (last === undefined || last.role !== 'assistant') {
53
+ return [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [tool] }]
54
+ }
55
+ return [...prev.slice(0, -1), { ...last, tools: [...last.tools, tool], live: undefined }]
56
+ })
57
+ }, [])
58
+
59
+ /** Append live tool output onto the current assistant bubble. */
60
+ const pushToolDelta = useCallback((text: string) => {
61
+ setLines(prev => {
62
+ const last = prev[prev.length - 1]
63
+ if (last === undefined || last.role !== 'assistant') return prev
64
+ return [...prev.slice(0, -1), { ...last, live: (last.live ?? '') + text }]
65
+ })
66
+ }, [])
67
+
68
+ /** Load persisted history on mount. */
69
+ useEffect(() => {
70
+ let cancelled = false
71
+ void (async () => {
72
+ try {
73
+ const history = await api.assistantHistory()
74
+ if (cancelled) return
75
+ const restored: ChatLine[] = []
76
+ for (const entry of history) {
77
+ if (entry.role === 'user') {
78
+ restored.push({ id: idRef.current++, role: 'user', text: entry.content, tools: [] })
79
+ } else if (entry.role === 'assistant') {
80
+ 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
+ }
87
+ }
88
+ setLines(restored)
89
+ } catch (err) {
90
+ if (!cancelled) setError((err as Error).message)
91
+ }
92
+ })()
93
+ return () => { cancelled = true }
94
+ }, [api])
95
+
96
+ /** Auto-scroll to the newest line. */
97
+ useEffect(() => {
98
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
99
+ }, [lines])
100
+
101
+ /** Send one message. */
102
+ const handleSend = async (): Promise<void> => {
103
+ const message = input.trim()
104
+ if (message === '' || busy) return
105
+ setInput('')
106
+ setError('')
107
+ pushLine({ role: 'user', text: message, tools: [] })
108
+ // Start an empty assistant bubble.
109
+ setLines(prev => [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [] }])
110
+ setBusy(true)
111
+ try {
112
+ await api.assistant(message, (frame: AssistantFrame) => {
113
+ if (frame.type === 'delta') {
114
+ pushLine({ role: 'assistant', text: frame.text, tools: [] })
115
+ } else if (frame.type === 'tool') {
116
+ pushTool({
117
+ name: frame.name,
118
+ status: frame.status,
119
+ detail: frame.detail,
120
+ })
121
+ } else if (frame.type === 'toolDelta') {
122
+ pushToolDelta(frame.text)
123
+ } else if (frame.type === 'error') {
124
+ pushTool({ name: 'error', status: 'error', detail: frame.message })
125
+ }
126
+ })
127
+ } catch (err) {
128
+ setError((err as Error).message)
129
+ } finally {
130
+ setBusy(false)
131
+ }
132
+ }
133
+
134
+ return (
135
+ <div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
136
+ <span className={css.cardTitle}>{tt('tab.assistant')}</span>
137
+ <span className={css.meta}>{tt('assistant.hint')}</span>
138
+ {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>{tt('common.error')}: {error}</span>}
139
+ <div
140
+ ref={scrollRef}
141
+ className={css.chatScroll}
142
+ >
143
+ {lines.length === 0 && <span className={css.meta}>{tt('assistant.empty')}</span>}
144
+ {lines.map(line => (
145
+ <div key={line.id} className={line.role === 'user' ? css.chatBubbleUser : css.chatBubbleAssistant}>
146
+ {line.role === 'user' && <div className={css.chatRole}>你</div>}
147
+ {line.text !== '' && <div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{line.text}</div>}
148
+ {line.live !== undefined && line.live !== '' && (
149
+ <div className={css.toolLive}>{line.live}</div>
150
+ )}
151
+ {line.tools.length > 0 && (
152
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 4, fontSize: 11 }}>
153
+ {line.tools.map((tool, i) => (
154
+ <span key={i} style={{ color: tool.status === 'error' ? 'var(--nf-error)' : tool.status === 'start' ? 'var(--nf-accent)' : 'var(--nf-success)' }}>
155
+ {tool.status === 'start'
156
+ ? tt('assistant.toolStart', { name: tool.name })
157
+ : tool.status === 'done'
158
+ ? tt('assistant.toolDone', { name: tool.name, detail: tool.detail ?? '' })
159
+ : tt('assistant.toolError', { name: tool.name, detail: tool.detail ?? '' })}
160
+ </span>
161
+ ))}
162
+ </div>
163
+ )}
164
+ </div>
165
+ ))}
166
+ {busy && <span className={css.meta} style={{ color: 'var(--nf-accent)' }}>…</span>}
167
+ </div>
168
+ <div className={css.row} style={{ marginTop: 8 }}>
169
+ <textarea
170
+ className={css.textarea}
171
+ style={{ minHeight: 64, flex: 1 }}
172
+ placeholder={tt('assistant.placeholder')}
173
+ value={input}
174
+ onChange={e => { setInput(e.target.value) }}
175
+ onKeyDown={e => {
176
+ if (e.key === 'Enter' && !e.shiftKey) {
177
+ e.preventDefault()
178
+ void handleSend()
179
+ }
180
+ }}
181
+ />
182
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || input.trim() === ''} onClick={() => { void handleSend() }}>
183
+ {tt('assistant.send')}
184
+ </button>
185
+ </div>
186
+ </div>
187
+ )
188
+ }