tavern-harness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +161 -0
  2. package/README.zh.md +123 -0
  3. package/bin/cli.js +48 -0
  4. package/index.html +35 -0
  5. package/package.json +50 -0
  6. package/public/favicon.png +0 -0
  7. package/public/tavern-harness-logo.png +0 -0
  8. package/sandbox-server.mjs +997 -0
  9. package/src/App.tsx +268 -0
  10. package/src/assets/tavern-handbook.md +39 -0
  11. package/src/components/AchievementModal.tsx +158 -0
  12. package/src/components/CharactersView.tsx +904 -0
  13. package/src/components/ChatView.tsx +1639 -0
  14. package/src/components/ConfirmationDialog.tsx +56 -0
  15. package/src/components/Dashboard.tsx +180 -0
  16. package/src/components/DeleteConfirmDialog.tsx +48 -0
  17. package/src/components/FileDisplayModal.tsx +556 -0
  18. package/src/components/GameplayDialogs.tsx +191 -0
  19. package/src/components/NewSessionMenu.tsx +362 -0
  20. package/src/components/SettingsView.tsx +517 -0
  21. package/src/components/Sidebar.tsx +392 -0
  22. package/src/components/StatsView.tsx +189 -0
  23. package/src/components/Toasts.tsx +20 -0
  24. package/src/components/WorkspaceFileManagerModal.tsx +216 -0
  25. package/src/components/shared.tsx +369 -0
  26. package/src/core/achievements.ts +85 -0
  27. package/src/core/fileDownload.ts +67 -0
  28. package/src/core/gameplay.ts +521 -0
  29. package/src/core/horizontalWheel.ts +93 -0
  30. package/src/core/i18n.ts +1403 -0
  31. package/src/core/linkedScroll.ts +236 -0
  32. package/src/core/markdown.ts +170 -0
  33. package/src/core/openai.ts +451 -0
  34. package/src/core/openingGreeting.ts +41 -0
  35. package/src/core/prompts.ts +254 -0
  36. package/src/core/proxy.ts +58 -0
  37. package/src/core/sillyTavernImporter.ts +317 -0
  38. package/src/core/stats.ts +91 -0
  39. package/src/core/strings.ts +19 -0
  40. package/src/core/toolDefinitions.ts +400 -0
  41. package/src/core/tools/builtinTools.ts +38 -0
  42. package/src/core/tools/generatedSkillExecutor.ts +706 -0
  43. package/src/core/tools/generatedWorkspace.ts +13 -0
  44. package/src/core/tools/toolExecutor.ts +718 -0
  45. package/src/core/turnLoop.ts +211 -0
  46. package/src/db/database.ts +647 -0
  47. package/src/main.tsx +23 -0
  48. package/src/store/store.ts +2419 -0
  49. package/src/theme/chat.css +1227 -0
  50. package/src/theme/global.css +1377 -0
  51. package/src/theme/theme.ts +143 -0
  52. package/src/theme/views.css +1430 -0
  53. package/src/types/models.ts +364 -0
  54. package/src/vite-env.d.ts +1 -0
  55. package/tsconfig.json +21 -0
  56. package/vite.config.ts +273 -0
@@ -0,0 +1,517 @@
1
+ import { useState } from 'react';
2
+ import { useStore } from '../store/store';
3
+ import { db } from '../db/database';
4
+ import { type ThemeMode } from '../theme/theme';
5
+ import { type ApiProvider, type AppLanguage, type ReasoningEffort, enabledModelIds, modelAliases } from '../types/models';
6
+ import { Icon, Modal } from './shared';
7
+ import { DeleteConfirmDialog } from './DeleteConfirmDialog';
8
+ import { isProxyUrl, isNetworkLikeError, toProxyUrl } from '../core/proxy';
9
+ import { useT } from '../core/i18n';
10
+
11
+ // ============================================================
12
+ // 设置视图(模型服务 / 超参数 / 主题)
13
+ // ============================================================
14
+
15
+ export function SettingsView() {
16
+ const settings = useStore((s) => s.settings);
17
+ const setSettings = useStore((s) => s.setSettings);
18
+ const resetTavern = useStore((s) => s.resetTavern);
19
+ const providers = useStore((s) => s.providers);
20
+ const refreshProviders = useStore((s) => s.refreshProviders);
21
+ const [showResetConfirm, setShowResetConfirm] = useState(false);
22
+ const [resetting, setResetting] = useState(false);
23
+ const t = useT();
24
+
25
+ if (!settings) return null;
26
+
27
+ // 数字滑杆——数值显示固定在行内,不溢出卡片
28
+ const slider = (key: keyof typeof settings, min: number, max: number, step: number, fmt?: (v: number) => string) => (
29
+ <label className="range-row" style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 10, alignItems: 'center' }}>
30
+ <input
31
+ type="range"
32
+ min={min}
33
+ max={max}
34
+ step={step}
35
+ value={settings[key] as number}
36
+ onChange={(e) => setSettings({ [key]: parseFloat(e.target.value) })}
37
+ />
38
+ <span className="range-val">{fmt ? fmt(settings[key] as number) : (settings[key] as number)}</span>
39
+ </label>
40
+ );
41
+
42
+ return (
43
+ <div className="view-page">
44
+ <div className="view-col">
45
+ <div>
46
+ <h2 className="view-title">{t('settings.title')}</h2>
47
+ <p className="view-sub">{t('settings.sub')}</p>
48
+ </div>
49
+
50
+ {/* 模型服务 Provider(置顶) */}
51
+ <ProviderManager providers={providers} onChanged={refreshProviders} />
52
+
53
+ {/* 生成参数 */}
54
+ <div className="set-section">
55
+ <h3>{t('settings.genParams')}</h3>
56
+ <table className="table param-table">
57
+ <tbody>
58
+ <tr>
59
+ <td style={{ width: 170 }}>
60
+ <div className="s-label">Temperature</div>
61
+ <div className="s-desc">{t('settings.tempDesc')}</div>
62
+ </td>
63
+ <td>{slider('temperature', 0, 2, 0.05, (v) => v.toFixed(2))}</td>
64
+ </tr>
65
+ <tr>
66
+ <td>
67
+ <div className="s-label">Top P</div>
68
+ <div className="s-desc">{t('settings.topPDesc')}</div>
69
+ </td>
70
+ <td>{slider('topP', 0, 1, 0.05, (v) => v.toFixed(2))}</td>
71
+ </tr>
72
+ <tr>
73
+ <td>
74
+ <div className="s-label">Top K</div>
75
+ <div className="s-desc">{t('settings.topKDesc')}</div>
76
+ </td>
77
+ <td>{slider('topK', 0, 100, 1)}</td>
78
+ </tr>
79
+ <tr>
80
+ <td>
81
+ <div className="s-label">Max Tokens</div>
82
+ <div className="s-desc">{t('settings.maxTokensDesc')}</div>
83
+ </td>
84
+ <td>{slider('maxTokens', 0, 16384, 256)}</td>
85
+ </tr>
86
+ <tr>
87
+ <td>
88
+ <div className="s-label">Frequency Penalty</div>
89
+ <div className="s-desc">{t('settings.freqDesc')}</div>
90
+ </td>
91
+ <td>{slider('frequencyPenalty', -2, 2, 0.05, (v) => v.toFixed(2))}</td>
92
+ </tr>
93
+ <tr>
94
+ <td>
95
+ <div className="s-label">Presence Penalty</div>
96
+ <div className="s-desc">{t('settings.presDesc')}</div>
97
+ </td>
98
+ <td>{slider('presencePenalty', -2, 2, 0.05, (v) => v.toFixed(2))}</td>
99
+ </tr>
100
+ <tr>
101
+ <td>
102
+ <div className="s-label">Repetition Penalty</div>
103
+ <div className="s-desc">{t('settings.repDesc')}</div>
104
+ </td>
105
+ <td>{slider('repetitionPenalty', 0.5, 2, 0.05, (v) => v.toFixed(2))}</td>
106
+ </tr>
107
+ <tr>
108
+ <td>
109
+ <div className="s-label">{t('settings.contextCompression')}</div>
110
+ <div className="s-desc">{t('settings.contextCompressionDesc')}</div>
111
+ </td>
112
+ <td>
113
+ <label className="range-row" style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 10, alignItems: 'center' }}>
114
+ <input
115
+ type="range"
116
+ min="0"
117
+ max="100"
118
+ step="1"
119
+ value={settings.contextCompressionThreshold === 'off' ? 0 : settings.contextCompressionThreshold}
120
+ onChange={(e) => setSettings({ contextCompressionThreshold: Number(e.target.value) || 'off' })}
121
+ />
122
+ <span className="range-val">
123
+ {settings.contextCompressionThreshold === 'off' ? t('settings.off') : settings.contextCompressionThreshold}
124
+ </span>
125
+ </label>
126
+ </td>
127
+ </tr>
128
+ <tr>
129
+ <td style={{ borderBottom: 'none' }}>
130
+ <div className="s-label">Reasoning Effort</div>
131
+ <div className="s-desc">{t('settings.effortDesc')}</div>
132
+ </td>
133
+ <td style={{ borderBottom: 'none' }}>
134
+ <select
135
+ className="select"
136
+ value={settings.reasoningEffort}
137
+ onChange={(e) => setSettings({ reasoningEffort: e.target.value as ReasoningEffort })}
138
+ >
139
+ {['auto', 'off', 'low', 'medium', 'xhigh'].map((r) => <option key={r} value={r}>{r}</option>)}
140
+ </select>
141
+ </td>
142
+ </tr>
143
+ </tbody>
144
+ </table>
145
+ </div>
146
+
147
+ <div className="set-grid">
148
+ {/* 界面语言 */}
149
+ <div className="set-section">
150
+ <h3>{t('settings.language')}</h3>
151
+ <div className="field">
152
+ <label>{t('settings.languageLabel')}</label>
153
+ <select
154
+ className="select"
155
+ value={settings.language ?? ''}
156
+ onChange={(e) => setSettings({ language: (e.target.value || null) as AppLanguage })}
157
+ >
158
+ <option value="">{t('settings.followBrowser')}</option>
159
+ <option value="zh-CN">简体中文</option>
160
+ <option value="zh-TW">繁體中文</option>
161
+ <option value="en">English</option>
162
+ </select>
163
+ </div>
164
+ <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>
165
+ {t('settings.followBrowser')}
166
+ </p>
167
+ </div>
168
+
169
+ {/* 主题 */}
170
+ <div className="set-section">
171
+ <h3>{t('settings.theme')}</h3>
172
+ <div className="field">
173
+ <label>{t('settings.appearance')}</label>
174
+ <select
175
+ className="select"
176
+ value={settings.themeMode}
177
+ onChange={(e) => setSettings({ themeMode: e.target.value as ThemeMode })}
178
+ >
179
+ <option value="system">{t('settings.system')}</option>
180
+ <option value="light">{t('settings.light')}</option>
181
+ <option value="dark">{t('settings.dark')}</option>
182
+ </select>
183
+ </div>
184
+ <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>
185
+ {t('settings.themeTip')}
186
+ </p>
187
+ </div>
188
+ </div>
189
+
190
+ <div className="set-section settings-reset-section">
191
+ <div>
192
+ <h3>{t('settings.resetTitle')}</h3>
193
+ <p className="settings-reset-desc">{t('settings.resetDesc')}</p>
194
+ </div>
195
+ <button className="btn btn-danger settings-reset-button" onClick={() => setShowResetConfirm(true)}>
196
+ <Icon name="refresh" size={14} /> {t('settings.resetBtn')}
197
+ </button>
198
+ </div>
199
+ </div>
200
+
201
+ {showResetConfirm && (
202
+ <Modal onClose={() => !resetting && setShowResetConfirm(false)} width="min(420px, calc(100vw - 32px))">
203
+ <div className="modal-head">
204
+ <span style={{ fontWeight: 800, fontSize: 15 }}>{t('settings.resetConfirmTitle')}</span>
205
+ <button className="icon-btn" disabled={resetting} onClick={() => setShowResetConfirm(false)}><Icon name="x" /></button>
206
+ </div>
207
+ <div className="modal-body">{t('settings.resetConfirm')}</div>
208
+ <div className="modal-foot">
209
+ <button className="btn" disabled={resetting} onClick={() => setShowResetConfirm(false)}>{t('common.cancel')}</button>
210
+ <button
211
+ className="btn btn-danger"
212
+ disabled={resetting}
213
+ onClick={async () => {
214
+ setResetting(true);
215
+ try {
216
+ await resetTavern();
217
+ } catch {
218
+ setResetting(false);
219
+ }
220
+ }}
221
+ >
222
+ {resetting && <span className="spinner" style={{ width: 11, height: 11 }} />}
223
+ <Icon name="refresh" size={13} /> {t('settings.resetBtn')}
224
+ </button>
225
+ </div>
226
+ </Modal>
227
+ )}
228
+ </div>
229
+ );
230
+ }
231
+
232
+ // ---------------- Providers ----------------
233
+
234
+ interface ModelsResult {
235
+ models: string[];
236
+ viaProxy: boolean;
237
+ }
238
+
239
+ /** 依次尝试常见 models 端点,返回第一个成功的模型列表。
240
+ * 若直连请求被 CORS 拦截,自动回退到开发服务器同源代理(若可用)。 */
241
+ async function fetchModelsSmart(baseUrl: string, apiKey: string): Promise<ModelsResult> {
242
+ const base = baseUrl.replace(/\/+$/, '');
243
+ const candidates = [`${base}/models`, `${base}/v1/models`];
244
+
245
+ // 收集“网络层失败”的次数:若直连失败且存在可用的代理 URL → 自动重试代理
246
+ const proxyUrl = toProxyUrl(baseUrl);
247
+
248
+ let lastErr: Error | null = null;
249
+ const tryFetch = async (url: string): Promise<string[]> => {
250
+ const ctrl = new AbortController();
251
+ const timer = setTimeout(() => ctrl.abort(), 7000);
252
+ try {
253
+ const resp = await fetch(url, {
254
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
255
+ signal: ctrl.signal,
256
+ });
257
+ if (!resp.ok) {
258
+ throw new Error(`HTTP ${resp.status}`);
259
+ }
260
+ const json = (await resp.json()) as { data?: Array<{ id: string }> };
261
+ const models = (json.data ?? []).map((m: { id: string }) => m.id).sort();
262
+ if (models.length === 0) throw new Error('empty-model-list');
263
+ return models;
264
+ } finally {
265
+ clearTimeout(timer);
266
+ }
267
+ };
268
+
269
+ // 第 1 轮:直连
270
+ for (const url of candidates) {
271
+ try {
272
+ return { models: await tryFetch(url), viaProxy: false };
273
+ } catch (e) {
274
+ lastErr = e as Error;
275
+ }
276
+ }
277
+
278
+ // 第 2 轮:CORS/网络失败 → 代理回退
279
+ if (proxyUrl && isNetworkLikeError(lastErr)) {
280
+ const proxiedCandidates = [`${proxyUrl}/models`, `${proxyUrl}/v1/models`];
281
+ for (const url of proxiedCandidates) {
282
+ try {
283
+ const models = await tryFetch(url);
284
+ return { models, viaProxy: !!proxyUrl };
285
+ } catch (e) {
286
+ lastErr = e as Error;
287
+ }
288
+ }
289
+ }
290
+
291
+ throw lastErr ?? new Error('unknown-error');
292
+ }
293
+
294
+ function ProviderManager({ providers, onChanged }: { providers: ApiProvider[]; onChanged: () => void }) {
295
+ const addToast = useStore((s) => s.addToast);
296
+ const t = useT();
297
+ const [editing, setEditing] = useState<ApiProvider | null>(null);
298
+ const [pendingDelete, setPendingDelete] = useState<ApiProvider | null>(null);
299
+
300
+ const toggleEnabled = async (p: ApiProvider) => {
301
+ await db.providers.update(p.id!, { isEnabled: !p.isEnabled });
302
+ onChanged();
303
+ addToast(p.isEnabled ? t('toast.providerDisabled', { name: p.name }) : t('toast.providerEnabled', { name: p.name }));
304
+ };
305
+
306
+ const deleteProvider = async (p: ApiProvider) => {
307
+ await db.providers.delete(p.id!);
308
+ onChanged();
309
+ addToast(t('toast.providerDeleted'));
310
+ };
311
+
312
+ return (
313
+ <div className="set-section">
314
+ <h3>{t('settings.providerTitle')}</h3>
315
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
316
+ {providers.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 12.5 }}>{t('settings.noProvider')}</div>}
317
+ {providers.map((p) => {
318
+ const aliases = modelAliases(p);
319
+ const enabledModels = enabledModelIds(p);
320
+ const visibleModels = p.cachedModelsCsv.split(',').map((model) => model.trim()).filter((model) => enabledModels.has(model));
321
+
322
+ return (
323
+ <div key={p.id} className={`provider-card ${p.isEnabled ? '' : 'disabled'}`}>
324
+ <div className="p-head">
325
+ {/* 启用开关放在卡片左侧最外面 */}
326
+ <label className="switch" title={p.isEnabled ? t('settings.disable') : t('settings.enable')}>
327
+ <input type="checkbox" checked={p.isEnabled} onChange={() => toggleEnabled(p)} />
328
+ <span className="switch-slider" />
329
+ </label>
330
+ <span className="p-name">{p.name}</span>
331
+ <span className={`p-status ${p.isEnabled ? 'on' : 'off'}`}>{p.isEnabled ? t('settings.enable') : t('settings.disable')}</span>
332
+ <div style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
333
+ <button className="btn btn-sm" onClick={() => setEditing(p)}>{t('common.edit')}</button>
334
+ <button className="btn btn-sm btn-danger" onClick={() => setPendingDelete(p)}><Icon name="trash" size={12} /></button>
335
+ </div>
336
+ </div>
337
+ <div className="p-url">{p.baseUrl}</div>
338
+ <div style={{ fontSize: 11.5, color: 'var(--text-faint)', maxHeight: 60, overflow: 'auto' }}>
339
+ <b>{t('settings.models')}</b>
340
+ {visibleModels.length > 0
341
+ ? visibleModels.slice(0, 12).map((model) => aliases[model]?.trim() || model).join(', ')
342
+ : t('settings.emptyModels')}
343
+ {visibleModels.length > 12 ? '…' : ''}
344
+ </div>
345
+ </div>
346
+ );
347
+ })}
348
+ <button className="btn" style={{ alignSelf: 'flex-start' }} onClick={() => setEditing({ name: '', baseUrl: '', apiKey: '', isEnabled: true, cachedModelsCsv: '', modelAliasesJson: '{}', createdAt: Date.now() })}>
349
+ <Icon name="plus" size={13} /> {t('settings.addProvider')}
350
+ </button>
351
+ </div>
352
+
353
+ {editing && (
354
+ <div className="overlay" onClick={() => setEditing(null)}>
355
+ <div className="modal-root" onClick={(e) => e.stopPropagation()}>
356
+ <div className="modal card">
357
+ <div className="modal-head">
358
+ <span style={{ fontWeight: 800, fontSize: 15 }}>{editing.id != null ? t('settings.editProvider') : t('settings.addProvider')}</span>
359
+ <button className="icon-btn" onClick={() => setEditing(null)}><Icon name="x" /></button>
360
+ </div>
361
+ <ProviderForm
362
+ initial={editing}
363
+ onSave={async (data) => {
364
+ if (editing.id != null) {
365
+ await db.providers.update(editing.id, data);
366
+ addToast(t('toast.providerUpdated'));
367
+ } else {
368
+ await db.providers.add(data);
369
+ addToast(t('toast.providerAdded'));
370
+ }
371
+ setEditing(null);
372
+ onChanged();
373
+ }}
374
+ />
375
+ </div>
376
+ </div>
377
+ </div>
378
+ )}
379
+
380
+ {pendingDelete && (
381
+ <DeleteConfirmDialog
382
+ title={t('settings.deleteProvider')}
383
+ itemName={pendingDelete.name}
384
+ onCancel={() => setPendingDelete(null)}
385
+ onConfirm={() => deleteProvider(pendingDelete)}
386
+ />
387
+ )}
388
+ </div>
389
+ );
390
+ }
391
+
392
+ function ProviderForm({ initial, onSave }: { initial: ApiProvider; onSave: (p: ApiProvider) => void }) {
393
+ const providers = useStore((s) => s.providers);
394
+ const addToast = useStore((s) => s.addToast);
395
+ const t = useT();
396
+ const [name, setName] = useState(initial.name);
397
+ const [baseUrl, setBaseUrl] = useState(initial.baseUrl);
398
+ const [apiKey, setApiKey] = useState(initial.apiKey);
399
+ const [models, setModels] = useState(() => initial.cachedModelsCsv.split(',').map((model) => model.trim()).filter(Boolean));
400
+ const [aliases, setAliases] = useState(() => modelAliases(initial));
401
+ const [enabledModels, setEnabledModels] = useState(() => enabledModelIds(initial));
402
+ const [testing, setTesting] = useState(false);
403
+
404
+ const testConnection = async () => {
405
+ if (!baseUrl.trim()) return;
406
+ setTesting(true);
407
+ try {
408
+ const { models: fetchedModels, viaProxy } = await fetchModelsSmart(baseUrl.trim(), apiKey);
409
+ const nextBaseUrl = viaProxy && !isProxyUrl(baseUrl) ? toProxyUrl(baseUrl) ?? baseUrl.trim() : baseUrl.trim();
410
+ const updatedAliases = Object.fromEntries(Object.entries(aliases).filter(([model]) => fetchedModels.includes(model)));
411
+ const updatedEnabledModels = new Set(fetchedModels.filter((model) => !models.includes(model) || enabledModels.has(model)));
412
+ setBaseUrl(nextBaseUrl);
413
+ setModels(fetchedModels);
414
+ setAliases(updatedAliases);
415
+ setEnabledModels(updatedEnabledModels);
416
+ addToast(t('toast.providerOk', { n: fetchedModels.length, via: viaProxy ? t('toast.providerViaProxy') : '' }));
417
+ } catch (e) {
418
+ const msg = (e as Error).message;
419
+ addToast(t('toast.providerFail', { msg, hint: isNetworkLikeError(e) ? t('toast.providerFailCors') : '' }), 'error');
420
+ } finally {
421
+ setTesting(false);
422
+ }
423
+ };
424
+
425
+ return (
426
+ <>
427
+ <div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
428
+ <div className="field">
429
+ <label>{t('settings.providerName')}</label>
430
+ <input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t('settings.providerNamePh')} />
431
+ </div>
432
+ <div className="field">
433
+ <label>{t('settings.baseUrl')}</label>
434
+ <input className="input mono" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={t('settings.baseUrlPh')} />
435
+ </div>
436
+ <div className="field">
437
+ <label>{t('settings.apiKey')}</label>
438
+ <input className="input mono" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={t('settings.apiKeyPh')} />
439
+ </div>
440
+ {models.length > 0 && (
441
+ <div className="field">
442
+ <label>{t('settings.models')}</label>
443
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 220, overflow: 'auto' }}>
444
+ {models.map((model) => (
445
+ <div key={model} style={{ display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr) minmax(0, 1fr)', gap: 8, alignItems: 'center' }}>
446
+ <label className="switch" title={enabledModels.has(model) ? t('settings.disable') : t('settings.enable')}>
447
+ <input
448
+ type="checkbox"
449
+ checked={enabledModels.has(model)}
450
+ onChange={() => setEnabledModels((current) => {
451
+ const next = new Set(current);
452
+ if (next.has(model)) next.delete(model);
453
+ else next.add(model);
454
+ return next;
455
+ })}
456
+ />
457
+ <span className="switch-slider" />
458
+ </label>
459
+ <span className="mono" title={model} style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{model}</span>
460
+ <input
461
+ className="input"
462
+ value={aliases[model] ?? ''}
463
+ onChange={(e) => setAliases((current) => ({ ...current, [model]: e.target.value }))}
464
+ placeholder={t('settings.modelAliasPh')}
465
+ aria-label={`${t('settings.modelAlias')}: ${model}`}
466
+ />
467
+ </div>
468
+ ))}
469
+ </div>
470
+ </div>
471
+ )}
472
+ </div>
473
+ <div className="modal-foot">
474
+ <button className="btn" onClick={() => {}} style={{ visibility: 'hidden' }}>{t('common.cancel')}</button>
475
+ <button className="btn" disabled={!baseUrl.trim() || testing} onClick={testConnection}>
476
+ {testing && <span className="spinner" style={{ width: 11, height: 11 }} />}
477
+ {testing ? t('settings.testing') : t('settings.test')}
478
+ </button>
479
+ <button
480
+ className="btn btn-primary"
481
+ disabled={!name.trim() || !baseUrl.trim()}
482
+ onClick={() => {
483
+ const modelNames = models.map((model) => (aliases[model]?.trim() || model).trim());
484
+ const otherModelNames = new Set(
485
+ providers
486
+ .filter((provider) => provider.id !== initial.id)
487
+ .flatMap((provider) => provider.cachedModelsCsv
488
+ .split(',')
489
+ .map((model) => model.trim())
490
+ .filter(Boolean)
491
+ .map((model) => (modelAliases(provider)[model]?.trim() || model).trim())),
492
+ );
493
+ const duplicate = modelNames.find((model, index) =>
494
+ modelNames.indexOf(model) !== index || otherModelNames.has(model),
495
+ );
496
+ if (duplicate) {
497
+ addToast(t('toast.providerDuplicateModel', { model: duplicate }), 'error');
498
+ return;
499
+ }
500
+ onSave({
501
+ ...initial,
502
+ name: name.trim(),
503
+ baseUrl: baseUrl.trim(),
504
+ apiKey,
505
+ cachedModelsCsv: models.join(','),
506
+ modelAliasesJson: JSON.stringify(Object.fromEntries(Object.entries(aliases).filter(([, alias]) => alias.trim()))),
507
+ enabledModelIdsJson: JSON.stringify([...enabledModels]),
508
+ });
509
+ }}
510
+ >
511
+ {t('common.save')}
512
+ </button>
513
+ </div>
514
+ </>
515
+ );
516
+ }
517
+