@waterwx/dsh-novel-forge 1.10.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 (194) hide show
  1. package/README.md +287 -281
  2. package/lib/client.js +19572 -18127
  3. package/lib/index.js +12171 -6932
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +30 -0
  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 +137 -21
  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/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +15 -70
  16. package/lib/types/client/mount.d.ts +0 -2
  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/AuthorHome.d.ts +13 -9
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  25. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  26. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  27. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  28. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  29. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  30. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  31. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  32. package/lib/types/client/panel/SettingsView.d.ts +12 -7
  33. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  34. package/lib/types/client/panel/SubPage.d.ts +27 -0
  35. package/lib/types/client/panel/TensionView.d.ts +18 -0
  36. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  37. package/lib/types/client/panel/helpers.d.ts +5 -3
  38. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  39. package/lib/types/client/panel/toast.d.ts +2 -0
  40. package/lib/types/client/panel/views.d.ts +21 -35
  41. package/lib/types/client/run-status.d.ts +21 -0
  42. package/lib/types/client/sidebar-entry.d.ts +0 -2
  43. package/lib/types/docx-core.d.ts +16 -0
  44. package/lib/types/docx.d.ts +2 -3
  45. package/lib/types/engine.d.ts +83 -79
  46. package/lib/types/global-assets.d.ts +6 -0
  47. package/lib/types/home.d.ts +11 -0
  48. package/lib/types/index.d.ts +27 -13
  49. package/lib/types/llm-live.d.ts +70 -0
  50. package/lib/types/llm-retry.d.ts +38 -0
  51. package/lib/types/market-radar-scan.d.ts +18 -0
  52. package/lib/types/market-radar-sources.d.ts +31 -0
  53. package/lib/types/migrate-output.d.ts +25 -0
  54. package/lib/types/model-capability.d.ts +93 -0
  55. package/lib/types/novel-context.d.ts +80 -0
  56. package/lib/types/output-budget.d.ts +86 -0
  57. package/lib/types/prompt-slots.d.ts +57 -0
  58. package/lib/types/prompting.d.ts +10 -0
  59. package/lib/types/protocol.d.ts +829 -522
  60. package/lib/types/revision.d.ts +76 -0
  61. package/lib/types/routes.d.ts +9 -3
  62. package/lib/types/run.d.ts +23 -1
  63. package/lib/types/settings-persist.d.ts +59 -0
  64. package/lib/types/snapshots.d.ts +46 -0
  65. package/lib/types/stage-contract.d.ts +58 -0
  66. package/lib/types/tension.d.ts +53 -0
  67. package/lib/types/timeline.d.ts +37 -0
  68. package/package.json +50 -28
  69. package/src/action-guard.ts +127 -0
  70. package/src/ai-scan.ts +155 -0
  71. package/src/assets.ts +1801 -535
  72. package/src/assistant.ts +1098 -770
  73. package/src/author-assets.ts +131 -147
  74. package/src/bookshelf.ts +193 -167
  75. package/src/client/api.ts +767 -607
  76. package/src/client/chip-prefs.ts +121 -0
  77. package/src/client/docx.ts +32 -69
  78. package/src/client/index.ts +3 -1
  79. package/src/client/llmLive.ts +111 -0
  80. package/src/client/locales.ts +15 -274
  81. package/src/client/mount.tsx +4 -3
  82. package/src/client/panel/AdaptModeView.tsx +535 -520
  83. package/src/client/panel/AiTag.tsx +7 -0
  84. package/src/client/panel/AssetsTab.tsx +448 -458
  85. package/src/client/panel/AssistantTab.tsx +355 -301
  86. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  87. package/src/client/panel/AuthorHome.tsx +309 -202
  88. package/src/client/panel/BookAnalysisView.tsx +63 -0
  89. package/src/client/panel/CmdPalette.tsx +102 -0
  90. package/src/client/panel/CreateBookView.tsx +366 -352
  91. package/src/client/panel/DirectorView.tsx +200 -0
  92. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  93. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  94. package/src/client/panel/ImportModal.tsx +302 -302
  95. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  96. package/src/client/panel/LiveFeedLog.tsx +204 -0
  97. package/src/client/panel/MarketRadarView.tsx +415 -0
  98. package/src/client/panel/ModelManager.tsx +205 -205
  99. package/src/client/panel/NovelPanel.tsx +5559 -4655
  100. package/src/client/panel/PageHeader.tsx +37 -0
  101. package/src/client/panel/ProgressConsole.tsx +63 -64
  102. package/src/client/panel/PromptSlotsView.tsx +97 -0
  103. package/src/client/panel/ReaderView.tsx +306 -306
  104. package/src/client/panel/RunPanel.tsx +56 -24
  105. package/src/client/panel/SettingsView.tsx +175 -112
  106. package/src/client/panel/ShelfView.tsx +63 -39
  107. package/src/client/panel/SubPage.tsx +42 -0
  108. package/src/client/panel/TensionView.tsx +212 -0
  109. package/src/client/panel/TimelineView.tsx +208 -0
  110. package/src/client/panel/WorldTab.tsx +202 -202
  111. package/src/client/panel/helpers.ts +7 -5
  112. package/src/client/panel/market-radar-state.ts +79 -0
  113. package/src/client/panel/panel.module.css +5767 -4199
  114. package/src/client/panel/toast.ts +51 -0
  115. package/src/client/panel/views.tsx +316 -314
  116. package/src/client/run-status.ts +171 -0
  117. package/src/client/sidebar-entry.ts +3 -3
  118. package/src/docx-core.ts +53 -0
  119. package/src/docx.ts +44 -83
  120. package/src/engine.ts +1610 -1541
  121. package/src/global-assets.ts +58 -0
  122. package/src/home.ts +33 -0
  123. package/src/index.ts +184 -103
  124. package/src/llm-live.ts +245 -0
  125. package/src/llm-retry.ts +77 -0
  126. package/src/market-radar-scan.ts +35 -0
  127. package/src/market-radar-sources.ts +173 -0
  128. package/src/migrate-output.ts +76 -0
  129. package/src/model-capability.ts +215 -0
  130. package/src/novel-context.ts +286 -0
  131. package/src/output-budget.ts +147 -0
  132. package/src/prompt-slots.ts +125 -0
  133. package/src/prompting.ts +26 -0
  134. package/src/protocol.ts +2454 -2148
  135. package/src/revision.ts +283 -0
  136. package/src/routes.ts +3795 -3359
  137. package/src/run.ts +113 -17
  138. package/src/settings-persist.ts +114 -0
  139. package/src/snapshots.ts +174 -0
  140. package/src/stage-contract.ts +186 -0
  141. package/src/tension.ts +251 -0
  142. package/src/timeline.ts +159 -0
  143. package/assets/styles/arcane-thick-3d.webp +0 -0
  144. package/assets/styles/chinese-ink.webp +0 -0
  145. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  146. package/assets/styles/dark-ghibli.webp +0 -0
  147. package/assets/styles/dark-gothic-3d.webp +0 -0
  148. package/assets/styles/film-photo.webp +0 -0
  149. package/assets/styles/guofeng-heavy.webp +0 -0
  150. package/assets/styles/horror-game-3d.webp +0 -0
  151. package/assets/styles/japan-cel-dark.webp +0 -0
  152. package/assets/styles/korean-webtoon.webp +0 -0
  153. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  154. package/assets/styles/nextgen-cg.webp +0 -0
  155. package/assets/styles/noir-film.webp +0 -0
  156. package/assets/styles/openworld-survival.webp +0 -0
  157. package/assets/styles/paper-cut-3d.webp +0 -0
  158. package/assets/styles/pixar-adult-3d.webp +0 -0
  159. package/assets/styles/pop-marvel-2d.webp +0 -0
  160. package/assets/styles/shinkai-anime.webp +0 -0
  161. package/assets/styles/tactical-stealth.webp +0 -0
  162. package/assets/styles/vaporwave-3d.webp +0 -0
  163. package/assets/styles/vhs90.webp +0 -0
  164. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  165. package/lib/types/client/panel/FlowGuide.d.ts +0 -21
  166. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -18
  167. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -15
  168. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  169. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  170. package/lib/types/client/panel/SceneLibrary.d.ts +0 -14
  171. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  172. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  173. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  174. package/lib/types/comic-presets.d.ts +0 -12
  175. package/lib/types/shot-language.d.ts +0 -46
  176. package/lib/types/story-beat-language.d.ts +0 -35
  177. package/lib/types/style-library.d.ts +0 -33
  178. package/skills/chapter-batch/SKILL.md +0 -81
  179. package/src/client/mount.tsx._new +0 -97
  180. package/src/client/mount.tsx._nf +0 -97
  181. package/src/client/mount.tsx._tmp +0 -97
  182. package/src/client/panel/BookshelfBar.tsx +0 -170
  183. package/src/client/panel/FlowGuide.tsx +0 -83
  184. package/src/client/panel/MangaRoleLibrary.tsx +0 -810
  185. package/src/client/panel/MangaWorkspace.tsx +0 -359
  186. package/src/client/panel/ProgressHomeView.tsx +0 -87
  187. package/src/client/panel/SceneLibrary.tsx +0 -396
  188. package/src/client/panel/StoryboardTab.tsx +0 -466
  189. package/src/client/panel/StyleCard.tsx +0 -90
  190. package/src/client/panel/StyleGallery.tsx +0 -58
  191. package/src/comic-presets.ts +0 -67
  192. package/src/shot-language.ts +0 -181
  193. package/src/story-beat-language.ts +0 -130
  194. package/src/style-library.ts +0 -78
@@ -1,205 +1,205 @@
1
- /**
2
- * 模型管理(DSH 风格):两块分区——
3
- * ① 模型(提供方管理):提供方卡片(编辑/删除)+ 添加提供方 / 添加自定义提供方;
4
- * ② 当前写作模型:提供商/模型下拉 + 测试连通 + 推理强度。
5
- */
6
- import { useCallback, useEffect, useState } from 'react'
7
- import { PlugZap, Plus, Trash2, RefreshCw, X } from 'lucide-react'
8
- import type { NovelApi } from '../api.ts'
9
- import type { SavedModel, AddModelRequest, 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-v4-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)' }}>
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
-
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
+