@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
@@ -0,0 +1,377 @@
1
+ /**
2
+ * 「热门题材雷达」:对齐上游市场雷达页(真实榜单扫榜 → 候选勾选 → 分析 → 信号卡片 → 影响模式 → 用信号创作)。
3
+ */
4
+ import { useMemo, useState } from 'react'
5
+ import type { NovelApi } from '../api.ts'
6
+ import type { MarketRadarResult, MarketCreativeBrief, IdeaInspirationResult } from '../../protocol.ts'
7
+ import css from './panel.module.css'
8
+ import { SubPage } from './SubPage.tsx'
9
+
10
+ const PLATFORMS = ['fanqie', 'qidian', 'jinjiang']
11
+ const PLATFORM_LABELS: Record<string, string> = { fanqie: '番茄小说', qidian: '起点中文网', jinjiang: '晋江文学城' }
12
+ const MAX_SIGNALS = 5
13
+
14
+ interface ScanItem { rank: number; title: string; author?: string; tags?: string[]; synopsis?: string; category?: string; heatLabel?: string }
15
+ interface ScanGroup { platform: string; platformLabel: string; listKey: string; listLabel: string; status: 'ok' | 'error'; error?: string; items: ScanItem[] }
16
+
17
+ const KIND_LABELS: Record<string, string> = {
18
+ genre: '题材', protagonist: '主角', advantage: '金手指', opening: '开篇', relationship: '关系', title_pattern: '标题', opportunity: '机会', crowding: '拥挤',
19
+ }
20
+ const MODE_LABELS: Record<string, string> = {
21
+ follow_hot: '跟随热门',
22
+ differentiate: '差异化',
23
+ light: '轻度参考',
24
+ }
25
+
26
+ export default function MarketRadarView({ api, bookId }: { api: NovelApi; bookId?: string }): JSX.Element {
27
+ const [platforms, setPlatforms] = useState<string[]>(['fanqie', 'qidian', 'jinjiang'])
28
+ const [feedText, setFeedText] = useState('')
29
+ const [scanGroups, setScanGroups] = useState<ScanGroup[] | null>(null)
30
+ const [selectedCandidates, setSelectedCandidates] = useState<Set<string>>(new Set())
31
+ const [viewPlatform, setViewPlatform] = useState('fanqie')
32
+ const [scanning, setScanning] = useState(false)
33
+ const [analyzing, setAnalyzing] = useState(false)
34
+ const [error, setError] = useState('')
35
+ const [result, setResult] = useState<MarketRadarResult | null>(null)
36
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
37
+ const [influenceMode, setInfluenceMode] = useState<'follow_hot' | 'differentiate' | 'light'>('differentiate')
38
+ const [briefBusy, setBriefBusy] = useState(false)
39
+ const [brief, setBrief] = useState<MarketCreativeBrief | null>(null)
40
+ const [ideas, setIdeas] = useState<IdeaInspirationResult['ideas'] | null>(null)
41
+ const [ideaBusy, setIdeaBusy] = useState(false)
42
+ const [applyBusy, setApplyBusy] = useState(false)
43
+ const [applied, setApplied] = useState('')
44
+ const [syncBusy, setSyncBusy] = useState(false)
45
+ const [syncedMsg, setSyncedMsg] = useState('')
46
+
47
+ const candKey = (g: ScanGroup, item: ScanItem): string => `${g.platform}:${g.listKey}:${item.rank}`
48
+ const togglePlatform = (p: string): void => {
49
+ setPlatforms(prev => prev.includes(p) ? (prev.length === 1 ? prev : prev.filter(x => x !== p)) : [...prev, p])
50
+ }
51
+ const platformList = useMemo(() => {
52
+ const set = new Set((scanGroups ?? []).map(g => g.platform))
53
+ return PLATFORMS.filter(p => set.has(p))
54
+ }, [scanGroups])
55
+
56
+ const scan = async (): Promise<void> => {
57
+ setScanning(true)
58
+ setError('')
59
+ setResult(null)
60
+ setBrief(null)
61
+ try {
62
+ const r = await api.marketRadarScan({ platforms })
63
+ const groups = r.result.groups
64
+ setScanGroups(groups)
65
+ const firstP = PLATFORMS.find(p => groups.some(g => g.platform === p))
66
+ if (firstP !== undefined) setViewPlatform(firstP)
67
+ // 默认不自动全选,交给用户手动勾选或用「全选」按钮。
68
+ setSelectedCandidates(new Set())
69
+ } catch (err) {
70
+ setError((err as Error).message)
71
+ } finally {
72
+ setScanning(false)
73
+ }
74
+ }
75
+
76
+ const toggleCandidate = (key: string): void => {
77
+ setSelectedCandidates(prev => {
78
+ const next = new Set(prev)
79
+ if (next.has(key)) next.delete(key)
80
+ else next.add(key)
81
+ return next
82
+ })
83
+ }
84
+
85
+ const selectAll = (): void => {
86
+ const next = new Set<string>()
87
+ for (const g of scanGroups ?? []) if (g.platform === viewPlatform && g.status === 'ok') for (const it of g.items) next.add(candKey(g, it))
88
+ setSelectedCandidates(next)
89
+ }
90
+
91
+ const clearSelection = (): void => setSelectedCandidates(new Set())
92
+
93
+ const selectGroup = (g: ScanGroup): void => {
94
+ setSelectedCandidates(prev => {
95
+ const next = new Set(prev)
96
+ for (const it of g.items) next.add(candKey(g, it))
97
+ return next
98
+ })
99
+ }
100
+
101
+ const analyze = async (): Promise<void> => {
102
+ if (scanGroups === null) return
103
+ const candidates: Array<{ title: string; author?: string; tags?: string[]; synopsis?: string; category?: string }> = []
104
+ for (const g of scanGroups) if (g.status === 'ok') for (const it of g.items) if (selectedCandidates.has(candKey(g, it))) candidates.push({ title: it.title, author: it.author, tags: it.tags, synopsis: it.synopsis, category: it.category })
105
+ setAnalyzing(true)
106
+ setError('')
107
+ setBrief(null)
108
+ try {
109
+ const r = await api.marketRadar({ candidates, feedText: feedText.trim() })
110
+ setResult(r.result)
111
+ const rec = r.result.signals.filter(s => s.recommended === true).map(s => s.id)
112
+ setSelectedIds(new Set(rec.slice(0, MAX_SIGNALS)))
113
+ } catch (err) {
114
+ setError((err as Error).message)
115
+ } finally {
116
+ setAnalyzing(false)
117
+ }
118
+ }
119
+
120
+ const toggleSignal = (id: string): void => {
121
+ setSelectedIds(prev => {
122
+ const next = new Set(prev)
123
+ if (next.has(id)) next.delete(id)
124
+ else if (next.size < MAX_SIGNALS) next.add(id)
125
+ return next
126
+ })
127
+ }
128
+
129
+ const createBrief = async (): Promise<void> => {
130
+ if (result === null) return
131
+ const signals = result.signals.filter(s => selectedIds.has(s.id))
132
+ setBriefBusy(true)
133
+ setError('')
134
+ try {
135
+ const r = await api.marketRadarBrief({ influenceMode, signals })
136
+ setBrief(r.creativeBrief)
137
+ } catch (err) {
138
+ setError((err as Error).message)
139
+ } finally {
140
+ setBriefBusy(false)
141
+ }
142
+ }
143
+
144
+ const createIdeas = async (): Promise<void> => {
145
+ if (result === null) return
146
+ const signals = result.signals.filter(s => selectedIds.has(s.id))
147
+ setIdeaBusy(true)
148
+ setError('')
149
+ try {
150
+ const r = await api.marketIdeaInspiration({ signals, foundation: result.productionFoundation, brief: brief ?? undefined, count: 5 })
151
+ setIdeas(r.result.ideas)
152
+ } catch (err) {
153
+ setError((err as Error).message)
154
+ } finally {
155
+ setIdeaBusy(false)
156
+ }
157
+ }
158
+
159
+ const apply = async (): Promise<void> => {
160
+ if (result === null) return
161
+ setApplyBusy(true)
162
+ setError('')
163
+ setApplied('')
164
+ try {
165
+ const r = await api.marketRadarApply({ bookId, foundation: result.productionFoundation })
166
+ setApplied(` 已应用到《${r.bookName}》:后续规划/生成将按此题材与推进模式。`)
167
+ } catch (err) {
168
+ setError((err as Error).message)
169
+ } finally {
170
+ setApplyBusy(false)
171
+ }
172
+ }
173
+
174
+ const sync = async (): Promise<void> => {
175
+ if (result === null) return
176
+ setSyncBusy(true)
177
+ setError('')
178
+ setSyncedMsg('')
179
+ try {
180
+ const r = await api.marketRadarSync({ foundation: result.productionFoundation })
181
+ const parts: string[] = []
182
+ if (r.synced.genre) parts.push('题材')
183
+ if (r.synced.primaryMode) parts.push('主推进')
184
+ if (r.synced.secondaryMode) parts.push('辅推进')
185
+ setSyncedMsg(parts.length > 0 ? ` 已同步到全局资源库:${parts.join('、')}` : '已在资源库中,无新增。')
186
+ } catch (err) {
187
+ setError((err as Error).message)
188
+ } finally {
189
+ setSyncBusy(false)
190
+ }
191
+ }
192
+
193
+ const badge = (kind: string): string => (kind === 'opportunity' ? 'var(--nf-success)' : kind === 'crowding' ? 'var(--nf-error)' : 'var(--nf-accent)')
194
+
195
+ return (
196
+ <SubPage
197
+ title="热门题材雷达"
198
+ meta="扫榜 → 选候选 → 分析 → 拿本期判断与可用信号,影响后续开书的题材与推进。"
199
+ >
200
+ {/* 平台扫榜 */}
201
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-12)' }}>
202
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
203
+ {PLATFORMS.map(p => (
204
+ <button key={p} type="button" onClick={() => togglePlatform(p)} style={{ padding: '4px 12px', borderRadius: 999, border: '1px solid var(--nf-border)', cursor: 'pointer', background: platforms.includes(p) ? 'var(--nf-accent-soft)' : 'var(--nf-bg-inset)', color: 'var(--nf-text)', fontWeight: platforms.includes(p) ? 600 : 400 }}>
205
+ {PLATFORM_LABELS[p]}
206
+ </button>
207
+ ))}
208
+ </div>
209
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
210
+ <label className={css.fieldLabel}>榜单文本兜底(可选,粘贴公开榜单文本;扫榜失败时可用于人工候选)</label>
211
+ <textarea className={css.input} style={{ minHeight: 90, resize: 'vertical' }} value={feedText} onChange={e => setFeedText(e.target.value)} placeholder="粘贴一份公开榜单文本…" />
212
+ </div>
213
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
214
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={scanning} onClick={() => { void scan() }}>
215
+ {scanning ? '正在扫榜…' : '扫榜'}
216
+ </button>
217
+ {scanning && <span style={{ fontSize: 12, color: 'var(--nf-text-2)' }}>正在抓取公开榜单({platforms.map(p => PLATFORM_LABELS[p]).join('、')}),多平台并行,约 10–30 秒…</span>}
218
+ {scanGroups !== null && (
219
+ <button type="button" className={css.button} style={{ marginLeft: 8 }} disabled={analyzing || selectedCandidates.size === 0} onClick={() => { void analyze() }}>
220
+ {analyzing ? '分析中…' : `开始 分析(${selectedCandidates.size} 本)`}
221
+ </button>
222
+ )}
223
+ </div>
224
+ </div>
225
+
226
+ {error !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 13 }}> {error}</div>}
227
+
228
+ {/* 榜单候选:平台 tabs 切换 + 平台内榜单横排(最多三列) + 每列 10 条可滚 */}
229
+ {scanGroups !== null && (
230
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-10)' }}>
231
+ <span className={css.cardTitle}>公开榜单候选</span>
232
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
233
+ {platformList.map(p => {
234
+ const active = viewPlatform === p
235
+ const count = (scanGroups ?? []).filter(g => g.platform === p && g.status === 'ok').reduce((a, g) => a + g.items.length, 0)
236
+ return (
237
+ <button key={p} type="button" onClick={() => setViewPlatform(p)} style={{ padding: '4px 12px', borderRadius: 999, border: '1px solid ' + (active ? 'var(--nf-accent)' : 'var(--nf-border)'), cursor: 'pointer', background: active ? 'var(--nf-accent-soft)' : 'var(--nf-bg-inset)', color: 'var(--nf-text)', fontWeight: active ? 600 : 400 }}>
238
+ {PLATFORM_LABELS[p]} ({count})
239
+ </button>
240
+ )
241
+ })}
242
+ </div>
243
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
244
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={selectAll}>全选当前平台</button>
245
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={clearSelection}>清空</button>
246
+ <span style={{ fontSize: 11, color: 'var(--nf-text-2)' }}>已选 {selectedCandidates.size} 条</span>
247
+ </div>
248
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: 'var(--nf-space-10)' }}>
249
+ {(scanGroups ?? []).filter(g => g.platform === viewPlatform).map(g => (
250
+ <div key={`${g.platform}:${g.listKey}`} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: 'var(--nf-space-8)', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
251
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, fontWeight: 600 }}>
252
+ {g.listLabel}
253
+ <span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 99, color: g.status === 'ok' ? 'var(--nf-success)' : 'var(--nf-error)', background: g.status === 'ok' ? 'rgba(79,191,139,0.14)' : 'rgba(212,99,79,0.16)' }}>{g.status === 'ok' ? `${g.items.length} 条` : '读取失败'}</span>
254
+ {g.status === 'ok' && <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => selectGroup(g)}>全选本榜</button>}
255
+ </div>
256
+ {g.status === 'error' && <div style={{ fontSize: 11, color: 'var(--nf-error)', marginTop: 4 }}>{g.error}</div>}
257
+ {g.status === 'ok' && (
258
+ <div style={{ marginTop: 6, maxHeight: 400, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
259
+ {g.items.map(it => {
260
+ const key = candKey(g, it)
261
+ const sel = selectedCandidates.has(key)
262
+ return (
263
+ <label key={key} onClick={e => { if ((e.target as HTMLElement).tagName !== 'INPUT') toggleCandidate(key) }} style={{ display: 'flex', alignItems: 'flex-start', gap: 7, padding: '5px 2px', fontSize: 13.5, cursor: 'pointer' }}>
264
+ <input type="checkbox" checked={sel} onChange={() => toggleCandidate(key)} onClick={e => e.stopPropagation()} style={{ marginTop: 3 }} />
265
+ <span style={{ lineHeight: 1.5 }}>
266
+ <b style={{ fontSize: 14 }}>{it.rank}. {it.title}</b>
267
+ {it.author !== undefined && it.author !== '' ? <span style={{ color: 'var(--nf-text-2)', fontSize: 12.5 }}>({it.author})</span> : null}
268
+ {(it.tags ?? []).length > 0 ? <span style={{ color: 'var(--nf-info)', fontSize: 12.5 }}> [{it.tags!.slice(0, 3).join('、')}]</span> : null}
269
+ </span>
270
+ </label>
271
+ )
272
+ })}
273
+ </div>
274
+ )}
275
+ </div>
276
+ ))}
277
+ </div>
278
+ </div>
279
+ )}
280
+
281
+ {/* 本期判断 + 信号 */}
282
+ {result !== null && (
283
+ <>
284
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-10)' }}>
285
+ <span className={css.cardTitle}>本期判断</span>
286
+ <div style={{ fontSize: 13, lineHeight: 1.7 }}>
287
+ <div><span style={{ color: 'var(--nf-text-2)' }}>题材基底:</span>{result.productionFoundation.genre.name}({result.productionFoundation.genre.description})</div>
288
+ <div><span style={{ color: 'var(--nf-text-2)' }}>主要推进:</span>{result.productionFoundation.primaryStoryMode.name} —— {result.productionFoundation.primaryStoryMode.driver}</div>
289
+ {result.productionFoundation.secondaryStoryMode !== undefined && (
290
+ <div><span style={{ color: 'var(--nf-text-2)' }}>辅助推进:</span>{result.productionFoundation.secondaryStoryMode.name} —— {result.productionFoundation.secondaryStoryMode.driver}</div>
291
+ )}
292
+ </div>
293
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
294
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={applyBusy} onClick={() => { void apply() }}>
295
+ {applyBusy ? '应用…' : '应用到当前书'}
296
+ </button>
297
+ <button type="button" className={css.button} disabled={syncBusy} onClick={() => { void sync() }}>
298
+ {syncBusy ? '同步…' : '同步到全局资源库'}
299
+ </button>
300
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
301
+ {applied !== '' && <span style={{ fontSize: 12, color: 'var(--nf-success)' }}>{applied}</span>}
302
+ {syncedMsg !== '' && <span style={{ fontSize: 12, color: 'var(--nf-info)' }}>{syncedMsg}</span>}
303
+ </div>
304
+ </div>
305
+ </div>
306
+
307
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 'var(--nf-space-10)' }}>
308
+ {result.signals.map(s => {
309
+ const selected = selectedIds.has(s.id)
310
+ return (
311
+ <article key={s.id} onClick={() => toggleSignal(s.id)} style={{ border: '1px solid var(--nf-border)', borderRadius: 12, padding: 'var(--nf-space-12)', cursor: 'pointer', background: selected ? 'var(--nf-accent-soft)' : 'var(--nf-bg)', boxShadow: selected ? '0 0 0 2px var(--nf-accent)' : 'none' }}>
312
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
313
+ <span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 99, color: '#fff', background: badge(s.kind) }}>{KIND_LABELS[s.kind] ?? s.kind}</span>
314
+ {selected && <span style={{ fontSize: 11, color: 'var(--nf-accent)', fontWeight: 600 }}>已选</span>}
315
+ </div>
316
+ <div style={{ marginTop: 8, fontSize: 15, fontWeight: 600 }}>{s.title}</div>
317
+ <p style={{ marginTop: 6, fontSize: 12, color: 'var(--nf-text-2)', lineHeight: 1.6 }}>{s.detail}</p>
318
+ <div style={{ marginTop: 8, display: 'flex', gap: 10, fontSize: 11, color: 'var(--nf-text-2)' }}>
319
+ <span>{s.direction === 'rising' ? '正在升温' : s.direction === 'falling' ? '正在降温' : s.direction === 'stable' ? '相对稳定' : '当前高频'}</span>
320
+ {s.recommended === true && <span style={{ color: 'var(--nf-success)' }}>★推荐</span>}
321
+ </div>
322
+ </article>
323
+ )
324
+ })}
325
+ </div>
326
+ </>
327
+ )}
328
+
329
+ {/* 底部:影响模式 + 用信号创作 */}
330
+ {result !== null && (
331
+ <div style={{ position: 'sticky', bottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', padding: 'var(--nf-space-10) var(--nf-space-12)', border: '1px solid var(--nf-accent)', borderRadius: 12, background: 'color-mix(in srgb, var(--nf-bg) 92%, transparent)', backdropFilter: 'blur(6px)', boxShadow: 'var(--nf-shadow)' }}>
332
+ <div>
333
+ <div style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedIds.size}/{MAX_SIGNALS} 项市场信号</div>
334
+ <div style={{ fontSize: 11, color: 'var(--nf-text-2)' }}>推荐已自动勾选,可替换后再开书。</div>
335
+ </div>
336
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
337
+ <select className={css.input} style={{ width: 'auto', padding: '4px 8px' }} value={influenceMode} onChange={e => setInfluenceMode(e.target.value as 'follow_hot' | 'differentiate' | 'light')}>
338
+ {Object.entries(MODE_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
339
+ </select>
340
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={selectedIds.size === 0 || briefBusy} onClick={() => { void createBrief() }}>
341
+ {briefBusy ? '生成中…' : '用这些信号创作'}
342
+ </button>
343
+ </div>
344
+ </div>
345
+ )}
346
+
347
+ {brief !== null && (
348
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-10)' }}>
349
+ <span className={css.cardTitle}> 开书创意简报</span>
350
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', fontSize: 13, lineHeight: 1.7 }}>
351
+ <div><b>创作约束</b>:{brief.promptBlock}</div>
352
+ <div><b>开篇想法</b>:{brief.openingIdea}</div>
353
+ <div><b>核心优势</b>:{brief.coreAdvantage}</div>
354
+ <div><b>追读卖点</b>:{brief.bookSellingPoint}</div>
355
+ <div><b>前30章承诺</b>:{brief.first30ChapterPromise}</div>
356
+ </div>
357
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={ideaBusy || selectedIds.size === 0} onClick={() => { void createIdeas() }}>
358
+ {ideaBusy ? '生成灵感中…' : ' 用这些信号生成灵感'}
359
+ </button>
360
+ </div>
361
+ )}
362
+
363
+ {ideas !== null && ideas.length > 0 && (
364
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 'var(--nf-space-10)' }}>
365
+ {ideas.map((it, i) => (
366
+ <div key={i} className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-6)' }}>
367
+ <div style={{ fontSize: 15, fontWeight: 600 }}>{it.title}</div>
368
+ <div style={{ fontSize: 12, color: 'var(--nf-success)' }}>{it.genre} · {it.pov}</div>
369
+ <div style={{ fontSize: 13 }}>钩子:{it.hook}</div>
370
+ <div style={{ fontSize: 12, color: 'var(--nf-text-2)' }}>兑现:{it.payoff}</div>
371
+ </div>
372
+ ))}
373
+ </div>
374
+ )}
375
+ </SubPage>
376
+ )
377
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * 模型管理(DSH 风格):两块分区——
3
+ * ① 模型(提供方管理):提供方卡片(编辑/删除)+ 添加提供方 / 添加自定义提供方;
4
+ * ② 当前写作模型:提供商/模型下拉 + 测试连通 + 推理强度。
5
+ */
6
+ import { useCallback, useEffect, useState } from 'react'
7
+ import { PlugZap, Plus, RefreshCw, X } from 'lucide-react'
8
+ import type { NovelApi } from '../api.ts'
9
+ import type { SavedModel, LlmModelOption, LlmVendorOption } from '../../protocol.ts'
10
+ import { LLM_VENDORS } from '../../protocol.ts'
11
+ import { tt } from './helpers.ts'
12
+ import css from './panel.module.css'
13
+
14
+ /** 目录不可用时回退的内置预设(对齐当前模型目录)。 */
15
+ export const FALLBACK_MODEL_PRESETS = ['deepseek-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
16
+
17
+ const CUSTOM = '__custom__'
18
+ interface TestState { testing: boolean; ok?: boolean; ms?: number; message?: string }
19
+ interface ProviderInfo { id: string; name: string }
20
+ type AddPanel = 'none' | 'standard' | 'custom'
21
+
22
+ export interface ModelManagerProps {
23
+ api: NovelApi
24
+ provider: string
25
+ model: string
26
+ savedModels: SavedModel[]
27
+ onProvider: (provider: string) => void
28
+ onModel: (model: string) => void
29
+ onSavedModels: (models: SavedModel[]) => void
30
+ }
31
+
32
+ function fallbackVendors(): LlmVendorOption[] {
33
+ return LLM_VENDORS.map(v => ({ id: v.route, name: v.name, models: v.models, apiKeyEnv: v.apiKeyEnv, builtin: v.builtin }))
34
+ }
35
+
36
+ export function ModelManager({ api, provider, model, onProvider, onModel }: ModelManagerProps): JSX.Element {
37
+ // ---- 当前写作模型
38
+ const [configuredProviders, setConfiguredProviders] = useState<ProviderInfo[]>([])
39
+ const [providerModels, setProviderModels] = useState<LlmModelOption[]>([])
40
+ const [modelsLoading, setModelsLoading] = useState(false)
41
+ const [providerCustom, setProviderCustom] = useState(false)
42
+ const [modelCustom, setModelCustom] = useState(false)
43
+ const [test, setTest] = useState<TestState>({ testing: false })
44
+
45
+ // ---- 提供方管理
46
+ const [vendors, setVendors] = useState<LlmVendorOption[]>(fallbackVendors())
47
+ const [addPanel, setAddPanel] = useState<AddPanel>('none')
48
+ const [editTarget, setEditTarget] = useState('')
49
+ const [vendorId, setVendorId] = useState('zai-coding-cn')
50
+ const [addModel, setAddModel] = useState('')
51
+ const [addApiKey, setAddApiKey] = useState('')
52
+ const [addName, setAddName] = useState('')
53
+ const [custProvider, setCustProvider] = useState('')
54
+ const [custName, setCustName] = useState('')
55
+ const [custBaseURL, setCustBaseURL] = useState('')
56
+ const [custProtocol, setCustProtocol] = useState('openai-completions')
57
+ const [custModel, setCustModel] = useState('')
58
+ const [custApiKey, setCustApiKey] = useState('')
59
+ const [savingAdd, setSavingAdd] = useState(false)
60
+ const [addMsg, setAddMsg] = useState('')
61
+ const [addErr, setAddErr] = useState('')
62
+
63
+
64
+ const loadProviders = useCallback(async (): Promise<void> => {
65
+ try { const r = await api.llmProviders(); if (r.providers.length > 0) setConfiguredProviders(r.providers) } catch { /* 兜底 */ }
66
+ }, [api])
67
+ const loadVendors = useCallback(async (): Promise<void> => {
68
+ try { const r = await api.llmVendors(); if (r.vendors.length > 0) setVendors(r.vendors) } catch { /* 静态兜底 */ }
69
+ }, [api])
70
+ const loadProviderModels = useCallback(async (p: string): Promise<void> => {
71
+ setModelsLoading(true)
72
+ try { const r = await api.llmModels(p); setProviderModels(r.models) } catch { setProviderModels([]) } finally { setModelsLoading(false) }
73
+ }, [api])
74
+
75
+ useEffect(() => { void loadProviders(); void loadVendors() }, [api, loadProviders, loadVendors])
76
+ useEffect(() => { if (provider !== '') void loadProviderModels(provider) }, [provider, loadProviderModels])
77
+
78
+ const currentVendor = vendors.find(v => v.id === vendorId)
79
+
80
+ const runTest = async (): Promise<void> => {
81
+ const p = provider.trim(); const m = model.trim()
82
+ if (p === '' || m === '' || test.testing) return
83
+ setTest({ testing: true })
84
+ try { const r = await api.llmTest(p, m); setTest({ testing: false, ok: r.ok, ms: r.ms, message: r.message }) }
85
+ catch (err) { setTest({ testing: false, ok: false, message: (err as Error).message }) }
86
+ }
87
+
88
+ const openPanel = (panel: AddPanel, target = ''): void => {
89
+ setAddPanel(panel); setEditTarget(target); setAddMsg(''); setAddErr('')
90
+ if (panel === 'standard') { setVendorId(target !== '' ? target : 'zai-coding-cn'); setAddModel(''); setAddApiKey(''); setAddName('') }
91
+ else if (panel === 'custom') { setCustProvider(target); setCustName(''); setCustBaseURL(''); setCustModel(''); setCustApiKey('') }
92
+ }
93
+
94
+ const saveStandard = async (): Promise<void> => {
95
+ if (vendorId.trim() === '') { setAddErr('请选择提供方'); return }
96
+ if (addApiKey.trim() === '') { setAddErr('请填写 API 密钥(或留空使用环境认证)'); return }
97
+ const modelId = addModel.trim()
98
+ setSavingAdd(true); setAddMsg(''); setAddErr('')
99
+ try {
100
+ const res = await api.addModel({ mode: 'vendor', vendor: vendorId, model: modelId || vendorId, apiKey: addApiKey.trim(), apiKeyEnv: currentVendor?.apiKeyEnv, name: addName.trim() || undefined })
101
+ setAddMsg(res.message ?? '已保存'); setAddPanel('none'); setAddApiKey(''); setAddModel(''); setAddName(''); setEditTarget('')
102
+ await loadProviders()
103
+ } catch (err) { setAddErr((err as Error).message) } finally { setSavingAdd(false) }
104
+ }
105
+
106
+ const saveCustom = async (): Promise<void> => {
107
+ if (custProvider.trim() === '') { setAddErr('请填 Provider ID'); return }
108
+ if (custBaseURL.trim() === '') { setAddErr('请填 API 地址'); return }
109
+ if (custApiKey.trim() === '') { setAddErr('请填 API 密钥(或留空使用环境认证)'); return }
110
+ if (custModel.trim() === '') { setAddErr('请填模型 id(或点“获取可用模型”)'); return }
111
+ setSavingAdd(true); setAddMsg(''); setAddErr('')
112
+ try {
113
+ const res = await api.addModel({ mode: 'custom', provider: custProvider.trim(), model: custModel.trim(), apiKey: custApiKey.trim(), baseURL: custBaseURL.trim(), name: custName.trim() || undefined })
114
+ setAddMsg(res.message ?? '已创建提供方'); setAddPanel('none'); setCustProvider(''); setCustName(''); setCustBaseURL(''); setCustModel(''); setCustApiKey('')
115
+ await loadProviders()
116
+ } catch (err) { setAddErr((err as Error).message) } finally { setSavingAdd(false) }
117
+ }
118
+
119
+ const removeProvider = async (p: ProviderInfo): Promise<void> => {
120
+ if (!window.confirm('删除提供方「' + (p.name !== '' ? p.name : p.id) + '」?将移除 DSH 配置与 API 密钥。')) return
121
+ try { const vendor = vendors.find(v => v.id === p.id); await api.removeProvider({ provider: p.id, apiKeyEnv: vendor?.apiKeyEnv }); await loadProviders() }
122
+ catch (err) { setAddErr((err as Error).message) }
123
+ }
124
+
125
+
126
+ const providerOptions: ProviderInfo[] = configuredProviders.some(p => p.id === provider) ? configuredProviders : (provider !== '' && provider !== CUSTOM ? [{ id: provider, name: provider }, ...configuredProviders] : configuredProviders)
127
+
128
+ const modelOptions: LlmModelOption[] = modelCustom ? [] : (provider === 'deepseek-official'
129
+ ? [...FALLBACK_MODEL_PRESETS.map(id => ({ id, name: id })), ...providerModels.filter(m => !(FALLBACK_MODEL_PRESETS as readonly string[]).includes(m.id))]
130
+ : providerModels)
131
+ const modelInList = modelOptions.some(m => m.id === model)
132
+ const modelValue = modelCustom || (!modelInList && model !== '' && modelOptions.length > 0) ? CUSTOM : model
133
+ const providerValue = providerCustom ? CUSTOM : provider
134
+
135
+
136
+ return (
137
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)', minWidth: 0, width: '100%' }}>
138
+ {/* ① 模型(提供方管理) */}
139
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-16)' }}>
140
+ <span className={css.cardTitle}>模型</span>
141
+ <span className={css.meta}>填入各提供方的 API 密钥即可使用其模型。</span>
142
+ {configuredProviders.map(p => {
143
+ const isBuiltin = p.id === 'deepseek-official'
144
+ const isKnownVendor = vendors.some(v => v.id === p.id)
145
+ return (
146
+ <div key={p.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-12)', display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)', flexWrap: 'wrap', justifyContent: 'space-between' }}>
147
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)' }}><span style={{ fontWeight: 600 }}>{p.name !== '' && p.name !== p.id ? p.name : p.id}</span><span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--nf-success)' }} /></div>
148
+ <div style={{ display: 'flex', gap: 'var(--nf-space-6)', alignItems: 'center' }}>
149
+ {!isBuiltin && <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel(isKnownVendor ? 'standard' : 'custom', p.id)}>{tt('settings.edit')}</button>}
150
+ {!isBuiltin && <button type="button" className={css.button + ' ' + css.buttonSmall} style={{ color: 'var(--nf-error)' }} onClick={() => { void removeProvider(p) }}>{tt('settings.delete')}</button>}
151
+ </div>
152
+ </div>
153
+ )
154
+ })}
155
+ <div className={css.row} style={{ gap: 'var(--nf-space-10)', flexWrap: 'wrap' }}>
156
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel('standard')}><Plus size={13} style={{ verticalAlign: -2 }} /> 添加提供方</button>
157
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel('custom')}><Plus size={13} style={{ verticalAlign: -2 }} /> 添加自定义提供方</button>
158
+ </div>
159
+
160
+ {addPanel !== 'none' && (
161
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-12)' }}>
162
+ <div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center' }}><span className={css.fieldLabel}>{addPanel === 'standard' ? (editTarget !== '' ? '编辑提供方' : '添加提供方') : '自定义提供方'}</span><button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel('none')}><X size={13} style={{ verticalAlign: -2 }} /> 关闭</button></div>
163
+ {addPanel === 'standard' ? (
164
+ <>
165
+ <div className={css.field}><label className={css.fieldLabel}>提供方</label><select className={css.input} value={vendorId} onChange={e => setVendorId(e.target.value)}>{vendors.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}</select></div>
166
+ <div className={css.field}><label className={css.fieldLabel}>API 密钥</label><input className={css.input} type="password" value={addApiKey} onChange={e => setAddApiKey(e.target.value)} placeholder="输入 API 密钥,或留空使用环境认证" /></div>
167
+ <div className={css.field}><label className={css.fieldLabel}>模型 id(可选)</label><input className={css.input} value={addModel} onChange={e => setAddModel(e.target.value)} placeholder="例如 glm-5.3-flash" /></div>
168
+ <div className={css.row} style={{ justifyContent: 'flex-end', gap: 'var(--nf-space-8)' }}><button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel('none')}>取消</button><button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary} disabled={savingAdd} onClick={() => { void saveStandard() }}>{savingAdd ? '保存中…' : '保存'}</button></div>
169
+ </>
170
+ ) : (
171
+ <>
172
+ <div className={css.field}><label className={css.fieldLabel}>Provider ID</label><input className={css.input} value={custProvider} onChange={e => setCustProvider(e.target.value)} placeholder="acme-gateway" /></div>
173
+ <div className={css.field}><label className={css.fieldLabel}>显示名称</label><input className={css.input} value={custName} onChange={e => setCustName(e.target.value)} placeholder="显示名称" /></div>
174
+ <div className={css.field}><label className={css.fieldLabel}>API 地址</label><input className={css.input} value={custBaseURL} onChange={e => setCustBaseURL(e.target.value)} placeholder="https://gateway.example/v1" /></div>
175
+ <div className={css.field}><label className={css.fieldLabel}>API 协议</label><select className={css.input} value={custProtocol} onChange={e => setCustProtocol(e.target.value)}><option value="openai-completions">openai-completions</option></select></div>
176
+ <div className={css.field}><label className={css.fieldLabel}>API 密钥</label><input className={css.input} type="password" value={custApiKey} onChange={e => setCustApiKey(e.target.value)} placeholder="输入 API 密钥" /></div>
177
+ <div className={css.field}><label className={css.fieldLabel}>模型目录 / 模型 id</label><input className={css.input} value={custModel} onChange={e => setCustModel(e.target.value)} placeholder="例如 glm-5.3-flash" /></div>
178
+ <div className={css.row} style={{ justifyContent: 'flex-end', gap: 'var(--nf-space-8)' }}><button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel('none')}>取消</button><button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary} disabled={savingAdd} onClick={() => { void saveCustom() }}>{savingAdd ? '创建中…' : '创建提供方'}</button></div>
179
+ </>
180
+ )}
181
+ </div>
182
+ )}
183
+ {addMsg !== '' && <span className={css.meta} style={{ color: 'var(--nf-success)' }}>✓ {addMsg}</span>}
184
+ {addErr !== '' && <span className={css.meta} style={{ color: 'var(--nf-error)' }}>✗ {addErr}</span>}
185
+ </div>
186
+
187
+ {/* ② 当前写作模型 */}
188
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-16)' }}>
189
+ <span className={css.cardTitle}>当前写作模型</span>
190
+ <div className={css.field}><label className={css.fieldLabel}>{tt('settings.provider')}</label><select className={css.input} value={providerValue} onChange={e => { const v = e.target.value; if (v === CUSTOM) setProviderCustom(true); else { setProviderCustom(false); onProvider(v) } }}>{providerOptions.map(p => <option key={p.id} value={p.id}>{p.name !== '' && p.name !== p.id ? p.name + '(' + p.id + ')' : p.id}</option>)}<option value={CUSTOM}>{tt('settings.customProvider')}</option></select>{providerCustom && <input className={css.input} style={{ marginTop: 'var(--nf-space-6)' }} value={provider} placeholder="provider 路由 id" onChange={e => onProvider(e.target.value)} />}</div>
191
+ <div className={css.field}>
192
+ <label className={css.fieldLabel}>{tt('settings.model')}</label>
193
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
194
+ <select className={css.input} style={{ flex: 1, minWidth: 200 }} value={modelValue} onChange={e => { const v = e.target.value; if (v === CUSTOM) setModelCustom(true); else { setModelCustom(false); onModel(v) } }}>{modelOptions.map(m => <option key={m.id} value={m.id}>{m.name !== '' && m.name !== m.id ? m.name + '(' + m.id + ')' : m.id}</option>)}<option value={CUSTOM}>{tt('settings.modelCustom')}</option></select>
195
+ <button type="button" className={css.button + ' ' + css.buttonSmall} disabled={modelsLoading || provider === ''} title="刷新模型" onClick={() => { void loadProviderModels(provider) }}><RefreshCw size={13} style={{ verticalAlign: -2 }} /> {modelsLoading ? '…' : tt('settings.refreshModels')}</button>
196
+ <button type="button" className={css.button + ' ' + css.buttonSmall} disabled={test.testing || provider.trim() === '' || model.trim() === ''} onClick={() => { void runTest() }} title={tt('settings.testHint')}><PlugZap size={13} style={{ verticalAlign: -2 }} /> {test.testing ? tt('settings.testRunning') : tt('settings.testConnection')}</button>
197
+ </div>
198
+ {modelCustom && <input className={css.input} style={{ marginTop: 'var(--nf-space-6)' }} value={model} placeholder={tt('settings.modelCustomPlaceholder')} onChange={e => onModel(e.target.value)} />}
199
+ <span className={css.meta} style={{ color: test.ok === true ? 'var(--nf-success)' : test.ok === false ? 'var(--nf-error)' : undefined }}>{test.testing ? tt('settings.testRunning') + '…' : test.ok === true ? '✓ ' + tt('settings.testOk') + ' · ' + (test.ms ?? '?') + 'ms' : test.ok === false ? '✗ ' + tt('settings.testFail') + (test.message !== undefined ? ':' + test.message : '') : tt('settings.testHint')}</span>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ )
204
+ }
205
+