@waterwx/dsh-novel-forge 2.0.0-alpha.1 → 2.0.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 (206) hide show
  1. package/README.md +286 -285
  2. package/lib/client.js +19561 -19439
  3. package/lib/index.js +8159 -4456
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +0 -6
  6. package/lib/types/assets.d.ts +20 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +1 -3
  9. package/lib/types/bookshelf.d.ts +13 -3
  10. package/lib/types/client/api.d.ts +136 -27
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  13. package/lib/types/client/llmLive.d.ts +31 -0
  14. package/lib/types/client/locales.d.ts +15 -70
  15. package/lib/types/client/mount.d.ts +0 -2
  16. package/lib/types/client/panel/AiTag.d.ts +1 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  18. package/lib/types/client/panel/AuthorHome.d.ts +13 -11
  19. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  20. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  21. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  22. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  23. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  24. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  25. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  26. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  27. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  28. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  29. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  30. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  31. package/lib/types/client/panel/SettingsView.d.ts +12 -9
  32. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  33. package/lib/types/client/panel/SubPage.d.ts +27 -0
  34. package/lib/types/client/panel/TensionView.d.ts +18 -0
  35. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  36. package/lib/types/client/panel/helpers.d.ts +5 -3
  37. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  38. package/lib/types/client/panel/toast.d.ts +2 -0
  39. package/lib/types/client/panel/views.d.ts +21 -35
  40. package/lib/types/client/run-status.d.ts +21 -0
  41. package/lib/types/client/sidebar-entry.d.ts +0 -2
  42. package/lib/types/docx-core.d.ts +16 -0
  43. package/lib/types/docx.d.ts +2 -3
  44. package/lib/types/engine.d.ts +78 -103
  45. package/lib/types/global-assets.d.ts +6 -0
  46. package/lib/types/home.d.ts +11 -0
  47. package/lib/types/index.d.ts +26 -12
  48. package/lib/types/llm-live.d.ts +70 -0
  49. package/lib/types/llm-retry.d.ts +38 -0
  50. package/lib/types/market-radar-scan.d.ts +18 -0
  51. package/lib/types/market-radar-sources.d.ts +31 -0
  52. package/lib/types/migrate-output.d.ts +25 -0
  53. package/lib/types/model-capability.d.ts +93 -0
  54. package/lib/types/output-budget.d.ts +86 -0
  55. package/lib/types/prompt-slots.d.ts +57 -0
  56. package/lib/types/prompting.d.ts +10 -0
  57. package/lib/types/protocol.d.ts +816 -601
  58. package/lib/types/revision.d.ts +76 -0
  59. package/lib/types/routes.d.ts +9 -3
  60. package/lib/types/run.d.ts +23 -1
  61. package/lib/types/settings-persist.d.ts +59 -0
  62. package/lib/types/snapshots.d.ts +46 -0
  63. package/lib/types/stage-contract.d.ts +58 -0
  64. package/lib/types/tension.d.ts +53 -0
  65. package/lib/types/timeline.d.ts +37 -0
  66. package/package.json +50 -25
  67. package/src/action-guard.ts +127 -0
  68. package/src/ai-scan.ts +0 -24
  69. package/src/assets.ts +1801 -879
  70. package/src/assistant.ts +1098 -770
  71. package/src/author-assets.ts +131 -147
  72. package/src/bookshelf.ts +193 -167
  73. package/src/client/api.ts +767 -617
  74. package/src/client/chip-prefs.ts +121 -0
  75. package/src/client/docx.ts +32 -69
  76. package/src/client/index.ts +2 -0
  77. package/src/client/llmLive.ts +111 -0
  78. package/src/client/locales.ts +15 -274
  79. package/src/client/mount.tsx +3 -2
  80. package/src/client/panel/AdaptModeView.tsx +535 -520
  81. package/src/client/panel/AiTag.tsx +7 -0
  82. package/src/client/panel/AssetsTab.tsx +448 -458
  83. package/src/client/panel/AssistantTab.tsx +355 -301
  84. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  85. package/src/client/panel/AuthorHome.tsx +309 -204
  86. package/src/client/panel/BookAnalysisView.tsx +63 -0
  87. package/src/client/panel/CmdPalette.tsx +102 -0
  88. package/src/client/panel/CreateBookView.tsx +366 -352
  89. package/src/client/panel/DirectorView.tsx +200 -0
  90. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  91. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  92. package/src/client/panel/ImportModal.tsx +302 -302
  93. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  94. package/src/client/panel/LiveFeedLog.tsx +204 -0
  95. package/src/client/panel/MarketRadarView.tsx +415 -0
  96. package/src/client/panel/ModelManager.tsx +205 -205
  97. package/src/client/panel/NovelPanel.tsx +3144 -2205
  98. package/src/client/panel/PageHeader.tsx +37 -0
  99. package/src/client/panel/ProgressConsole.tsx +63 -64
  100. package/src/client/panel/PromptSlotsView.tsx +97 -0
  101. package/src/client/panel/ReaderView.tsx +306 -306
  102. package/src/client/panel/RunPanel.tsx +56 -24
  103. package/src/client/panel/SettingsView.tsx +175 -106
  104. package/src/client/panel/ShelfView.tsx +63 -39
  105. package/src/client/panel/SubPage.tsx +42 -0
  106. package/src/client/panel/TensionView.tsx +212 -0
  107. package/src/client/panel/TimelineView.tsx +208 -0
  108. package/src/client/panel/WorldTab.tsx +202 -202
  109. package/src/client/panel/helpers.ts +7 -5
  110. package/src/client/panel/market-radar-state.ts +79 -0
  111. package/src/client/panel/panel.module.css +5767 -4360
  112. package/src/client/panel/toast.ts +51 -0
  113. package/src/client/panel/views.tsx +316 -314
  114. package/src/client/run-status.ts +171 -0
  115. package/src/client/sidebar-entry.ts +3 -3
  116. package/src/docx-core.ts +53 -0
  117. package/src/docx.ts +44 -83
  118. package/src/engine.ts +1197 -1638
  119. package/src/global-assets.ts +58 -0
  120. package/src/home.ts +33 -0
  121. package/src/index.ts +181 -101
  122. package/src/llm-live.ts +245 -0
  123. package/src/llm-retry.ts +77 -0
  124. package/src/market-radar-scan.ts +35 -0
  125. package/src/market-radar-sources.ts +173 -0
  126. package/src/migrate-output.ts +76 -0
  127. package/src/model-capability.ts +215 -0
  128. package/src/novel-context.ts +286 -287
  129. package/src/output-budget.ts +147 -0
  130. package/src/prompt-slots.ts +125 -0
  131. package/src/prompting.ts +26 -0
  132. package/src/protocol.ts +835 -614
  133. package/src/revision.ts +283 -0
  134. package/src/routes.ts +1286 -910
  135. package/src/run.ts +113 -17
  136. package/src/settings-persist.ts +114 -0
  137. package/src/snapshots.ts +174 -0
  138. package/src/stage-contract.ts +186 -0
  139. package/src/tension.ts +251 -0
  140. package/src/timeline.ts +159 -0
  141. package/assets/styles/arcane-thick-3d.webp +0 -0
  142. package/assets/styles/chinese-ink.webp +0 -0
  143. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  144. package/assets/styles/dark-ghibli.webp +0 -0
  145. package/assets/styles/dark-gothic-3d.webp +0 -0
  146. package/assets/styles/film-photo.webp +0 -0
  147. package/assets/styles/guofeng-heavy.webp +0 -0
  148. package/assets/styles/guoman-2d-gufeng.webp +0 -0
  149. package/assets/styles/guoman-3d-tianyuan.webp +0 -0
  150. package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
  151. package/assets/styles/horror-game-3d.webp +0 -0
  152. package/assets/styles/hyperreal-3d.webp +0 -0
  153. package/assets/styles/japan-cel-dark.webp +0 -0
  154. package/assets/styles/korean-webtoon.webp +0 -0
  155. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  156. package/assets/styles/nextgen-cg.webp +0 -0
  157. package/assets/styles/noir-film.webp +0 -0
  158. package/assets/styles/openworld-survival.webp +0 -0
  159. package/assets/styles/paper-cut-3d.webp +0 -0
  160. package/assets/styles/pixar-adult-3d.webp +0 -0
  161. package/assets/styles/pop-marvel-2d.webp +0 -0
  162. package/assets/styles/sandiao-qban.webp +0 -0
  163. package/assets/styles/shinkai-anime.webp +0 -0
  164. package/assets/styles/tactical-stealth.webp +0 -0
  165. package/assets/styles/urban-lightreal-3d.webp +0 -0
  166. package/assets/styles/vaporwave-3d.webp +0 -0
  167. package/assets/styles/vhs90.webp +0 -0
  168. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  169. package/lib/types/client/panel/FlowGuide.d.ts +0 -22
  170. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
  171. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
  172. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  173. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  174. package/lib/types/client/panel/PropLibrary.d.ts +0 -8
  175. package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
  176. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  177. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  178. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  179. package/lib/types/comic-presets.d.ts +0 -12
  180. package/lib/types/manga-genre-rules.d.ts +0 -22
  181. package/lib/types/prompt-utils.d.ts +0 -35
  182. package/lib/types/review-policy.d.ts +0 -97
  183. package/lib/types/shot-language.d.ts +0 -46
  184. package/lib/types/story-beat-language.d.ts +0 -35
  185. package/lib/types/style-library.d.ts +0 -33
  186. package/skills/chapter-batch/SKILL.md +0 -81
  187. package/src/client/mount.tsx._new +0 -97
  188. package/src/client/mount.tsx._nf +0 -97
  189. package/src/client/mount.tsx._tmp +0 -97
  190. package/src/client/panel/BookshelfBar.tsx +0 -170
  191. package/src/client/panel/FlowGuide.tsx +0 -84
  192. package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
  193. package/src/client/panel/MangaWorkspace.tsx +0 -715
  194. package/src/client/panel/ProgressHomeView.tsx +0 -87
  195. package/src/client/panel/PropLibrary.tsx +0 -175
  196. package/src/client/panel/SceneLibrary.tsx +0 -453
  197. package/src/client/panel/StoryboardTab.tsx +0 -472
  198. package/src/client/panel/StyleCard.tsx +0 -90
  199. package/src/client/panel/StyleGallery.tsx +0 -58
  200. package/src/comic-presets.ts +0 -67
  201. package/src/manga-genre-rules.ts +0 -87
  202. package/src/prompt-utils.ts +0 -73
  203. package/src/review-policy.ts +0 -161
  204. package/src/shot-language.ts +0 -181
  205. package/src/story-beat-language.ts +0 -130
  206. package/src/style-library.ts +0 -84
@@ -1,208 +1,206 @@
1
- /**
2
- * 作者资产库/总数据页:跨书可复用的笔法/红线/套路/角色模板/世界观模板。
3
- * 支持 新增 / 编辑 / 删除;数据持久化到 ~/.dsh/dsh-novel-forge-author-assets.json。
4
- */
5
- import { useEffect, useMemo, useState } from 'react'
6
- import { Plus, Trash2, Pencil } from 'lucide-react'
7
- import type { NovelApi } from '../api.ts'
8
- import type { AuthorStyleAsset } from '../../protocol.ts'
9
- import css from './panel.module.css'
10
-
11
- const KIND_LABELS: Record<AuthorStyleAsset['kind'], string> = {
12
- style: '笔法',
13
- antiAi: '红线/反AI',
14
- progression: '推进模式',
15
- genre: '题材',
16
- roleTemplate: '角色模板',
17
- worldTemplate: '世界观模板',
18
- plotBeat: '剧情桥段',
19
- custom: '自定义',
20
- }
21
-
22
- const KIND_OPTIONS = Object.keys(KIND_LABELS) as AuthorStyleAsset['kind'][]
23
-
24
- const KIND_COLORS: Record<AuthorStyleAsset['kind'], string> = {
25
- style: 'var(--nf-info)',
26
- antiAi: 'var(--nf-warn)',
27
- progression: 'var(--nf-accent)',
28
- genre: 'var(--nf-info)',
29
- roleTemplate: 'var(--nf-accent)',
30
- worldTemplate: 'var(--nf-warn)',
31
- plotBeat: 'var(--nf-accent)',
32
- custom: 'var(--nf-muted)',
33
- }
34
-
35
- /** 计算资产的分组(分类):题材按顶层题材名('/' 之前),其余按类型。 */
36
- function assetCategory(a: AuthorStyleAsset): string {
37
- if (a.kind === 'genre') {
38
- const top = a.name.split('/')[0]?.trim()
39
- return top !== undefined && top !== '' ? top : '题材'
40
- }
41
- return KIND_LABELS[a.kind] ?? '其他'
42
- }
43
-
44
- interface EditorState {
45
- id?: string
46
- name: string; kind: AuthorStyleAsset['kind']; summary: string; content: string; tags: string; sourceBooks: string
47
- }
48
-
49
- function emptyEditor(): EditorState {
50
- return { name: '', kind: 'style', summary: '', content: '', tags: '', sourceBooks: '' }
51
- }
52
-
53
- export function AuthorAssetsView({ api }: { api: NovelApi }) {
54
- const [assets, setAssets] = useState<AuthorStyleAsset[]>([]);
55
- const [editing, setEditing] = useState<EditorState | null>(null);
56
- const [error, setError] = useState('');
57
- const [busy, setBusy] = useState(false);
58
- const [kindFilter, setKindFilter] = useState<AuthorStyleAsset['kind'] | 'all'>('all');
59
-
60
- const load = async (): Promise<void> => {
61
- try { const res = await api.authorAssets(); setAssets(res.assets.items) } catch (err) { setError((err as Error).message) }
62
- };
63
-
64
- /** 导入默认:把书架书的写作资产/角色 + 内置全局库批量沉淀到作者资产库。 */
65
- const importDefaults = async (): Promise<void> => {
66
- setBusy(true); setError('');
67
- try { const res = await api.authorAssetsImportDefault(); setAssets(res.assets.items) } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
68
- };
69
-
70
- // 打开即加载;若资产库为空,自动导入默认(书架资产 + 内置全局库)。
71
- useEffect(() => {
72
- let cancelled = false
73
- void (async () => {
74
- try {
75
- const res = await api.authorAssets()
76
- if (cancelled) return
77
- if (res.assets.items.length === 0) {
78
- const imp = await api.authorAssetsImportDefault()
79
- if (!cancelled) setAssets(imp.assets.items)
80
- } else {
81
- setAssets(res.assets.items)
82
- }
83
- } catch (err) {
84
- if (!cancelled) setError((err as Error).message)
85
- }
86
- })()
87
- return () => { cancelled = true }
88
- }, [api]);
89
-
90
- const startNew = (): void => { setEditing(emptyEditor()); setError('') };
91
- const startEdit = (a: AuthorStyleAsset): void => {
92
- setEditing({ id: a.id, name: a.name, kind: a.kind, summary: a.summary, content: a.content, tags: a.tags.join('、'), sourceBooks: a.sourceBooks.join('、') });
93
- setError('');
94
- };
95
-
96
- const save = async (): Promise<void> => {
97
- if (editing === null) return;
98
- if (editing.name.trim() === '') { setError('请填写资产名'); return }
99
- setBusy(true); setError('');
100
- try {
101
- const asset: AuthorStyleAsset = {
102
- id: editing.id ?? '', name: editing.name.trim(), kind: editing.kind, summary: editing.summary.trim(), content: editing.content.trim(),
103
- sourceBooks: editing.sourceBooks.split(/[、,,\s]+/).map(s => s.trim()).filter(Boolean),
104
- tags: editing.tags.split(/[、,,\s]+/).map(s => s.trim()).filter(Boolean),
105
- createdAt: '', updatedAt: '',
106
- };
107
- await api.authorAssetsUpsert(asset); setEditing(null); await load();
108
- } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
109
- };
110
-
111
- const del = async (id: string): Promise<void> => {
112
- setBusy(true); setError('');
113
- try { await api.authorAssetsRemove(id); await load() } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
114
- };
115
-
116
- const visible = useMemo(() => (kindFilter === 'all' ? assets : assets.filter(a => a.kind === kindFilter)), [assets, kindFilter]);
117
-
118
- /** 按分类分组展示(题材按顶层题材名,其余按类型)。 */
119
- const grouped = useMemo(() => {
120
- const map = new Map<string, AuthorStyleAsset[]>()
121
- for (const a of visible) {
122
- const cat = assetCategory(a)
123
- const arr = map.get(cat) ?? []
124
- arr.push(a)
125
- map.set(cat, arr)
126
- }
127
- return Array.from(map.entries()).sort((x, y) => x[0].localeCompare(y[0], 'zh'))
128
- }, [visible]);
129
-
130
- return (
131
- <div className={css.authorPageBody}>
132
- <div className={css.authorPageHero}>
133
- <div className={css.authorPageHeader}>
134
- <div>
135
- <h2 className={css.panelTitle} style={{ margin: 0 }}>🧬 作者资产库 · 总数据</h2>
136
- <span className={css.meta}>跨书可复用的笔法/红线/套路/角色模板/世界观模板。任意新书与改编都能调用。</span>
137
- </div>
138
- <div className={css.rowEnd}>
139
- <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { void importDefaults() }} disabled={busy} title="把书架里所有书的写作资产/角色批量沉淀到作者资产库(内置库请到全局写作资产库浏览/套用)">导入默认资产</button>
140
- <button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={startNew}><Plus size={14} style={{ verticalAlign: -2 }} /> 新增资产</button>
141
- </div>
142
- </div>
143
- <div className={css.assetFilterBar}>
144
- {KIND_OPTIONS.map(k => (
145
- <button key={k} type="button" className={css.assetFilterChip + (kindFilter === k ? ' ' + css.assetFilterChipActive : '')} onClick={() => setKindFilter(kindFilter === k ? 'all' : k)}>{KIND_LABELS[k]} {assets.filter(a => a.kind === k).length}</button>
146
- ))}
147
- </div>
148
- </div>
149
-
150
- {error !== '' && <div className={css.noticeError}>{error}</div>}
151
-
152
- {editing !== null ? (
153
- <div className={css.assetEditor}>
154
- <h3 style={{ margin: 0 }}>{editing.id !== '' && editing.id !== undefined ? '编辑资产' : '新增资产'}</h3>
155
- <div className={css.assetEditorRow}>
156
- <label className={css.assetLabel}>资产名 <input className={css.input} value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} placeholder="如:短句快节奏爽文风" /></label>
157
- <label className={css.assetLabel}>类型 <select className={css.input} value={editing.kind} onChange={e => setEditing({ ...editing, kind: e.target.value as AuthorStyleAsset['kind'] })}>{KIND_OPTIONS.map(k => <option key={k} value={k}>{KIND_LABELS[k]}</option>)}</select></label>
158
- </div>
159
- <label className={css.assetLabel}>一句话摘要 <input className={css.input} value={editing.summary} onChange={e => setEditing({ ...editing, summary: e.target.value })} placeholder="这个资产解决什么问题 / 有什么特点" /></label>
160
- <label className={css.assetLabel}>内容(按类型约定的描述 / 规则 / 结构化文本)
161
- <textarea className={`${css.input} ${css.assetTextarea}`} value={editing.content} onChange={e => setEditing({ ...editing, content: e.target.value })} placeholder={'角色模板示例:\n姓名:林越\n定位:痞坏型主角\n口头禅:"有点意思"\n性格:表面懒散、内里狠辣\n成长弧线:从底层游街到一方枭雄'} />
162
- </label>
163
- <div className={css.assetEditorRow}>
164
- <label className={css.assetLabel}>标签(顿号分隔) <input className={css.input} value={editing.tags} onChange={e => setEditing({ ...editing, tags: e.target.value })} placeholder="扮猪吃虎、快节奏、冷面" /></label>
165
- <label className={css.assetLabel}>来源书(顿号分隔) <input className={css.input} value={editing.sourceBooks} onChange={e => setEditing({ ...editing, sourceBooks: e.target.value })} placeholder="《某某传》《某某录》" /></label>
166
- </div>
167
- <div className={css.rowEnd}><button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={save} disabled={busy}>{busy ? '保存中…' : '保存'}</button>{(editing.id !== '' && editing.id !== undefined) && <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => setEditing(null)}>取消</button>}</div>
168
- </div>
169
- ) : null}
170
-
171
- {visible.length === 0 ? (
172
- <div className={css.shelfEmpty} style={{ minHeight: 160 }}>
173
- <span className={css.shelfEmptyIcon}><Plus size={30} /></span>
174
- <span className={css.shelfEmptyTitle}>还没有资产</span>
175
- <span className={css.meta}>点「新增资产」收藏你的笔法/角色模板/套路,跨书调用。</span>
176
- </div>
177
- ) : (
178
- <div className={css.assetGroups}>
179
- {grouped.map(([cat, items]) => (
180
- <div key={cat} className={css.assetGroup}>
181
- <div className={css.assetGroupHeader}>{cat} <span className={css.meta}>{items.length}</span></div>
182
- <div className={css.assetGrid}>
183
- {items.map(a => (
184
- <div key={a.id} className={css.assetCard}>
185
- <div className={css.assetCardTop}>
186
- <span className={css.assetKind} style={{ color: KIND_COLORS[a.kind] }}>{KIND_LABELS[a.kind]}</span>
187
- <div className={css.rowEnd}>
188
- <button type="button" className={css.iconBtn} title="编辑" onClick={() => startEdit(a)}><Pencil size={13} /></button>
189
- <button type="button" className={css.iconBtn} title="删除" onClick={() => { void del(a.id) }}><Trash2 size={13} /></button>
190
- </div>
191
- </div>
192
- <div className={css.assetCardName}>{a.name}</div>
193
- {a.summary !== '' && <div className={css.meta}>{a.summary}</div>}
194
- <div className={css.assetCardContent}>{a.content}</div>
195
- <div className={css.assetCardFooter}>
196
- {a.tags.map(t => <span key={t} className={css.badge}>{t}</span>)}
197
- {a.sourceBooks.length > 0 && <span className={css.meta}>来源:{a.sourceBooks.join(' / ')}</span>}
198
- </div>
199
- </div>
200
- ))}
201
- </div>
202
- </div>
203
- ))}
204
- </div>
205
- )}
206
- </div>
207
- );
1
+ /**
2
+ * 作者资产库/总数据页:跨书可复用的笔法/红线/套路/角色模板/世界观模板。
3
+ * 支持 新增 / 编辑 / 删除;数据持久化到 Harness 数据目录(`$DSH_HOME`,未设时 ~/.dsh)。
4
+ */
5
+ import { useEffect, useMemo, useState } from 'react'
6
+ import { Plus, Trash2, Pencil } from 'lucide-react'
7
+ import type { NovelApi } from '../api.ts'
8
+ import type { AuthorStyleAsset } from '../../protocol.ts'
9
+ import css from './panel.module.css'
10
+ import { SubPage } from './SubPage.tsx'
11
+
12
+ const KIND_LABELS: Record<AuthorStyleAsset['kind'], string> = {
13
+ style: '笔法',
14
+ antiAi: '红线/反AI',
15
+ progression: '推进模式',
16
+ genre: '题材',
17
+ roleTemplate: '角色模板',
18
+ worldTemplate: '世界观模板',
19
+ plotBeat: '剧情桥段',
20
+ custom: '自定义',
21
+ }
22
+
23
+ const KIND_OPTIONS = Object.keys(KIND_LABELS) as AuthorStyleAsset['kind'][]
24
+
25
+ const KIND_COLORS: Record<AuthorStyleAsset['kind'], string> = {
26
+ style: 'var(--nf-info)',
27
+ antiAi: 'var(--nf-warn)',
28
+ progression: 'var(--nf-accent)',
29
+ genre: 'var(--nf-info)',
30
+ roleTemplate: 'var(--nf-accent)',
31
+ worldTemplate: 'var(--nf-warn)',
32
+ plotBeat: 'var(--nf-accent)',
33
+ custom: 'var(--nf-muted)',
34
+ }
35
+
36
+ /** 计算资产的分组(分类):题材按顶层题材名('/' 之前),其余按类型。 */
37
+ function assetCategory(a: AuthorStyleAsset): string {
38
+ if (a.kind === 'genre') {
39
+ const top = a.name.split('/')[0]?.trim()
40
+ return top !== undefined && top !== '' ? top : '题材'
41
+ }
42
+ return KIND_LABELS[a.kind] ?? '其他'
43
+ }
44
+
45
+ interface EditorState {
46
+ id?: string
47
+ name: string; kind: AuthorStyleAsset['kind']; summary: string; content: string; tags: string; sourceBooks: string
48
+ }
49
+
50
+ function emptyEditor(): EditorState {
51
+ return { name: '', kind: 'style', summary: '', content: '', tags: '', sourceBooks: '' }
52
+ }
53
+
54
+ export function AuthorAssetsView({ api }: { api: NovelApi }) {
55
+ const [assets, setAssets] = useState<AuthorStyleAsset[]>([]);
56
+ const [editing, setEditing] = useState<EditorState | null>(null);
57
+ const [error, setError] = useState('');
58
+ const [busy, setBusy] = useState(false);
59
+ const [kindFilter, setKindFilter] = useState<AuthorStyleAsset['kind'] | 'all'>('all');
60
+
61
+ const load = async (): Promise<void> => {
62
+ try { const res = await api.authorAssets(); setAssets(res.assets.items) } catch (err) { setError((err as Error).message) }
63
+ };
64
+
65
+ /** 导入默认:把书架书的写作资产/角色 + 内置全局库批量沉淀到作者资产库。 */
66
+ const importDefaults = async (): Promise<void> => {
67
+ setBusy(true); setError('');
68
+ try { const res = await api.authorAssetsImportDefault(); setAssets(res.assets.items) } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
69
+ };
70
+
71
+ // 打开即加载;若资产库为空,自动导入默认(书架资产 + 内置全局库)。
72
+ useEffect(() => {
73
+ let cancelled = false
74
+ void (async () => {
75
+ try {
76
+ const res = await api.authorAssets()
77
+ if (cancelled) return
78
+ if (res.assets.items.length === 0) {
79
+ const imp = await api.authorAssetsImportDefault()
80
+ if (!cancelled) setAssets(imp.assets.items)
81
+ } else {
82
+ setAssets(res.assets.items)
83
+ }
84
+ } catch (err) {
85
+ if (!cancelled) setError((err as Error).message)
86
+ }
87
+ })()
88
+ return () => { cancelled = true }
89
+ }, [api]);
90
+
91
+ const startNew = (): void => { setEditing(emptyEditor()); setError('') };
92
+ const startEdit = (a: AuthorStyleAsset): void => {
93
+ setEditing({ id: a.id, name: a.name, kind: a.kind, summary: a.summary, content: a.content, tags: a.tags.join('、'), sourceBooks: a.sourceBooks.join('、') });
94
+ setError('');
95
+ };
96
+
97
+ const save = async (): Promise<void> => {
98
+ if (editing === null) return;
99
+ if (editing.name.trim() === '') { setError('请填写资产名'); return }
100
+ setBusy(true); setError('');
101
+ try {
102
+ const asset: AuthorStyleAsset = {
103
+ id: editing.id ?? '', name: editing.name.trim(), kind: editing.kind, summary: editing.summary.trim(), content: editing.content.trim(),
104
+ sourceBooks: editing.sourceBooks.split(/[、,,\s]+/).map(s => s.trim()).filter(Boolean),
105
+ tags: editing.tags.split(/[、,,\s]+/).map(s => s.trim()).filter(Boolean),
106
+ createdAt: '', updatedAt: '',
107
+ };
108
+ await api.authorAssetsUpsert(asset); setEditing(null); await load();
109
+ } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
110
+ };
111
+
112
+ const del = async (id: string): Promise<void> => {
113
+ setBusy(true); setError('');
114
+ try { await api.authorAssetsRemove(id); await load() } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
115
+ };
116
+
117
+ const visible = useMemo(() => (kindFilter === 'all' ? assets : assets.filter(a => a.kind === kindFilter)), [assets, kindFilter]);
118
+
119
+ /** 按分类分组展示(题材按顶层题材名,其余按类型)。 */
120
+ const grouped = useMemo(() => {
121
+ const map = new Map<string, AuthorStyleAsset[]>()
122
+ for (const a of visible) {
123
+ const cat = assetCategory(a)
124
+ const arr = map.get(cat) ?? []
125
+ arr.push(a)
126
+ map.set(cat, arr)
127
+ }
128
+ return Array.from(map.entries()).sort((x, y) => x[0].localeCompare(y[0], 'zh'))
129
+ }, [visible]);
130
+
131
+ return (
132
+ <SubPage
133
+ title="作者资产库"
134
+ meta="跨书可复用的笔法/红线/套路/角色模板/世界观模板。任意新书与改编都能调用。"
135
+ actions={(
136
+ <>
137
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { void importDefaults() }} disabled={busy} title="把书架里所有书的写作资产/角色批量沉淀到作者资产库(内置库请到全局写作资产库浏览/套用)">导入默认资产</button>
138
+ <button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={startNew}><Plus size={14} style={{ verticalAlign: -2 }} /> 新增资产</button>
139
+ </>
140
+ )}
141
+ >
142
+ <div className={css.shelfToolbar}>
143
+ {KIND_OPTIONS.map(k => (
144
+ <button key={k} type="button" className={css.assetFilterChip + (kindFilter === k ? ' ' + css.assetFilterChipActive : '')} onClick={() => setKindFilter(kindFilter === k ? 'all' : k)}>{KIND_LABELS[k]} {assets.filter(a => a.kind === k).length}</button>
145
+ ))}
146
+ </div>
147
+
148
+ {error !== '' && <div className={css.noticeError}>{error}</div>}
149
+
150
+ {editing !== null ? (
151
+ <div className={css.assetEditor}>
152
+ <h3 style={{ margin: 0 }}>{editing.id !== '' && editing.id !== undefined ? '编辑资产' : '新增资产'}</h3>
153
+ <div className={css.assetEditorRow}>
154
+ <label className={css.assetLabel}>资产名 <input className={css.input} value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} placeholder="如:短句快节奏爽文风" /></label>
155
+ <label className={css.assetLabel}>类型 <select className={css.input} value={editing.kind} onChange={e => setEditing({ ...editing, kind: e.target.value as AuthorStyleAsset['kind'] })}>{KIND_OPTIONS.map(k => <option key={k} value={k}>{KIND_LABELS[k]}</option>)}</select></label>
156
+ </div>
157
+ <label className={css.assetLabel}>一句话摘要 <input className={css.input} value={editing.summary} onChange={e => setEditing({ ...editing, summary: e.target.value })} placeholder="这个资产解决什么问题 / 有什么特点" /></label>
158
+ <label className={css.assetLabel}>内容(按类型约定的描述 / 规则 / 结构化文本)
159
+ <textarea className={`${css.input} ${css.assetTextarea}`} value={editing.content} onChange={e => setEditing({ ...editing, content: e.target.value })} placeholder={'角色模板示例:\n姓名:林越\n定位:痞坏型主角\n口头禅:"有点意思"\n性格:表面懒散、内里狠辣\n成长弧线:从底层游街到一方枭雄'} />
160
+ </label>
161
+ <div className={css.assetEditorRow}>
162
+ <label className={css.assetLabel}>标签(顿号分隔) <input className={css.input} value={editing.tags} onChange={e => setEditing({ ...editing, tags: e.target.value })} placeholder="扮猪吃虎、快节奏、冷面" /></label>
163
+ <label className={css.assetLabel}>来源书(顿号分隔) <input className={css.input} value={editing.sourceBooks} onChange={e => setEditing({ ...editing, sourceBooks: e.target.value })} placeholder="《某某传》《某某录》" /></label>
164
+ </div>
165
+ <div className={css.rowEnd}><button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={save} disabled={busy}>{busy ? '保存中…' : '保存'}</button>{(editing.id !== '' && editing.id !== undefined) && <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => setEditing(null)}>取消</button>}</div>
166
+ </div>
167
+ ) : null}
168
+
169
+ {visible.length === 0 ? (
170
+ <div className={css.shelfEmpty} style={{ minHeight: 160 }}>
171
+ <span className={css.shelfEmptyIcon}><Plus size={30} /></span>
172
+ <span className={css.shelfEmptyTitle}>还没有资产</span>
173
+ <span className={css.meta}>点「新增资产」收藏你的笔法/角色模板/套路,跨书调用。</span>
174
+ </div>
175
+ ) : (
176
+ <div className={css.assetGroups}>
177
+ {grouped.map(([cat, items]) => (
178
+ <div key={cat} className={css.assetGroup}>
179
+ <div className={css.assetGroupHeader}>{cat} <span className={css.meta}>{items.length}</span></div>
180
+ <div className={css.assetGrid}>
181
+ {items.map(a => (
182
+ <div key={a.id} className={css.assetCard}>
183
+ <div className={css.assetCardTop}>
184
+ <span className={css.assetKind} style={{ color: KIND_COLORS[a.kind] }}>{KIND_LABELS[a.kind]}</span>
185
+ <div className={css.rowEnd}>
186
+ <button type="button" className={css.iconBtn} title="编辑" onClick={() => startEdit(a)}><Pencil size={13} /></button>
187
+ <button type="button" className={css.iconBtn} title="删除" onClick={() => { void del(a.id) }}><Trash2 size={13} /></button>
188
+ </div>
189
+ </div>
190
+ <div className={css.assetCardName}>{a.name}</div>
191
+ {a.summary !== '' && <div className={css.meta}>{a.summary}</div>}
192
+ <div className={css.assetCardContent}>{a.content}</div>
193
+ <div className={css.assetCardFooter}>
194
+ {a.tags.map(t => <span key={t} className={css.badge}>{t}</span>)}
195
+ {a.sourceBooks.length > 0 && <span className={css.meta}>来源:{a.sourceBooks.join(' / ')}</span>}
196
+ </div>
197
+ </div>
198
+ ))}
199
+ </div>
200
+ </div>
201
+ ))}
202
+ </div>
203
+ )}
204
+ </SubPage>
205
+ );
208
206
  }