free-coding-models 0.5.5 → 0.5.8
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/README.md +19 -11
- package/bin/free-coding-models.js +6 -0
- package/changelog/v0.5.5.md +1 -0
- package/changelog/v0.5.6.md +24 -0
- package/changelog/v0.5.7.md +20 -0
- package/package.json +4 -4
- package/src/core/changelog-loader.js +5 -1
- package/src/core/endpoint-installer.js +3 -3
- package/src/core/router-daemon.js +11 -0
- package/src/core/tool-launchers.js +2 -2
- package/web/dist/assets/index-DKmmiDip.css +1 -0
- package/web/dist/assets/index-DzVt42Nf.js +39 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +532 -2
- package/web/src/App.jsx +235 -73
- package/web/src/components/analytics/AnalyticsView.jsx +4 -0
- package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
- package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
- package/web/src/components/atoms/TierBadge.module.css +3 -3
- package/web/src/components/changelog/ChangelogView.jsx +135 -0
- package/web/src/components/changelog/ChangelogView.module.css +160 -0
- package/web/src/components/dashboard/DetailPanel.jsx +29 -4
- package/web/src/components/dashboard/DetailPanel.module.css +26 -0
- package/web/src/components/dashboard/FilterBar.jsx +17 -2
- package/web/src/components/dashboard/FilterBar.module.css +17 -0
- package/web/src/components/dashboard/ModelTable.jsx +17 -5
- package/web/src/components/dashboard/ModelTable.module.css +14 -1
- package/web/src/components/help/HelpView.jsx +188 -0
- package/web/src/components/help/HelpView.module.css +157 -0
- package/web/src/components/install/InstallEndpointsView.jsx +278 -0
- package/web/src/components/install/InstallEndpointsView.module.css +351 -0
- package/web/src/components/installed/InstalledModelsView.jsx +89 -0
- package/web/src/components/installed/InstalledModelsView.module.css +200 -0
- package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
- package/web/src/components/launch/LaunchButton.jsx +30 -0
- package/web/src/components/launch/LaunchButton.module.css +35 -0
- package/web/src/components/launch/LaunchModal.module.css +125 -0
- package/web/src/components/layout/Header.jsx +78 -14
- package/web/src/components/layout/Header.module.css +62 -2
- package/web/src/components/palette/CommandPalette.jsx +228 -74
- package/web/src/components/recommend/RecommendView.jsx +121 -0
- package/web/src/components/recommend/RecommendView.module.css +55 -0
- package/web/src/components/router/RouterView.jsx +286 -0
- package/web/src/components/router/RouterView.module.css +357 -0
- package/web/src/components/settings/SettingsView.jsx +281 -8
- package/web/src/components/settings/SettingsView.module.css +174 -0
- package/web/src/components/tools/ToolPicker.jsx +86 -0
- package/web/src/components/tools/ToolPicker.module.css +84 -0
- package/web/src/components/update/UpdateChip.jsx +104 -0
- package/web/src/components/update/UpdateChip.module.css +146 -0
- package/web/src/global.css +23 -0
- package/web/src/hooks/urlState.constants.js +28 -0
- package/web/src/hooks/useChangelog.js +51 -0
- package/web/src/hooks/useInstalledModels.js +42 -0
- package/web/src/hooks/useRecommend.js +67 -0
- package/web/src/hooks/useRouterDashboard.js +133 -0
- package/web/src/hooks/useTokenUsage.js +103 -0
- package/web/src/hooks/useToolMode.js +77 -0
- package/web/src/hooks/useUpdateChecker.js +91 -0
- package/web/src/hooks/useUrlState.js +123 -62
- package/web/src/utils/m3.js +55 -0
- package/web/dist/assets/index-uD3faN3G.js +0 -39
- package/web/dist/assets/index-uTifVKX1.css +0 -1
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
.overlay {
|
|
2
|
+
position: fixed;
|
|
3
|
+
inset: 0;
|
|
4
|
+
z-index: 200;
|
|
5
|
+
background: rgba(0, 0, 0, 0.6);
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
justify-content: center;
|
|
9
|
+
animation: fadeIn 0.15s ease;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.modal {
|
|
13
|
+
background: var(--bg-primary, #0f0f17);
|
|
14
|
+
border: 1px solid var(--border, #1e1e2e);
|
|
15
|
+
border-radius: 12px;
|
|
16
|
+
width: 720px;
|
|
17
|
+
max-width: 95vw;
|
|
18
|
+
max-height: 85vh;
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.header {
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
justify-content: space-between;
|
|
28
|
+
padding: 16px 20px;
|
|
29
|
+
border-bottom: 1px solid var(--border, #1e1e2e);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.title {
|
|
33
|
+
margin: 0;
|
|
34
|
+
font-size: 16px;
|
|
35
|
+
font-weight: 600;
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: 8px;
|
|
39
|
+
color: var(--text-primary, #e0e0e0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.closeBtn {
|
|
43
|
+
background: none;
|
|
44
|
+
border: none;
|
|
45
|
+
color: var(--text-muted, #888);
|
|
46
|
+
font-size: 18px;
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
padding: 4px 8px;
|
|
49
|
+
border-radius: 6px;
|
|
50
|
+
&:hover { background: var(--bg-hover, #1a1a2e); color: var(--text-primary, #e0e0e0); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.body {
|
|
54
|
+
padding: 20px;
|
|
55
|
+
overflow-y: auto;
|
|
56
|
+
flex: 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Hero Card */
|
|
60
|
+
.heroCard {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: space-between;
|
|
64
|
+
padding: 16px;
|
|
65
|
+
border-radius: 10px;
|
|
66
|
+
margin-bottom: 16px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.heroRunning {
|
|
70
|
+
background: linear-gradient(135deg, rgba(0, 200, 100, 0.08), rgba(0, 200, 100, 0.02));
|
|
71
|
+
border: 1px solid rgba(0, 200, 100, 0.2);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.heroStopped {
|
|
75
|
+
background: var(--bg-secondary, #14141f);
|
|
76
|
+
border: 1px solid var(--border, #1e1e2e);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.heroLeft {
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
gap: 4px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.heroStatus {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
gap: 8px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.statusDot {
|
|
92
|
+
width: 10px;
|
|
93
|
+
height: 10px;
|
|
94
|
+
border-radius: 50%;
|
|
95
|
+
display: inline-block;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.dotGreen {
|
|
99
|
+
background: #00c864;
|
|
100
|
+
box-shadow: 0 0 6px rgba(0, 200, 100, 0.4);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.dotGray {
|
|
104
|
+
background: #555;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.heroLabel {
|
|
108
|
+
font-size: 15px;
|
|
109
|
+
font-weight: 600;
|
|
110
|
+
color: var(--text-primary, #e0e0e0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.heroMeta {
|
|
114
|
+
font-size: 12px;
|
|
115
|
+
color: var(--text-muted, #888);
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 6px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.heroActions {
|
|
122
|
+
display: flex;
|
|
123
|
+
align-items: center;
|
|
124
|
+
gap: 8px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.startBtn {
|
|
128
|
+
display: flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
gap: 6px;
|
|
131
|
+
padding: 8px 16px;
|
|
132
|
+
background: #00c864;
|
|
133
|
+
color: #000;
|
|
134
|
+
border: none;
|
|
135
|
+
border-radius: 8px;
|
|
136
|
+
font-size: 13px;
|
|
137
|
+
font-weight: 600;
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
&:hover { background: #00d874; }
|
|
140
|
+
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.stopBtn {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: 6px;
|
|
147
|
+
padding: 8px 16px;
|
|
148
|
+
background: #dc3545;
|
|
149
|
+
color: #fff;
|
|
150
|
+
border: none;
|
|
151
|
+
border-radius: 8px;
|
|
152
|
+
font-size: 13px;
|
|
153
|
+
font-weight: 600;
|
|
154
|
+
cursor: pointer;
|
|
155
|
+
&:hover { background: #e04555; }
|
|
156
|
+
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.refreshBtn {
|
|
160
|
+
display: flex;
|
|
161
|
+
align-items: center;
|
|
162
|
+
justify-content: center;
|
|
163
|
+
width: 32px;
|
|
164
|
+
height: 32px;
|
|
165
|
+
background: var(--bg-secondary, #14141f);
|
|
166
|
+
border: 1px solid var(--border, #1e1e2e);
|
|
167
|
+
border-radius: 8px;
|
|
168
|
+
color: var(--text-muted, #888);
|
|
169
|
+
cursor: pointer;
|
|
170
|
+
&:hover { color: var(--text-primary, #e0e0e0); background: var(--bg-hover, #1a1a2e); }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* Quick Setup */
|
|
174
|
+
.quickSetup {
|
|
175
|
+
margin-bottom: 16px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.sectionTitle {
|
|
179
|
+
display: flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
gap: 6px;
|
|
182
|
+
font-size: 13px;
|
|
183
|
+
font-weight: 600;
|
|
184
|
+
color: var(--text-secondary, #aaa);
|
|
185
|
+
margin: 0 0 8px 0;
|
|
186
|
+
text-transform: uppercase;
|
|
187
|
+
letter-spacing: 0.5px;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.quickRows {
|
|
191
|
+
display: flex;
|
|
192
|
+
flex-direction: column;
|
|
193
|
+
gap: 6px;
|
|
194
|
+
background: var(--bg-secondary, #14141f);
|
|
195
|
+
border: 1px solid var(--border, #1e1e2e);
|
|
196
|
+
border-radius: 8px;
|
|
197
|
+
padding: 10px 12px;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.quickRow {
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
gap: 8px;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.quickLabel {
|
|
207
|
+
font-size: 11px;
|
|
208
|
+
font-weight: 600;
|
|
209
|
+
color: var(--text-muted, #888);
|
|
210
|
+
min-width: 60px;
|
|
211
|
+
text-transform: uppercase;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.quickValue {
|
|
215
|
+
flex: 1;
|
|
216
|
+
font-size: 13px;
|
|
217
|
+
color: var(--text-primary, #e0e0e0);
|
|
218
|
+
background: var(--bg-primary, #0f0f17);
|
|
219
|
+
padding: 4px 8px;
|
|
220
|
+
border-radius: 4px;
|
|
221
|
+
font-family: 'SF Mono', monospace;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.copyBtn {
|
|
225
|
+
background: none;
|
|
226
|
+
border: none;
|
|
227
|
+
color: var(--text-muted, #888);
|
|
228
|
+
cursor: pointer;
|
|
229
|
+
padding: 4px;
|
|
230
|
+
border-radius: 4px;
|
|
231
|
+
&:hover { color: var(--text-primary, #e0e0e0); background: var(--bg-hover, #1a1a2e); }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* Probe Mode */
|
|
235
|
+
.section {
|
|
236
|
+
margin-bottom: 16px;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.probeModes {
|
|
240
|
+
display: flex;
|
|
241
|
+
gap: 8px;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.probeBtn {
|
|
245
|
+
padding: 6px 14px;
|
|
246
|
+
border: 1px solid var(--border, #1e1e2e);
|
|
247
|
+
border-radius: 6px;
|
|
248
|
+
background: var(--bg-secondary, #14141f);
|
|
249
|
+
color: var(--text-secondary, #aaa);
|
|
250
|
+
font-size: 12px;
|
|
251
|
+
cursor: pointer;
|
|
252
|
+
text-transform: capitalize;
|
|
253
|
+
&:hover { border-color: var(--text-muted, #888); }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.probeActive {
|
|
257
|
+
background: rgba(0, 200, 100, 0.1);
|
|
258
|
+
border-color: rgba(0, 200, 100, 0.3);
|
|
259
|
+
color: #00c864;
|
|
260
|
+
font-weight: 600;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* Model Health */
|
|
264
|
+
.modelList {
|
|
265
|
+
display: flex;
|
|
266
|
+
flex-direction: column;
|
|
267
|
+
gap: 4px;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.modelRow {
|
|
271
|
+
display: flex;
|
|
272
|
+
align-items: center;
|
|
273
|
+
gap: 8px;
|
|
274
|
+
padding: 6px 10px;
|
|
275
|
+
background: var(--bg-secondary, #14141f);
|
|
276
|
+
border-radius: 6px;
|
|
277
|
+
font-size: 12px;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.modelPriority {
|
|
281
|
+
color: var(--text-muted, #888);
|
|
282
|
+
min-width: 22px;
|
|
283
|
+
font-weight: 600;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.modelName {
|
|
287
|
+
flex: 1;
|
|
288
|
+
color: var(--text-primary, #e0e0e0);
|
|
289
|
+
font-family: 'SF Mono', monospace;
|
|
290
|
+
overflow: hidden;
|
|
291
|
+
text-overflow: ellipsis;
|
|
292
|
+
white-space: nowrap;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.modelScore {
|
|
296
|
+
color: var(--text-muted, #888);
|
|
297
|
+
min-width: 40px;
|
|
298
|
+
text-align: right;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.modelLatency {
|
|
302
|
+
color: var(--text-muted, #888);
|
|
303
|
+
min-width: 50px;
|
|
304
|
+
text-align: right;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.circuitBadge {
|
|
308
|
+
font-size: 10px;
|
|
309
|
+
font-weight: 700;
|
|
310
|
+
padding: 2px 6px;
|
|
311
|
+
border-radius: 4px;
|
|
312
|
+
text-transform: uppercase;
|
|
313
|
+
letter-spacing: 0.5px;
|
|
314
|
+
min-width: 70px;
|
|
315
|
+
text-align: center;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.circuitClosed { background: rgba(0, 200, 100, 0.15); color: #00c864; }
|
|
319
|
+
.circuitOpen { background: rgba(220, 53, 69, 0.15); color: #dc3545; }
|
|
320
|
+
.circuitHalfOpen { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
|
|
321
|
+
.circuitAuth { background: rgba(255, 140, 0, 0.15); color: #ff8c00; }
|
|
322
|
+
.circuitUnknown { background: rgba(128, 128, 128, 0.15); color: #888; }
|
|
323
|
+
|
|
324
|
+
/* Request Log */
|
|
325
|
+
.logList {
|
|
326
|
+
display: flex;
|
|
327
|
+
flex-direction: column;
|
|
328
|
+
gap: 2px;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.logRow {
|
|
332
|
+
display: flex;
|
|
333
|
+
align-items: center;
|
|
334
|
+
gap: 8px;
|
|
335
|
+
padding: 4px 8px;
|
|
336
|
+
font-size: 11px;
|
|
337
|
+
font-family: 'SF Mono', monospace;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.logOk { color: #00c864; min-width: 28px; }
|
|
341
|
+
.logErr { color: #dc3545; min-width: 28px; }
|
|
342
|
+
.logModel { color: var(--text-secondary, #aaa); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
343
|
+
.logLatency { color: var(--text-muted, #888); min-width: 50px; text-align: right; }
|
|
344
|
+
.logTokens { color: var(--text-muted, #888); min-width: 60px; text-align: right; }
|
|
345
|
+
.logFailover {
|
|
346
|
+
font-size: 9px;
|
|
347
|
+
padding: 1px 4px;
|
|
348
|
+
background: rgba(255, 193, 7, 0.15);
|
|
349
|
+
color: #ffc107;
|
|
350
|
+
border-radius: 3px;
|
|
351
|
+
text-transform: uppercase;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
@keyframes fadeIn {
|
|
355
|
+
from { opacity: 0; }
|
|
356
|
+
to { opacity: 1; }
|
|
357
|
+
}
|
|
@@ -1,21 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/components/settings/SettingsView.jsx
|
|
3
|
-
* @description Full settings page
|
|
4
|
-
* 📖
|
|
5
|
-
*
|
|
3
|
+
* @description Full settings page — M2 parity with the TUI Settings overlay.
|
|
4
|
+
* 📖 M1: API key management (per-provider cards: enable/disable, masked key,
|
|
5
|
+
* 📖 reveal, copy, save, delete, search filter).
|
|
6
|
+
* 📖 M2: theme dropdown, favorites display mode toggle, startup AI speed scan
|
|
7
|
+
* 📖 toggle, shell-env export toggle, legacy proxy cleanup button, per-provider
|
|
8
|
+
* 📖 test key button (calls /api/key/:provider/test), open Changelog link,
|
|
9
|
+
* 📖 update status row.
|
|
6
10
|
* @functions SettingsView → main settings page component
|
|
7
11
|
*/
|
|
8
12
|
import { useState, useEffect, useCallback } from 'react'
|
|
9
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
IconSettings, IconPlug, IconCircleCheck, IconKey, IconEye, IconEyeOff, IconCopy, IconTrash,
|
|
15
|
+
IconBolt, IconCircleCheckFilled, IconHistory, IconRefresh, IconDownload, IconSun, IconStar,
|
|
16
|
+
} from '@tabler/icons-react'
|
|
10
17
|
import styles from './SettingsView.module.css'
|
|
11
18
|
import { maskKey } from '../../utils/format.js'
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
const TEST_OUTCOME_META = {
|
|
21
|
+
ok: { label: 'OK', icon: IconCircleCheckFilled, className: 'testOk' },
|
|
22
|
+
auth_error: { label: 'Auth error', icon: IconKey, className: 'testErr' },
|
|
23
|
+
rate_limited: { label: 'Rate limited', icon: IconRefresh, className: 'testWarn' },
|
|
24
|
+
no_callable_model: { label: 'No callable model', icon: IconKey, className: 'testWarn' },
|
|
25
|
+
fail: { label: 'Failed', icon: IconKey, className: 'testErr' },
|
|
26
|
+
missing_key: { label: 'Missing key', icon: IconKey, className: 'testNeutral' },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function SettingsView({ onToast, onOpenChangelog, onCheckForUpdate }) {
|
|
14
30
|
const [config, setConfig] = useState(null)
|
|
15
31
|
const [searchQuery, setSearchQuery] = useState('')
|
|
16
32
|
const [expandedCards, setExpandedCards] = useState(new Set())
|
|
17
33
|
const [revealedKeys, setRevealedKeys] = useState(new Set())
|
|
18
34
|
const [keyInputs, setKeyInputs] = useState({})
|
|
35
|
+
const [testResults, setTestResults] = useState({}) // { providerKey: { outcome, code?, detail? } }
|
|
36
|
+
const [testingKeys, setTestingKeys] = useState(new Set())
|
|
37
|
+
const [legacyCleanupMsg, setLegacyCleanupMsg] = useState(null)
|
|
19
38
|
|
|
20
39
|
const loadConfig = useCallback(async () => {
|
|
21
40
|
try {
|
|
@@ -142,6 +161,100 @@ export default function SettingsView({ onToast }) {
|
|
|
142
161
|
}
|
|
143
162
|
}
|
|
144
163
|
|
|
164
|
+
// 📖 M2: per-provider key test. Fires a parallel auth probe + chat ping
|
|
165
|
+
// 📖 through /api/key/:provider/test and stores the outcome for badge display.
|
|
166
|
+
const testKey = useCallback(async (key) => {
|
|
167
|
+
if (testingKeys.has(key)) return
|
|
168
|
+
setTestingKeys((prev) => new Set(prev).add(key))
|
|
169
|
+
setTestResults((prev) => ({ ...prev, [key]: { outcome: 'pending' } }))
|
|
170
|
+
try {
|
|
171
|
+
const resp = await fetch(`/api/key/${encodeURIComponent(key)}/test`, { method: 'POST' })
|
|
172
|
+
const data = await resp.json().catch(() => ({}))
|
|
173
|
+
if (resp.ok) {
|
|
174
|
+
setTestResults((prev) => ({ ...prev, [key]: data }))
|
|
175
|
+
const meta = TEST_OUTCOME_META[data.outcome] || TEST_OUTCOME_META.fail
|
|
176
|
+
onToast?.(`${key} test: ${meta.label}${data.code ? ` (HTTP ${data.code})` : ''}`, data.outcome === 'ok' ? 'success' : 'info')
|
|
177
|
+
} else {
|
|
178
|
+
setTestResults((prev) => ({ ...prev, [key]: { outcome: 'fail', detail: data.error || 'HTTP ' + resp.status } }))
|
|
179
|
+
onToast?.(`${key} test failed: ${data.error || resp.statusText}`, 'error')
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
setTestResults((prev) => ({ ...prev, [key]: { outcome: 'fail', detail: err.message } }))
|
|
183
|
+
onToast?.(`${key} test failed: ${err.message}`, 'error')
|
|
184
|
+
} finally {
|
|
185
|
+
setTestingKeys((prev) => {
|
|
186
|
+
const next = new Set(prev)
|
|
187
|
+
next.delete(key)
|
|
188
|
+
return next
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}, [testingKeys, onToast])
|
|
192
|
+
|
|
193
|
+
// 📖 M2: feature toggles (theme / favorites mode / startup AI scan / shell env)
|
|
194
|
+
// 📖 go through /api/settings/feature which persists to the same config file
|
|
195
|
+
// 📖 the TUI uses. Theme is a tri-state string, not a boolean.
|
|
196
|
+
const toggleFeature = useCallback(async (feature, value) => {
|
|
197
|
+
try {
|
|
198
|
+
const body = value === undefined ? { feature } : { feature, value }
|
|
199
|
+
const resp = await fetch('/api/settings/feature', {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Content-Type': 'application/json' },
|
|
202
|
+
body: JSON.stringify(body),
|
|
203
|
+
})
|
|
204
|
+
const data = await resp.json()
|
|
205
|
+
if (data.success) {
|
|
206
|
+
await loadConfig()
|
|
207
|
+
onToast?.(`${feature} updated`, 'success')
|
|
208
|
+
} else {
|
|
209
|
+
onToast?.(data.error || 'Failed to update feature', 'error')
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
onToast?.('Network error', 'error')
|
|
213
|
+
}
|
|
214
|
+
}, [onToast])
|
|
215
|
+
|
|
216
|
+
const setShellEnv = useCallback(async (enabled) => {
|
|
217
|
+
try {
|
|
218
|
+
const resp = await fetch('/api/shell-env/toggle', {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: { 'Content-Type': 'application/json' },
|
|
221
|
+
body: JSON.stringify({ enabled }),
|
|
222
|
+
})
|
|
223
|
+
const data = await resp.json()
|
|
224
|
+
if (data.success) {
|
|
225
|
+
await loadConfig()
|
|
226
|
+
onToast?.(`Shell env export ${data.enabled ? 'enabled' : 'disabled'} — restart your shell to apply.`, 'success')
|
|
227
|
+
} else {
|
|
228
|
+
onToast?.(data.error || 'Failed to toggle shell env', 'error')
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
onToast?.('Network error', 'error')
|
|
232
|
+
}
|
|
233
|
+
}, [onToast])
|
|
234
|
+
|
|
235
|
+
const runLegacyCleanup = useCallback(async () => {
|
|
236
|
+
if (!confirm('Remove discontinued proxy config leftovers? This is safe to run.')) return
|
|
237
|
+
try {
|
|
238
|
+
const resp = await fetch('/api/legacy-cleanup', { method: 'POST' })
|
|
239
|
+
const data = await resp.json()
|
|
240
|
+
const cleaned = (data.removedFiles?.length || 0) + (data.updatedFiles?.length || 0)
|
|
241
|
+
if (data.changed) {
|
|
242
|
+
setLegacyCleanupMsg(`Cleaned ${cleaned} legacy file(s). ${data.errors.length} error(s).`)
|
|
243
|
+
onToast?.(`Legacy proxy cleanup: ${cleaned} file(s) cleaned.`, 'success')
|
|
244
|
+
} else {
|
|
245
|
+
setLegacyCleanupMsg('No discontinued proxy config was found. You are on the stable direct-provider setup.')
|
|
246
|
+
onToast?.('No legacy proxy config found — already on stable setup.', 'info')
|
|
247
|
+
}
|
|
248
|
+
await loadConfig()
|
|
249
|
+
} catch (err) {
|
|
250
|
+
onToast?.(`Legacy cleanup failed: ${err.message}`, 'error')
|
|
251
|
+
}
|
|
252
|
+
}, [onToast])
|
|
253
|
+
|
|
254
|
+
const onCheckUpdatesClick = useCallback(() => {
|
|
255
|
+
onCheckForUpdate?.()
|
|
256
|
+
}, [onCheckForUpdate])
|
|
257
|
+
|
|
145
258
|
if (!config) {
|
|
146
259
|
return (
|
|
147
260
|
<div className={styles.page}>
|
|
@@ -163,14 +276,150 @@ export default function SettingsView({ onToast }) {
|
|
|
163
276
|
<div className={styles.pageHeader}>
|
|
164
277
|
<h1 className={styles.pageTitle}>
|
|
165
278
|
<IconSettings size={24} stroke={1.5} style={{ marginRight: 8, verticalAlign: 'middle' }} />
|
|
166
|
-
|
|
279
|
+
Settings
|
|
167
280
|
</h1>
|
|
168
281
|
<p className={styles.pageSubtitle}>
|
|
169
|
-
|
|
170
|
-
<code>~/.free-coding-models.json</code>
|
|
282
|
+
API keys, theme, favorites mode, shell env, and update controls.
|
|
283
|
+
All settings are stored locally in <code>~/.free-coding-models.json</code>
|
|
284
|
+
and shared with the TUI.
|
|
171
285
|
</p>
|
|
172
286
|
</div>
|
|
173
287
|
|
|
288
|
+
{/* ── M2: global feature toggles ────────────────────────────────────── */}
|
|
289
|
+
{config && (
|
|
290
|
+
<section className={styles.featureSection}>
|
|
291
|
+
<h2 className={styles.sectionHeading}>⚙️ Global settings</h2>
|
|
292
|
+
<div className={styles.featureGrid}>
|
|
293
|
+
{/* Theme */}
|
|
294
|
+
<div className={styles.featureRow}>
|
|
295
|
+
<div className={styles.featureLabel}>
|
|
296
|
+
<IconSun size={16} stroke={1.5} />
|
|
297
|
+
<div>
|
|
298
|
+
<div className={styles.featureTitle}>Theme</div>
|
|
299
|
+
<div className={styles.featureDesc}>Tri-state cycle. Auto follows your OS.</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
<select
|
|
303
|
+
className={styles.select}
|
|
304
|
+
value={config.settings?.theme || 'auto'}
|
|
305
|
+
onChange={(e) => toggleFeature('theme', e.target.value)}
|
|
306
|
+
>
|
|
307
|
+
<option value="auto">Auto (OS)</option>
|
|
308
|
+
<option value="dark">Dark</option>
|
|
309
|
+
<option value="light">Light</option>
|
|
310
|
+
</select>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
{/* Favorites display mode */}
|
|
314
|
+
<div className={styles.featureRow}>
|
|
315
|
+
<div className={styles.featureLabel}>
|
|
316
|
+
<IconStar size={16} stroke={1.5} />
|
|
317
|
+
<div>
|
|
318
|
+
<div className={styles.featureTitle}>Favorites pinned + always visible</div>
|
|
319
|
+
<div className={styles.featureDesc}>Favorites bypass filters and stay on top (TUI: Y key).</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
<label className={styles.toggleSwitch}>
|
|
323
|
+
<input
|
|
324
|
+
type="checkbox"
|
|
325
|
+
checked={Boolean(config.settings?.favoritesPinnedAndSticky)}
|
|
326
|
+
onChange={(e) => toggleFeature('favoritesPinnedAndSticky', e.target.checked)}
|
|
327
|
+
/>
|
|
328
|
+
<span className={styles.toggleSlider} />
|
|
329
|
+
</label>
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
{/* Startup AI Speed Scan */}
|
|
333
|
+
<div className={styles.featureRow}>
|
|
334
|
+
<div className={styles.featureLabel}>
|
|
335
|
+
<IconBolt size={16} stroke={1.5} />
|
|
336
|
+
<div>
|
|
337
|
+
<div className={styles.featureTitle}>Run AI Speed Test on startup</div>
|
|
338
|
+
<div className={styles.featureDesc}>Auto-fire the global benchmark right after launch (TUI: U → 'Enable').</div>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
<label className={styles.toggleSwitch}>
|
|
342
|
+
<input
|
|
343
|
+
type="checkbox"
|
|
344
|
+
checked={Boolean(config.settings?.runAiSpeedTestOnStartup)}
|
|
345
|
+
onChange={(e) => toggleFeature('runAiSpeedTestOnStartup', e.target.checked)}
|
|
346
|
+
/>
|
|
347
|
+
<span className={styles.toggleSlider} />
|
|
348
|
+
</label>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{/* Shell env export */}
|
|
352
|
+
<div className={styles.featureRow}>
|
|
353
|
+
<div className={styles.featureLabel}>
|
|
354
|
+
<IconCircleCheck size={16} stroke={1.5} />
|
|
355
|
+
<div>
|
|
356
|
+
<div className={styles.featureTitle}>Export API keys to shell rc</div>
|
|
357
|
+
<div className={styles.featureDesc}>Write NVIDIA_API_KEY / GROQ_API_KEY / … to your shell rc file.</div>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
<label className={styles.toggleSwitch}>
|
|
361
|
+
<input
|
|
362
|
+
type="checkbox"
|
|
363
|
+
checked={Boolean(config.settings?.shellEnvEnabled)}
|
|
364
|
+
onChange={(e) => setShellEnv(e.target.checked)}
|
|
365
|
+
/>
|
|
366
|
+
<span className={styles.toggleSlider} />
|
|
367
|
+
</label>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
{/* Update row */}
|
|
371
|
+
<div className={styles.featureRow}>
|
|
372
|
+
<div className={styles.featureLabel}>
|
|
373
|
+
<IconDownload size={16} stroke={1.5} />
|
|
374
|
+
<div>
|
|
375
|
+
<div className={styles.featureTitle}>Check for updates</div>
|
|
376
|
+
<div className={styles.featureDesc}>
|
|
377
|
+
The header chip turns green when a newer npm version is available.
|
|
378
|
+
Use the 'Update now' button to install.
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
<div className={styles.featureActions}>
|
|
383
|
+
<button
|
|
384
|
+
className={styles.smallBtn}
|
|
385
|
+
onClick={onCheckUpdatesClick}
|
|
386
|
+
>
|
|
387
|
+
Check now
|
|
388
|
+
</button>
|
|
389
|
+
<button
|
|
390
|
+
className={styles.smallBtn}
|
|
391
|
+
onClick={() => onOpenChangelog?.(null)}
|
|
392
|
+
>
|
|
393
|
+
<IconHistory size={13} stroke={1.5} /> Changelog
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
{/* Legacy proxy cleanup */}
|
|
399
|
+
<div className={styles.featureRow}>
|
|
400
|
+
<div className={styles.featureLabel}>
|
|
401
|
+
<IconRefresh size={16} stroke={1.5} />
|
|
402
|
+
<div>
|
|
403
|
+
<div className={styles.featureTitle}>Cleanup discontinued proxy artifacts</div>
|
|
404
|
+
<div className={styles.featureDesc}>
|
|
405
|
+
Remove old config / env / service leftovers from the old multi-tool proxy.
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
<button
|
|
410
|
+
className={styles.smallBtn}
|
|
411
|
+
onClick={runLegacyCleanup}
|
|
412
|
+
>
|
|
413
|
+
Run cleanup
|
|
414
|
+
</button>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
{legacyCleanupMsg && (
|
|
418
|
+
<div className={styles.notice}>{legacyCleanupMsg}</div>
|
|
419
|
+
)}
|
|
420
|
+
</section>
|
|
421
|
+
)}
|
|
422
|
+
|
|
174
423
|
<div className={styles.toolbar}>
|
|
175
424
|
<div className={styles.toolbarSearch}>
|
|
176
425
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
@@ -221,6 +470,20 @@ export default function SettingsView({ onToast }) {
|
|
|
221
470
|
|
|
222
471
|
<div className={styles.cardBody}>
|
|
223
472
|
<div className={styles.cardContent}>
|
|
473
|
+
{p.hasKey && testResults[key] && (
|
|
474
|
+
<div className={`${styles.testBadge} ${styles[`test_${testResults[key].outcome}`] || ''}`}>
|
|
475
|
+
{(() => {
|
|
476
|
+
const meta = TEST_OUTCOME_META[testResults[key].outcome] || TEST_OUTCOME_META.fail
|
|
477
|
+
const Icon = meta.icon
|
|
478
|
+
return (
|
|
479
|
+
<>
|
|
480
|
+
<Icon size={12} stroke={1.5} />
|
|
481
|
+
<span>Last test: {meta.label}{testResults[key].code ? ` (HTTP ${testResults[key].code})` : ''}</span>
|
|
482
|
+
</>
|
|
483
|
+
)
|
|
484
|
+
})()}
|
|
485
|
+
</div>
|
|
486
|
+
)}
|
|
224
487
|
{p.hasKey && (
|
|
225
488
|
<div className={styles.keyGroup}>
|
|
226
489
|
<label className={styles.keyLabel}>Current API Key</label>
|
|
@@ -235,6 +498,16 @@ export default function SettingsView({ onToast }) {
|
|
|
235
498
|
<button className={styles.actionBtn} onClick={() => copyKey(key)} title="Copy">
|
|
236
499
|
<IconCopy size={14} stroke={1.5} />
|
|
237
500
|
</button>
|
|
501
|
+
<button
|
|
502
|
+
className={styles.actionBtn}
|
|
503
|
+
onClick={() => testKey(key)}
|
|
504
|
+
disabled={testingKeys.has(key)}
|
|
505
|
+
title="Test this key against the provider (TUI: T key in Settings)"
|
|
506
|
+
aria-label={`Test key for ${key}`}
|
|
507
|
+
>
|
|
508
|
+
{testingKeys.has(key) ? <span className={styles.testSpinner} /> : <IconBolt size={14} stroke={1.5} />}
|
|
509
|
+
{testingKeys.has(key) ? 'Testing…' : 'Test'}
|
|
510
|
+
</button>
|
|
238
511
|
<button className={`${styles.actionBtn} ${styles.actionBtnDanger}`} onClick={() => deleteKey(key)} title="Delete Key">
|
|
239
512
|
<IconTrash size={14} stroke={1.5} />
|
|
240
513
|
</button>
|