free-coding-models 0.5.12 → 0.5.15
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/changelog/v0.5.13.md +23 -0
- package/changelog/v0.5.15.md +17 -0
- package/package.json +4 -3
- package/src/tui/app.js +16 -4
- package/src/tui/cli-help.js +87 -0
- package/src/tui/key-handler.js +2 -14
- package/src/tui/render-table.js +46 -26
- package/src/tui/theme.js +70 -0
- package/web/README.md +1 -1
- package/web/dist/assets/index-BRqzsHVw.css +1 -0
- package/web/dist/assets/index-CUYQh5_t.js +39 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +83 -14
- package/web/src/App.jsx +15 -1
- package/web/src/components/dashboard/DetailPanel.jsx +1 -1
- package/web/src/components/dashboard/DetailPanel.module.css +5 -0
- package/web/src/components/dashboard/FilterBar.jsx +98 -58
- package/web/src/components/dashboard/FilterBar.module.css +103 -33
- package/web/src/components/dashboard/ProviderDropdown.jsx +156 -0
- package/web/src/components/dashboard/ProviderDropdown.module.css +248 -0
- package/web/src/components/help/HelpView.jsx +13 -0
- package/web/src/components/playground/PlaygroundView.jsx +193 -12
- package/web/src/components/playground/PlaygroundView.module.css +82 -0
- package/web/src/components/router/RouterView.jsx +15 -6
- package/web/vite.config.js +1 -1
- package/web/dist/assets/index-Ce_pr2YF.js +0 -39
- package/web/dist/assets/index-H9JWDRIh.css +0 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/dashboard/ProviderDropdown.jsx
|
|
3
|
+
* @description Custom dropdown for provider filtering with inline SVG logos + health indicator.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* - Replaces the native `<select>` for providers with a custom dropdown that
|
|
7
|
+
* renders each provider as [SVG icon + wordmark + health dot + model count].
|
|
8
|
+
* - Uses the same `ProviderLogo` component as the model table so branding is
|
|
9
|
+
* visually consistent across the dashboard.
|
|
10
|
+
* - The health indicator dot shows:
|
|
11
|
+
* 🟢 Green (glow) — provider has at least one model with status 'up' (key works).
|
|
12
|
+
* 🟡 Yellow — provider has keys configured but no models are 'up' yet (pending).
|
|
13
|
+
* 🔴 Red — provider has keys but models are down / auth errors.
|
|
14
|
+
* ⚪ Gray — provider has no API key configured at all.
|
|
15
|
+
* - Theme-aware: colors adapt for both dark and light modes via CSS variables.
|
|
16
|
+
* - Click-outside + Escape to close, keyboard-friendly, scrollable for long lists.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} props
|
|
19
|
+
* @param {Array} props.providers — Array of { key, name, count, hasKey, anyUp }
|
|
20
|
+
* @param {string} props.value — Currently selected provider key ('all' for no filter).
|
|
21
|
+
* @param {function} props.onChange — Callback with new provider key.
|
|
22
|
+
*
|
|
23
|
+
* @functions
|
|
24
|
+
* → ProviderDropdown (default export)
|
|
25
|
+
*
|
|
26
|
+
* @see web/src/components/dashboard/FilterBar.jsx (consumer)
|
|
27
|
+
* @see web/src/components/atoms/ProviderLogo.jsx (logo renderer)
|
|
28
|
+
*/
|
|
29
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
30
|
+
import { IconChevronDown } from '@tabler/icons-react'
|
|
31
|
+
import ProviderLogo from '../atoms/ProviderLogo.jsx'
|
|
32
|
+
import styles from './ProviderDropdown.module.css'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 📖 Derives the health indicator state from aggregated provider data.
|
|
36
|
+
* Returns one of: 'active' | 'pending' | 'down' | 'nokey'.
|
|
37
|
+
*/
|
|
38
|
+
function providerHealthState(provider) {
|
|
39
|
+
if (!provider.hasKey) return 'nokey'
|
|
40
|
+
if (provider.anyUp) return 'active'
|
|
41
|
+
return 'down'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function HealthIndicator({ state }) {
|
|
45
|
+
const cls = styles[`dot_${state}`] || styles.dot_nokey
|
|
46
|
+
const titles = {
|
|
47
|
+
active: 'API key works — at least one model is UP',
|
|
48
|
+
pending: 'API key set — waiting for health data',
|
|
49
|
+
down: 'API key set — models are DOWN or have auth errors',
|
|
50
|
+
nokey: 'No API key configured',
|
|
51
|
+
}
|
|
52
|
+
return <span className={`${styles.healthDot} ${cls}`} title={titles[state] || ''} />
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function ProviderDropdown({ providers, value, onChange }) {
|
|
56
|
+
const [open, setOpen] = useState(false)
|
|
57
|
+
const ref = useRef(null)
|
|
58
|
+
const listRef = useRef(null)
|
|
59
|
+
|
|
60
|
+
const close = useCallback(() => setOpen(false), [])
|
|
61
|
+
|
|
62
|
+
// 📖 Close on outside click or Escape.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!open) return
|
|
65
|
+
const handleClickOutside = (e) => {
|
|
66
|
+
if (ref.current && !ref.current.contains(e.target)) close()
|
|
67
|
+
}
|
|
68
|
+
const handleKey = (e) => { if (e.key === 'Escape') close() }
|
|
69
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
70
|
+
document.addEventListener('keydown', handleKey)
|
|
71
|
+
return () => {
|
|
72
|
+
document.removeEventListener('mousedown', handleClickOutside)
|
|
73
|
+
document.removeEventListener('keydown', handleKey)
|
|
74
|
+
}
|
|
75
|
+
}, [open, close])
|
|
76
|
+
|
|
77
|
+
// 📖 Scroll selected item into view when dropdown opens.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!open || !listRef.current) return
|
|
80
|
+
const selected = listRef.current.querySelector(`[data-active="true"]`)
|
|
81
|
+
if (selected) selected.scrollIntoView({ block: 'nearest' })
|
|
82
|
+
}, [open])
|
|
83
|
+
|
|
84
|
+
const selectedProvider = providers.find(p => p.key === value)
|
|
85
|
+
const isFiltered = value !== 'all'
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className={styles.dropdown} ref={ref}>
|
|
89
|
+
<button
|
|
90
|
+
className={`${styles.trigger} ${isFiltered ? styles.triggerActive : ''} ${open ? styles.triggerOpen : ''}`}
|
|
91
|
+
onClick={() => setOpen(!open)}
|
|
92
|
+
aria-expanded={open}
|
|
93
|
+
aria-haspopup="listbox"
|
|
94
|
+
title={isFiltered ? `Provider: ${selectedProvider?.name || value}` : 'All providers'}
|
|
95
|
+
>
|
|
96
|
+
<span className={styles.triggerContent}>
|
|
97
|
+
{isFiltered && selectedProvider ? (
|
|
98
|
+
<>
|
|
99
|
+
<span className={styles.triggerLogo}>
|
|
100
|
+
<ProviderLogo providerKey={selectedProvider.key} origin={selectedProvider.name} />
|
|
101
|
+
</span>
|
|
102
|
+
<span className={styles.triggerCount}>{selectedProvider.count}</span>
|
|
103
|
+
</>
|
|
104
|
+
) : (
|
|
105
|
+
<span className={styles.triggerLabel}>All Providers</span>
|
|
106
|
+
)}
|
|
107
|
+
</span>
|
|
108
|
+
<IconChevronDown
|
|
109
|
+
size={12}
|
|
110
|
+
stroke={2}
|
|
111
|
+
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
|
112
|
+
/>
|
|
113
|
+
</button>
|
|
114
|
+
|
|
115
|
+
{open && (
|
|
116
|
+
<div className={styles.menu} role="listbox" ref={listRef}>
|
|
117
|
+
{/* ── "All Providers" option ── */}
|
|
118
|
+
<button
|
|
119
|
+
role="option"
|
|
120
|
+
aria-selected={value === 'all'}
|
|
121
|
+
data-active={value === 'all'}
|
|
122
|
+
className={`${styles.option} ${value === 'all' ? styles.optionActive : ''}`}
|
|
123
|
+
onClick={() => { onChange('all'); close() }}
|
|
124
|
+
>
|
|
125
|
+
<span className={styles.optionLabel}>All Providers</span>
|
|
126
|
+
<span className={styles.optionCount}>{providers.reduce((s, p) => s + p.count, 0)}</span>
|
|
127
|
+
</button>
|
|
128
|
+
|
|
129
|
+
<div className={styles.separator} />
|
|
130
|
+
|
|
131
|
+
{/* ── Per-provider options ── */}
|
|
132
|
+
{providers.map((p) => {
|
|
133
|
+
const healthState = providerHealthState(p)
|
|
134
|
+
return (
|
|
135
|
+
<button
|
|
136
|
+
key={p.key}
|
|
137
|
+
role="option"
|
|
138
|
+
aria-selected={value === p.key}
|
|
139
|
+
data-active={value === p.key}
|
|
140
|
+
className={`${styles.option} ${value === p.key ? styles.optionActive : ''}`}
|
|
141
|
+
onClick={() => { onChange(p.key); close() }}
|
|
142
|
+
title={`${p.name} — ${p.count} model${p.count !== 1 ? 's' : ''}`}
|
|
143
|
+
>
|
|
144
|
+
<span className={styles.optionLogo}>
|
|
145
|
+
<ProviderLogo providerKey={p.key} origin={p.name} />
|
|
146
|
+
</span>
|
|
147
|
+
<HealthIndicator state={healthState} />
|
|
148
|
+
<span className={styles.optionCount}>{p.count}</span>
|
|
149
|
+
</button>
|
|
150
|
+
)
|
|
151
|
+
})}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/dashboard/ProviderDropdown.module.css
|
|
3
|
+
* @description Styles for the custom provider dropdown — SVG logos + health dots.
|
|
4
|
+
*
|
|
5
|
+
* 📖 Theme-aware: uses CSS variables for all colors. Dark + light mode both
|
|
6
|
+
* 📖 fully supported. Health dot colors are defined per-theme with explicit
|
|
7
|
+
* 📖 overrides for light mode to maintain contrast.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
.dropdown {
|
|
11
|
+
position: relative;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* ── Trigger button ── */
|
|
15
|
+
.trigger {
|
|
16
|
+
display: inline-flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: 6px;
|
|
19
|
+
padding: 4px 10px 4px 8px;
|
|
20
|
+
border: 1px solid var(--color-border);
|
|
21
|
+
border-radius: 5px;
|
|
22
|
+
background: var(--color-surface);
|
|
23
|
+
color: var(--color-text);
|
|
24
|
+
font-size: 11px;
|
|
25
|
+
font-family: var(--font-sans);
|
|
26
|
+
cursor: pointer;
|
|
27
|
+
transition: all 150ms;
|
|
28
|
+
min-width: 130px;
|
|
29
|
+
max-width: 240px;
|
|
30
|
+
user-select: none;
|
|
31
|
+
outline: none;
|
|
32
|
+
}
|
|
33
|
+
.trigger:hover {
|
|
34
|
+
background: var(--color-bg-hover);
|
|
35
|
+
border-color: var(--color-text-muted);
|
|
36
|
+
}
|
|
37
|
+
.trigger:focus {
|
|
38
|
+
border-color: var(--color-accent);
|
|
39
|
+
}
|
|
40
|
+
.triggerActive {
|
|
41
|
+
border-color: var(--color-accent);
|
|
42
|
+
color: var(--color-text);
|
|
43
|
+
}
|
|
44
|
+
.triggerOpen {
|
|
45
|
+
border-color: var(--color-accent);
|
|
46
|
+
border-bottom-left-radius: 0;
|
|
47
|
+
border-bottom-right-radius: 0;
|
|
48
|
+
z-index: 101;
|
|
49
|
+
background: var(--color-bg-hover);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.triggerContent {
|
|
53
|
+
display: inline-flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: 8px;
|
|
56
|
+
flex: 1;
|
|
57
|
+
min-width: 0;
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.triggerLogo {
|
|
62
|
+
display: inline-flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
min-width: 0;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
/* 📖 Inherit max-height from ProviderLogo's row. */
|
|
67
|
+
--pl-max-h: 20px;
|
|
68
|
+
--pl-icon-h: 14px;
|
|
69
|
+
--pl-text-h: 10px;
|
|
70
|
+
}
|
|
71
|
+
/* 📖 Clamp ProviderLogo width inside trigger so long wordmarks don't overflow. */
|
|
72
|
+
.triggerLogo > span {
|
|
73
|
+
max-width: 160px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.triggerLabel {
|
|
77
|
+
font-size: 11px;
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
color: var(--color-text-muted);
|
|
80
|
+
white-space: nowrap;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.triggerCount {
|
|
84
|
+
font-size: 10px;
|
|
85
|
+
font-weight: 700;
|
|
86
|
+
color: var(--color-text-muted);
|
|
87
|
+
font-family: var(--font-mono);
|
|
88
|
+
margin-left: auto;
|
|
89
|
+
flex-shrink: 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.chevron {
|
|
93
|
+
flex-shrink: 0;
|
|
94
|
+
opacity: 0.5;
|
|
95
|
+
transition: transform 200ms ease;
|
|
96
|
+
}
|
|
97
|
+
.chevronOpen {
|
|
98
|
+
transform: rotate(180deg);
|
|
99
|
+
opacity: 0.8;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ── Dropdown menu ── */
|
|
103
|
+
.menu {
|
|
104
|
+
position: absolute;
|
|
105
|
+
top: 100%;
|
|
106
|
+
left: 0;
|
|
107
|
+
z-index: 100;
|
|
108
|
+
min-width: 100%;
|
|
109
|
+
max-width: 300px;
|
|
110
|
+
max-height: 320px;
|
|
111
|
+
overflow-y: auto;
|
|
112
|
+
background: var(--color-surface);
|
|
113
|
+
border: 1px solid var(--color-accent);
|
|
114
|
+
border-top: none;
|
|
115
|
+
border-radius: 0 0 6px 6px;
|
|
116
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
117
|
+
padding: 4px 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* 📖 Custom scrollbar for the dropdown. */
|
|
121
|
+
.menu::-webkit-scrollbar {
|
|
122
|
+
width: 5px;
|
|
123
|
+
}
|
|
124
|
+
.menu::-webkit-scrollbar-track {
|
|
125
|
+
background: transparent;
|
|
126
|
+
}
|
|
127
|
+
.menu::-webkit-scrollbar-thumb {
|
|
128
|
+
background: var(--color-border);
|
|
129
|
+
border-radius: 3px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ── Menu options ── */
|
|
133
|
+
.option {
|
|
134
|
+
display: flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
gap: 8px;
|
|
137
|
+
width: 100%;
|
|
138
|
+
padding: 6px 10px;
|
|
139
|
+
border: none;
|
|
140
|
+
background: transparent;
|
|
141
|
+
color: var(--color-text-muted);
|
|
142
|
+
font-size: 11px;
|
|
143
|
+
font-family: var(--font-sans);
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
transition: all 100ms;
|
|
146
|
+
text-align: left;
|
|
147
|
+
outline: none;
|
|
148
|
+
}
|
|
149
|
+
.option:hover {
|
|
150
|
+
background: var(--color-bg-hover);
|
|
151
|
+
color: var(--color-text);
|
|
152
|
+
}
|
|
153
|
+
.option:focus-visible {
|
|
154
|
+
background: var(--color-bg-hover);
|
|
155
|
+
color: var(--color-text);
|
|
156
|
+
box-shadow: inset 0 0 0 1px var(--color-accent);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.optionActive {
|
|
160
|
+
background: var(--color-accent-dim);
|
|
161
|
+
color: var(--color-text);
|
|
162
|
+
font-weight: 600;
|
|
163
|
+
}
|
|
164
|
+
.optionActive:hover {
|
|
165
|
+
background: var(--color-bg-hover);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.optionLabel {
|
|
169
|
+
font-weight: 600;
|
|
170
|
+
white-space: nowrap;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.optionLogo {
|
|
174
|
+
display: inline-flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
min-width: 0;
|
|
177
|
+
flex: 1;
|
|
178
|
+
overflow: hidden;
|
|
179
|
+
/* 📖 Slightly larger than trigger for readability in the expanded menu. */
|
|
180
|
+
--pl-max-h: 20px;
|
|
181
|
+
--pl-icon-h: 15px;
|
|
182
|
+
--pl-text-h: 11px;
|
|
183
|
+
}
|
|
184
|
+
.optionLogo > span {
|
|
185
|
+
max-width: 180px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.optionCount {
|
|
189
|
+
font-size: 10px;
|
|
190
|
+
font-weight: 700;
|
|
191
|
+
font-family: var(--font-mono);
|
|
192
|
+
color: var(--color-text-dim, #666);
|
|
193
|
+
flex-shrink: 0;
|
|
194
|
+
margin-left: auto;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ── Separator between "All" and per-provider options ── */
|
|
198
|
+
.separator {
|
|
199
|
+
height: 1px;
|
|
200
|
+
background: var(--color-border);
|
|
201
|
+
margin: 4px 8px;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ── Health indicator dots ── */
|
|
205
|
+
.healthDot {
|
|
206
|
+
display: inline-block;
|
|
207
|
+
width: 7px;
|
|
208
|
+
height: 7px;
|
|
209
|
+
border-radius: 50%;
|
|
210
|
+
flex-shrink: 0;
|
|
211
|
+
transition: background 200ms ease, box-shadow 200ms ease;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* 📖 Active: green glow — key works and models are UP. */
|
|
215
|
+
.dot_active {
|
|
216
|
+
background: #00ff88;
|
|
217
|
+
box-shadow: 0 0 5px rgba(0, 255, 136, 0.5);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* 📖 Pending: yellow — key set, health data not yet available. */
|
|
221
|
+
.dot_pending {
|
|
222
|
+
background: #ffaa00;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* 📖 Down: red — key set but models are down / auth errors. */
|
|
226
|
+
.dot_down {
|
|
227
|
+
background: #ff4444;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* 📖 No key: gray — no API key configured for this provider. */
|
|
231
|
+
.dot_nokey {
|
|
232
|
+
background: #555570;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ── Light theme overrides — deeper colors for white background contrast ── */
|
|
236
|
+
:global([data-theme="light"]) .dot_active {
|
|
237
|
+
background: #008f4d;
|
|
238
|
+
box-shadow: 0 0 4px rgba(0, 143, 77, 0.35);
|
|
239
|
+
}
|
|
240
|
+
:global([data-theme="light"]) .dot_pending {
|
|
241
|
+
background: #b38600;
|
|
242
|
+
}
|
|
243
|
+
:global([data-theme="light"]) .dot_down {
|
|
244
|
+
background: #c8143a;
|
|
245
|
+
}
|
|
246
|
+
:global([data-theme="light"]) .dot_nokey {
|
|
247
|
+
background: #aaa;
|
|
248
|
+
}
|
|
@@ -118,6 +118,19 @@ const SECTIONS = [
|
|
|
118
118
|
{ key: 'Same engine', desc: 'All model parsing, ping, and benchmark code is shared with the TUI' },
|
|
119
119
|
],
|
|
120
120
|
},
|
|
121
|
+
{
|
|
122
|
+
id: 'how-router-works',
|
|
123
|
+
title: '🌐 How the FCM Router works',
|
|
124
|
+
items: [
|
|
125
|
+
{ key: 'Smart router', desc: 'Point any OpenAI client at http://localhost:19280/v1 with model: "fcm". The daemon picks the healthiest model in the active set and forwards the request with automatic failover.' },
|
|
126
|
+
{ key: 'Pre-prompt', desc: 'A first-class system message is injected on every proxied request. The default introduces the assistant as the FCM routing agent. Edit from Settings.' },
|
|
127
|
+
{ key: 'Probes', desc: 'Every 10s/30s/120s (eco/balanced/aggressive) the daemon sends a 1-token chat-completion ping to every model in the active set. The probe measures latency + status code, not just URL reachability — so a wrong API key is caught and the circuit opens.' },
|
|
128
|
+
{ key: 'Circuit breaker', desc: 'Per-model state. Healthy (green) = last probe 2xx, route here. Down (red) = last 3 probes failed, skip until cooldown. Recovering (yellow) = cooldown expired, retrying. Auth error (orange) = 401/403, your key is wrong. Deprecated (gray) = removed from catalog, will be replaced by auto-heal.' },
|
|
129
|
+
{ key: 'Failover order', desc: 'Models are tried in priority order. A model in Recovering/Down/Auth error is skipped — the request goes to the next healthy one. If ALL fail, you get 503 with the "models_tried" list in the error body.' },
|
|
130
|
+
{ key: 'Auto-heal', desc: 'On daemon start, every Auth-error / Deprecated model in the active set is swapped for a working alternative (same provider first, then cross-provider). The first time you add/remove/reorder a model, auto-heal switches off.' },
|
|
131
|
+
{ key: 'Rate limits', desc: 'Each provider has its own quota. Common free-tier limits: Groq 14 400 RPD, Mistral 1 RPS, NVIDIA ~40 RPM, OpenRouter 50 RPD. When a provider returns 429, the router fails over. When daily quota is exhausted, the model goes Auth error and auto-heal swaps it out next start.' },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
121
134
|
]
|
|
122
135
|
|
|
123
136
|
export default function HelpView({ onClose }) {
|