@hina114514/chaite 1.9.3 → 1.9.5

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.
@@ -1,537 +1,533 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Chaite Dashboard</title>
7
- <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
8
- <style>
9
- :root {
10
- --bg: #0f1117;
11
- --surface: #1a1d27;
12
- --surface2: #252836;
13
- --border: #2d3040;
14
- --text: #e4e6f0;
15
- --text2: #8b8fa8;
16
- --accent: #6366f1;
17
- --accent2: #818cf8;
18
- --green: #22c55e;
19
- --red: #ef4444;
20
- --orange: #f59e0b;
21
- --radius: 10px;
22
- }
23
- * { margin: 0; padding: 0; box-sizing: border-box; }
24
- body {
25
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
26
- background: var(--bg);
27
- color: var(--text);
28
- min-height: 100vh;
29
- }
30
- .app { max-width: 1200px; margin: 0 auto; padding: 24px; }
31
-
32
- /* Header */
33
- .header {
34
- display: flex; justify-content: space-between; align-items: center;
35
- padding: 16px 0; margin-bottom: 24px;
36
- border-bottom: 1px solid var(--border);
37
- }
38
- .header h1 { font-size: 22px; font-weight: 600; }
39
- .header h1 span { color: var(--accent2); }
40
- .header .meta { display: flex; gap: 12px; align-items: center; }
41
- .badge {
42
- padding: 4px 10px; border-radius: 20px;
43
- font-size: 12px; font-weight: 500;
44
- background: var(--surface2); border: 1px solid var(--border);
45
- }
46
- .badge.ok { border-color: var(--green); color: var(--green); }
47
- .badge.err { border-color: var(--red); color: var(--red); }
48
-
49
- /* Cards grid */
50
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; margin-bottom: 24px; }
51
- .card {
52
- background: var(--surface);
53
- border: 1px solid var(--border);
54
- border-radius: var(--radius);
55
- padding: 20px;
56
- transition: border-color .15s;
57
- }
58
- .card:hover { border-color: var(--accent); }
59
- .card .label { font-size: 12px; color: var(--text2); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; }
60
- .card .value { font-size: 28px; font-weight: 700; color: var(--text); }
61
- .card .sub { font-size: 13px; color: var(--text2); margin-top: 4px; }
62
-
63
- /* Section */
64
- .section { margin-bottom: 32px; }
65
- .section h2 {
66
- font-size: 16px; font-weight: 600; margin-bottom: 16px;
67
- display: flex; align-items: center; gap: 8px;
68
- }
69
- .section h2 .dot {
70
- width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
71
- }
72
-
73
- /* Table */
74
- .table-wrap {
75
- background: var(--surface);
76
- border: 1px solid var(--border);
77
- border-radius: var(--radius);
78
- overflow: hidden;
79
- }
80
- table { width: 100%; border-collapse: collapse; }
81
- th {
82
- text-align: left; padding: 12px 16px;
83
- font-size: 11px; text-transform: uppercase; letter-spacing: .5px;
84
- color: var(--text2); border-bottom: 1px solid var(--border);
85
- background: var(--surface2);
86
- }
87
- td {
88
- padding: 12px 16px; font-size: 14px;
89
- border-bottom: 1px solid var(--border);
90
- }
91
- tr:last-child td { border-bottom: none; }
92
- tr:hover td { background: rgba(99,102,241,.04); }
93
-
94
- .status-dot {
95
- display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 6px;
96
- }
97
- .status-dot.on { background: var(--green); }
98
- .status-dot.off { background: var(--red); }
99
-
100
- .model-tag {
101
- display: inline-block; padding: 2px 8px; border-radius: 4px;
102
- font-size: 11px; background: var(--surface2); color: var(--text2);
103
- margin: 2px 2px;
104
- }
105
-
106
- /* Button */
107
- .btn {
108
- padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 500;
109
- border: 1px solid var(--border); background: var(--surface2); color: var(--text);
110
- cursor: pointer; transition: all .15s;
111
- }
112
- .btn:hover { border-color: var(--accent); color: var(--accent2); }
113
- .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
114
- .btn.primary:hover { background: var(--accent2); }
115
- .btn:disabled { opacity: .5; cursor: not-allowed; }
116
-
117
- /* Login */
118
- .login-overlay {
119
- position: fixed; inset: 0; background: var(--bg);
120
- display: flex; align-items: center; justify-content: center;
121
- z-index: 1000;
122
- }
123
- .login-box {
124
- background: var(--surface); border: 1px solid var(--border);
125
- border-radius: var(--radius); padding: 40px; width: 360px; text-align: center;
126
- }
127
- .login-box h2 { margin-bottom: 24px; font-size: 20px; }
128
- .login-box input {
129
- width: 100%; padding: 10px 14px; border-radius: 6px;
130
- border: 1px solid var(--border); background: var(--surface2);
131
- color: var(--text); font-size: 14px; margin-bottom: 16px; outline: none;
132
- }
133
- .login-box input:focus { border-color: var(--accent); }
134
- .login-box .error { color: var(--red); font-size: 13px; margin-bottom: 12px; min-height: 20px; }
135
- .login-box .btn { width: 100%; }
136
- .login-box .hint { font-size: 12px; color: var(--text2); margin-top: 12px; line-height: 1.5; }
137
-
138
- /* Refresh bar */
139
- .toolbar {
140
- display: flex; justify-content: space-between; align-items: center;
141
- margin-bottom: 16px;
142
- }
143
- .toolbar .info { font-size: 13px; color: var(--text2); }
144
-
145
- /* Spinner */
146
- .spinner {
147
- display: inline-block; width: 14px; height: 14px;
148
- border: 2px solid var(--border); border-top-color: var(--accent);
149
- border-radius: 50%; animation: spin .6s linear infinite;
150
- }
151
- @keyframes spin { to { transform: rotate(360deg); } }
152
-
153
- /* Test result */
154
- .test-result {
155
- margin-top: 16px; padding: 12px 16px;
156
- border-radius: var(--radius); font-size: 13px;
157
- }
158
- .test-result.ok { background: rgba(34,197,94,.1); border: 1px solid rgba(34,197,94,.3); }
159
- .test-result.err { background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.3); }
160
-
161
- /* Conversations */
162
- .conv-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
163
- .conv-card {
164
- background: var(--surface); border: 1px solid var(--border);
165
- border-radius: var(--radius); padding: 16px;
166
- }
167
- .conv-card .uid { font-size: 14px; font-weight: 600; }
168
- .conv-card .cid { font-size: 12px; color: var(--text2); margin-top: 4px; word-break: break-all; }
169
- .conv-card .model { font-size: 12px; color: var(--accent2); margin-top: 4px; }
170
-
171
- @media (max-width: 640px) {
172
- .app { padding: 16px; }
173
- .grid { grid-template-columns: 1fr 1fr; }
174
- .conv-list { grid-template-columns: 1fr; }
175
- }
176
- </style>
177
- </head>
178
- <body>
179
- <div id="app"></div>
180
- <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
181
- <script>
182
- const { createApp, ref, reactive, onMounted, onUnmounted } = Vue
183
-
184
- createApp({
185
- setup() {
186
- const jwt = ref(localStorage.getItem('chaite_jwt') || '')
187
- const loggedIn = ref(!!jwt.value)
188
- const loginError = ref('')
189
- const loginLoading = ref(false)
190
-
191
- const health = ref(null)
192
- const stats = ref(null)
193
- const channels = ref([])
194
- const conversations = ref([])
195
- const testResult = ref(null)
196
- const testLoading = ref(false)
197
- const loading = ref(true)
198
- const refreshTime = ref('')
199
- let timer = null
200
- let tokenExpiresAt = parseInt(localStorage.getItem('chaite_jwt_exp') || '0')
201
-
202
- const headers = () => ({
203
- 'Content-Type': 'application/json',
204
- 'Authorization': `Bearer ${jwt.value}`
205
- })
206
-
207
- // Extract ?token=xxx from URL
208
- function getUrlToken() {
209
- const params = new URLSearchParams(window.location.search)
210
- return params.get('token') || ''
211
- }
212
-
213
- function isTokenExpired() {
214
- if (!tokenExpiresAt) return false
215
- return Date.now() / 1000 > tokenExpiresAt - 300
216
- }
217
-
218
- // Auto-login: if ?token= is in URL, use it to get a JWT
219
- async function autoLogin() {
220
- const urlToken = getUrlToken()
221
- if (!urlToken) {
222
- // No URL token — check if we have a stored JWT
223
- if (jwt.value && !isTokenExpired()) {
224
- loggedIn.value = true
225
- refreshAll()
226
- } else if (jwt.value && isTokenExpired()) {
227
- // Try refresh
228
- tryRefresh()
229
- }
230
- return
231
- }
232
-
233
- // Exchange access token for JWT
234
- loginLoading.value = true
235
- loginError.value = ''
236
- try {
237
- const r = await fetch('/api/auth/login', {
238
- method: 'POST',
239
- headers: { 'Content-Type': 'application/json' },
240
- body: JSON.stringify({ token: urlToken })
241
- })
242
- const d = await r.json()
243
- if (d.data?.token) {
244
- jwt.value = d.data.token
245
- tokenExpiresAt = Math.floor(Date.now() / 1000) + (d.data.expiresIn || 604800)
246
- localStorage.setItem('chaite_jwt', d.data.token)
247
- localStorage.setItem('chaite_jwt_exp', String(tokenExpiresAt))
248
- loggedIn.value = true
249
- // Clean URL (remove ?token= from address bar)
250
- window.history.replaceState({}, '', window.location.pathname)
251
- refreshAll()
252
- } else {
253
- loginError.value = 'Invalid or expired access token'
254
- }
255
- } catch (e) {
256
- loginError.value = 'Network error'
257
- } finally {
258
- loginLoading.value = false
259
- }
260
- }
261
-
262
- async function tryRefresh() {
263
- try {
264
- const r = await fetch('/api/auth/refresh', { method: 'POST', headers: headers() })
265
- const d = await r.json()
266
- if (d.data?.token) {
267
- jwt.value = d.data.token
268
- tokenExpiresAt = Math.floor(Date.now() / 1000) + (d.data.expiresIn || 604800)
269
- localStorage.setItem('chaite_jwt', d.data.token)
270
- localStorage.setItem('chaite_jwt_exp', String(tokenExpiresAt))
271
- loggedIn.value = true
272
- refreshAll()
273
- } else {
274
- logout()
275
- }
276
- } catch {
277
- logout()
278
- }
279
- }
280
-
281
- function logout() {
282
- jwt.value = ''
283
- tokenExpiresAt = 0
284
- localStorage.removeItem('chaite_jwt')
285
- localStorage.removeItem('chaite_jwt_exp')
286
- loggedIn.value = false
287
- health.value = null
288
- stats.value = null
289
- channels.value = []
290
- }
291
-
292
- async function api(path) {
293
- if (isTokenExpired()) {
294
- await tryRefresh()
295
- if (!loggedIn.value) return null
296
- }
297
- const r = await fetch(path, { headers: headers() })
298
- if (r.status === 401) { logout(); return null }
299
- const d = await r.json()
300
- return d.data
301
- }
302
-
303
- async function refreshAll() {
304
- loading.value = true
305
- try {
306
- const [h, s, ch, cv] = await Promise.all([
307
- api('/api/system/health'),
308
- api('/api/system/stats'),
309
- api('/api/channels/list'),
310
- api('/api/state/conversations/list'),
311
- ])
312
- health.value = h
313
- stats.value = s
314
- channels.value = ch || []
315
- conversations.value = cv || []
316
- refreshTime.value = new Date().toLocaleTimeString()
317
- } catch (e) {
318
- console.error('refresh error', e)
319
- } finally {
320
- loading.value = false
321
- }
322
- }
323
-
324
- async function testChannel(channelId) {
325
- testLoading.value = true
326
- testResult.value = null
327
- try {
328
- const r = await fetch('/api/system/test-channel', {
329
- method: 'POST',
330
- headers: headers(),
331
- body: JSON.stringify({ channelId })
332
- })
333
- testResult.value = await r.json()
334
- } catch (e) {
335
- testResult.value = { data: { status: 'error', error: e.message } }
336
- } finally {
337
- testLoading.value = false
338
- }
339
- }
340
-
341
- function uptimeStr(s) {
342
- if (!s && s !== 0) return '-'
343
- const h = Math.floor(s / 3600)
344
- const m = Math.floor((s % 3600) / 60)
345
- const sec = s % 60
346
- return h ? `${h}h ${m}m` : m ? `${m}m ${sec}s` : `${sec}s`
347
- }
348
-
349
- function fmtNum(n) {
350
- if (!n && n !== 0) return '0'
351
- if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
352
- if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
353
- return String(n)
354
- }
355
-
356
- onMounted(() => {
357
- autoLogin()
358
- timer = setInterval(() => { if (loggedIn.value) refreshAll() }, 30000)
359
- })
360
- onUnmounted(() => clearInterval(timer))
361
-
362
- return {
363
- jwt, loggedIn, loginError, loginLoading,
364
- health, stats, channels, conversations,
365
- testResult, testLoading, loading, refreshTime,
366
- logout, refreshAll, testChannel,
367
- uptimeStr, fmtNum,
368
- }
369
- },
370
- template: `
371
- <!-- No token in URL and no stored JWT -->
372
- <div v-if="!loggedIn && !loginLoading" class="login-overlay">
373
- <div class="login-box">
374
- <h2>⚡ Chaite Dashboard</h2>
375
- <div class="error">{{ loginError }}</div>
376
- <div class="hint">
377
- No access token found.<br><br>
378
- Open the dashboard with your access token in the URL:<br>
379
- <code style="word-break:break-all;color:var(--accent2);">http://host:port?token=YOUR_TOKEN</code><br><br>
380
- The token is printed in the server console at startup.
381
- </div>
382
- </div>
383
- </div>
384
-
385
- <!-- Loading state (auto-login in progress) -->
386
- <div v-else-if="loginLoading" class="login-overlay">
387
- <div class="login-box">
388
- <h2>⚡ Chaite Dashboard</h2>
389
- <div class="spinner" style="margin:16px auto;"></div>
390
- <div style="color:var(--text2);font-size:13px;">Authenticating...</div>
391
- </div>
392
- </div>
393
-
394
- <div v-else class="app">
395
- <!-- Header -->
396
- <div class="header">
397
- <h1>⚡ <span>Chaite</span> Dashboard</h1>
398
- <div class="meta">
399
- <span class="badge" v-if="refreshTime">Updated {{ refreshTime }}</span>
400
- <span class="badge ok" v-if="health?.status === 'ok'">Healthy</span>
401
- <span class="badge err" v-else-if="health">Unhealthy</span>
402
- <button class="btn" @click="refreshAll" :disabled="loading">
403
- <span v-if="loading" class="spinner"></span>
404
- <span v-else>↻ Refresh</span>
405
- </button>
406
- <button class="btn" @click="logout">Logout</button>
407
- </div>
408
- </div>
409
-
410
- <!-- Stats cards -->
411
- <div class="grid" v-if="health">
412
- <div class="card">
413
- <div class="label">Uptime</div>
414
- <div class="value">{{ uptimeStr(health.uptime) }}</div>
415
- <div class="sub">v{{ health.version }} · Node {{ health.system?.nodeVersion }}</div>
416
- </div>
417
- <div class="card">
418
- <div class="label">Channels</div>
419
- <div class="value">{{ health.channels?.total }}</div>
420
- <div class="sub">{{ health.channels?.enabled }} enabled · {{ health.channels?.disabled }} disabled</div>
421
- </div>
422
- <div class="card">
423
- <div class="label">Models</div>
424
- <div class="value">{{ health.models?.count }}</div>
425
- <div class="sub">{{ health.tools?.count ?? 0 }} tools registered</div>
426
- </div>
427
- <div class="card">
428
- <div class="label">Memory</div>
429
- <div class="value">{{ health.system?.processMemory ?? '-' }} MB</div>
430
- <div class="sub">Heap {{ health.system?.heapUsed ?? '-' }} MB · Free {{ health.system?.freeMemory ?? '-' }} MB</div>
431
- </div>
432
- <div class="card" v-if="stats?.summary">
433
- <div class="label">Total Calls</div>
434
- <div class="value">{{ fmtNum(stats.summary.totalCalls) }}</div>
435
- <div class="sub">{{ fmtNum(stats.summary.totalTokens) }} tokens used</div>
436
- </div>
437
- </div>
438
-
439
- <!-- Channels -->
440
- <div class="section">
441
- <h2><span class="dot"></span> Channels</h2>
442
- <div class="table-wrap">
443
- <table>
444
- <thead>
445
- <tr>
446
- <th>Status</th>
447
- <th>Name</th>
448
- <th>Adapter</th>
449
- <th>Models</th>
450
- <th>Priority</th>
451
- <th>Calls</th>
452
- <th>Actions</th>
453
- </tr>
454
- </thead>
455
- <tbody>
456
- <tr v-for="ch in channels" :key="ch.id">
457
- <td>
458
- <span class="status-dot" :class="ch.status === 'enabled' ? 'on' : 'off'"></span>
459
- {{ ch.status }}
460
- </td>
461
- <td>{{ ch.name || ch.id?.slice(0, 8) }}</td>
462
- <td>{{ ch.adapterType }}</td>
463
- <td>
464
- <span class="model-tag" v-for="m in (ch.models || []).slice(0, 3)" :key="m">{{ m }}</span>
465
- <span v-if="(ch.models || []).length > 3" class="model-tag">+{{ ch.models.length - 3 }}</span>
466
- </td>
467
- <td>{{ ch.priority }}</td>
468
- <td>{{ ch.statistics?.callTimes ?? 0 }}</td>
469
- <td>
470
- <button class="btn" @click="testChannel(ch.id)" :disabled="testLoading"
471
- style="font-size:12px;padding:4px 10px;">
472
- <span v-if="testLoading" class="spinner"></span>
473
- <span v-else>Test</span>
474
- </button>
475
- </td>
476
- </tr>
477
- <tr v-if="!channels.length">
478
- <td colspan="7" style="text-align:center;color:var(--text2);padding:24px;">No channels configured</td>
479
- </tr>
480
- </tbody>
481
- </table>
482
- </div>
483
- <div v-if="testResult" class="test-result" :class="testResult.data?.status === 'ok' ? 'ok' : 'err'">
484
- <template v-if="testResult.data?.status === 'ok'">
485
- Channel test passed · Model: {{ testResult.data.model }} · Latency: {{ testResult.data.latency }}ms
486
- </template>
487
- <template v-else>
488
- Test failed: {{ testResult.data?.error || testResult.message }}
489
- </template>
490
- </div>
491
- </div>
492
-
493
- <!-- Models -->
494
- <div class="section" v-if="health?.models?.list?.length">
495
- <h2><span class="dot"></span> Available Models</h2>
496
- <div style="display:flex;flex-wrap:wrap;gap:6px;">
497
- <span class="model-tag" v-for="m in health.models.list" :key="m"
498
- style="font-size:13px;padding:5px 12px;">{{ m }}</span>
499
- </div>
500
- </div>
501
-
502
- <!-- Conversations -->
503
- <div class="section" v-if="conversations.length">
504
- <h2><span class="dot"></span> Active Conversations</h2>
505
- <div class="conv-list">
506
- <div class="conv-card" v-for="c in conversations" :key="c.userId">
507
- <div class="uid">User {{ c.userId }}</div>
508
- <div class="cid" v-if="c.conversationId">Conv: {{ c.conversationId }}</div>
509
- <div class="model" v-if="c.currentModel">Model: {{ c.currentModel }}</div>
510
- </div>
511
- </div>
512
- </div>
513
-
514
- <!-- Per-model stats -->
515
- <div class="section" v-if="stats?.perModel && Object.keys(stats.perModel).length">
516
- <h2><span class="dot"></span> Per-Model Stats</h2>
517
- <div class="table-wrap">
518
- <table>
519
- <thead>
520
- <tr><th>Model</th><th>Calls</th><th>Tokens</th></tr>
521
- </thead>
522
- <tbody>
523
- <tr v-for="(s, model) in stats.perModel" :key="model">
524
- <td>{{ model }}</td>
525
- <td>{{ fmtNum(s.calls) }}</td>
526
- <td>{{ fmtNum(s.tokens) }}</td>
527
- </tr>
528
- </tbody>
529
- </table>
530
- </div>
531
- </div>
532
- </div>
533
- `
534
- }).mount('#app')
535
- </script>
536
- </body>
537
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
6
+ <title>Chaite Dashboard</title>
7
+ <style>
8
+ *{margin:0;padding:0;box-sizing:border-box}
9
+ :root{
10
+ --bg:#0c0e14;--surface:#141720;--surface2:#1a1e2a;--surface3:#222738;
11
+ --border:#2a2f42;--text:#e0e4ef;--text2:#8b92a8;--accent:#6c8cff;--accent2:#4a6aef;
12
+ --green:#4ade80;--red:#f87171;--yellow:#facc15;--purple:#a78bfa;
13
+ --radius:10px;--shadow:0 2px 12px rgba(0,0,0,.4);
14
+ }
15
+ body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;min-height:100vh}
16
+ a{color:var(--accent);text-decoration:none}
17
+ a:hover{text-decoration:underline}
18
+ .app{max-width:1280px;margin:0 auto;padding:20px}
19
+
20
+ /* Login */
21
+ .login-wrap{display:flex;align-items:center;justify-content:center;min-height:100vh}
22
+ .login-box{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:48px 40px;width:380px;text-align:center;box-shadow:var(--shadow)}
23
+ .login-box h1{font-size:24px;margin-bottom:8px}
24
+ .login-box p{color:var(--text2);font-size:14px;margin-bottom:32px}
25
+ .login-box .spinner{display:inline-block;width:18px;height:18px;border:2px solid var(--text2);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
26
+ .login-box .err{color:var(--red);font-size:13px;margin-bottom:12px;min-height:20px}
27
+ @keyframes spin{to{transform:rotate(360deg)}}
28
+
29
+ /* Header */
30
+ .header{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:24px}
31
+ .header h1{font-size:20px;display:flex;align-items:center;gap:8px}
32
+ .header h1 span{color:var(--accent)}
33
+ .header .meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
34
+ .badge{background:var(--surface2);border:1px solid var(--border);border-radius:20px;padding:3px 12px;font-size:12px;color:var(--text2)}
35
+ .badge.ok{border-color:var(--green);color:var(--green)}
36
+ .badge.err{border-color:var(--red);color:var(--red)}
37
+
38
+ /* Buttons */
39
+ .btn{background:var(--surface2);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:6px 14px;font-size:13px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:all .15s}
40
+ .btn:hover{background:var(--surface3);border-color:var(--accent)}
41
+ .btn:disabled{opacity:.5;cursor:not-allowed}
42
+ .btn.primary{background:var(--accent2);border-color:var(--accent);color:#fff}
43
+ .btn.primary:hover{background:var(--accent)}
44
+ .btn.danger{border-color:var(--red);color:var(--red)}
45
+ .btn.danger:hover{background:rgba(248,113,113,.15)}
46
+ .btn.sm{padding:3px 10px;font-size:12px}
47
+
48
+ /* Cards */
49
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:16px;box-shadow:var(--shadow)}
50
+ .card h3{font-size:15px;margin-bottom:14px;display:flex;align-items:center;gap:8px}
51
+ .card h3 .dot{width:8px;height:8px;border-radius:50%}
52
+
53
+ /* Stat cards */
54
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px}
55
+ .stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center}
56
+ .stat .val{font-size:28px;font-weight:700;color:var(--accent)}
57
+ .stat .label{font-size:12px;color:var(--text2);margin-top:4px}
58
+
59
+ /* Table */
60
+ .tbl{width:100%;border-collapse:collapse;font-size:13px}
61
+ .tbl th{text-align:left;padding:10px 12px;color:var(--text2);font-weight:600;border-bottom:1px solid var(--border);font-size:12px;text-transform:uppercase;letter-spacing:.5px}
62
+ .tbl td{padding:10px 12px;border-bottom:1px solid var(--border)}
63
+ .tbl tr:hover td{background:var(--surface2)}
64
+ .pill{display:inline-block;padding:2px 10px;border-radius:12px;font-size:11px;font-weight:600}
65
+ .pill.on{background:rgba(74,222,128,.15);color:var(--green)}
66
+ .pill.off{background:rgba(248,113,113,.12);color:var(--red)}
67
+ .pill.model{background:rgba(108,140,255,.12);color:var(--accent);margin:2px 3px 2px 0}
68
+
69
+ /* Tabs */
70
+ .tabs{display:flex;gap:0;margin-bottom:20px;border-bottom:1px solid var(--border)}
71
+ .tab{padding:10px 20px;font-size:13px;cursor:pointer;color:var(--text2);border-bottom:2px solid transparent;transition:all .15s}
72
+ .tab:hover{color:var(--text)}
73
+ .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
74
+
75
+ /* Modal */
76
+ .modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:100}
77
+ .modal{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:28px;width:520px;max-height:85vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,.5)}
78
+ .modal h3{margin-bottom:20px;font-size:17px}
79
+ .form-row{margin-bottom:14px}
80
+ .form-row label{display:block;font-size:12px;color:var(--text2);margin-bottom:5px}
81
+ .form-row input,.form-row select,.form-row textarea{width:100%;padding:8px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;font-family:inherit}
82
+ .form-row textarea{min-height:80px;resize:vertical}
83
+ .form-row input:focus,.form-row select:focus,.form-row textarea:focus{outline:none;border-color:var(--accent)}
84
+ .modal-btns{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}
85
+
86
+ /* Toast */
87
+ .toast{position:fixed;top:20px;right:20px;padding:10px 20px;border-radius:8px;font-size:13px;z-index:200;animation:slideIn .3s}
88
+ .toast.ok{background:var(--green);color:#000}
89
+ .toast.err{background:var(--red);color:#fff}
90
+ @keyframes slideIn{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:none}}
91
+
92
+ /* Responsive */
93
+ @media(max-width:768px){
94
+ .stats{grid-template-columns:repeat(2,1fr)}
95
+ .modal{width:95%;margin:10px}
96
+ .header{flex-direction:column;align-items:flex-start}
97
+ }
98
+ </style>
99
+ </head>
100
+ <body>
101
+ <div id="app"></div>
102
+ <script>
103
+ const{createApp,ref,reactive,computed,onMounted,onUnmounted,watch}=Vue
104
+
105
+ const API=(path,opt={})=>{
106
+ const t=localStorage.getItem('chaite_jwt')
107
+ return fetch(path,{...opt,headers:{'Content-Type':'application/json','Authorization':t?'Bearer '+t:'',...(opt.headers||{})}}).then(async r=>{
108
+ if(r.status===401){localStorage.removeItem('chaite_jwt');location.reload()}
109
+ return r.json()
110
+ })
111
+ }
112
+
113
+ createApp({
114
+ setup(){
115
+ // Auth
116
+ const jwt=ref(localStorage.getItem('chaite_jwt')||'')
117
+ const loggedIn=ref(!!jwt.value)
118
+ const loginLoading=ref(false)
119
+ const loginError=ref('')
120
+
121
+ // State
122
+ const tab=ref('overview')
123
+ const health=ref(null)
124
+ const stats=ref(null)
125
+ const channels=ref([])
126
+ const tools=ref([])
127
+ const presets=ref([])
128
+ const processors=ref([])
129
+ const triggers=ref([])
130
+ const toolGroups=ref([])
131
+ const loading=ref(true)
132
+ const refreshTime=ref('')
133
+ let timer=null
134
+
135
+ // Modal
136
+ const modal=ref(null) // 'channel'|'preset'|'tool'|'processor'|'trigger'|'toolGroup'|null
137
+ const editing=ref(null) // object being edited
138
+ const form=ref({})
139
+
140
+ // Toast
141
+ const toastMsg=ref('')
142
+ const toastType=ref('ok')
143
+ let toastTimer=null
144
+ function toast(msg,type='ok'){toastMsg.value=msg;toastType.value=type;clearTimeout(toastTimer);toastTimer=setTimeout(()=>toastMsg.value='',3000)}
145
+
146
+ // Login
147
+ async function doLogin(){
148
+ loginError.value='';loginLoading.value=true
149
+ try{
150
+ const url=new URL(location.href)
151
+ const urlToken=url.searchParams.get('token')
152
+ const token=urlToken||prompt('Enter access token:')
153
+ if(!token){loginLoading.value=false;return}
154
+ const d=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token})}).then(r=>r.json())
155
+ if(d.data?.token){
156
+ jwt.value=d.data.token;localStorage.setItem('chaite_jwt',d.data.token);loggedIn.value=true
157
+ url.searchParams.delete('token');history.replaceState(null,'',url.pathname+url.search)
158
+ refreshAll()
159
+ }else{loginError.value=d.message||'Login failed'}
160
+ }catch(e){loginError.value='Network error'}
161
+ finally{loginLoading.value=false}
162
+ }
163
+
164
+ function logout(){jwt.value='';localStorage.removeItem('chaite_jwt');loggedIn.value=false;clearInterval(timer)}
165
+
166
+ // Data fetching
167
+ async function refreshAll(){
168
+ loading.value=true
169
+ try{
170
+ const[h,s,c,t,p,pr,tr,tg]=await Promise.all([
171
+ API('/api/system/health').catch(()=>({})),
172
+ API('/api/system/stats').catch(()=>({})),
173
+ API('/api/channels/list').catch(()=>({})),
174
+ API('/api/tools/list').catch(()=>({})),
175
+ API('/api/preset/list').catch(()=>({})),
176
+ API('/api/processors/list').catch(()=>({})),
177
+ API('/api/triggers/list').catch(()=>({})),
178
+ API('/api/toolGroups/list').catch(()=>({})),
179
+ ])
180
+ health.value=h.data||null
181
+ stats.value=s.data||null
182
+ channels.value=c.data||[]
183
+ tools.value=t.data||[]
184
+ presets.value=p.data||[]
185
+ processors.value=pr.data||[]
186
+ triggers.value=tr.data||[]
187
+ toolGroups.value=tg.data||[]
188
+ refreshTime.value=new Date().toLocaleTimeString()
189
+ }catch(e){console.error(e)}
190
+ finally{loading.value=false}
191
+ }
192
+
193
+ // CRUD helpers
194
+ async function testChannel(id){
195
+ const d=await API('/api/system/test-channel',{method:'POST',body:JSON.stringify({channelId:id})})
196
+ if(d.data?.status==='ok')toast(`✅ ${d.data.channelName} ${d.data.latency}ms`)
197
+ else toast(`❌ ${d.data?.error||d.message}`,'err')
198
+ }
199
+
200
+ function openModal(type,item=null){
201
+ modal.value=type
202
+ editing.value=item
203
+ if(item)form.value=JSON.parse(JSON.stringify(item))
204
+ else{
205
+ const defaults={
206
+ channel:{name:'',adapterType:'openai',type:'openai',models:[''],options:{baseUrl:'',apiKey:''},status:'enabled',weight:1,priority:0},
207
+ preset:{name:'',prefix:'',sendMessageOption:{model:'',temperature:0.8,maxToken:4096,systemOverride:''}},
208
+ tool:{name:'',description:'',code:''},
209
+ processor:{name:'',type:'pre',description:'',code:''},
210
+ trigger:{name:'',description:'',code:''},
211
+ toolGroup:{name:'',description:'',toolIds:[],status:'enabled',isDefault:false},
212
+ }
213
+ form.value=defaults[type]||{}
214
+ }
215
+ }
216
+ function closeModal(){modal.value=null;editing.value=null;form.value={}}
217
+
218
+ async function saveItem(){
219
+ const type=modal.value
220
+ const apiMap={channel:'/api/channels',preset:'/api/preset',tool:'/api/tools',processor:'/api/processors',trigger:'/api/triggers',toolGroup:'/api/toolGroups'}
221
+ try{
222
+ const id=editing.value?.id
223
+ const url=apiMap[type]+(id?'/'+id:'')
224
+ const method=id?'PUT':'POST'
225
+ const d=await API(url,{method,body:JSON.stringify(form.value)})
226
+ if(d.data!==undefined){toast('Saved');closeModal();refreshAll()}
227
+ else toast(d.message||'Error','err')
228
+ }catch(e){toast(e.message,'err')}
229
+ }
230
+
231
+ async function deleteItem(type,id){
232
+ if(!confirm('Delete this item?'))return
233
+ const apiMap={channel:'/api/channels',preset:'/api/preset',tool:'/api/tools',processor:'/api/processors',trigger:'/api/triggers',toolGroup:'/api/toolGroups'}
234
+ try{
235
+ await API(apiMap[type]+'/'+id,{method:'DELETE'})
236
+ toast('Deleted');refreshAll()
237
+ }catch(e){toast(e.message,'err')}
238
+ }
239
+
240
+ function uptimeStr(s){
241
+ if(!s)return '-'
242
+ const h=Math.floor(s/3600),m=Math.floor(s%3600/60),sec=s%60
243
+ return h>0?`${h}h ${m}m ${sec}s`:`${m}m ${sec}s`
244
+ }
245
+ function fmtNum(n){if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'K';return String(n||0)}
246
+
247
+ onMounted(()=>{
248
+ if(loggedIn.value)refreshAll()
249
+ timer=setInterval(()=>{if(logged.value)refreshAll()},30000)
250
+ })
251
+ onUnmounted(()=>clearInterval(timer))
252
+
253
+ const loggedIn=computed(()=>!!jwt.value)
254
+
255
+ return{
256
+ jwt,loggedIn,loginLoading,loginError,doLogin,logout,
257
+ tab,health,stats,channels,tools,presets,processors,triggers,toolGroups,loading,refreshTime,
258
+ modal,editing,form,openModal,closeModal,saveItem,deleteItem,testChannel,
259
+ toastMsg,toastType,uptimeStr,fmtNum,refreshAll,
260
+ }
261
+ },
262
+ template:`
263
+ <!-- Toast -->
264
+ <div v-if="toastMsg" :class="['toast',toastType]">{{toastMsg}}</div>
265
+
266
+ <!-- Login -->
267
+ <div v-if="!loggedIn" class="login-wrap">
268
+ <div class="login-box">
269
+ <h1>⚡ <span style="color:var(--accent)">Chaite</span></h1>
270
+ <p>Management Dashboard</p>
271
+ <div v-if="!loginLoading">
272
+ <div style="margin-bottom:16px">
273
+ <input v-model="loginInput" type="password" placeholder="Paste access token here..."
274
+ @keyup.enter="doLogin" autofocus
275
+ style="width:100%;padding:10px 14px;border-radius:8px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-size:14px;outline:none"/>
276
+ </div>
277
+ <div class="err">{{loginError}}</div>
278
+ <button class="btn primary" @click="doLogin" style="width:100%;padding:10px">Login</button>
279
+ <p style="margin-top:16px;font-size:12px;color:var(--text2)">Or append <code>?token=xxx</code> to URL</p>
280
+ </div>
281
+ <div v-else><div class="spinner"></div></div>
282
+ </div>
283
+ </div>
284
+
285
+ <!-- Dashboard -->
286
+ <div v-else class="app">
287
+ <!-- Header -->
288
+ <div class="header">
289
+ <h1>⚡ <span>Chaite</span> Dashboard</h1>
290
+ <div class="meta">
291
+ <span class="badge" v-if="refreshTime">↻ {{refreshTime}}</span>
292
+ <span class="badge ok" v-if="health?.status==='ok'">Healthy</span>
293
+ <span class="badge err" v-else-if="health">Error</span>
294
+ <button class="btn" @click="refreshAll" :disabled="loading">
295
+ <span v-if="loading" style="display:inline-block;width:14px;height:14px;border:2px solid var(--text2);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite"></span>
296
+ <span v-else>↻ Refresh</span>
297
+ </button>
298
+ <button class="btn" @click="logout">Logout</button>
299
+ </div>
300
+ </div>
301
+
302
+ <!-- Stats -->
303
+ <div class="stats" v-if="health">
304
+ <div class="stat"><div class="val">{{uptimeStr(health.uptime)}}</div><div class="label">Uptime</div></div>
305
+ <div class="stat"><div class="val">{{health.channels?.total||0}}</div><div class="label">Channels</div></div>
306
+ <div class="stat"><div class="val">{{health.models?.count||0}}</div><div class="label">Models</div></div>
307
+ <div class="stat"><div class="val">{{health.tools?.count||0}}</div><div class="label">Tools</div></div>
308
+ <div class="stat"><div class="val">{{health.system?.processMemory||0}}MB</div><div class="label">Memory</div></div>
309
+ <div class="stat"><div class="val">{{fmtNum(stats?.summary?.totalCalls)}}</div><div class="label">Total Calls</div></div>
310
+ </div>
311
+
312
+ <!-- Tabs -->
313
+ <div class="tabs">
314
+ <div :class="['tab',tab==='overview'&&'active']" @click="tab='overview'">Overview</div>
315
+ <div :class="['tab',tab==='channels'&&'active']" @click="tab='channels'">Channels</div>
316
+ <div :class="['tab',tab==='tools'&&'active']" @click="tab='tools'">Tools</div>
317
+ <div :class="['tab',tab==='presets'&&'active']" @click="tab='presets'">Presets</div>
318
+ <div :class="['tab',tab==='processors'&&'active']" @click="tab='processors'">Processors</div>
319
+ <div :class="['tab',tab==='triggers'&&'active']" @click="tab='triggers'">Triggers</div>
320
+ <div :class="['tab',tab==='groups'&&'active']" @click="tab='groups'">Groups</div>
321
+ </div>
322
+
323
+ <!-- Overview -->
324
+ <div v-if="tab==='overview'">
325
+ <div class="card" v-if="health">
326
+ <h3><span class="dot" style="background:var(--green)"></span> System</h3>
327
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:13px">
328
+ <div><span style="color:var(--text2)">Version:</span> {{health.version}}</div>
329
+ <div><span style="color:var(--text2)">Node:</span> {{health.system?.nodeVersion}}</div>
330
+ <div><span style="color:var(--text2)">Platform:</span> {{health.system?.platform}} {{health.system?.arch}}</div>
331
+ <div><span style="color:var(--text2)">CPUs:</span> {{health.system?.cpus}}</div>
332
+ <div><span style="color:var(--text2)">Heap:</span> {{health.system?.heapUsed}}MB / {{health.system?.processMemory}}MB</div>
333
+ <div><span style="color:var(--text2)">System Mem:</span> {{health.system?.freeMemory}}MB free / {{health.system?.totalMemory}}MB</div>
334
+ </div>
335
+ </div>
336
+
337
+ <div class="card" v-if="health?.models?.list?.length">
338
+ <h3>Available Models</h3>
339
+ <div><span v-for="m in health.models.list" class="pill model">{{m}}</span></div>
340
+ </div>
341
+
342
+ <div class="card" v-if="stats">
343
+ <h3>Usage by Model</h3>
344
+ <table class="tbl">
345
+ <tr><th>Model</th><th>Calls</th><th>Tokens</th></tr>
346
+ <tr v-for="(v,k) in stats.perModel" :key="k">
347
+ <td><span class="pill model">{{k}}</span></td>
348
+ <td>{{fmtNum(v.calls)}}</td>
349
+ <td>{{fmtNum(v.tokens)}}</td>
350
+ </tr>
351
+ <tr v-if="!Object.keys(stats.perModel||{}).length"><td colspan="3" style="color:var(--text2)">No usage data</td></tr>
352
+ </table>
353
+ </div>
354
+ </div>
355
+
356
+ <!-- Channels -->
357
+ <div v-if="tab==='channels'">
358
+ <div class="card">
359
+ <h3 style="justify-content:space-between">Channels <button class="btn primary sm" @click="openModal('channel')">+ New</button></h3>
360
+ <table class="tbl">
361
+ <tr><th>Status</th><th>Name</th><th>Adapter</th><th>Models</th><th>Priority</th><th>Calls</th><th>Actions</th></tr>
362
+ <tr v-for="ch in channels" :key="ch.id">
363
+ <td><span :class="['pill',ch.status==='enabled'?'on':'off']">{{ch.status}}</span></td>
364
+ <td>{{ch.name}}</td>
365
+ <td>{{ch.adapterType}}</td>
366
+ <td><span v-for="m in (ch.models||[])" class="pill model">{{m}}</span></td>
367
+ <td>{{ch.priority}}</td>
368
+ <td>{{fmtNum(ch.statistics?.callTimes)}}</td>
369
+ <td>
370
+ <button class="btn sm" @click="testChannel(ch.id)">Test</button>
371
+ <button class="btn sm" @click="openModal('channel',ch)">Edit</button>
372
+ <button class="btn sm danger" @click="deleteItem('channel',ch.id)">Del</button>
373
+ </td>
374
+ </tr>
375
+ <tr v-if="!channels.length"><td colspan="7" style="color:var(--text2)">No channels</td></tr>
376
+ </table>
377
+ </div>
378
+ </div>
379
+
380
+ <!-- Tools -->
381
+ <div v-if="tab==='tools'">
382
+ <div class="card">
383
+ <h3 style="justify-content:space-between">Tools <button class="btn primary sm" @click="openModal('tool')">+ New</button></h3>
384
+ <table class="tbl">
385
+ <tr><th>Name</th><th>Description</th><th>Actions</th></tr>
386
+ <tr v-for="t in tools" :key="t.id">
387
+ <td>{{t.name||t.id}}</td>
388
+ <td style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{t.description}}</td>
389
+ <td>
390
+ <button class="btn sm" @click="openModal('tool',t)">Edit</button>
391
+ <button class="btn sm danger" @click="deleteItem('tool',t.id)">Del</button>
392
+ </td>
393
+ </tr>
394
+ <tr v-if="!tools.length"><td colspan="3" style="color:var(--text2)">No tools</td></tr>
395
+ </table>
396
+ </div>
397
+ </div>
398
+
399
+ <!-- Presets -->
400
+ <div v-if="tab==='presets'">
401
+ <div class="card">
402
+ <h3 style="justify-content:space-between">Presets <button class="btn primary sm" @click="openModal('preset')">+ New</button></h3>
403
+ <table class="tbl">
404
+ <tr><th>Name</th><th>Prefix</th><th>Model</th><th>Temp</th><th>Actions</th></tr>
405
+ <tr v-for="p in presets" :key="p.id">
406
+ <td>{{p.name}}</td>
407
+ <td><code>{{p.prefix}}</code></td>
408
+ <td><span class="pill model">{{p.sendMessageOption?.model||'-'}}</span></td>
409
+ <td>{{p.sendMessageOption?.temperature}}</td>
410
+ <td>
411
+ <button class="btn sm" @click="openModal('preset',p)">Edit</button>
412
+ <button class="btn sm danger" @click="deleteItem('preset',p.id)">Del</button>
413
+ </td>
414
+ </tr>
415
+ <tr v-if="!presets.length"><td colspan="5" style="color:var(--text2)">No presets</td></tr>
416
+ </table>
417
+ </div>
418
+ </div>
419
+
420
+ <!-- Processors -->
421
+ <div v-if="tab==='processors'">
422
+ <div class="card">
423
+ <h3 style="justify-content:space-between">Processors <button class="btn primary sm" @click="openModal('processor')">+ New</button></h3>
424
+ <table class="tbl">
425
+ <tr><th>Name</th><th>Type</th><th>Description</th><th>Actions</th></tr>
426
+ <tr v-for="p in processors" :key="p.id">
427
+ <td>{{p.name}}</td>
428
+ <td><span class="pill" :style="{background:p.type==='pre'?'rgba(167,139,250,.15)':'rgba(250,204,21,.15)',color:p.type==='pre'?'var(--purple)':'var(--yellow)'}">{{p.type}}</span></td>
429
+ <td>{{p.description}}</td>
430
+ <td>
431
+ <button class="btn sm" @click="openModal('processor',p)">Edit</button>
432
+ <button class="btn sm danger" @click="deleteItem('processor',p.id)">Del</button>
433
+ </td>
434
+ </tr>
435
+ <tr v-if="!processors.length"><td colspan="4" style="color:var(--text2)">No processors</td></tr>
436
+ </table>
437
+ </div>
438
+ </div>
439
+
440
+ <!-- Triggers -->
441
+ <div v-if="tab==='triggers'">
442
+ <div class="card">
443
+ <h3 style="justify-content:space-between">Triggers <button class="btn primary sm" @click="openModal('trigger')">+ New</button></h3>
444
+ <table class="tbl">
445
+ <tr><th>Name</th><th>Description</th><th>Actions</th></tr>
446
+ <tr v-for="t in triggers" :key="t.id">
447
+ <td>{{t.name||t.id}}</td>
448
+ <td>{{t.description}}</td>
449
+ <td>
450
+ <button class="btn sm" @click="openModal('trigger',t)">Edit</button>
451
+ <button class="btn sm danger" @click="deleteItem('trigger',t.id)">Del</button>
452
+ </td>
453
+ </tr>
454
+ <tr v-if="!triggers.length"><td colspan="3" style="color:var(--text2)">No triggers</td></tr>
455
+ </table>
456
+ </div>
457
+ </div>
458
+
459
+ <!-- Tool Groups -->
460
+ <div v-if="tab==='groups'">
461
+ <div class="card">
462
+ <h3 style="justify-content:space-between">Tool Groups <button class="btn primary sm" @click="openModal('toolGroup')">+ New</button></h3>
463
+ <table class="tbl">
464
+ <tr><th>Name</th><th>Description</th><th>Default</th><th>Actions</th></tr>
465
+ <tr v-for="g in toolGroups" :key="g.id">
466
+ <td>{{g.name}}</td>
467
+ <td>{{g.description}}</td>
468
+ <td><span v-if="g.isDefault" class="pill on">Default</span></td>
469
+ <td>
470
+ <button class="btn sm" @click="openModal('toolGroup',g)">Edit</button>
471
+ <button class="btn sm danger" @click="deleteItem('toolGroup',g.id)">Del</button>
472
+ </td>
473
+ </tr>
474
+ <tr v-if="!toolGroups.length"><td colspan="4" style="color:var(--text2)">No groups</td></tr>
475
+ </table>
476
+ </div>
477
+ </div>
478
+
479
+ <!-- Modal -->
480
+ <div v-if="modal" class="modal-mask" @click.self="closeModal">
481
+ <div class="modal">
482
+ <h3>{{editing?'Edit':'New'}} {{modal}}</h3>
483
+
484
+ <!-- Channel form -->
485
+ <template v-if="modal==='channel'">
486
+ <div class="form-row"><label>Name</label><input v-model="form.name"/></div>
487
+ <div class="form-row"><label>Adapter Type</label><select v-model="form.adapterType"><option>openai</option><option>gemini</option><option>claude</option></select></div>
488
+ <div class="form-row"><label>Models (comma separated)</label><input v-model="form._models" :placeholder="(form.models||[]).join(', ')"/></div>
489
+ <div class="form-row"><label>Base URL</label><input v-model="form.options.baseUrl"/></div>
490
+ <div class="form-row"><label>API Key</label><input v-model="form.options.apiKey" type="password"/></div>
491
+ <div class="form-row"><label>Priority</label><input v-model.number="form.priority" type="number"/></div>
492
+ <div class="form-row"><label>Weight</label><input v-model.number="form.weight" type="number"/></div>
493
+ <div class="form-row"><label>Status</label><select v-model="form.status"><option>enabled</option><option>disabled</option></select></div>
494
+ </template>
495
+
496
+ <!-- Preset form -->
497
+ <template v-if="modal==='preset'">
498
+ <div class="form-row"><label>Name</label><input v-model="form.name"/></div>
499
+ <div class="form-row"><label>Prefix</label><input v-model="form.prefix"/></div>
500
+ <div class="form-row"><label>Model</label><input v-model="form.sendMessageOption.model"/></div>
501
+ <div class="form-row"><label>Temperature</label><input v-model.number="form.sendMessageOption.temperature" type="number" step="0.1"/></div>
502
+ <div class="form-row"><label>Max Token</label><input v-model.number="form.sendMessageOption.maxToken" type="number"/></div>
503
+ <div class="form-row"><label>System Prompt</label><textarea v-model="form.sendMessageOption.systemOverride"></textarea></div>
504
+ </template>
505
+
506
+ <!-- Tool / Processor / Trigger form -->
507
+ <template v-if="modal==='tool'||modal==='processor'||modal==='trigger'">
508
+ <div class="form-row"><label>Name</label><input v-model="form.name"/></div>
509
+ <div class="form-row" v-if="modal==='processor'"><label>Type</label><select v-model="form.type"><option>pre</option><option>post</option></select></div>
510
+ <div class="form-row"><label>Description</label><input v-model="form.description"/></div>
511
+ <div class="form-row"><label>Code</label><textarea v-model="form.code" style="min-height:200px;font-family:monospace;font-size:12px"></textarea></div>
512
+ </template>
513
+
514
+ <!-- Tool Group form -->
515
+ <template v-if="modal==='toolGroup'">
516
+ <div class="form-row"><label>Name</label><input v-model="form.name"/></div>
517
+ <div class="form-row"><label>Description</label><input v-model="form.description"/></div>
518
+ <div class="form-row"><label>Tool IDs (comma separated)</label><input v-model="form._toolIds" :placeholder="(form.toolIds||[]).join(', ')"/></div>
519
+ <div class="form-row"><label>Status</label><select v-model="form.status"><option>enabled</option><option>disabled</option></select></div>
520
+ </template>
521
+
522
+ <div class="modal-btns">
523
+ <button class="btn" @click="closeModal">Cancel</button>
524
+ <button class="btn primary" @click="saveItem">Save</button>
525
+ </div>
526
+ </div>
527
+ </div>
528
+ </div>
529
+ `
530
+ }).mount('#app')
531
+ </script>
532
+ </body>
533
+ </html>