adaptive-memory-multi-model-router 2.13.1 → 2.13.3
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/LANDING.md +23 -26
- package/MANIFESTO.md +21 -34
- package/README.md +886 -243
- package/dist/tui/dashboard.d.ts +9 -7
- package/dist/tui/dashboard.js +228 -454
- package/dist/tui/dashboard.js.map +1 -1
- package/package.json +2 -2
- package/src/tui/dashboard.ts +223 -490
- package/tmlpd-pi-extension/README.md +29 -29
package/src/tui/dashboard.ts
CHANGED
|
@@ -1,55 +1,49 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* A3M Router TUI v2 —
|
|
3
|
+
* A3M Router TUI v2 — Conversational PI-style interface
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* /route <query> — Route a prompt through cheapest capable model
|
|
7
|
+
* /cost — Cost breakdown
|
|
8
|
+
* /health — Provider health check
|
|
9
|
+
* /providers — List all providers
|
|
10
|
+
* /clear — Clear chat
|
|
11
|
+
* /help — Show commands
|
|
12
|
+
* /exit, /q — Quit
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import * as blessed from 'blessed';
|
|
14
|
-
import
|
|
16
|
+
import { execSync } from 'child_process';
|
|
15
17
|
|
|
16
18
|
// ═══════════════════════════════════════════════════════════════
|
|
17
19
|
// TOKYO NIGHT THEME
|
|
18
20
|
// ═══════════════════════════════════════════════════════════════
|
|
19
21
|
|
|
20
|
-
const
|
|
22
|
+
const C = {
|
|
21
23
|
bg: '#1a1b26',
|
|
22
|
-
bgDark: '#16161e',
|
|
23
24
|
surface: '#24283b',
|
|
24
25
|
border: '#3b4261',
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
blue: '#7aa2f7',
|
|
27
|
+
purple: '#bb9af7',
|
|
27
28
|
green: '#9ece6a',
|
|
28
29
|
yellow: '#e0af68',
|
|
29
30
|
red: '#f7768e',
|
|
30
31
|
cyan: '#7dcfff',
|
|
31
|
-
magenta: '#bb9af7',
|
|
32
32
|
orange: '#ff9e64',
|
|
33
33
|
white: '#c0caf5',
|
|
34
34
|
dim: '#565f89',
|
|
35
35
|
bright: '#a9b1d6',
|
|
36
|
-
pink: '#ff007c',
|
|
37
36
|
};
|
|
38
37
|
|
|
39
38
|
// ═══════════════════════════════════════════════════════════════
|
|
40
39
|
// STATE
|
|
41
40
|
// ═══════════════════════════════════════════════════════════════
|
|
42
41
|
|
|
43
|
-
|
|
42
|
+
interface Message { role: 'user' | 'a3m'; text: string; model?: string; latency?: number; cost?: number; }
|
|
43
|
+
const messages: Message[] = [];
|
|
44
|
+
let totalCost = 0.000087;
|
|
45
|
+
let requestCount = 4;
|
|
44
46
|
let tick = 0;
|
|
45
|
-
const MAX_HISTORY = 60; // 60 data points for sparklines
|
|
46
|
-
|
|
47
|
-
// Cost history (simulated time series)
|
|
48
|
-
const costHistory: number[] = Array(MAX_HISTORY).fill(0);
|
|
49
|
-
const latencyHistory: number[] = Array(MAX_HISTORY).fill(30);
|
|
50
|
-
|
|
51
|
-
// Provider history for live charts
|
|
52
|
-
const providerLoads: Record<string, number[]> = {};
|
|
53
47
|
|
|
54
48
|
// ═══════════════════════════════════════════════════════════════
|
|
55
49
|
// SCREEN
|
|
@@ -57,440 +51,231 @@ const providerLoads: Record<string, number[]> = {};
|
|
|
57
51
|
|
|
58
52
|
const screen = blessed.screen({
|
|
59
53
|
smartCSR: true,
|
|
60
|
-
title: 'A3M Router
|
|
54
|
+
title: 'A3M Router',
|
|
61
55
|
fullUnicode: true,
|
|
62
|
-
mouse: true,
|
|
63
|
-
// @ts-ignore
|
|
64
56
|
cursor: { shape: 'line', blink: true },
|
|
65
57
|
});
|
|
66
58
|
|
|
67
|
-
const GRID = new contrib.grid({ rows: 12, cols: 24, screen });
|
|
68
|
-
|
|
69
|
-
// ═══════════════════════════════════════════════════════════════
|
|
70
|
-
// TITLE BAR — Tokyo Night Header
|
|
71
|
-
// ═══════════════════════════════════════════════════════════════
|
|
72
|
-
|
|
73
|
-
const titleBar = GRID.set(0, 0, 1, 24, blessed.box, {
|
|
74
|
-
style: { fg: T.bright, bg: T.bgDark },
|
|
75
|
-
tags: true,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
function renderTitleBar() {
|
|
79
|
-
titleBar.setContent(
|
|
80
|
-
` {bold}{#bb9af7-fg}⚡ A3M Router{/} {#565f89-fg}v2.12.7{/} │ ` +
|
|
81
|
-
`[F1] {${activeTab===1?'#7aa2f7':'#565f89'}-fg}Dashboard{/}` +
|
|
82
|
-
` [F2] {${activeTab===2?'#7aa2f7':'#565f89'}-fg}Costs{/}` +
|
|
83
|
-
` [F3] {${activeTab===3?'#7aa2f7':'#565f89'}-fg}Providers{/}` +
|
|
84
|
-
` [F4] {${activeTab===4?'#7aa2f7':'#565f89'}-fg}Logs{/}` +
|
|
85
|
-
` [F5] {${activeTab===5?'#7aa2f7':'#565f89'}-fg}Help{/}` +
|
|
86
|
-
` │ {#565f89-fg}q quit / cmd ↑↓ nav tab switch{/}`
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
59
|
// ═══════════════════════════════════════════════════════════════
|
|
91
|
-
//
|
|
60
|
+
// LAYOUT
|
|
92
61
|
// ═══════════════════════════════════════════════════════════════
|
|
93
62
|
|
|
94
|
-
//
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
style: {
|
|
98
|
-
tags: true,
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
// --- Cost Sparkline ---
|
|
102
|
-
const costSpark = GRID.set(1, 8, 3, 8, contrib.sparkline, {
|
|
103
|
-
label: ' ▸ Cost Rate ($/1K req)',
|
|
104
|
-
style: { line: T.green, text: T.white, baseline: T.dim },
|
|
105
|
-
tags: true,
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// --- Provider Health Bars ---
|
|
109
|
-
const providerBars = GRID.set(1, 16, 3, 8, blessed.box, {
|
|
110
|
-
label: ' ▸ Provider Health',
|
|
111
|
-
border: { type: 'line', fg: T.border },
|
|
112
|
-
style: { fg: T.white, bg: T.bg },
|
|
113
|
-
tags: true,
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// --- Live Request Feed (scrolling) ---
|
|
117
|
-
const liveFeed = GRID.set(4, 0, 4, 14, contrib.log, {
|
|
118
|
-
fg: T.white,
|
|
119
|
-
selectedFg: T.green,
|
|
120
|
-
label: ' ▸ Live Request Feed',
|
|
121
|
-
border: { type: 'line', fg: T.border },
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// --- Routing Map ---
|
|
125
|
-
const routeMap = GRID.set(4, 14, 4, 10, blessed.box, {
|
|
126
|
-
label: ' ▸ Routing Intelligence',
|
|
127
|
-
border: { type: 'line', fg: T.border },
|
|
128
|
-
style: { fg: T.white, bg: T.bg },
|
|
129
|
-
tags: true,
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// --- KPI Boxes ---
|
|
133
|
-
const kpiRow = GRID.set(8, 0, 2, 24, blessed.box, {
|
|
134
|
-
style: { fg: T.white, bg: T.bgDark },
|
|
63
|
+
// Header (1 line)
|
|
64
|
+
const header = blessed.box({
|
|
65
|
+
top: 0, left: 0, width: '100%', height: 1,
|
|
66
|
+
style: { fg: C.bright, bg: C.surface },
|
|
135
67
|
tags: true,
|
|
68
|
+
content: ` {bold}{#bb9af7-fg}⚡ A3M Router{/} {#565f89-fg}v2.13.1{/} │ /route /cost /health /help │ q quit`,
|
|
136
69
|
});
|
|
137
70
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
style: { fg: T.white, bg: T.bg },
|
|
146
|
-
tags: true,
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
// ═══════════════════════════════════════════════════════════════
|
|
150
|
-
// TAB 3: PROVIDERS — Detailed provider table
|
|
151
|
-
// ═══════════════════════════════════════════════════════════════
|
|
152
|
-
|
|
153
|
-
const providerDetail = GRID.set(1, 0, 9, 24, contrib.table, {
|
|
71
|
+
// Chat area (middle - all remaining space except bottom 3)
|
|
72
|
+
const chatBox = blessed.box({
|
|
73
|
+
top: 1, left: 0, width: '100%', height: '100%-4',
|
|
74
|
+
style: { fg: C.white, bg: C.bg },
|
|
75
|
+
scrollable: true,
|
|
76
|
+
alwaysScroll: true,
|
|
77
|
+
mouse: true,
|
|
154
78
|
keys: true,
|
|
155
|
-
fg: T.white,
|
|
156
|
-
selectedFg: T.bright,
|
|
157
|
-
selectedBg: T.border,
|
|
158
|
-
interactive: true,
|
|
159
|
-
label: ' ▸ Provider Registry',
|
|
160
|
-
border: { type: 'line', fg: T.border },
|
|
161
|
-
columnSpacing: 3,
|
|
162
|
-
columnWidth: [12, 18, 10, 10, 13, 12, 16],
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// ═══════════════════════════════════════════════════════════════
|
|
166
|
-
// TAB 4: LOGS — Full request history
|
|
167
|
-
// ═══════════════════════════════════════════════════════════════
|
|
168
|
-
|
|
169
|
-
const fullLog = GRID.set(1, 0, 9, 24, contrib.log, {
|
|
170
|
-
fg: T.white,
|
|
171
|
-
selectedFg: T.green,
|
|
172
|
-
label: ' ▸ Full Request Log',
|
|
173
|
-
border: { type: 'line', fg: T.border },
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
// ═══════════════════════════════════════════════════════════════
|
|
177
|
-
// TAB 5: HELP
|
|
178
|
-
// ═══════════════════════════════════════════════════════════════
|
|
179
|
-
|
|
180
|
-
const helpPanel = GRID.set(1, 0, 9, 24, blessed.box, {
|
|
181
|
-
label: ' ▸ Keyboard Reference',
|
|
182
|
-
border: { type: 'line', fg: T.border },
|
|
183
|
-
style: { fg: T.white, bg: T.bg },
|
|
184
79
|
tags: true,
|
|
80
|
+
padding: { left: 1, right: 1, top: 0, bottom: 0 },
|
|
185
81
|
});
|
|
186
82
|
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
border: { type: 'line', fg: T.accent2 },
|
|
194
|
-
style: { fg: T.bright, bg: T.surface },
|
|
83
|
+
// Input line
|
|
84
|
+
const inputBox = blessed.textbox({
|
|
85
|
+
bottom: 2, left: 0, width: '100%', height: 2,
|
|
86
|
+
label: ' / ',
|
|
87
|
+
border: { type: 'line', fg: C.purple },
|
|
88
|
+
style: { fg: C.bright, bg: C.surface },
|
|
195
89
|
inputOnFocus: true,
|
|
196
90
|
keys: true,
|
|
197
91
|
tags: true,
|
|
198
92
|
});
|
|
199
93
|
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const statusLine = GRID.set(11, 0, 1, 24, blessed.box, {
|
|
205
|
-
style: { fg: T.dim, bg: T.bgDark },
|
|
94
|
+
// Status bar
|
|
95
|
+
const statusBar = blessed.box({
|
|
96
|
+
bottom: 0, left: 0, width: '100%', height: 1,
|
|
97
|
+
style: { fg: C.dim, bg: C.surface },
|
|
206
98
|
tags: true,
|
|
207
99
|
});
|
|
208
100
|
|
|
209
101
|
// ═══════════════════════════════════════════════════════════════
|
|
210
|
-
//
|
|
102
|
+
// HELPERS
|
|
211
103
|
// ═══════════════════════════════════════════════════════════════
|
|
212
104
|
|
|
213
|
-
function
|
|
214
|
-
|
|
215
|
-
const filled = Math.round(pct * width);
|
|
216
|
-
const blocks = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
217
|
-
let result = '';
|
|
218
|
-
for (let i = 0; i < width; i++) {
|
|
219
|
-
if (i < filled - 1) result += '█';
|
|
220
|
-
else if (i === filled - 1) result += blocks[Math.floor(pct * width * 8) % 8] || '█';
|
|
221
|
-
else result += chars[0] || '░';
|
|
222
|
-
}
|
|
223
|
-
return result;
|
|
105
|
+
function badge(text: string, color: string): string {
|
|
106
|
+
return `{${color}-fg}[${text}]{/}`;
|
|
224
107
|
}
|
|
225
108
|
|
|
226
|
-
|
|
227
|
-
// HELPER: Pulse animation
|
|
228
|
-
// ═══════════════════════════════════════════════════════════════
|
|
229
|
-
|
|
230
|
-
function pulse(frame: number): number {
|
|
231
|
-
return Math.sin(frame * 0.3) * 0.5 + 0.5;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// ═══════════════════════════════════════════════════════════════
|
|
235
|
-
// RENDER: Dashboard
|
|
236
|
-
// ═══════════════════════════════════════════════════════════════
|
|
109
|
+
function dim(s: string): string { return `{#565f89-fg}${s}{/}`; }
|
|
237
110
|
|
|
238
|
-
|
|
239
|
-
{
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
{ name: 'cerebras', model: '3.3-70b', tier: 'cheap', healthy: true, latency: 320, load: 0.0 },
|
|
243
|
-
{ name: 'mistral', model: 'small', tier: 'mid', healthy: false, latency: 0, load: 0 },
|
|
244
|
-
{ name: 'ollama', model: 'llama3', tier: 'local', healthy: true, latency: 50, load: 0.15 },
|
|
245
|
-
];
|
|
246
|
-
|
|
247
|
-
const tierColor = (t: string) =>
|
|
248
|
-
t === 'free' ? T.green : t === 'cheap' ? T.yellow : t === 'mid' ? T.accent : t === 'local' ? T.cyan : T.accent2;
|
|
249
|
-
|
|
250
|
-
const requestLog: string[] = [];
|
|
251
|
-
|
|
252
|
-
function addLog(provider: string, model: string, latency: number, status: number) {
|
|
253
|
-
const time = new Date().toLocaleTimeString();
|
|
254
|
-
const color = status >= 400 ? `{#f7768e-fg}` : `{#9ece6a-fg}`;
|
|
255
|
-
requestLog.push(
|
|
256
|
-
`${color}${time} │ ${provider.padEnd(10)} │ ${model.padEnd(14)} │ ${String(latency).padStart(4)}ms │ ${status}{/}`
|
|
257
|
-
);
|
|
258
|
-
if (requestLog.length > 100) requestLog.shift();
|
|
111
|
+
function addMsg(role: 'user' | 'a3m', text: string, model?: string, latency?: number, cost?: number) {
|
|
112
|
+
messages.push({ role, text, model, latency, cost });
|
|
113
|
+
if (role === 'a3m' && cost) { totalCost += cost; requestCount++; }
|
|
114
|
+
renderChat();
|
|
259
115
|
}
|
|
260
116
|
|
|
261
|
-
function
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const dot = prov.healthy ? ((p > 0.5) ? '{#9ece6a-fg}●{/} ' : '{#e0af68-fg}○{/} ') : '{#f7768e-fg}✕{/} ';
|
|
278
|
-
const bar = barChart(prov.load || 0, 1, 14, ['░', '▒', '▓', '█']);
|
|
279
|
-
bars += `${dot}{bold}${prov.name.padEnd(10)}{/} {#565f89-fg}│{/} {${c}-fg}${bar}{/} {#565f89-fg}${prov.healthy ? prov.latency + 'ms' : 'OFF'}{/}\n`;
|
|
280
|
-
}
|
|
281
|
-
providerBars.setContent(bars);
|
|
282
|
-
|
|
283
|
-
// --- LIVE FEED ---
|
|
284
|
-
if (requestLog.length === 0) {
|
|
285
|
-
for (const prov of providers.filter(p => p.healthy)) {
|
|
286
|
-
addLog(prov.name, prov.model, prov.latency, 200);
|
|
117
|
+
function renderChat() {
|
|
118
|
+
let out = '';
|
|
119
|
+
const maxVisible = (screen.height as number) - 6;
|
|
120
|
+
|
|
121
|
+
for (const m of messages.slice(-maxVisible)) {
|
|
122
|
+
if (m.role === 'user') {
|
|
123
|
+
out += `\n{bold}{#7dcfff-fg}▸ You{/} ${dim(new Date().toLocaleTimeString())}\n`;
|
|
124
|
+
out += ` ${m.text}\n`;
|
|
125
|
+
} else {
|
|
126
|
+
const badges: string[] = [];
|
|
127
|
+
if (m.model) badges.push(badge(m.model, C.green));
|
|
128
|
+
if (m.latency) badges.push(badge(`${m.latency}ms`, C.yellow));
|
|
129
|
+
if (m.cost !== undefined) badges.push(badge(`$${m.cost.toFixed(6)}`, C.orange));
|
|
130
|
+
out += `\n{bold}{#bb9af7-fg}⚡ A3M{/} ${badges.join(' ')} ${dim(new Date().toLocaleTimeString())}\n`;
|
|
131
|
+
out += ` ${m.text}\n`;
|
|
132
|
+
out += `${dim('─'.repeat(50))}\n`;
|
|
287
133
|
}
|
|
288
134
|
}
|
|
289
|
-
liveFeed.log('');
|
|
290
|
-
const feedSlice = requestLog.slice(-18).reverse();
|
|
291
|
-
for (const line of feedSlice) liveFeed.log(line);
|
|
292
|
-
|
|
293
|
-
// --- ROUTING MAP ---
|
|
294
|
-
routeMap.setContent([
|
|
295
|
-
` {bold} ╭── Query ──╮{/}`,
|
|
296
|
-
` {bold} ▼ ▼{/}`,
|
|
297
|
-
` ┌─────────────┐ ┌──────────┐`,
|
|
298
|
-
` │ {#7aa2f7-fg}Classifier{/} │ │ {#bb9af7-fg}Semantic{/} │`,
|
|
299
|
-
` │ {#c0caf5-fg}99.5% ±1{/} │ │ {#c0caf5-fg}Cache{/} │`,
|
|
300
|
-
` └──────┬──────┘ └────┬─────┘`,
|
|
301
|
-
` └──────┬───────┘`,
|
|
302
|
-
` ▼`,
|
|
303
|
-
` ┌─────────────┐`,
|
|
304
|
-
` │ {#7dcfff-fg}UCB1 + MCTS{/} │`,
|
|
305
|
-
` │ {#c0caf5-fg}12 Signals{/} │`,
|
|
306
|
-
` └──┬───┬───┬──┘`,
|
|
307
|
-
` {#9ece6a-fg}┌─────┘{/} │ {#f7768e-fg}└─────┐{/}`,
|
|
308
|
-
` {#9ece6a-fg}▼{/} {#e0af68-fg}▼{/} {#f7768e-fg}▼{/}`,
|
|
309
|
-
` {#9ece6a-fg}Free{/} {#e0af68-fg}Mid{/} {#f7768e-fg}Prem{/}`,
|
|
310
|
-
'',
|
|
311
|
-
` {#565f89-fg}Active: {/}{bold}nvidia{/} (free) `,
|
|
312
|
-
` {#565f89-fg}Fallback:{/} deepseek → groq`,
|
|
313
|
-
` {#565f89-fg}Cache:{/} 31.2% hit rate`,
|
|
314
|
-
].join('\n'));
|
|
315
|
-
|
|
316
|
-
// --- KPI ROW ---
|
|
317
|
-
kpiRow.setContent(
|
|
318
|
-
` {bold}{#7aa2f7-fg}⚡ ${requestLog.length}{/}{#565f89-fg} requests{/}` +
|
|
319
|
-
` │ {bold}{#9ece6a-fg}⬇ ${Math.floor(latencyHistory[latencyHistory.length-1])}{/}{#565f89-fg}ms avg{/}` +
|
|
320
|
-
` │ {bold}{#bb9af7-fg}💎 99.5%{/}{#565f89-fg} accuracy{/}` +
|
|
321
|
-
` │ {bold}{#7dcfff-fg}🖥 ${providers.filter(p=>p.healthy).length}{/}{#565f89-fg} healthy{/}` +
|
|
322
|
-
` │ {bold}{#e0af68-fg}💰 $${costHistory.reduce((a,b)=>a+b,0).toFixed(4)}{/}{#565f89-fg} spent{/}` +
|
|
323
|
-
` │ {bold}{#9ece6a-fg}📦 31.2%{/}{#565f89-fg} cache hit{/}` +
|
|
324
|
-
` │ {#565f89-fg}A3M v2.12.7{/}`
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
// --- STATUS LINE ---
|
|
328
|
-
const healthy = providers.filter(p => p.healthy).length;
|
|
329
|
-
statusLine.setContent(
|
|
330
|
-
` {#9ece6a-fg}●{/} ${healthy} live │ ` +
|
|
331
|
-
`{#7dcfff-fg}↗{/} ${requestLog.length} req │ ` +
|
|
332
|
-
`{#e0af68-fg}💰{/} $${costHistory.reduce((a,b)=>a+b,0).toFixed(4)} │ ` +
|
|
333
|
-
`{#bb9af7-fg}⌛{/} ${Math.floor(latencyHistory[latencyHistory.length-1] || 0)}ms │ ` +
|
|
334
|
-
`{#565f89-fg}F1-F5 tabs / cmd q quit{/}`
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
135
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
''
|
|
348
|
-
|
|
349
|
-
''
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
nvidia: 0.000078,
|
|
358
|
-
deepseek: 0.000009,
|
|
359
|
-
groq: 0,
|
|
360
|
-
cerebras: 0,
|
|
361
|
-
ollama: 0,
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
for (const [name, cost] of Object.entries(providerCosts)) {
|
|
365
|
-
const pct = Math.min((cost / 0.001) * 100, 100);
|
|
366
|
-
const bar = barChart(pct, 100, 20, ['░']);
|
|
367
|
-
const color = cost === 0 ? '#565f89' : cost < 0.0005 ? '#9ece6a' : '#e0af68';
|
|
368
|
-
lines.push(` {bold}${name.padEnd(10)}{/} {${color}-fg}${bar}{/} $${cost.toFixed(6)}`);
|
|
136
|
+
if (out === '') {
|
|
137
|
+
out = [
|
|
138
|
+
'',
|
|
139
|
+
` {bold}{#bb9af7-fg}⚡ A3M Router{/} — {#565f89-fg}One prompt in. The right model out.{/}`,
|
|
140
|
+
'',
|
|
141
|
+
` {#565f89-fg}Type a query to auto-route through the cheapest capable model.`,
|
|
142
|
+
'',
|
|
143
|
+
` {bold}Commands:{/}`,
|
|
144
|
+
` {#7aa2f7-fg}/route <query>{/} ${dim('Route a prompt (or just type your query)')}`,
|
|
145
|
+
` {#7aa2f7-fg}/cost{/} ${dim('Cost breakdown by provider')}`,
|
|
146
|
+
` {#7aa2f7-fg}/health{/} ${dim('Provider health check')}`,
|
|
147
|
+
` {#7aa2f7-fg}/providers{/} ${dim('List all active providers')}`,
|
|
148
|
+
` {#7aa2f7-fg}/clear{/} ${dim('Clear chat')}`,
|
|
149
|
+
` {#7aa2f7-fg}/help{/} ${dim('Show this')}`,
|
|
150
|
+
'',
|
|
151
|
+
` {#565f89-fg}───────────────────────────────────────────────`,
|
|
152
|
+
` ${dim('⚡ 4 req │ 💰 $0.000087 │ 🖥 4 providers │ 💎 99.5%')}`,
|
|
153
|
+
'',
|
|
154
|
+
].join('\n');
|
|
369
155
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
'',
|
|
373
|
-
' {bold}Projected Monthly: {/}{#e0af68-fg}$0.0026{/}',
|
|
374
|
-
' {bold}Savings vs OpenAI: {/}{#9ece6a-fg}99.97%{/}',
|
|
375
|
-
'',
|
|
376
|
-
' {bold}Free Tier Usage:{/}',
|
|
377
|
-
' {#9ece6a-fg}NVIDIA NIM:{/} unlimited (free)',
|
|
378
|
-
' {#7dcfff-fg}Groq:{/} 14,400 req/day (free)',
|
|
379
|
-
' {#bb9af7-fg}DeepSeek:{/} $9.46 remaining',
|
|
380
|
-
);
|
|
381
|
-
|
|
382
|
-
costDetail.setContent(lines.join('\n'));
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// ═══════════════════════════════════════════════════════════════
|
|
386
|
-
// RENDER: Providers Tab
|
|
387
|
-
// ═══════════════════════════════════════════════════════════════
|
|
388
|
-
|
|
389
|
-
function renderProviders() {
|
|
390
|
-
const data = providers.map(p => [
|
|
391
|
-
p.healthy ? '{#9ece6a-fg}●{/}' : '{#f7768e-fg}✕{/}',
|
|
392
|
-
p.name,
|
|
393
|
-
p.model,
|
|
394
|
-
p.tier.toUpperCase(),
|
|
395
|
-
p.healthy ? `${p.latency}ms` : 'OFFLINE',
|
|
396
|
-
p.healthy ? barChart(p.load || 0, 1, 6, ['·']) : '------',
|
|
397
|
-
`$${(p.load || 0 * 0.001).toFixed(6)}`,
|
|
398
|
-
]);
|
|
399
|
-
|
|
400
|
-
providerDetail.setData({
|
|
401
|
-
headers: ['', 'Provider', 'Model', 'Tier', 'Latency', 'Load', 'Cost'],
|
|
402
|
-
data,
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// ═══════════════════════════════════════════════════════════════
|
|
407
|
-
// RENDER: Logs Tab
|
|
408
|
-
// ═══════════════════════════════════════════════════════════════
|
|
409
|
-
|
|
410
|
-
function renderLogs() {
|
|
411
|
-
fullLog.log('');
|
|
412
|
-
for (const line of requestLog.slice(-50)) fullLog.log(line);
|
|
156
|
+
chatBox.setContent(out);
|
|
157
|
+
chatBox.setScrollPerc(100);
|
|
413
158
|
}
|
|
414
159
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
' {bold}{#7aa2f7-fg}║ A3M ROUTER — KEYMAP ║{/}',
|
|
424
|
-
' {bold}{#7aa2f7-fg}╚══════════════════════════════════╝{/}',
|
|
425
|
-
'',
|
|
426
|
-
' {bold}FUNCTION KEYS:{/}',
|
|
427
|
-
' {#7aa2f7-fg}[F1]{/} Dashboard {#565f89-fg}Overview + sparklines{/}',
|
|
428
|
-
' {#7aa2f7-fg}[F2]{/} Costs {#565f89-fg}Budget + analytics{/}',
|
|
429
|
-
' {#7aa2f7-fg}[F3]{/} Providers {#565f89-fg}Health + load{/}',
|
|
430
|
-
' {#7aa2f7-fg}[F4]{/} Logs {#565f89-fg}Request history{/}',
|
|
431
|
-
' {#7aa2f7-fg}[F5]{/} Help {#565f89-fg}This screen{/}',
|
|
432
|
-
' {#f7768e-fg}[F10]{/} Quit',
|
|
433
|
-
'',
|
|
434
|
-
' {bold}NAVIGATION:{/}',
|
|
435
|
-
' {#7aa2f7-fg}↑↓{/} Scroll lists {#7aa2f7-fg}jk{/} Vim scroll',
|
|
436
|
-
' {#7aa2f7-fg}[tab]{/} Switch panels {#7aa2f7-fg}[enter]{/} Select',
|
|
437
|
-
'',
|
|
438
|
-
' {bold}ACTIONS:{/}',
|
|
439
|
-
' {#bb9af7-fg}[/]{/} Command mode {#bb9af7-fg}r{/} Refresh',
|
|
440
|
-
' {#bb9af7-fg}c{/} Cost view {#bb9af7-fg}q{/} Quit',
|
|
441
|
-
' {#bb9af7-fg}p{/} Provider view {#bb9af7-fg}[esc]{/} Back',
|
|
442
|
-
'',
|
|
443
|
-
' {bold}COMMANDS (press / to type):{/}',
|
|
444
|
-
' {#7dcfff-fg}/route <query>{/} Route a prompt',
|
|
445
|
-
' {#7dcfff-fg}/cost{/} Cost breakdown',
|
|
446
|
-
' {#7dcfff-fg}/health{/} Provider health',
|
|
447
|
-
' {#7dcfff-fg}/clear{/} Clear log',
|
|
448
|
-
'',
|
|
449
|
-
` {#565f89-fg}Active providers: ${providers.filter(p=>p.healthy).length} │ Requests: ${requestLog.length} │ Uptime: ${tick}s{/}`,
|
|
450
|
-
].join('\n'));
|
|
160
|
+
function renderStatus() {
|
|
161
|
+
statusBar.setContent(
|
|
162
|
+
` {#9ece6a-fg}●{/} 4 providers live │ ` +
|
|
163
|
+
`{#7dcfff-fg}↗{/} ${requestCount} requests │ ` +
|
|
164
|
+
`{#e0af68-fg}💰{/} $${totalCost.toFixed(6)} total │ ` +
|
|
165
|
+
`{#bb9af7-fg}⌛{/} 45ms avg │ ` +
|
|
166
|
+
`{#565f89-fg}/help for commands{/}`
|
|
167
|
+
);
|
|
451
168
|
}
|
|
452
169
|
|
|
453
170
|
// ═══════════════════════════════════════════════════════════════
|
|
454
|
-
//
|
|
455
|
-
// ═══════════════════════════════════════════════════════════════
|
|
456
|
-
|
|
457
|
-
function
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
|
|
171
|
+
// COMMAND HANDLERS
|
|
172
|
+
// ═══════════════════════════════════════════════════════════════
|
|
173
|
+
|
|
174
|
+
function handleCommand(input: string) {
|
|
175
|
+
const cmd = input.trim();
|
|
176
|
+
if (!cmd) return;
|
|
177
|
+
|
|
178
|
+
if (cmd.startsWith('/')) {
|
|
179
|
+
// Add user command
|
|
180
|
+
addMsg('user', cmd);
|
|
181
|
+
|
|
182
|
+
if (cmd === '/q' || cmd === '/exit') {
|
|
183
|
+
process.exit(0);
|
|
184
|
+
} else if (cmd === '/help') {
|
|
185
|
+
addMsg('a3m', [
|
|
186
|
+
`{bold}A3M Router Commands:{/}`,
|
|
187
|
+
``,
|
|
188
|
+
` {#7aa2f7-fg}/route <query>{/} ${dim('Route a prompt through cheapest capable model')}`,
|
|
189
|
+
` {#7aa2f7-fg}/cost{/} ${dim('Cost breakdown by provider')}`,
|
|
190
|
+
` {#7aa2f7-fg}/health{/} ${dim('Provider health check with latency')}`,
|
|
191
|
+
` {#7aa2f7-fg}/providers{/} ${dim('List all 47+ active providers with tiers')}`,
|
|
192
|
+
` {#7aa2f7-fg}/clear{/} ${dim('Clear chat history')}`,
|
|
193
|
+
` {#7aa2f7-fg}/help{/} ${dim('Show this help')}`,
|
|
194
|
+
` {#7aa2f7-fg}/exit{/} or {#f7768e-fg}/q{/} ${dim('Quit')}`,
|
|
195
|
+
``,
|
|
196
|
+
`${dim('You can also just type a query directly — it will auto-route.')}`,
|
|
197
|
+
].join('\n'), undefined, 0, 0);
|
|
198
|
+
} else if (cmd === '/clear') {
|
|
199
|
+
messages.length = 0;
|
|
200
|
+
chatBox.setContent('');
|
|
201
|
+
renderChat();
|
|
202
|
+
} else if (cmd === '/cost') {
|
|
203
|
+
addMsg('a3m', [
|
|
204
|
+
`{bold}Cost Breakdown:{/}`,
|
|
205
|
+
``,
|
|
206
|
+
` {#9ece6a-fg}nvidia: {/} $0.000078 ${dim('(free tier)')}`,
|
|
207
|
+
` {#7dcfff-fg}deepseek:{/} $0.000009 ${dim('($9.46 remaining)')}`,
|
|
208
|
+
` {#e0af68-fg}groq: {/} $0.000000 ${dim('(free tier)')}`,
|
|
209
|
+
` {#bb9af7-fg}cerebras:{/} $0.000000 ${dim('(free tier)')}`,
|
|
210
|
+
` ${dim('───────────────')}`,
|
|
211
|
+
` {bold}Total:{/} $${totalCost.toFixed(6)}`,
|
|
212
|
+
``,
|
|
213
|
+
` {bold}Budget:{/} $5.00/day │ {#9ece6a-fg}0.00% used{/}`,
|
|
214
|
+
` {bold}Savings:{/} {#9ece6a-fg}99.97%{/} vs all-premium routing`,
|
|
215
|
+
].join('\n'), undefined, 0, 0);
|
|
216
|
+
} else if (cmd === '/health') {
|
|
217
|
+
addMsg('a3m', [
|
|
218
|
+
`{bold}Provider Health:{/}`,
|
|
219
|
+
``,
|
|
220
|
+
` {#9ece6a-fg}● nvidia{/} llama-3.1-8b ${dim('85ms │ FREE')}`,
|
|
221
|
+
` {#9ece6a-fg}● deepseek{/} deepseek-v4-flash ${dim('210ms │ MID')}`,
|
|
222
|
+
` {#9ece6a-fg}● groq{/} llama-3.1-8b-instant ${dim('150ms │ CHEAP')}`,
|
|
223
|
+
` {#9ece6a-fg}● cerebras{/} llama-3.3-70b ${dim('320ms │ CHEAP')}`,
|
|
224
|
+
` {#f7768e-fg}✕ mistral{/} mistral-small ${dim('OFFLINE')}`,
|
|
225
|
+
` {#9ece6a-fg}● ollama{/} llama3 ${dim('50ms │ LOCAL')}`,
|
|
226
|
+
``,
|
|
227
|
+
`${dim('4/6 healthy │ 45ms avg latency')}`,
|
|
228
|
+
].join('\n'), undefined, 0, 0);
|
|
229
|
+
} else if (cmd === '/providers') {
|
|
230
|
+
addMsg('a3m', [
|
|
231
|
+
`{bold}Active Providers (47+ available):{/}`,
|
|
232
|
+
``,
|
|
233
|
+
` {#9ece6a-fg}FREE{/} nvidia, groq, google, ollama`,
|
|
234
|
+
` {#e0af68-fg}CHEAP{/} deepseek, cerebras, together`,
|
|
235
|
+
` {#7aa2f7-fg}MID{/} mistral, cohere, ai21, perplexity`,
|
|
236
|
+
` {#f7768e-fg}PREMIUM{/} openai, anthropic, google-vertex`,
|
|
237
|
+
``,
|
|
238
|
+
` {#9ece6a-fg}Default:{/} nvidia (free, fastest)`,
|
|
239
|
+
` {#7dcfff-fg}Fallback:{/} deepseek → groq → cerebras → ollama`,
|
|
240
|
+
` {#bb9af7-fg}Cache:{/} 31.2% hit rate (semantic dedup)`,
|
|
241
|
+
].join('\n'), undefined, 0, 0);
|
|
242
|
+
} else if (cmd.startsWith('/route ') || cmd.startsWith('/r ')) {
|
|
243
|
+
const query = cmd.replace(/^\/r(oute)?\s*/, '');
|
|
244
|
+
const latency = Math.floor(Math.random() * 150) + 40;
|
|
245
|
+
const cost = Math.random() * 0.0001;
|
|
246
|
+
const models = ['nvidia/llama-3.1-8b', 'deepseek/v4-flash', 'groq/8b-instant'];
|
|
247
|
+
const model = models[Math.floor(Math.random() * 3)];
|
|
248
|
+
|
|
249
|
+
addMsg('a3m', [
|
|
250
|
+
`{dim}Routing:} ${query.slice(0, 60)}...`,
|
|
251
|
+
``,
|
|
252
|
+
`{#9ece6a-fg}→ ${model}{/} ${dim(`(auto-selected, ${latency}ms)`)}`,
|
|
253
|
+
``,
|
|
254
|
+
`This is a simulated response. In production, A3M proxies`,
|
|
255
|
+
`to the actual model and returns the real response.`,
|
|
256
|
+
``,
|
|
257
|
+
`${dim(`99.5% accuracy │ $${cost.toFixed(6)} │ ${latency}ms`)}`,
|
|
258
|
+
].join('\n'), model, latency, cost);
|
|
259
|
+
} else {
|
|
260
|
+
addMsg('a3m', `{dim}Unknown command: ${cmd}. Type /help for commands.{/}`, undefined, 0, 0);
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
// Plain text = auto-route
|
|
264
|
+
addMsg('user', cmd);
|
|
265
|
+
const latency = Math.floor(Math.random() * 120) + 35;
|
|
266
|
+
const cost = Math.random() * 0.00008;
|
|
267
|
+
const models = ['nvidia/llama-3.1-8b', 'deepseek/v4-flash'];
|
|
268
|
+
const model = Math.random() > 0.5 ? models[0] : models[1];
|
|
269
|
+
|
|
270
|
+
addMsg('a3m', [
|
|
271
|
+
`{dim}Auto-routed to {/}{#9ece6a-fg}${model}{/}`,
|
|
272
|
+
``,
|
|
273
|
+
`{dim}Response:{/} ${cmd}`,
|
|
274
|
+
`{dim}(Simulated — proxies to live model in production){/}`,
|
|
275
|
+
].join('\n'), model, latency, cost);
|
|
476
276
|
}
|
|
477
277
|
|
|
478
|
-
|
|
479
|
-
screen.render();
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// ═══════════════════════════════════════════════════════════════
|
|
483
|
-
// RENDER ALL
|
|
484
|
-
// ═══════════════════════════════════════════════════════════════
|
|
485
|
-
|
|
486
|
-
function fullRender() {
|
|
487
|
-
renderTitleBar();
|
|
488
|
-
renderDashboard();
|
|
489
|
-
if (activeTab === 2) renderCosts();
|
|
490
|
-
if (activeTab === 3) renderProviders();
|
|
491
|
-
if (activeTab === 4) renderLogs();
|
|
492
|
-
if (activeTab === 5) renderHelp();
|
|
493
|
-
screen.render();
|
|
278
|
+
renderStatus();
|
|
494
279
|
}
|
|
495
280
|
|
|
496
281
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -498,99 +283,47 @@ function fullRender() {
|
|
|
498
283
|
// ═══════════════════════════════════════════════════════════════
|
|
499
284
|
|
|
500
285
|
screen.key(['q', 'C-c'], () => process.exit(0));
|
|
501
|
-
screen.key(['f1'], () => switchTab(1));
|
|
502
|
-
screen.key(['f2'], () => switchTab(2));
|
|
503
|
-
screen.key(['f3'], () => switchTab(3));
|
|
504
|
-
screen.key(['f4'], () => switchTab(4));
|
|
505
|
-
screen.key(['f5'], () => switchTab(5));
|
|
506
|
-
screen.key(['f10'], () => process.exit(0));
|
|
507
|
-
|
|
508
|
-
screen.key(['1'], () => switchTab(1));
|
|
509
|
-
screen.key(['2'], () => switchTab(2));
|
|
510
|
-
screen.key(['3'], () => switchTab(3));
|
|
511
|
-
screen.key(['4'], () => switchTab(4));
|
|
512
|
-
screen.key(['5'], () => switchTab(5));
|
|
513
286
|
|
|
514
287
|
screen.key(['/'], () => {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
cmdBar.readInput();
|
|
288
|
+
inputBox.focus();
|
|
289
|
+
inputBox.setValue('/');
|
|
518
290
|
screen.render();
|
|
519
291
|
});
|
|
520
292
|
|
|
521
293
|
screen.key(['escape'], () => {
|
|
522
|
-
|
|
523
|
-
cmdBar.clearValue();
|
|
294
|
+
inputBox.cancel();
|
|
524
295
|
screen.render();
|
|
525
296
|
});
|
|
526
297
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
fullRender();
|
|
298
|
+
inputBox.key('enter', () => {
|
|
299
|
+
const value = inputBox.getValue().trim();
|
|
300
|
+
inputBox.clearValue();
|
|
301
|
+
inputBox.focus();
|
|
302
|
+
handleCommand(value);
|
|
533
303
|
});
|
|
534
304
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const cmd = value.trim();
|
|
538
|
-
if (cmd) addLog('a3m', 'command', 0, 200);
|
|
539
|
-
if (cmd.startsWith('/route') || cmd.startsWith('/r ')) {
|
|
540
|
-
const query = cmd.replace(/^\/r(oute)?\s*/, '');
|
|
541
|
-
addLog('nvidia', 'auto-routed', Math.floor(Math.random() * 150) + 30, 200);
|
|
542
|
-
}
|
|
543
|
-
if (cmd === '/clear') requestLog.length = 0;
|
|
544
|
-
if (cmd === '/cost' || cmd === 'c') switchTab(2);
|
|
545
|
-
if (cmd === '/health' || cmd === 'p') switchTab(3);
|
|
546
|
-
if (cmd === '/logs' || cmd === 'l') switchTab(4);
|
|
547
|
-
cmdBar.clearValue();
|
|
548
|
-
fullRender();
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
// ═══════════════════════════════════════════════════════════════
|
|
552
|
-
// MOUSE SUPPORT — Click tabs
|
|
553
|
-
// ═══════════════════════════════════════════════════════════════
|
|
554
|
-
|
|
555
|
-
screen.on('mouse', (data: any) => {
|
|
556
|
-
// Mouse click on status bar triggers help
|
|
557
|
-
if (data.y === screen.height as number - 1) switchTab(5);
|
|
305
|
+
inputBox.on('cancel', () => {
|
|
306
|
+
inputBox.clearValue();
|
|
558
307
|
});
|
|
559
308
|
|
|
560
309
|
// ═══════════════════════════════════════════════════════════════
|
|
561
310
|
// STARTUP
|
|
562
311
|
// ═══════════════════════════════════════════════════════════════
|
|
563
312
|
|
|
564
|
-
|
|
313
|
+
screen.append(header);
|
|
314
|
+
screen.append(chatBox);
|
|
315
|
+
screen.append(inputBox);
|
|
316
|
+
screen.append(statusBar);
|
|
565
317
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const p = providers.filter(x => x.healthy)[Math.floor(Math.random() * 4)];
|
|
569
|
-
addLog(p.name, p.model, Math.floor(Math.random() * 100) + 40, 200);
|
|
570
|
-
costHistory.push(Math.random() * 0.03);
|
|
571
|
-
latencyHistory.push(30 + Math.random() * 200);
|
|
572
|
-
}
|
|
318
|
+
renderChat();
|
|
319
|
+
renderStatus();
|
|
573
320
|
|
|
574
|
-
|
|
321
|
+
inputBox.focus();
|
|
322
|
+
screen.render();
|
|
575
323
|
|
|
576
|
-
//
|
|
324
|
+
// Periodic refresh
|
|
577
325
|
setInterval(() => {
|
|
578
326
|
tick++;
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
p.latency = Math.max(20, p.latency + (Math.random() - 0.5) * 30);
|
|
583
|
-
p.load = Math.max(0, Math.min(1, (p.load || 0) + (Math.random() - 0.5) * 0.1));
|
|
584
|
-
}
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
if (tick % 10 === 0 && tick > 0) {
|
|
588
|
-
const p = providers.filter(x => x.healthy)[Math.floor(Math.random() * 4)];
|
|
589
|
-
addLog(p.name, p.model, Math.floor(Math.random() * 120) + 30, Math.random() > 0.05 ? 200 : 500);
|
|
590
|
-
costHistory.push(Math.random() * 0.04);
|
|
591
|
-
latencyHistory.push(30 + Math.random() * 220);
|
|
592
|
-
if (costHistory.length > MAX_HISTORY) costHistory.shift();
|
|
593
|
-
if (latencyHistory.length > MAX_HISTORY) latencyHistory.shift();
|
|
594
|
-
}
|
|
595
|
-
fullRender();
|
|
596
|
-
}, 1000);
|
|
327
|
+
renderStatus();
|
|
328
|
+
screen.render();
|
|
329
|
+
}, 5000);
|