@waterwx/dsh-novel-forge 1.7.3 → 1.9.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.
- package/lib/client.js +3627 -447
- package/lib/client.js.map +1 -1
- package/lib/index.js +1127 -141
- package/lib/index.js.map +1 -1
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/client/api.d.ts +31 -1
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/client/panel/AdaptModeView.d.ts +4 -0
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +30 -0
- package/lib/types/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/client/panel/ModelPicker.d.ts +11 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +14 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/engine.d.ts +39 -1
- package/lib/types/index.d.ts +8 -0
- package/lib/types/protocol.d.ts +339 -0
- package/package.json +1 -1
- package/src/author-assets.ts +166 -0
- package/src/client/api.ts +82 -0
- package/src/client/locales.ts +32 -0
- package/src/client/mount.tsx._new +97 -0
- package/src/client/mount.tsx._nf +97 -0
- package/src/client/mount.tsx._tmp +97 -0
- package/src/client/panel/AdaptModeView.tsx +202 -0
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +107 -0
- package/src/client/panel/ImportModal.tsx +2 -1
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +73 -79
- package/src/client/panel/ProgressConsole.tsx +64 -0
- package/src/client/panel/ProgressHomeView.tsx +87 -0
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/SettingsView.tsx +292 -0
- package/src/client/panel/panel.module.css +325 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/engine.ts +442 -0
- package/src/index.ts +20 -0
- package/src/protocol.ts +356 -0
- package/src/routes.ts +323 -0
|
@@ -0,0 +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
|
+
custom: '自定义',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const KIND_OPTIONS = Object.keys(KIND_LABELS) as AuthorStyleAsset['kind'][]
|
|
22
|
+
|
|
23
|
+
const KIND_COLORS: Record<AuthorStyleAsset['kind'], string> = {
|
|
24
|
+
style: 'var(--nf-info)',
|
|
25
|
+
antiAi: 'var(--nf-warn)',
|
|
26
|
+
progression: 'var(--nf-accent)',
|
|
27
|
+
genre: 'var(--nf-info)',
|
|
28
|
+
roleTemplate: 'var(--nf-accent)',
|
|
29
|
+
worldTemplate: 'var(--nf-warn)',
|
|
30
|
+
custom: 'var(--nf-muted)',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 计算资产的分组(分类):题材按顶层题材名('/' 之前),其余按类型。 */
|
|
34
|
+
function assetCategory(a: AuthorStyleAsset): string {
|
|
35
|
+
if (a.kind === 'genre') {
|
|
36
|
+
const top = a.name.split('/')[0]?.trim()
|
|
37
|
+
return top !== undefined && top !== '' ? top : '题材'
|
|
38
|
+
}
|
|
39
|
+
return KIND_LABELS[a.kind] ?? '其他'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface EditorState {
|
|
43
|
+
id?: string
|
|
44
|
+
name: string; kind: AuthorStyleAsset['kind']; summary: string; content: string; tags: string; sourceBooks: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function emptyEditor(): EditorState {
|
|
48
|
+
return { name: '', kind: 'style', summary: '', content: '', tags: '', sourceBooks: '' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function AuthorAssetsView({ api }: { api: NovelApi }) {
|
|
52
|
+
const [assets, setAssets] = useState<AuthorStyleAsset[]>([]);
|
|
53
|
+
const [editing, setEditing] = useState<EditorState | null>(null);
|
|
54
|
+
const [error, setError] = useState('');
|
|
55
|
+
const [busy, setBusy] = useState(false);
|
|
56
|
+
const [kindFilter, setKindFilter] = useState<AuthorStyleAsset['kind'] | 'all'>('all');
|
|
57
|
+
|
|
58
|
+
const load = async (): Promise<void> => {
|
|
59
|
+
try { const res = await api.authorAssets(); setAssets(res.assets.items) } catch (err) { setError((err as Error).message) }
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** 导入默认:把书架书的写作资产/角色 + 内置全局库批量沉淀到作者资产库。 */
|
|
63
|
+
const importDefaults = async (): Promise<void> => {
|
|
64
|
+
setBusy(true); setError('');
|
|
65
|
+
try { const res = await api.authorAssetsImportDefault(); setAssets(res.assets.items) } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 打开即加载;若资产库为空,自动导入默认(书架资产 + 内置全局库)。
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
let cancelled = false
|
|
71
|
+
void (async () => {
|
|
72
|
+
try {
|
|
73
|
+
const res = await api.authorAssets()
|
|
74
|
+
if (cancelled) return
|
|
75
|
+
if (res.assets.items.length === 0) {
|
|
76
|
+
const imp = await api.authorAssetsImportDefault()
|
|
77
|
+
if (!cancelled) setAssets(imp.assets.items)
|
|
78
|
+
} else {
|
|
79
|
+
setAssets(res.assets.items)
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (!cancelled) setError((err as Error).message)
|
|
83
|
+
}
|
|
84
|
+
})()
|
|
85
|
+
return () => { cancelled = true }
|
|
86
|
+
}, [api]);
|
|
87
|
+
|
|
88
|
+
const startNew = (): void => { setEditing(emptyEditor()); setError('') };
|
|
89
|
+
const startEdit = (a: AuthorStyleAsset): void => {
|
|
90
|
+
setEditing({ id: a.id, name: a.name, kind: a.kind, summary: a.summary, content: a.content, tags: a.tags.join('、'), sourceBooks: a.sourceBooks.join('、') });
|
|
91
|
+
setError('');
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const save = async (): Promise<void> => {
|
|
95
|
+
if (editing === null) return;
|
|
96
|
+
if (editing.name.trim() === '') { setError('请填写资产名'); return }
|
|
97
|
+
setBusy(true); setError('');
|
|
98
|
+
try {
|
|
99
|
+
const asset: AuthorStyleAsset = {
|
|
100
|
+
id: editing.id ?? '', name: editing.name.trim(), kind: editing.kind, summary: editing.summary.trim(), content: editing.content.trim(),
|
|
101
|
+
sourceBooks: editing.sourceBooks.split(/[、,,\s]+/).map(s => s.trim()).filter(Boolean),
|
|
102
|
+
tags: editing.tags.split(/[、,,\s]+/).map(s => s.trim()).filter(Boolean),
|
|
103
|
+
createdAt: '', updatedAt: '',
|
|
104
|
+
};
|
|
105
|
+
await api.authorAssetsUpsert(asset); setEditing(null); await load();
|
|
106
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false) }
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const del = async (id: string): Promise<void> => {
|
|
110
|
+
setBusy(true); setError('');
|
|
111
|
+
try { await api.authorAssetsRemove(id); await load() } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const visible = useMemo(() => (kindFilter === 'all' ? assets : assets.filter(a => a.kind === kindFilter)), [assets, kindFilter]);
|
|
115
|
+
|
|
116
|
+
/** 按分类分组展示(题材按顶层题材名,其余按类型)。 */
|
|
117
|
+
const grouped = useMemo(() => {
|
|
118
|
+
const map = new Map<string, AuthorStyleAsset[]>()
|
|
119
|
+
for (const a of visible) {
|
|
120
|
+
const cat = assetCategory(a)
|
|
121
|
+
const arr = map.get(cat) ?? []
|
|
122
|
+
arr.push(a)
|
|
123
|
+
map.set(cat, arr)
|
|
124
|
+
}
|
|
125
|
+
return Array.from(map.entries()).sort((x, y) => x[0].localeCompare(y[0], 'zh'))
|
|
126
|
+
}, [visible]);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className={css.authorPageBody}>
|
|
130
|
+
<div className={css.authorPageHero}>
|
|
131
|
+
<div className={css.authorPageHeader}>
|
|
132
|
+
<div>
|
|
133
|
+
<h2 className={css.panelTitle} style={{ margin: 0 }}>🧬 作者资产库 · 总数据</h2>
|
|
134
|
+
<span className={css.meta}>跨书可复用的笔法/红线/套路/角色模板/世界观模板。任意新书与改编都能调用。</span>
|
|
135
|
+
</div>
|
|
136
|
+
<div className={css.rowEnd}>
|
|
137
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { void importDefaults() }} disabled={busy} title="把书架里所有书的写作资产/角色 + 内置题材/反AI/风格模板/推进模式批量导入">导入默认资产</button>
|
|
138
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={startNew}><Plus size={14} style={{ verticalAlign: -2 }} /> 新增资产</button>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
<div className={css.assetFilterBar}>
|
|
142
|
+
{KIND_OPTIONS.map(k => (
|
|
143
|
+
<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>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
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
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 作者级首页容器:书架页左侧导航 + 内容区。
|
|
3
|
+
* 导航复用工作区 .panelNav 玻璃卡片样式(同款),导航项为作者级:书架/改编/资产库/全局资产库/AI 进度/设置。
|
|
4
|
+
*/
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { Library, Wand2, Boxes, Brush, BarChart3, Settings, Info } from 'lucide-react'
|
|
7
|
+
import type { NovelApi } from '../api.ts'
|
|
8
|
+
import type { BookshelfSnapshot } from '../../protocol.ts'
|
|
9
|
+
import { ShelfView } from './ShelfView.tsx'
|
|
10
|
+
import { AdaptModeView } from './AdaptModeView.tsx'
|
|
11
|
+
import { AuthorAssetsView } from './AuthorAssetsView.tsx'
|
|
12
|
+
import { ProgressConsole, type ProgressLine } from './ProgressConsole.tsx'
|
|
13
|
+
import { SettingsView } from './SettingsView.tsx'
|
|
14
|
+
import css from './panel.module.css'
|
|
15
|
+
|
|
16
|
+
/** 构建时注入的插件版本(tsdown define 替换为字符串字面量)。 */
|
|
17
|
+
declare const __NOVEL_FORGE_VERSION__: string | undefined
|
|
18
|
+
const PLUGIN_VERSION: string = typeof __NOVEL_FORGE_VERSION__ !== 'undefined' ? __NOVEL_FORGE_VERSION__ : '0.0.0'
|
|
19
|
+
|
|
20
|
+
type AuthorNav = 'shelf' | 'adapt' | 'assets' | 'library' | 'progress' | 'settings';
|
|
21
|
+
|
|
22
|
+
const NAV_ITEMS: Array<{ id: Exclude<AuthorNav, 'settings'>; label: string; icon: JSX.Element; hint: string }> = [
|
|
23
|
+
{ id: 'shelf', label: '书架', icon: <Library size={18} />, hint: '我的书' },
|
|
24
|
+
{ id: 'adapt', label: '改编模式', icon: <Wand2 size={18} />, hint: '上传全文→可改范围' },
|
|
25
|
+
{ id: 'assets', label: '作者资产库', icon: <Boxes size={18} />, hint: '跨书总数据' },
|
|
26
|
+
{ id: 'library', label: '全局写作资产库', icon: <Brush size={18} />, hint: '内置题材/规则/模板' },
|
|
27
|
+
{ id: 'progress', label: 'AI 进度', icon: <BarChart3 size={18} />, hint: '书架级聚合进度' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function Placeholder({ title, hint }: { title: string; hint: string }) {
|
|
31
|
+
return (
|
|
32
|
+
<div className={css.authorPageBody}>
|
|
33
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-6)' }}><h2 className={css.panelTitle} style={{ margin: 0 }}>{title}</h2><span className={css.meta}>{hint}</span></div>
|
|
34
|
+
<div className={css.shelfEmpty} style={{ minHeight: 160 }}>
|
|
35
|
+
<span className={css.shelfEmptyIcon}><Info size={30} /></span>
|
|
36
|
+
<span className={css.shelfEmptyTitle}>此入口为占位</span>
|
|
37
|
+
<span className={css.meta}>后续版本实现。当前「全局写作资产库」可进入某本书「创作资产」页查看内置题材库/反AI规则库/风格模板;「设置」为独立设置页。</span>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function AuthorHome({ api, shelf, onOpenBook, onReadBook, onAddBook, onImportBook, onOpenSettings, onTheme, onBackground, onOpacity, adaptEnabled, progress, busy, busyLabel, liveBar, onClearProgress }: {
|
|
44
|
+
api: NovelApi; shelf: BookshelfSnapshot;
|
|
45
|
+
onOpenBook: (id: string) => void; onReadBook: (id: string) => void; onAddBook: () => void; onImportBook: () => void;
|
|
46
|
+
/** 兼容旧入口:首页设置现为独立设置页,此回调保留但不再使用。 */
|
|
47
|
+
onOpenSettings?: () => void;
|
|
48
|
+
/** 主题/模式/密度变化回调(供面板根容器实时生效)。 */
|
|
49
|
+
onTheme?: (theme: 'liquid' | 'neumorph' | 'macos' | 'clay', mode: 'system' | 'light' | 'dark', density: 'comfort' | 'compact' | 'spacious') => void;
|
|
50
|
+
/** 自定义背景变化回调。 */
|
|
51
|
+
onBackground?: (bg: string | undefined, blur: number) => void;
|
|
52
|
+
/** 玻璃透明度变化回调。 */
|
|
53
|
+
onOpacity?: (n: number) => void;
|
|
54
|
+
/** 是否启用改编模式(默认 false=隐藏入口)。 */
|
|
55
|
+
adaptEnabled?: boolean;
|
|
56
|
+
/** AI 进度实时状态(与书内共享同一份)。 */
|
|
57
|
+
progress?: ProgressLine[];
|
|
58
|
+
busy?: boolean;
|
|
59
|
+
busyLabel?: string;
|
|
60
|
+
liveBar?: { text: string; ratio?: number } | null;
|
|
61
|
+
onClearProgress?: () => void;
|
|
62
|
+
}) {
|
|
63
|
+
const [nav, setNav] = useState<AuthorNav>('shelf');
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={css.authorHome}>
|
|
67
|
+
<nav className={css.panelNav} aria-label="作者级导航">
|
|
68
|
+
<div className={css.navTitle}>
|
|
69
|
+
<div className={css.navTitleLogo}>书</div>
|
|
70
|
+
<div style={{ minWidth: 0 }}>
|
|
71
|
+
<div className={css.navTitleName}>小说工坊</div>
|
|
72
|
+
<div className={css.navTitleBook}>作者级 · 跨书</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div className={css.navGroup}>
|
|
76
|
+
<div className={css.navGroupLabel}>作者</div>
|
|
77
|
+
{NAV_ITEMS.filter(item => item.id !== 'adapt' || adaptEnabled).map(item => (
|
|
78
|
+
<button key={item.id} type="button" role="tab" aria-selected={nav === item.id} data-active={nav === item.id ? '' : undefined} className={css.navTab} title={item.hint} onClick={() => setNav(item.id)}>
|
|
79
|
+
<span className={css.navTabIcon}>{item.icon}</span>
|
|
80
|
+
<span className={css.navTabLabel}>{item.label}</span>
|
|
81
|
+
</button>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
<div className={css.navSpacer} />
|
|
85
|
+
<button type="button" role="tab" aria-selected={nav === 'settings'} data-active={nav === 'settings' ? '' : undefined} className={css.navTab} title="设置" onClick={() => setNav('settings')}>
|
|
86
|
+
<span className={css.navTabIcon}><Settings size={18} /></span>
|
|
87
|
+
<span className={css.navTabLabel}>设置</span>
|
|
88
|
+
</button>
|
|
89
|
+
<div className={css.navAbout}>
|
|
90
|
+
<div className={css.navAboutRow}><span>ℹ️ v{PLUGIN_VERSION} · 改编/资产库</span></div>
|
|
91
|
+
</div>
|
|
92
|
+
</nav>
|
|
93
|
+
<div className={css.authorContent}>
|
|
94
|
+
{nav === 'shelf' && <ShelfView api={api} shelf={shelf} onOpenBook={onOpenBook} onReadBook={onReadBook} onAddBook={onAddBook} onImportBook={onImportBook} />}
|
|
95
|
+
{nav === 'adapt' && <AdaptModeView api={api} />}
|
|
96
|
+
{nav === 'assets' && <AuthorAssetsView api={api} />}
|
|
97
|
+
{nav === 'library' && <Placeholder title="🎨 全局写作资产库" hint="内置题材基底库 / 反AI规则库 / 风格模板 / 推进模式(跨书)" />}
|
|
98
|
+
{nav === 'progress' && (
|
|
99
|
+
<div className={css.authorPageBody}>
|
|
100
|
+
<ProgressConsole progress={progress ?? []} busy={busy ?? false} busyLabel={busyLabel ?? ''} liveBar={liveBar ?? null} onClear={() => onClearProgress?.()} />
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
{nav === 'settings' && <SettingsView api={api} onTheme={onTheme} onBackground={onBackground} onOpacity={onOpacity} />}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { useEffect, useRef, useState } from 'react'
|
|
8
8
|
import type { NovelApi } from '../api.ts'
|
|
9
9
|
import type { BookImportTextPreviewResponse } from '../../protocol.ts'
|
|
10
|
+
import { readFileTextSmart } from '../text.ts'
|
|
10
11
|
import css from './panel.module.css'
|
|
11
12
|
|
|
12
13
|
type ImportMode = 'dir' | 'text'
|
|
@@ -57,7 +58,7 @@ export function ImportModal({
|
|
|
57
58
|
setFileName(file.name)
|
|
58
59
|
setPreviewing(true)
|
|
59
60
|
try {
|
|
60
|
-
const text = await file
|
|
61
|
+
const text = await readFileTextSmart(file)
|
|
61
62
|
setFileText(text)
|
|
62
63
|
const p = await api.bookImportTextPreview(text, file.name)
|
|
63
64
|
setPreview(p)
|
|
@@ -0,0 +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
|
+
|